mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
682 lines
29 KiB
Rust
682 lines
29 KiB
Rust
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; 12] = ["return", "typeof", "case", "do", "else", "in", "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"];
|
||
|
||
/// Context of an open delimiter. Only the left side of a for header can introduce `of`.
|
||
#[derive(Clone, Copy)]
|
||
enum Delimiter {
|
||
Group,
|
||
Condition,
|
||
For { left: bool },
|
||
}
|
||
|
||
/// 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;
|
||
// Nested groups hide the surrounding for header: an `of` in a destructuring default
|
||
// or nested expression is an identifier, not the for-of separator.
|
||
let mut delimiters: Vec<(char, Delimiter)> = Vec::new();
|
||
let mut after_condition = false;
|
||
let mut after_for_of = 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'\\') => {
|
||
let Some(step) = js.get(j + 1..).and_then(|r| r.chars().next()).map(char::len_utf8) else {
|
||
break 'scan;
|
||
};
|
||
j += 1 + step;
|
||
}
|
||
Some(b'`') => break,
|
||
Some(_) => j += 1,
|
||
}
|
||
}
|
||
i = j + 1;
|
||
Js::Other
|
||
}
|
||
b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition, after_for_of) => {
|
||
// 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'\\') => {
|
||
if s.get(j + 1).is_none() || line_ends_at(s, j + 1) {
|
||
j += 1;
|
||
break None;
|
||
}
|
||
let step = js.get(j + 1..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
|
||
j += 1 + step;
|
||
}
|
||
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('/')
|
||
}
|
||
}
|
||
}
|
||
b'+' | b'-'
|
||
if s.get(i + 1) == Some(&c)
|
||
&& !regex_may_follow(js, &out, after_condition, after_for_of)
|
||
&& out.last().is_some_and(|(range, _)| !(range.end..i).any(|j| line_ends_at(s, j))) =>
|
||
{
|
||
// Postfix update ends an expression: a following slash divides. A line
|
||
// terminator forbids postfix syntax, including one inside a comment.
|
||
i += 2;
|
||
Js::Other
|
||
}
|
||
_ 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))
|
||
}
|
||
};
|
||
let token = (start..i, token);
|
||
(after_condition, after_for_of) = advance_context(js, &token, &out, &mut delimiters);
|
||
out.push(token);
|
||
}
|
||
(out, comments)
|
||
}
|
||
|
||
/// Advance only the delimiter/for-header context after a token has been classified.
|
||
/// Returns whether it closes a statement's condition or is the for-of separator.
|
||
fn advance_context(js: &str, token: &Token, tokens: &[Token], delimiters: &mut Vec<(char, Delimiter)>) -> (bool, bool) {
|
||
let mut after_for_of = false;
|
||
if let Some((_, Delimiter::For { left })) = delimiters.last_mut() {
|
||
if matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("of") && *left && for_target_ends(js, tokens) {
|
||
*left = false;
|
||
after_for_of = true;
|
||
} else if matches!(token.1, Js::Punct(';' | '='))
|
||
|| (matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("in") && !matches!(tokens.last(), Some((_, Js::Punct('.')))))
|
||
{
|
||
*left = false;
|
||
}
|
||
}
|
||
let mut after_condition = false;
|
||
match token.1 {
|
||
Js::Punct('(') => delimiters.push((')', opens_condition(js, tokens))),
|
||
Js::Punct('[') => delimiters.push((']', Delimiter::Group)),
|
||
Js::Punct('{') => delimiters.push(('}', Delimiter::Group)),
|
||
Js::Punct(close @ (')' | ']' | '}')) if delimiters.last().is_some_and(|(end, _)| *end == close) => {
|
||
after_condition = matches!(delimiters.pop(), Some((_, Delimiter::Condition | Delimiter::For { .. })));
|
||
}
|
||
_ => {}
|
||
}
|
||
(after_condition, after_for_of)
|
||
}
|
||
|
||
/// 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, after_for_of: bool) -> bool {
|
||
match tokens.last() {
|
||
None => true,
|
||
Some((range, Js::Ident)) => {
|
||
!matches!(tokens.get(tokens.len().wrapping_sub(2)), Some((_, Js::Punct('.'))))
|
||
&& js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w) || (w == "of" && after_for_of))
|
||
}
|
||
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` (including
|
||
/// `for await`) 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]) -> Delimiter {
|
||
let mut back = tokens.iter().rev();
|
||
let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group };
|
||
let mut keyword = js.get(range.clone());
|
||
if keyword == Some("await") {
|
||
let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group };
|
||
keyword = js.get(range.clone());
|
||
if keyword != Some("for") {
|
||
return Delimiter::Group;
|
||
}
|
||
}
|
||
if matches!(back.next(), Some((_, Js::Punct('.')))) {
|
||
return Delimiter::Group;
|
||
}
|
||
match keyword {
|
||
Some("for") => Delimiter::For { left: true },
|
||
Some(w) if BEFORE_CONDITION.contains(&w) => Delimiter::Condition,
|
||
_ => Delimiter::Group,
|
||
}
|
||
}
|
||
|
||
/// A binding/assignment target before the for-of separator, not a declaration keyword,
|
||
/// expression-opening operator, or the start of the header itself.
|
||
fn for_target_ends(js: &str, tokens: &[Token]) -> bool {
|
||
match tokens.last() {
|
||
Some((range, Js::Ident)) => !matches!(js.get(range.clone()), Some("var" | "let" | "const")),
|
||
Some((_, Js::Punct(']' | '}' | ')'))) => true,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
/// A field read by a name built at run time: a `getField` whose argument is more than one
|
||
/// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't
|
||
/// written out. Which field it is isn't known.
|
||
Built,
|
||
}
|
||
|
||
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(),
|
||
Site::Built => Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// 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(","))
|
||
})
|
||
}
|
||
Site::Built => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 a [`Site::Built`], at its `getField` or
|
||
/// `AFSimple_Calculate`.
|
||
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,
|
||
};
|
||
let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built));
|
||
for k in 0..tokens.len() {
|
||
if ident(k, "getField") && punct(k + 1, '(') {
|
||
match string(k + 2) {
|
||
Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))),
|
||
_ => sites.extend(built(k)),
|
||
}
|
||
}
|
||
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) {
|
||
continue;
|
||
}
|
||
if !(string(k + 2).is_some() && punct(k + 3, ',')) {
|
||
sites.extend(built(k));
|
||
continue;
|
||
}
|
||
// One string of comma-separated names, when it is the whole list (not `"row" + suffix`).
|
||
// An argument after it leaves the list as written, as after an array: its names are read,
|
||
// and what follows is a name built at run time.
|
||
if let Some((range, list)) = string(k + 4) {
|
||
if punct(k + 5, ')') || punct(k + 5, ',') {
|
||
sites.push((range, Site::Commas(list)));
|
||
}
|
||
if !punct(k + 5, ')') {
|
||
sites.extend(built(k));
|
||
}
|
||
continue;
|
||
}
|
||
// `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has
|
||
// to end the call. An item that is more than a string (`"row" + i`), or anything after
|
||
// the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no
|
||
// token is read for two calls.
|
||
let (first, close) = if punct(k + 4, '[') {
|
||
(k + 5, ']')
|
||
} else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
|
||
(k + 7, ')')
|
||
} else {
|
||
sites.extend(built(k));
|
||
continue;
|
||
};
|
||
let mut j = first;
|
||
loop {
|
||
match tokens.get(j) {
|
||
// An item ends at a comma or the list's end, or where a script cut short ends.
|
||
Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => {
|
||
sites.push((range.clone(), Site::Literal(name.clone())));
|
||
j += 1;
|
||
}
|
||
Some((_, Js::Punct(','))) => j += 1,
|
||
Some((_, Js::Punct(c))) if *c == close => {
|
||
if !punct(j + 1, ')') {
|
||
sites.extend(built(k));
|
||
}
|
||
break;
|
||
}
|
||
_ => {
|
||
sites.extend(built(k));
|
||
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))
|
||
}
|
||
|
||
/// Whether a Calculate script also reads fields by names it builds at run time
|
||
/// (`getField("row" + i)`), which [`calculation_reads`] can't list.
|
||
pub fn reads_built_names(js: &str) -> bool {
|
||
calculation_sites(js).iter().any(|(_, site)| *site == Site::Built)
|
||
}
|
||
|
||
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)`; adjacent signs
|
||
/// are separated so binary plus/minus followed by a unary sign cannot become an update.
|
||
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)))),
|
||
Notation::Op(sign @ ('+' | '-')) if expr.get(range.end..).is_some_and(|rest| rest.starts_with(sign)) => Some((range, format!("{sign} "))),
|
||
_ => 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)
|
||
}
|
||
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)
|
||
}
|
||
|
||
/// Most field values one `AFSimple_Calculate` list may expand to (a form has some thousands).
|
||
const MAX_EXPANDED_VALUES: usize = 1 << 20;
|
||
|
||
/// 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,
|
||
// A product of no values is 0, as an empty sum, average, minimum or maximum is, not the
|
||
// empty product 1: a blank quantity × price row reads 0, and a total over the rows
|
||
// doesn't count it as 1. (The form's `values` leaves out a field that has no value.)
|
||
Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()),
|
||
Calculate::Simple { op, fields } => {
|
||
// The values are folded as they are read, never collected: a group name listed many
|
||
// times would otherwise be held once per listing. A list that expands past
|
||
// MAX_EXPANDED_VALUES is not calculated.
|
||
let mut n = 0usize;
|
||
let xs = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).inspect(|_| n += 1).take(MAX_EXPANDED_VALUES + 1);
|
||
let v = match op {
|
||
CalcOp::Sum | CalcOp::Average => xs.sum(),
|
||
CalcOp::Product => xs.product(),
|
||
CalcOp::Minimum => xs.fold(f64::INFINITY, f64::min),
|
||
CalcOp::Maximum => xs.fold(f64::NEG_INFINITY, f64::max),
|
||
};
|
||
if n > MAX_EXPANDED_VALUES {
|
||
return None;
|
||
}
|
||
let v = if *op == CalcOp::Average { if n == 0 { 0.0 } else { v / n as f64 } } else { v };
|
||
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 {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn regex_scan_does_not_cross_line_break_after_backslash_or_split_utf8() {
|
||
let js = "x = /a\\\nthis.getField(\"price\").value / 2;";
|
||
assert_eq!(calculation_reads(js), vec!["price".to_owned()]);
|
||
|
||
let js_utf8 = "var r = /\\é/; event.value = this.getField(\"tax\").value;";
|
||
assert_eq!(calculation_reads(js_utf8), vec!["tax".to_owned()]);
|
||
|
||
let trailing_backslash = "event.value = this.getField(\"subtotal\").value + /unterminated\\";
|
||
assert_eq!(calculation_reads(trailing_backslash), vec!["subtotal".to_owned()]);
|
||
let template_trailing = "event.value = this.getField(\"shipping\").value + `unterminated\\";
|
||
assert_eq!(calculation_reads(template_trailing), vec!["shipping".to_owned()]);
|
||
}
|
||
|
||
#[test]
|
||
fn eval_notation_supports_unary_plus() {
|
||
let c = Calculate::Notation("+a + (+b)".to_owned());
|
||
let out = calculate(&c, &|name| match name {
|
||
"a" => vec!["3".to_owned()],
|
||
"b" => vec!["4".to_owned()],
|
||
_ => Vec::new(),
|
||
});
|
||
assert_eq!(out.as_deref(), Some("7"));
|
||
}
|
||
}
|