refactor(forms): split af.rs into af/

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