Files
pdfcraft/crates/forms/src/af/format.rs
T
aiongg 0bcf83d474 refactor(forms): split af.rs into af/
New modules: af/script.rs, af/calc.rs, af/format.rs.
2026-10-26 22:18:50 +00:00

534 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets.
pub const DATE_PRESETS: [&str; 14] = [
"m/d",
"m/d/yy",
"mm/dd/yy",
"mm/yy",
"d-mmm",
"d-mmm-yy",
"dd-mmm-yy",
"yy-mm-dd",
"mmm-yy",
"mmmm-yy",
"mmm d, yyyy",
"mmmm d, yyyy",
"m/d/yy h:MM tt",
"m/d/yy HH:MM",
];
pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"];
// ── running them ────────────────────────────────────────────────────────────────────────────
/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a
/// trailing % ignored; a comma decimal separator accepted).
pub fn make_number(s: &str) -> Option<f64> {
let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect();
if t.is_empty() {
return None;
}
let neg = t.starts_with('(') && t.ends_with(')');
let t = t.trim_start_matches('(').trim_end_matches(')');
// "1.234,56" or "1234,56": comma is the decimal separator when it comes last.
let normalized = match (t.rfind(','), t.rfind('.')) {
(Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."),
(Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."),
_ => t.replace(',', ""),
};
let v: f64 = normalized.parse().ok()?;
v.is_finite().then_some(if neg { -v } else { v })
}
/// A number as JavaScript prints it (integers without a fraction).
pub fn js_number(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") }
}
fn group(int: &str, sep: char) -> String {
let mut out = String::new();
for (i, c) in int.chars().enumerate() {
if i > 0 && (int.len() - i).is_multiple_of(3) {
out.push(sep);
}
out.push(c);
}
out
}
/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56",
/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56").
fn number_text(v: f64, decimals: u8, sep: u8) -> String {
// Round half away from zero, as JavaScript's toFixed does for these values.
let p = 10f64.powi(i32::from(decimals));
let scaled = v.abs() * p;
// Past 2^53 every value is already whole, and scaling could overflow to infinity.
let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() };
let s = format!("{:.*}", decimals as usize, r);
let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b));
let (g, d) = match sep {
1 => (None, '.'),
2 => (Some('.'), ','),
3 => (None, ','),
4 => (Some('\''), '.'),
_ => (Some(','), '.'),
};
let mut out = match g {
Some(c) => group(int, c),
None => int.to_string(),
};
if !frac.is_empty() {
out.push(d);
out.push_str(frac);
}
out
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
/// A date and time read from text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DateTime {
pub y: i32,
pub m: u32,
pub d: u32,
pub hh: u32,
pub mm: u32,
pub ss: u32,
}
fn days_in(y: i32, m: u32) -> u32 {
match m {
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
fn weekday(y: i32, m: u32, d: u32) -> usize {
// Sakamoto's method, in i64 so no year or day can overflow it.
const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
let y = i64::from(y) - i64::from(m < 3);
let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0);
(y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize
}
/// One field of a date format, in the order the format has them.
struct Part {
/// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in
/// "dd.mm.26") is `'#'`.
kind: char,
/// The value of a `'#'` run; 0 for the others.
literal: u32,
/// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for
/// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is
/// a name ("mmm").
width: usize,
/// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits
/// can hold both.
joined: bool,
}
/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in
/// order.
fn order(fmt: &str) -> Vec<Part> {
let mut out: Vec<Part> = Vec::new();
// Weekday names (ddd, dddd) are shown, not typed: leave them out.
let mut cleaned = fmt.replace("dddd", " ");
cleaned = cleaned.replace("ddd", " ");
let mut prev = ' ';
// The letter of the last field's current run and its length so far.
let mut run: Option<(char, usize)> = None;
let fixed = |k: char, n: usize| match (k, n) {
('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n,
_ => 0,
};
for c in cleaned.chars() {
let continues = run.is_some_and(|(r, _)| r == c);
if let Some((r, n)) = run
&& !continues
{
// The run ended; it was the last field's width.
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
}
run = None;
}
if let Some(digit) = c.to_digit(10) {
match out.last_mut() {
Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit),
_ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }),
}
prev = c;
continue;
}
let k = match c {
'y' => 'y',
'm' => 'm',
'd' => 'd',
'H' | 'h' => 'H',
'M' => 'M',
's' => 's',
_ => {
prev = c;
continue;
}
};
if continues {
run = run.map(|(r, n)| (r, n.saturating_add(1)));
} else {
// A field starting right where the last one's letters stopped is joined to it.
let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's');
if out.last().map(|p| p.kind) != Some(k) {
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
last.joined = adjacent;
}
out.push(Part { kind: k, literal: 0, width: 0, joined: false });
}
run = Some((c, 1));
}
prev = c;
}
if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) {
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
}
out
}
/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any
/// separators, month names, two-digit years). `None` when it isn't a valid date.
pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
let t = text.trim();
if t.is_empty() {
return None;
}
let lower = t.to_lowercase();
// A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep".
let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
let pm = lower.contains("pm") || bare_p;
let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
let am = lower.contains("am") || bare_a;
// Tokens: runs of digits (kept as text, so a run can be split between fields typed without
// separators) and month names.
let mut nums: VecDeque<String> = VecDeque::new();
let mut month_name: Option<u32> = None;
let mut cur = String::new();
let flush = |cur: &mut String, nums: &mut VecDeque<String>, month: &mut Option<u32>| {
if cur.is_empty() {
return;
}
if cur.starts_with(|c: char| c.is_ascii_digit()) {
nums.push_back(std::mem::take(cur));
} else if cur.len() >= 3
&& let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase()))
{
*month = Some(i as u32 + 1);
}
cur.clear();
};
for c in t.chars() {
// Digits and letters form separate tokens ("5Mar" is 5 and Mar).
let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit();
if c.is_alphanumeric() && same_kind {
cur.push(c);
} else {
flush(&mut cur, &mut nums, &mut month_name);
if c.is_alphanumeric() {
cur.push(c);
}
}
}
flush(&mut cur, &mut nums, &mut month_name);
let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 };
// The next number for a field. A field joined to the next one in the format ("yyyymmdd")
// takes only its own width from a longer run of digits and leaves the rest for the next.
// A run typed on its own is split only when fewer runs were typed than the fields of its kind
// still to fill (`need`, this one included: date fields for a date field, time fields for a
// time field, since times may be left out), and only when its rest can fill the next field
// (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its
// four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left
// of a split run (`tail`: the front of `nums` is one) goes on being split along the fields
// written side by side, and must fill its last field exactly, so "202610018" leaves "018" for
// dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a
// rest has the wrong width.
let next_number = |nums: &mut VecDeque<String>, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option<u32> {
let run = nums.pop_front()?;
let was_tail = std::mem::replace(tail, false);
let short = nums.len() < need.saturating_sub(1);
let digits = match (run.get(..part.width), run.get(part.width..)) {
(Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => {
nums.push_front(rest.to_string());
*tail = true;
head
}
_ if was_tail && part.width > 0 && run.len() != part.width => return None,
_ => run.as_str(),
};
digits.parse::<u32>().ok()
};
let parts = order(fmt);
let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none());
let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's');
// The fields of each kind still to fill: [date, time].
let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()];
let mut tail = false;
let mut have_y = false;
for (i, part) in parts.iter().enumerate() {
let kind = usize::from(is_time(part));
let need = wanted.get(kind).copied().unwrap_or(0);
let next_width = parts.get(i + 1).map_or(0, |p| p.width);
if takes_number(part)
&& let Some(left) = wanted.get_mut(kind)
{
*left = left.saturating_sub(1);
}
let take = |nums: &mut VecDeque<String>, tail: &mut bool| next_number(nums, tail, part, need, next_width);
match part.kind {
// Literal digits in the format may be typed or left out ("15.05.26" or "15.05"
// for "dd.mm.26"); a different number there is left over and rejected below.
'#' if nums.front().and_then(|n| n.parse::<u32>().ok()) == Some(part.literal) => {
nums.pop_front();
tail = false;
}
'm' => {
dt.m = match month_name {
Some(m) => m,
None => take(&mut nums, &mut tail)?,
}
}
'd' => dt.d = take(&mut nums, &mut tail)?,
'y' => {
let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?;
dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y };
have_y = true;
}
// Times may be left out; a number that is there must be readable.
'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
_ => {}
}
}
if !nums.is_empty() {
return None;
}
if !have_y {
dt.y = 2000;
}
if pm && dt.hh < 12 {
dt.hh += 12;
}
if am && dt.hh == 12 {
dt.hh = 0;
}
let has_date = fmt.contains(['y', 'm', 'd']);
if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) {
return None;
}
if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 {
return None;
}
Some(dt)
}
/// Format a date/time with Acrobat's date format letters.
pub fn format_date(dt: DateTime, fmt: &str) -> String {
let chars: Vec<char> = fmt.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let mut n = 1;
while i + n < chars.len() && chars[i + n] == c {
n += 1;
}
let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 };
match (c, n) {
('y', 4..) => {
let _ = write!(out, "{:04}", dt.y);
}
('y', _) => {
let _ = write!(out, "{:02}", dt.y.rem_euclid(100));
}
('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) {
Some(name) if n >= 4 => out.push_str(name),
Some(name) => out.push_str(name.get(..3).unwrap_or(name)),
None => {
let _ = write!(out, "{}", dt.m);
}
},
('m', 2) => {
let _ = write!(out, "{:02}", dt.m);
}
('m', _) => {
let _ = write!(out, "{}", dt.m);
}
('d', 3..) => {
let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default();
out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) });
}
('d', 2) => {
let _ = write!(out, "{:02}", dt.d);
}
('d', _) => {
let _ = write!(out, "{}", dt.d);
}
('H', 2) => {
let _ = write!(out, "{:02}", dt.hh);
}
('H', _) => {
let _ = write!(out, "{}", dt.hh);
}
('h', 2) => {
let _ = write!(out, "{h12:02}");
}
('h', _) => {
let _ = write!(out, "{h12}");
}
('M', 2) => {
let _ = write!(out, "{:02}", dt.mm);
}
('M', _) => {
let _ = write!(out, "{}", dt.mm);
}
('s', 2) => {
let _ = write!(out, "{:02}", dt.ss);
}
('s', _) => {
let _ = write!(out, "{}", dt.ss);
}
('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }),
('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }),
(c, n) => out.extend(std::iter::repeat_n(c, n)),
}
i += n;
}
out
}
const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"];
fn mask_fits(mask: &str, text: &str) -> bool {
let t: Vec<char> = text.chars().collect();
let m: Vec<char> = mask.chars().collect();
t.len() == m.len()
&& m.iter().zip(&t).all(|(mc, tc)| match mc {
'9' => tc.is_ascii_digit(),
'A' => tc.is_alphabetic(),
'O' => tc.is_alphanumeric(),
'X' => true,
other => other == tc,
})
}
/// Fill a mask from the typed characters that fit its slots.
fn apply_mask(mask: &str, text: &str) -> Option<String> {
let mut src = text.chars().filter(|c| c.is_alphanumeric());
let mut out = String::new();
for mc in mask.chars() {
match mc {
'9' | 'A' | 'O' | 'X' => {
let c = src.next()?;
let ok = match mc {
'9' => c.is_ascii_digit(),
'A' => c.is_alphabetic(),
_ => true,
};
if !ok {
return None;
}
out.push(c);
}
other => out.push(other),
}
}
src.next().is_none().then_some(out)
}
/// The text shown for a value (the Format event).
pub fn format_value(f: &Format, value: &str) -> String {
if value.is_empty() {
return String::new();
}
match f {
Format::None => value.to_string(),
Format::Number { decimals, sep, neg, currency, prepend } => {
let Some(v) = make_number(value) else { return value.to_string() };
let body = number_text(v, *decimals, *sep);
let body = match (currency.is_empty(), *prepend) {
(true, _) => body,
(false, true) => format!("{currency}{body}"),
(false, false) => format!("{body}{currency}"),
};
match (v < 0.0, neg) {
(false, _) => body,
// Styles 2 and 3 use parentheses (3 is also red, which the appearance can't
// know here); 0 and 1 a minus sign.
(true, 2 | 3) => format!("({body})"),
(true, _) => format!("-{body}"),
}
}
Format::Percent { decimals, sep } => match make_number(value) {
Some(v) => format!(
"{}%",
if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) }
),
None => value.to_string(),
},
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)),
Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()),
Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()),
}
}
/// The Keystroke event on commit: does the value fit the format? Returns the value to store
/// (numbers are normalised), or Acrobat's message.
pub fn keystroke(f: &Format, field: &str, value: &str) -> Result<String, String> {
let bad = || format!("The value entered does not match the format of the field [ {field} ]");
// Blank input clears a formatted field; without a format, whitespace is text like any other.
if *f != Format::None && value.trim().is_empty() {
return Ok(String::new());
}
match f {
Format::None => Ok(value.to_string()),
Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad),
Format::Percent { .. } => {
// Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number
// as typed and shows it ×100: keep the typed number.
make_number(value).map(js_number).ok_or_else(bad)
}
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt)
.map(|_| value.trim().to_string())
.ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")),
Format::Special(n) => {
let m = SPECIAL_MASKS[(*n).min(3) as usize];
apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad)
}
Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad),
}
}
/// The Validate event: `Err` with Acrobat's message when the value is out of range.
pub fn validate(v: &Validate, value: &str) -> Result<(), String> {
let Validate::Range { min, max } = v else { return Ok(()) };
if value.trim().is_empty() {
return Ok(());
}
let Some(x) = make_number(value) else { return Ok(()) };
let fmt = |n: f64| js_number(n);
let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m);
if ok {
return Ok(());
}
Err(match (min, max) {
(Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)),
(Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)),
(None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)),
(None, None) => String::new(),
})
}