diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 99e49c1bb..ee4aa18e3 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -13,6 +13,17 @@ use std::collections::VecDeque; use std::fmt::Write as _; use std::ops::Range; +mod calc; +mod format; +mod script; + +#[cfg(test)] +use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; +pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation}; +pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +use script::{Arg, call}; +pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js}; + /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] pub enum Format { @@ -112,1384 +123,6 @@ impl Actions { } } -// ── parsing scripts ───────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Arg { - Num(f64), - Str(String), - Bool(bool), - List(Vec), -} - -impl Arg { - fn num(&self) -> Option { - match self { - Arg::Num(n) => Some(*n), - Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), - Arg::List(_) => None, - } - } - - fn string(&self) -> Option { - match self { - Arg::Str(s) => Some(s.clone()), - Arg::Num(n) => Some(n.to_string()), - _ => None, - } - } - - fn truthy(&self) -> bool { - match self { - Arg::Bool(b) => *b, - Arg::Num(n) => *n != 0.0, - Arg::Str(s) => !s.is_empty(), - Arg::List(l) => !l.is_empty(), - } - } -} - -/// How deeply script arguments and field notation may nest; deeper input is rejected rather -/// than recursed into (a hostile file could otherwise overflow the stack). -const MAX_NESTING: usize = 64; - -fn hex4(bytes: &[u8]) -> Option { - if bytes.len() != 4 { - return None; - } - bytes.iter().try_fold(0u16, |value, byte| { - let digit = match byte { - b'0'..=b'9' => byte - b'0', - b'a'..=b'f' => byte - b'a' + 10, - b'A'..=b'F' => byte - b'A' + 10, - _ => return None, - }; - value.checked_mul(16)?.checked_add(u16::from(digit)) - }) -} - -struct Parser<'a> { - s: &'a [u8], - i: usize, - depth: usize, -} - -impl Parser<'_> { - fn ws(&mut self) { - while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { - self.i += 1; - } - } - - fn string(&mut self) -> Option { - let q = self.s[self.i]; - self.i += 1; - let mut out = Vec::new(); - while self.i < self.s.len() { - let c = self.s[self.i]; - self.i += 1; - match c { - b'\\' if self.i < self.s.len() => { - let e = self.s[self.i]; - self.i += 1; - match e { - b'n' => out.push(b'\n'), - b'r' => out.push(b'\r'), - b't' => out.push(b'\t'), - b'b' => out.push(0x08), - b'f' => out.push(0x0C), - b'v' => out.push(0x0B), - b'0' => out.push(0), - b'x' => { - let digits = self.s.get(self.i..self.i.checked_add(2)?)?; - let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; - self.i += 2; - let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - b'u' => { - let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; - self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } - } else { - u32::from(code) - }; - let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - other => out.push(other), - } - } - c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), - c => out.push(c), - } - } - None - } - - fn arg(&mut self) -> Option { - self.ws(); - let c = *self.s.get(self.i)?; - match c { - b'"' | b'\'' => self.string().map(Arg::Str), - b'[' => { - self.i += 1; - self.nested(b']').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"new Array") => { - self.i += b"new Array".len(); - self.ws(); - if self.s.get(self.i) != Some(&b'(') { - return None; - } - self.i += 1; - self.nested(b')').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"true") => { - self.i += 4; - Some(Arg::Bool(true)) - } - _ if self.s[self.i..].starts_with(b"false") => { - self.i += 5; - Some(Arg::Bool(false)) - } - _ => { - let start = self.i; - while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { - self.i += 1; - } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) - } - } - } - - /// A list inside another, at most [`MAX_NESTING`] deep. - fn nested(&mut self, close: u8) -> Option> { - if self.depth >= MAX_NESTING { - return None; - } - self.depth += 1; - let out = self.list(close); - self.depth -= 1; - out - } - - fn list(&mut self, close: u8) -> Option> { - let mut out = Vec::new(); - loop { - self.ws(); - if self.s.get(self.i) == Some(&close) { - self.i += 1; - return Some(out); - } - out.push(self.arg()?); - self.ws(); - match self.s.get(self.i) { - Some(b',') => self.i += 1, - Some(c) if *c == close => { - self.i += 1; - return Some(out); - } - _ => return None, - } - } - } -} - -/// The first call to `name(` in `js` and its arguments. -fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; - } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') -} - -/// 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"]; - -pub fn parse_format(js: &str) -> Option { - let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; - if let Some(args) = call(js, "AFNumber_Format") { - return Some(Format::Number { - decimals: a(&args, 0), - sep: a(&args, 1), - neg: a(&args, 2), - currency: args.get(4).and_then(Arg::string).unwrap_or_default(), - prepend: args.get(5).is_none_or(Arg::truthy), - }); - } - if let Some(args) = call(js, "AFPercent_Format") { - return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); - } - if let Some(args) = call(js, "AFDate_FormatEx") { - return Some(Format::Date(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFDate_Format") { - return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFTime_FormatEx") { - return Some(Format::Time(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFTime_Format") { - return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFSpecial_Format") { - return Some(Format::Special(a(&args, 0).min(3))); - } - if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { - return Some(Format::Mask(args.first().and_then(Arg::string)?)); - } - None -} - -pub fn parse_validate(js: &str) -> Option { - let args = call(js, "AFRange_Validate")?; - let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); - let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) -} - -pub fn parse_calculate(js: &str) -> Option { - // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for - // its comments only when it spells the marker. - if js.contains("BVCALC") - && let Some(range) = notation_range(js, &js_tokens(js).1) - { - return Some(Calculate::Notation(js.get(range)?.trim().to_string())); - } - let args = call(js, "AFSimple_Calculate")?; - let op = match args.first().and_then(Arg::string)?.as_str() { - "SUM" => CalcOp::Sum, - "PRD" => CalcOp::Product, - "AVG" => CalcOp::Average, - "MIN" => CalcOp::Minimum, - "MAX" => CalcOp::Maximum, - _ => return None, - }; - let fields = match args.get(1)? { - Arg::List(l) => l.iter().filter_map(Arg::string).collect(), - Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), - _ => return None, - }; - Some(Calculate::Simple { op, fields }) -} - -// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── - -fn js_str(s: &str) -> String { - let mut o = String::from("\""); - for c in s.chars() { - match c { - '"' | '\\' => { - o.push('\\'); - o.push(c); - } - '\n' => o.push_str("\\n"), - '\r' => o.push_str("\\r"), - '\t' => o.push_str("\\t"), - c if c.is_ascii() && !c.is_control() => o.push(c), - c => { - let mut units = [0u16; 2]; - for unit in c.encode_utf16(&mut units) { - o.push_str(&format!("\\u{unit:04X}")); - } - } - } - } - o.push('"'); - o -} - -/// The (format, keystroke) scripts for a format. -pub fn format_js(f: &Format) -> Option<(String, String)> { - Some(match f { - Format::None => return None, - Format::Number { decimals, sep, neg, currency, prepend } => { - let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); - (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) - } - Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), - Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), - Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), - Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), - Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), - }) -} - -pub fn validate_js(v: &Validate) -> Option { - match v { - Validate::None => None, - Validate::Range { min, max } => { - Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) - } - } -} - -pub fn calculate_js(c: &Calculate) -> Option { - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => Some(format!( - "AFSimple_Calculate({}, new Array({}));", - js_str(op.code()), - fields.iter().map(|f| js_str(f)).collect::>().join(", ") - )), - // Acrobat's own translation follows the comment, so a viewer that runs the script instead - // of reading the comment computes the same value (a placeholder here showed 0). - Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), - } -} - -// ── renaming fields in calculations ───────────────────────────────────────────────────────── - -/// A JavaScript token, as far as finding the field names a calculation reads needs. -#[derive(Clone, Debug, PartialEq)] -enum Js { - Ident, - /// A string literal, its escapes undone. - Str(String), - Punct(char), - /// A number, a regular expression or a template literal. - Other, -} - -/// A [`Js`] token with its byte range in the script. -type Token = (Range, Js); - -/// Words after which `/` starts a regular expression instead of dividing. -const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; - -/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a -/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so -/// a `/` after it starts the next statement, and can't divide. -const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; - -/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. -fn line_ends_at(s: &[u8], j: usize) -> bool { - matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) -} - -/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment -/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, -/// comments and regular expressions. The tokens stop where a string, comment or template literal -/// never ends; a comment that never ends runs to the end of the script. -fn js_tokens(js: &str) -> (Vec, Vec>) { - let s = js.as_bytes(); - // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except - // U+2028 and U+2029, which end a line: a range never ends inside a character. - let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); - let mut out: Vec = Vec::new(); - let mut comments = Vec::new(); - let mut i = 0; - // Where a regular expression that never ended stopped being read: every `/` before it - // divides, so a line of them is read once, not once for each. - let mut divides_until = 0; - // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or - // `with`; and whether the last token is the `)` that closed one. - let mut conditions: Vec = Vec::new(); - let mut after_condition = false; - 'scan: while let Some(&c) = s.get(i) { - let start = i; - let token = match c { - _ if c.is_ascii_whitespace() => { - i += 1; - continue; - } - // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a - // time, the rest of it would be read from inside the character. - 0xE2 if line_ends_at(s, i) => { - i += 3; - continue; - } - b'/' if s.get(i + 1) == Some(&b'/') => { - i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); - comments.push(start + 2..i); - continue; - } - b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { - Some(n) => { - comments.push(i + 2..i + 2 + n); - i += n + 4; - continue; - } - None => { - comments.push(i + 2..s.len()); - break 'scan; - } - }, - b'"' | b'\'' => { - let mut p = Parser { s, i, depth: 0 }; - let Some(text) = p.string() else { break 'scan }; - i = p.i; - Js::Str(text) - } - b'`' => { - let mut j = i + 1; - loop { - match s.get(j) { - None => break 'scan, - Some(b'\\') => j += 2, - Some(b'`') => break, - Some(_) => j += 1, - } - } - i = j + 1; - Js::Other - } - b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { - // A regular expression ends at a `/` outside a class; a line break first means it - // was a division after all. - let (mut j, mut class) = (i + 1, false); - let end = loop { - match s.get(j) { - None => break None, - Some(_) if line_ends_at(s, j) => break None, - Some(b'\\') => j += 2, - Some(b'/') if !class => break Some(j), - Some(b) => { - class = match b { - b'[' => true, - b']' => false, - _ => class, - }; - j += 1; - } - } - }; - match end { - Some(e) => { - i = e + 1; - while word(i) { - i += 1; - } - Js::Other - } - None => { - divides_until = j; - i += 1; - Js::Punct('/') - } - } - } - _ if c.is_ascii_digit() => { - while word(i) || s.get(i) == Some(&b'.') { - i += 1; - } - Js::Other - } - _ if word(i) => { - while word(i) { - i += 1; - } - Js::Ident - } - _ => { - i += 1; - Js::Punct(char::from(c)) - } - }; - after_condition = match token { - Js::Punct('(') => { - conditions.push(opens_condition(js, &out)); - false - } - Js::Punct(')') => conditions.pop().unwrap_or(false), - _ => false, - }; - out.push((start..i, token)); - } - (out, comments) -} - -/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening -/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not -/// after a value. `after_condition` says the last token is the `)` that ends such a condition. -fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { - match tokens.last() { - None => true, - Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), - Some((_, Js::Punct(')'))) => after_condition, - Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), - Some((_, Js::Str(_) | Js::Other)) => false, - } -} - -/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not -/// the arguments of a method that has one of those names (`o.if(x)`). -fn opens_condition(js: &str, tokens: &[Token]) -> bool { - let mut back = tokens.iter().rev(); - let Some((range, Js::Ident)) = back.next() else { return false }; - js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) -} - -/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the -/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it -/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. -fn notation_range(js: &str, comments: &[Range]) -> Option> { - comments.iter().find_map(|comment| { - let text = js.get(comment.clone())?; - let a = text.find("BVCALC")? + 6; - let b = a + text.get(a..)?.find("EVCALC")?; - Some(comment.start + a..comment.start + b) - }) -} - -/// A place where a Calculate script names a field. -#[derive(Clone, Debug, PartialEq)] -enum Site { - /// A string literal holding one name: `getField("a")`, or an item of an - /// `AFSimple_Calculate` list. - Literal(String), - /// An `AFSimple_Calculate` list written as one string of comma-separated names. - Commas(String), - /// A name in the simplified field notation of a `BVCALC` comment. - Notation(String), -} - -impl Site { - fn names(&self) -> Vec<&str> { - match self { - Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], - Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), - } - } - - /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. - fn renamed(&self, old: &str, new: &str) -> Option { - match self { - Site::Literal(name) => (name == old).then(|| js_str(new)), - Site::Notation(name) => (name == old).then(|| notation_name(new)), - Site::Commas(list) => { - let parts: Vec<&str> = list.split(',').collect(); - parts.iter().any(|p| p.trim() == old).then(|| { - // The spaces around each name stay as they were. - let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); - js_str(&parts.join(",")) - }) - } - } - } -} - -/// Where a Calculate script names fields, in order: the simplified field notation in its comment -/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of -/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. -/// A name built at run time (`getField("row" + i)`) is not a site. -fn calculation_sites(js: &str) -> Vec<(Range, Site)> { - let mut sites = Vec::new(); - let (tokens, comments) = js_tokens(js); - if let Some(at) = notation_range(js, &comments) - && let Some(expr) = js.get(at.clone()) - { - for (range, token) in notation_tokens(expr) { - if let Notation::Name(name) = token { - sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); - } - } - } - let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); - let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); - let string = |k: usize| match tokens.get(k) { - Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), - _ => None, - }; - for k in 0..tokens.len() { - if ident(k, "getField") - && punct(k + 1, '(') - && punct(k + 3, ')') - && let Some((range, name)) = string(k + 2) - { - sites.push((range, Site::Literal(name))); - } - if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { - continue; - } - if let Some((range, list)) = string(k + 4) { - sites.push((range, Site::Commas(list))); - continue; - } - // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. - // Nothing else is read, so no token is read for two calls. - let first = if punct(k + 4, '[') { - k + 5 - } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { - k + 7 - } else { - continue; - }; - for (range, token) in tokens.iter().skip(first) { - match token { - Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), - Js::Punct(',') => {} - _ => break, - } - } - } - sites.sort_by_key(|(range, _)| range.start); - sites -} - -/// A Calculate script with field `old` named `new` everywhere it reads it (see -/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also -/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end -/// the notation early), so a script is never left meaning something else. -pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { - let sites = calculation_sites(js); - let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); - if edits.is_empty() { - return None; - } - let out = splice(js, edits); - let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); - let written = calculation_sites(&out); - let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); - (got == expected).then_some(out) -} - -/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the -/// order it first names them. -pub fn calculation_reads(js: &str) -> Vec { - let sites = calculation_sites(js); - unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) -} - -impl Calculate { - /// The fields it reads, each once, in the order it first names them. A name also stands for - /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. - pub fn reads(&self) -> Vec { - match self { - Calculate::None => Vec::new(), - Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), - Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { - Notation::Name(name) => Some(name), - _ => None, - })), - } - } -} - -/// `names` without repeats, in order. -fn unique(names: impl IntoIterator) -> Vec { - let mut seen = std::collections::HashSet::new(); - names.into_iter().filter(|n| seen.insert(n.clone())).collect() -} - -// ── 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 { - 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 { - let mut out: Vec = 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 { - 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 = VecDeque::new(); - let mut month_name: Option = None; - let mut cur = String::new(); - let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { - 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, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { - 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::().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, 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::().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 = 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 = text.chars().collect(); - let m: Vec = 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 { - 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 { - 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(), - }) -} - -/// A token of simplified field notation. -#[derive(Clone, Debug, PartialEq)] -enum Notation { - /// A number, as written. - Number, - /// `+ - * / ( )`. - Op(char), - /// A field name, its `\` escapes undone. - Name(String), -} - -/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates -/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. -fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { - let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); - let mut out = Vec::new(); - let mut cs = src.char_indices().peekable(); - while let Some(&(start, c)) = cs.peek() { - let mut end = start + c.len_utf8(); - if c.is_whitespace() { - cs.next(); - } else if breaks(c) { - cs.next(); - out.push((start..end, Notation::Op(c))); - } else if c.is_ascii_digit() || c == '.' { - while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { - end = i + d.len_utf8(); - } - out.push((start..end, Notation::Number)); - } else { - let mut name = String::new(); - while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { - end = i + n.len_utf8(); - if n == '\\' - && let Some((j, next)) = cs.next() - { - end = j + next.len_utf8(); - name.push(next); - } else { - name.push(n); - } - } - out.push((start..end, Notation::Name(name))); - } - } - out -} - -/// A field name as simplified field notation writes it: `\` before a space, an operator or a -/// `\`, and before a first character that would start a number. -fn notation_name(name: &str) -> String { - let mut out = String::new(); - for (i, c) in name.chars().enumerate() { - if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { - out.push('\\'); - } - out.push(c); - } - out -} - -/// `src` with each range replaced. The ranges come in order and don't overlap; one that does -/// is left out rather than written over another. -fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { - let mut out = String::with_capacity(src.len()); - let mut done = 0; - for (range, with) in edits { - let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; - out.push_str(kept); - out.push_str(&with); - done = range.end; - } - out.push_str(src.get(done..).unwrap_or_default()); - out -} - -/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. -pub fn notation_js(expr: &str) -> String { - let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { - Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), - _ => None, - }); - splice(expr, names) -} - -/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. -fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { - #[derive(Debug, Clone, PartialEq)] - enum T { - N(f64), - Op(char), - } - let toks = notation_tokens(src) - .into_iter() - .map(|(range, t)| match t { - Notation::Number => src.get(range)?.parse().ok().map(T::N), - Notation::Op(c) => Some(T::Op(c)), - Notation::Name(name) => Some(T::N(value_of(&name))), - }) - .collect::>>()?; - // Recursive descent, `d` levels deep (at most MAX_NESTING). - fn expr(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = term(t, i, d)?; - while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { - *i += 1; - let r = term(t, i, d)?; - v = if *o == '+' { v + r } else { v - r }; - } - Some(v) - } - fn term(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = factor(t, i, d)?; - while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { - *i += 1; - let r = factor(t, i, d)?; - v = if *o == '*' { v * r } else { v / r }; - } - Some(v) - } - fn factor(t: &[T], i: &mut usize, d: usize) -> Option { - if d > MAX_NESTING { - return None; - } - match t.get(*i)? { - T::N(n) => { - *i += 1; - Some(*n) - } - T::Op('-') => { - *i += 1; - factor(t, i, d + 1).map(|v| -v) - } - T::Op('(') => { - *i += 1; - let v = expr(t, i, d + 1)?; - (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; - Some(v) - } - _ => None, - } - } - let mut i = 0; - let v = expr(&toks, &mut i, 0)?; - (i == toks.len() && v.is_finite()).then_some(v) -} - -/// The Calculate event: the new value, given the current values of the other fields -/// (`values(name)` returns every field named `name` or below it, for groups). -pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { - let num = |s: &String| make_number(s).unwrap_or(0.0); - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => { - let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); - let v = match op { - CalcOp::Sum => xs.iter().sum(), - CalcOp::Product => xs.iter().product(), - CalcOp::Average => { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f64 - } - } - CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), - CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - Some(if v.is_finite() { js_number(v) } else { "0".into() }) - } - Calculate::Notation(expr) => { - let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; - Some(js_number(v)) - } - } -} - #[cfg(test)] mod tests; diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs new file mode 100644 index 000000000..b3a1524a4 --- /dev/null +++ b/crates/forms/src/af/calc.rs @@ -0,0 +1,514 @@ +use super::format::{js_number, make_number}; +use super::script::{MAX_NESTING, Parser, js_str}; +use super::*; + +// ── renaming fields in calculations ───────────────────────────────────────────────────────── + +/// A JavaScript token, as far as finding the field names a calculation reads needs. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Js { + Ident, + /// A string literal, its escapes undone. + Str(String), + Punct(char), + /// A number, a regular expression or a template literal. + Other, +} + +/// A [`Js`] token with its byte range in the script. +type Token = (Range, Js); + +/// Words after which `/` starts a regular expression instead of dividing. +const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; + +/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a +/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so +/// a `/` after it starts the next statement, and can't divide. +const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; + +/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. +fn line_ends_at(s: &[u8], j: usize) -> bool { + matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) +} + +/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment +/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, +/// comments and regular expressions. The tokens stop where a string, comment or template literal +/// never ends; a comment that never ends runs to the end of the script. +pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { + let s = js.as_bytes(); + // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except + // U+2028 and U+2029, which end a line: a range never ends inside a character. + let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); + let mut out: Vec = Vec::new(); + let mut comments = Vec::new(); + let mut i = 0; + // Where a regular expression that never ended stopped being read: every `/` before it + // divides, so a line of them is read once, not once for each. + let mut divides_until = 0; + // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or + // `with`; and whether the last token is the `)` that closed one. + let mut conditions: Vec = Vec::new(); + let mut after_condition = false; + 'scan: while let Some(&c) = s.get(i) { + let start = i; + let token = match c { + _ if c.is_ascii_whitespace() => { + i += 1; + continue; + } + // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a + // time, the rest of it would be read from inside the character. + 0xE2 if line_ends_at(s, i) => { + i += 3; + continue; + } + b'/' if s.get(i + 1) == Some(&b'/') => { + i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); + comments.push(start + 2..i); + continue; + } + b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { + Some(n) => { + comments.push(i + 2..i + 2 + n); + i += n + 4; + continue; + } + None => { + comments.push(i + 2..s.len()); + break 'scan; + } + }, + b'"' | b'\'' => { + let mut p = Parser { s, i, depth: 0 }; + let Some(text) = p.string() else { break 'scan }; + i = p.i; + Js::Str(text) + } + b'`' => { + let mut j = i + 1; + loop { + match s.get(j) { + None => break 'scan, + Some(b'\\') => j += 2, + Some(b'`') => break, + Some(_) => j += 1, + } + } + i = j + 1; + Js::Other + } + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + // A regular expression ends at a `/` outside a class; a line break first means it + // was a division after all. + let (mut j, mut class) = (i + 1, false); + let end = loop { + match s.get(j) { + None => break None, + Some(_) if line_ends_at(s, j) => break None, + Some(b'\\') => j += 2, + Some(b'/') if !class => break Some(j), + Some(b) => { + class = match b { + b'[' => true, + b']' => false, + _ => class, + }; + j += 1; + } + } + }; + match end { + Some(e) => { + i = e + 1; + while word(i) { + i += 1; + } + Js::Other + } + None => { + divides_until = j; + i += 1; + Js::Punct('/') + } + } + } + _ if c.is_ascii_digit() => { + while word(i) || s.get(i) == Some(&b'.') { + i += 1; + } + Js::Other + } + _ if word(i) => { + while word(i) { + i += 1; + } + Js::Ident + } + _ => { + i += 1; + Js::Punct(char::from(c)) + } + }; + after_condition = match token { + Js::Punct('(') => { + conditions.push(opens_condition(js, &out)); + false + } + Js::Punct(')') => conditions.pop().unwrap_or(false), + _ => false, + }; + out.push((start..i, token)); + } + (out, comments) +} + +/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening +/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not +/// after a value. `after_condition` says the last token is the `)` that ends such a condition. +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { + match tokens.last() { + None => true, + Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((_, Js::Punct(')'))) => after_condition, + Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), + Some((_, Js::Str(_) | Js::Other)) => false, + } +} + +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// the arguments of a method that has one of those names (`o.if(x)`). +fn opens_condition(js: &str, tokens: &[Token]) -> bool { + let mut back = tokens.iter().rev(); + let Some((range, Js::Ident)) = back.next() else { return false }; + js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) +} + +/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the +/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it +/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. +pub(super) fn notation_range(js: &str, comments: &[Range]) -> Option> { + comments.iter().find_map(|comment| { + let text = js.get(comment.clone())?; + let a = text.find("BVCALC")? + 6; + let b = a + text.get(a..)?.find("EVCALC")?; + Some(comment.start + a..comment.start + b) + }) +} + +/// A place where a Calculate script names a field. +#[derive(Clone, Debug, PartialEq)] +enum Site { + /// A string literal holding one name: `getField("a")`, or an item of an + /// `AFSimple_Calculate` list. + Literal(String), + /// An `AFSimple_Calculate` list written as one string of comma-separated names. + Commas(String), + /// A name in the simplified field notation of a `BVCALC` comment. + Notation(String), +} + +impl Site { + fn names(&self) -> Vec<&str> { + match self { + Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], + Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + } + } + + /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. + fn renamed(&self, old: &str, new: &str) -> Option { + match self { + Site::Literal(name) => (name == old).then(|| js_str(new)), + Site::Notation(name) => (name == old).then(|| notation_name(new)), + Site::Commas(list) => { + let parts: Vec<&str> = list.split(',').collect(); + parts.iter().any(|p| p.trim() == old).then(|| { + // The spaces around each name stay as they were. + let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); + js_str(&parts.join(",")) + }) + } + } + } +} + +/// Where a Calculate script names fields, in order: the simplified field notation in its comment +/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. +/// A name built at run time (`getField("row" + i)`) is not a site. +fn calculation_sites(js: &str) -> Vec<(Range, Site)> { + let mut sites = Vec::new(); + let (tokens, comments) = js_tokens(js); + if let Some(at) = notation_range(js, &comments) + && let Some(expr) = js.get(at.clone()) + { + for (range, token) in notation_tokens(expr) { + if let Notation::Name(name) = token { + sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); + } + } + } + let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); + let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); + let string = |k: usize| match tokens.get(k) { + Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), + _ => None, + }; + for k in 0..tokens.len() { + if ident(k, "getField") + && punct(k + 1, '(') + && punct(k + 3, ')') + && let Some((range, name)) = string(k + 2) + { + sites.push((range, Site::Literal(name))); + } + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { + continue; + } + if let Some((range, list)) = string(k + 4) { + sites.push((range, Site::Commas(list))); + continue; + } + // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. + // Nothing else is read, so no token is read for two calls. + let first = if punct(k + 4, '[') { + k + 5 + } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { + k + 7 + } else { + continue; + }; + for (range, token) in tokens.iter().skip(first) { + match token { + Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), + Js::Punct(',') => {} + _ => break, + } + } + } + sites.sort_by_key(|(range, _)| range.start); + sites +} + +/// A Calculate script with field `old` named `new` everywhere it reads it (see +/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also +/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end +/// the notation early), so a script is never left meaning something else. +pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { + let sites = calculation_sites(js); + let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); + if edits.is_empty() { + return None; + } + let out = splice(js, edits); + let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); + let written = calculation_sites(&out); + let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); + (got == expected).then_some(out) +} + +/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the +/// order it first names them. +pub fn calculation_reads(js: &str) -> Vec { + let sites = calculation_sites(js); + unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) +} + +impl Calculate { + /// The fields it reads, each once, in the order it first names them. A name also stands for + /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. + pub fn reads(&self) -> Vec { + match self { + Calculate::None => Vec::new(), + Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { + Notation::Name(name) => Some(name), + _ => None, + })), + } + } +} + +/// `names` without repeats, in order. +pub(super) fn unique(names: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + names.into_iter().filter(|n| seen.insert(n.clone())).collect() +} + +/// A token of simplified field notation. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Notation { + /// A number, as written. + Number, + /// `+ - * / ( )`. + Op(char), + /// A field name, its `\` escapes undone. + Name(String), +} + +/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates +/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. +pub(super) fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { + let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); + let mut out = Vec::new(); + let mut cs = src.char_indices().peekable(); + while let Some(&(start, c)) = cs.peek() { + let mut end = start + c.len_utf8(); + if c.is_whitespace() { + cs.next(); + } else if breaks(c) { + cs.next(); + out.push((start..end, Notation::Op(c))); + } else if c.is_ascii_digit() || c == '.' { + while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { + end = i + d.len_utf8(); + } + out.push((start..end, Notation::Number)); + } else { + let mut name = String::new(); + while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { + end = i + n.len_utf8(); + if n == '\\' + && let Some((j, next)) = cs.next() + { + end = j + next.len_utf8(); + name.push(next); + } else { + name.push(n); + } + } + out.push((start..end, Notation::Name(name))); + } + } + out +} + +/// A field name as simplified field notation writes it: `\` before a space, an operator or a +/// `\`, and before a first character that would start a number. +pub(super) fn notation_name(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// `src` with each range replaced. The ranges come in order and don't overlap; one that does +/// is left out rather than written over another. +fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { + let mut out = String::with_capacity(src.len()); + let mut done = 0; + for (range, with) in edits { + let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; + out.push_str(kept); + out.push_str(&with); + done = range.end; + } + out.push_str(src.get(done..).unwrap_or_default()); + out +} + +/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: +/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +pub fn notation_js(expr: &str) -> String { + let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { + Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + _ => None, + }); + splice(expr, names) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. +pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { + #[derive(Debug, Clone, PartialEq)] + enum T { + N(f64), + Op(char), + } + let toks = notation_tokens(src) + .into_iter() + .map(|(range, t)| match t { + Notation::Number => src.get(range)?.parse().ok().map(T::N), + Notation::Op(c) => Some(T::Op(c)), + Notation::Name(name) => Some(T::N(value_of(&name))), + }) + .collect::>>()?; + // Recursive descent, `d` levels deep (at most MAX_NESTING). + fn expr(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = term(t, i, d)?; + while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { + *i += 1; + let r = term(t, i, d)?; + v = if *o == '+' { v + r } else { v - r }; + } + Some(v) + } + fn term(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = factor(t, i, d)?; + while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { + *i += 1; + let r = factor(t, i, d)?; + v = if *o == '*' { v * r } else { v / r }; + } + Some(v) + } + fn factor(t: &[T], i: &mut usize, d: usize) -> Option { + if d > MAX_NESTING { + return None; + } + match t.get(*i)? { + T::N(n) => { + *i += 1; + Some(*n) + } + T::Op('-') => { + *i += 1; + factor(t, i, d + 1).map(|v| -v) + } + T::Op('(') => { + *i += 1; + let v = expr(t, i, d + 1)?; + (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; + Some(v) + } + _ => None, + } + } + let mut i = 0; + let v = expr(&toks, &mut i, 0)?; + (i == toks.len() && v.is_finite()).then_some(v) +} + +/// The Calculate event: the new value, given the current values of the other fields +/// (`values(name)` returns every field named `name` or below it, for groups). +pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { + let num = |s: &String| make_number(s).unwrap_or(0.0); + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => { + let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + let v = match op { + CalcOp::Sum => xs.iter().sum(), + CalcOp::Product => xs.iter().product(), + CalcOp::Average => { + if xs.is_empty() { + 0.0 + } else { + xs.iter().sum::() / xs.len() as f64 + } + } + CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + Some(if v.is_finite() { js_number(v) } else { "0".into() }) + } + Calculate::Notation(expr) => { + let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; + Some(js_number(v)) + } + } +} diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs new file mode 100644 index 000000000..df4720bec --- /dev/null +++ b/crates/forms/src/af/format.rs @@ -0,0 +1,533 @@ +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 { + 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 { + let mut out: Vec = 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 { + 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 = VecDeque::new(); + let mut month_name: Option = None; + let mut cur = String::new(); + let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { + 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, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { + 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::().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, 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::().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 = 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 = text.chars().collect(); + let m: Vec = 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 { + 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 { + 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(), + }) +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs new file mode 100644 index 000000000..c807f2d7d --- /dev/null +++ b/crates/forms/src/af/script.rs @@ -0,0 +1,340 @@ +use super::calc::{js_tokens, notation_js, notation_range}; +use super::format::{DATE_PRESETS, TIME_PRESETS}; +use super::*; + +// ── parsing scripts ───────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Arg { + Num(f64), + Str(String), + Bool(bool), + List(Vec), +} + +impl Arg { + fn num(&self) -> Option { + match self { + Arg::Num(n) => Some(*n), + Arg::Bool(b) => Some(f64::from(u8::from(*b))), + Arg::Str(s) => s.trim().parse().ok(), + Arg::List(_) => None, + } + } + + pub(super) fn string(&self) -> Option { + match self { + Arg::Str(s) => Some(s.clone()), + Arg::Num(n) => Some(n.to_string()), + _ => None, + } + } + + fn truthy(&self) -> bool { + match self { + Arg::Bool(b) => *b, + Arg::Num(n) => *n != 0.0, + Arg::Str(s) => !s.is_empty(), + Arg::List(l) => !l.is_empty(), + } + } +} + +/// How deeply script arguments and field notation may nest; deeper input is rejected rather +/// than recursed into (a hostile file could otherwise overflow the stack). +pub(super) const MAX_NESTING: usize = 64; + +fn hex4(bytes: &[u8]) -> Option { + if bytes.len() != 4 { + return None; + } + bytes.iter().try_fold(0u16, |value, byte| { + let digit = match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => return None, + }; + value.checked_mul(16)?.checked_add(u16::from(digit)) + }) +} + +pub(super) struct Parser<'a> { + pub(super) s: &'a [u8], + pub(super) i: usize, + pub(super) depth: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { + self.i += 1; + } + } + + pub(super) fn string(&mut self) -> Option { + let q = self.s[self.i]; + self.i += 1; + let mut out = Vec::new(); + while self.i < self.s.len() { + let c = self.s[self.i]; + self.i += 1; + match c { + b'\\' if self.i < self.s.len() => { + let e = self.s[self.i]; + self.i += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(0x08), + b'f' => out.push(0x0C), + b'v' => out.push(0x0B), + b'0' => out.push(0), + b'x' => { + let digits = self.s.get(self.i..self.i.checked_add(2)?)?; + let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; + self.i += 2; + let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + b'u' => { + let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; + self.i += 4; + let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { + let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; + if (0xDC00..=0xDFFF).contains(&low) { + self.i += 6; + 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) + } else { + u32::from(code) + } + } else { + u32::from(code) + }; + let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + other => out.push(other), + } + } + c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), + c => out.push(c), + } + } + None + } + + fn arg(&mut self) -> Option { + self.ws(); + let c = *self.s.get(self.i)?; + match c { + b'"' | b'\'' => self.string().map(Arg::Str), + b'[' => { + self.i += 1; + self.nested(b']').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"new Array") => { + self.i += b"new Array".len(); + self.ws(); + if self.s.get(self.i) != Some(&b'(') { + return None; + } + self.i += 1; + self.nested(b')').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"true") => { + self.i += 4; + Some(Arg::Bool(true)) + } + _ if self.s[self.i..].starts_with(b"false") => { + self.i += 5; + Some(Arg::Bool(false)) + } + _ => { + let start = self.i; + while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { + self.i += 1; + } + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + } + } + } + + /// A list inside another, at most [`MAX_NESTING`] deep. + fn nested(&mut self, close: u8) -> Option> { + if self.depth >= MAX_NESTING { + return None; + } + self.depth += 1; + let out = self.list(close); + self.depth -= 1; + out + } + + fn list(&mut self, close: u8) -> Option> { + let mut out = Vec::new(); + loop { + self.ws(); + if self.s.get(self.i) == Some(&close) { + self.i += 1; + return Some(out); + } + out.push(self.arg()?); + self.ws(); + match self.s.get(self.i) { + Some(b',') => self.i += 1, + Some(c) if *c == close => { + self.i += 1; + return Some(out); + } + _ => return None, + } + } + } +} + +/// The first call to `name(` in `js` and its arguments. +pub(super) fn call(js: &str, name: &str) -> Option> { + let at = js.find(&format!("{name}("))?; + // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). + if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { + return None; + } + let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; + p.list(b')') +} + +pub fn parse_format(js: &str) -> Option { + let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; + if let Some(args) = call(js, "AFNumber_Format") { + return Some(Format::Number { + decimals: a(&args, 0), + sep: a(&args, 1), + neg: a(&args, 2), + currency: args.get(4).and_then(Arg::string).unwrap_or_default(), + prepend: args.get(5).is_none_or(Arg::truthy), + }); + } + if let Some(args) = call(js, "AFPercent_Format") { + return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); + } + if let Some(args) = call(js, "AFDate_FormatEx") { + return Some(Format::Date(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFDate_Format") { + return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFTime_FormatEx") { + return Some(Format::Time(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFTime_Format") { + return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFSpecial_Format") { + return Some(Format::Special(a(&args, 0).min(3))); + } + if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { + return Some(Format::Mask(args.first().and_then(Arg::string)?)); + } + None +} + +pub fn parse_validate(js: &str) -> Option { + let args = call(js, "AFRange_Validate")?; + let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); + let num = |i: usize| args.get(i).and_then(Arg::num); + Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) +} + +pub fn parse_calculate(js: &str) -> Option { + // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for + // its comments only when it spells the marker. + if js.contains("BVCALC") + && let Some(range) = notation_range(js, &js_tokens(js).1) + { + return Some(Calculate::Notation(js.get(range)?.trim().to_string())); + } + let args = call(js, "AFSimple_Calculate")?; + let op = match args.first().and_then(Arg::string)?.as_str() { + "SUM" => CalcOp::Sum, + "PRD" => CalcOp::Product, + "AVG" => CalcOp::Average, + "MIN" => CalcOp::Minimum, + "MAX" => CalcOp::Maximum, + _ => return None, + }; + let fields = match args.get(1)? { + Arg::List(l) => l.iter().filter_map(Arg::string).collect(), + Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), + _ => return None, + }; + Some(Calculate::Simple { op, fields }) +} + +// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── + +pub(super) fn js_str(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' | '\\' => { + o.push('\\'); + o.push(c); + } + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if c.is_ascii() && !c.is_control() => o.push(c), + c => { + let mut units = [0u16; 2]; + for unit in c.encode_utf16(&mut units) { + o.push_str(&format!("\\u{unit:04X}")); + } + } + } + } + o.push('"'); + o +} + +/// The (format, keystroke) scripts for a format. +pub fn format_js(f: &Format) -> Option<(String, String)> { + Some(match f { + Format::None => return None, + Format::Number { decimals, sep, neg, currency, prepend } => { + let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); + (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) + } + Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), + Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), + Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), + Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), + Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), + }) +} + +pub fn validate_js(v: &Validate) -> Option { + match v { + Validate::None => None, + Validate::Range { min, max } => { + Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) + } + } +} + +pub fn calculate_js(c: &Calculate) -> Option { + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => Some(format!( + "AFSimple_Calculate({}, new Array({}));", + js_str(op.code()), + fields.iter().map(|f| js_str(f)).collect::>().join(", ") + )), + // Acrobat's own translation follows the comment, so a viewer that runs the script instead + // of reading the comment computes the same value (a placeholder here showed 0). + Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), + } +}