From 87f635cf657747281277ecda5cae2f803ad7233f Mon Sep 17 00:00:00 2001 From: ysrdevs <32823553+ysrdevs@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:06:33 -0500 Subject: [PATCH] XFA: run FormCalc scripts (#301) * XFA: run FormCalc scripts A FormCalc interpreter (XFA 3.3 part 8) in the js crate, over the same form table, effects and budgets as the XFA JavaScript model, so the engine runs both languages alike: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Get/Post/Put refuse. The JavaScript model's XHost, HNode, SOM resolution and instance operations are shared (pub(crate)) with Context-free add_instance and remove_instance that the boa wrappers call. Bounded like the JavaScript path: steps, loop iterations, recursion, parse depth, string and list sizes, clamped dates, the instance cap, and the per-activity time budget with abandonment. Tests: language, built-ins, control flow, form objects and hostile scripts in the js crate; a FormCalc fixture form in the engine that initializes, calculates, validates, adds and removes rows, shows a subform and round-trips through save; hostile scripts at the engine level. parity form.xfa-formcalc planned -> partial. Hardened after two adversarial review passes: statement nesting and chain length count against the limits (verified on a 1 MiB stack), the script honours the engine's deadline itself, strings are capped after every built-in and messages/values clipped and noted (shared host), lists hold each object once and the shared SOM resolver deduplicates, values go into fields only (shared host, JavaScript too), saturating Stuff and time arithmetic, FormCalc-specific size check instead of the JavaScript refuse scan. Semantics: bare row[*] and row[n] from inside a row see every row, relative [-n]/[+n] indexes, Count of non-null, Parse with a date picture gives the ISO date, assignment is an expression. * form.xfa-formcalc: bound FormCalc values, results, errors and copying (#301 review) - XHost::set_value cuts a value to 64 Ki chars before storing it, so later reads match what is saved and the snapshot stays bounded. - A script's completion value (calculate result) is cut to 64 Ki chars with a note, in both the FormCalc and the JavaScript paths. - throw clips its value like any message. - Copying, dereferencing and comparing long strings is charged to the step budget (len/256), so wasm (no clock) can't spend unbounded time on it; OneOf charges per comparison. - Exists propagates step/deadline/depth/size limits, answering 0 only for lookup failures. - IPmt/PPmt charge a step per period. - The parser counts each top-level expression's chain from zero, so an elseif condition no longer inherits the previous arm's count. - Comment on the interpreter's depth limit fixed; parity notes say memory isn't capped and Uuid/Date/Time are deterministic on wasm. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/src/tools.rs | 2 +- crates/engine/src/tests.rs | 92 +- crates/engine/src/xfa.rs | 17 +- crates/js/src/formcalc.rs | 2569 ++++++++++++++++++++++++++++++++ crates/js/src/lib.rs | 3 +- crates/js/src/tests.rs | 421 +++++- crates/js/src/xfa.rs | 343 +++-- crates/xfa/README.md | 7 +- crates/xfa/src/fixtures.rs | 48 + parity/acrobat-features.toml | 12 +- 10 files changed, 3367 insertions(+), 147 deletions(-) create mode 100644 crates/js/src/formcalc.rs diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 641a5917..2588efaa 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -775,7 +775,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0ddcedb6..6689667c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1700,10 +1700,10 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { s.run_javascript(id, "", Some("removeRow")).unwrap(); assert!(!names(&s).iter().any(|n| n == "amount_2")); assert_eq!(value(&s, "grand"), vec!["10".to_string()]); - // FormCalc is reported, not run. + // FormCalc scripts run too. let o = s.run_javascript(id, "", Some("legacy")).unwrap(); - assert!(o.error.as_deref().is_some_and(|e| e.contains("FormCalc")), "{o:?}"); - assert!(o.alerts.is_empty()); + assert_eq!(o.error, None); + assert_eq!(o.alerts, vec!["FormCalc".to_string()]); // Saved and reopened: the visible subform, the values and the rows are what they were. let saved = s.save_bytes(id).unwrap(); let id2 = s.open("again.pdf", None, saved, None).unwrap(); @@ -1715,6 +1715,92 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { assert!(!d2.dirty); } +#[test] +fn dynamic_xfa_forms_run_formcalc_scripts() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("formcalc.pdf", None, bytes, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + // Initialize set qty; the calculates ran from it, including the English words. + assert_eq!(value(&s, "qty"), vec!["2".to_string()], "{:?}", s.take_js_output(id)); + assert_eq!(value(&s, "total"), vec!["10".to_string()]); + assert_eq!(value(&s, "words"), vec!["Ten".to_string()]); + assert!(s.take_js_output(id).is_empty()); + // Editing recalculates and validates. + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("7".into()) }).unwrap(); + assert_eq!(value(&s, "total"), vec!["35".to_string()]); + assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert_eq!(value(&s, "qty"), vec!["500".to_string()]); + // Buttons: a row is added, summed and removed; the message box reaches the viewer. + let before = names(&s).len(); + let o = s.run_javascript(id, "", Some("addRow")).unwrap(); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(names(&s).len(), before + 2, "{:?}", names(&s)); + s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap(); + assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); + assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); + s.run_javascript(id, "", Some("removeRow")).unwrap(); + assert!(!names(&s).iter().any(|n| n == "amount_2")); + assert_eq!(value(&s, "grand"), vec!["10".to_string()]); + // Saved and reopened, the form is what it was. + let saved = s.save_bytes(id).unwrap(); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!(d2.form.iter().find(|f| f.name == "words").unwrap().value, vec!["Two Thousand Five Hundred".to_string()]); + assert!(d2.form.iter().any(|f| f.name == "note")); +} + +#[test] +fn hostile_formcalc_scripts_cannot_hang_or_bloat_the_document() { + let tpl = pdfcraft_xfa::fixtures::formcalc_template() + .replace( + "", + "", + ) + .replace("", "") + .replace( + "", + "", + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let started = std::time::Instant::now(); + let id = s.open("hostile.pdf", None, bytes, None).expect("opens"); + let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count(); + assert!(rows(&s) <= 50, "{} rows", rows(&s)); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|l| l.contains("nested")), "{out:?}"); + let o = s.run_javascript(id, "", Some("addRow")).unwrap(); + assert!(o.error.is_some() || rows(&s) <= 50, "{o:?}"); + assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); +} + +#[test] +fn a_formcalc_calculate_result_is_cut_before_it_is_saved() { + let tpl = pdfcraft_xfa::fixtures::formcalc_template() + .replace("", ""); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("long.pdf", None, bytes, None).expect("opens"); + let words = |s: &Session, id| s.get(id).unwrap().form.iter().find(|f| f.name == "words").unwrap().value.concat(); + assert_eq!(words(&s, id).chars().count(), 65_536); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|e| e.contains("cut")), "{out:?}"); + let saved = s.save_bytes(id).unwrap(); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + assert!(words(&s, id2).chars().count() <= 65_536); +} + #[test] fn xfa_scripts_stay_off_with_javascript_off() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 58042f46..50fdec2a 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::sync::Arc; +use pdfcraft_js::formcalc::run_formcalc_within; use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, run_xfa_within}; use pdfcraft_xfa::model::Template; use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent}; @@ -241,7 +242,6 @@ struct Runner<'a, 'b> { tpl: &'a Template, run: &'a mut XfaRun<'b>, clock: Clock, - formcalc_warned: bool, runs: usize, skipped: usize, relayouts: usize, @@ -270,7 +270,6 @@ impl<'a, 'b> Runner<'a, 'b> { tpl, run, clock: Clock::start(budget_ms), - formcalc_warned: false, runs: 0, skipped: 0, relayouts: 0, @@ -581,17 +580,15 @@ impl<'a, 'b> Runner<'a, 'b> { return Ok(None); } self.runs += 1; - if ev.formcalc { - if !self.formcalc_warned { - self.error(format!("{}: FormCalc scripts don't run yet (only JavaScript ones)", ev.som)); - self.formcalc_warned = true; - } - return Ok(Some(Default::default())); - } let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() }; let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count }; let root_js = to_js(&self.live().root); - let o = run_xfa_within(&ev.script, &event, &doc_info, root_js, limits_for(&ev.activity), timeout_for(&ev.activity)); + let (limits, timeout) = (limits_for(&ev.activity), timeout_for(&ev.activity)); + let o = if ev.formcalc { + run_formcalc_within(&ev.script, &event, &doc_info, root_js, limits, timeout) + } else { + run_xfa_within(&ev.script, &event, &doc_info, root_js, limits, timeout) + }; if o.abandoned { self.run.ran_away = true; } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs new file mode 100644 index 00000000..0fc07164 --- /dev/null +++ b/crates/js/src/formcalc.rs @@ -0,0 +1,2569 @@ +//! FormCalc (XFA 3.3 part 8), the default scripting language of older Designer forms: a lexer, +//! a parser and a tree-walking evaluator over the same form table and effects as the JavaScript +//! model ([`crate::xfa`]), so the engine treats both languages alike. +//! +//! Covered: expressions with FormCalc's operators and coercions, `var`, assignment to fields +//! and properties, `if`/`elseif`/`else`, `while`, `for … upto/downto … step`, `foreach`, +//! `func … endfunc`, `break`/`continue`/`return`/`exit`, accessors (`$`, `$host`, `$form`, +//! `$record`, `$layout`, `$event`, `xfa.…`, `..name`, `[n]`, `[*]`, `_name` instance managers), +//! and the arithmetic, logical, string, date/time, financial and unit built-in functions. +//! `Get`, `Post`, `Put` (network) refuse. + +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use crate::Limits; +use crate::xfa::{ + HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, + resolve_som, run_within, +}; + +/// Deepest nesting the parser follows. +const MAX_PARSE_DEPTH: usize = 64; +/// Most statements and expressions evaluated in one run (loops included). +const MAX_STEPS: u64 = 5_000_000; +/// Longest string a script may build. +const MAX_STRING: usize = 1 << 20; +/// Most elements a list may hold. +const MAX_LIST: usize = 100_000; +/// Most operators and accessors in one expression (`a + b + …`, `a.b.c…`, brackets included): +/// the tree is as deep as the chain, and dropping or cloning it recurses. +const MAX_CHAIN: usize = 1000; +// The errors a run stops with when it reaches a limit (rather than a fault in the script). +const RAN_TOO_LONG: &str = "the script ran too long"; +const TOO_DEEP: &str = "the script is nested too deeply"; +const CALLS_TOO_DEEP: &str = "functions are nested too deeply"; +const LOOPED_TOO_MUCH: &str = "the script looped too many times"; +const LIST_TOO_LONG: &str = "a list is too long"; +const STRING_TOO_LONG: &str = "a string is too long"; + +/// Whether `e` is a limit the run reached: those stop the script even where a fault is +/// otherwise an answer (`Exists`). +fn is_limit(e: &str) -> bool { + [RAN_TOO_LONG, TOO_DEEP, CALLS_TOO_DEEP, LOOPED_TOO_MUCH, LIST_TOO_LONG, STRING_TOO_LONG].contains(&e) +} + +/// Steps between looks at the clock. +#[cfg(not(target_arch = "wasm32"))] +const CLOCK_EVERY: u64 = 256; + +// ── lexer ─────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +enum Tok { + Num(f64), + Str(String), + Ident(String), + Op(&'static str), + Newline, + Eof, +} + +#[derive(Clone, Debug, PartialEq)] +struct Token { + tok: Tok, + line: usize, +} + +const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; + +fn lex(src: &str) -> Result, String> { + let chars: Vec = src.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + let mut line = 1; + let mut depth = 0usize; + while i < chars.len() { + let c = chars[i]; + match c { + '\n' => { + line += 1; + i += 1; + // Newlines inside brackets continue the expression. + if depth == 0 { + out.push(Token { tok: Tok::Newline, line }); + } + } + ' ' | '\t' | '\r' => i += 1, + ';' => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '/' if chars.get(i + 1) == Some(&'/') => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '"' => { + let mut s = String::new(); + i += 1; + loop { + match chars.get(i) { + None => return Err(format!("line {line}: unterminated string")), + Some('"') if chars.get(i + 1) == Some(&'"') => { + s.push('"'); + i += 2; + } + Some('"') => { + i += 1; + break; + } + Some(&ch) => { + if ch == '\n' { + line += 1; + } + s.push(ch); + i += 1; + } + } + if s.len() > MAX_STRING { + return Err(format!("line {line}: a string is too long")); + } + } + out.push(Token { tok: Tok::Str(s), line }); + } + c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) => { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { + i += 1; + } + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + i = j; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } + } + let text: String = chars[start..i].iter().collect(); + let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; + out.push(Token { tok: Tok::Num(n), line }); + } + '!' => { + out.push(Token { tok: Tok::Ident("!".into()), line }); + i += 1; + } + c if c.is_alphabetic() || c == '_' || c == '$' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { + i += 1; + } + let text: String = chars[start..i].iter().collect(); + out.push(Token { tok: Tok::Ident(text), line }); + } + _ => { + let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); + let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; + match op { + "(" | "[" => depth += 1, + ")" | "]" => depth = depth.saturating_sub(1), + _ => {} + } + out.push(Token { tok: Tok::Op(op), line }); + i += op.len(); + } + } + } + out.push(Token { tok: Tok::Newline, line }); + out.push(Token { tok: Tok::Eof, line }); + Ok(out) +} + +// ── syntax ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +enum Expr { + Num(f64), + Str(String), + Null, + Ident(String), + /// `base.name` + Member(Box, String), + /// `base[n]`, `base[*]`, `base[-n]` + Index(Box, Idx), + /// `base..name` + Descend(Box, String), + Call(Box, Vec), + Neg(Box), + Not(Box), + Bin(&'static str, Box, Box), +} + +#[derive(Clone, Debug, PartialEq)] +enum Idx { + /// `[*]`: every instance. + All, + /// `[n]`: the n-th instance (from 0). + Abs(Box), + /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. + Rel(i64), +} + +#[derive(Clone, Debug, PartialEq)] +enum Stmt { + Var(String, Option), + Assign(Expr, Expr), + Expr(Expr), + If(Vec<(Expr, Vec)>, Option>), + While(Expr, Vec), + For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, + Foreach { var: String, list: Vec, body: Vec }, + Func { name: String, f: Rc }, + Return(Option), + Break, + Continue, + Exit, + Throw(Expr), +} + +struct Parser { + toks: Vec, + at: usize, + depth: usize, + /// Operators and accessors chained so far in the expression being parsed. + chain: usize, +} + +fn kw(t: &Tok, word: &str) -> bool { + matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) +} + +impl Parser { + fn peek(&self) -> &Tok { + self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) + } + + fn line(&self) -> usize { + self.toks.get(self.at).map_or(0, |t| t.line) + } + + fn next(&mut self) -> Tok { + let t = self.peek().clone(); + self.at += 1; + t + } + + fn is_op(&self, op: &str) -> bool { + matches!(self.peek(), Tok::Op(o) if *o == op) + } + + fn eat_op(&mut self, op: &str) -> bool { + if self.is_op(op) { + self.at += 1; + true + } else { + false + } + } + + fn expect_op(&mut self, op: &str) -> Result<(), String> { + if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } + } + + fn eat_kw(&mut self, word: &str) -> bool { + if kw(self.peek(), word) { + self.at += 1; + true + } else { + false + } + } + + fn expect_kw(&mut self, word: &str) -> Result<(), String> { + if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } + } + + fn skip_newlines(&mut self) { + while matches!(self.peek(), Tok::Newline) { + self.at += 1; + } + } + + fn enter(&mut self) -> Result<(), String> { + self.depth += 1; + if self.depth > MAX_PARSE_DEPTH { + return Err("the script is nested too deeply".into()); + } + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// One more link in an operator or accessor chain. + fn link(&mut self) -> Result<(), String> { + self.chain += 1; + if self.chain > MAX_CHAIN { + return Err(format!("line {}: an expression is too long", self.line())); + } + Ok(()) + } + + /// Statements up to one of `enders` (not consumed). + fn block(&mut self, enders: &[&str]) -> Result, String> { + self.enter()?; + let mut out = Vec::new(); + loop { + self.skip_newlines(); + match self.peek() { + Tok::Eof => break, + Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, + _ => out.push(self.statement()?), + } + } + self.leave(); + Ok(out) + } + + fn statement(&mut self) -> Result { + let line = self.line(); + match self.peek().clone() { + Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; + let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; + Ok(Stmt::Var(name, init)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { + self.at += 1; + let mut arms = Vec::new(); + let cond = self.top_expr()?; + self.expect_kw("then")?; + let body = self.block(&["elseif", "else", "endif"])?; + arms.push((cond, body)); + let mut otherwise = None; + loop { + if self.eat_kw("elseif") { + let c = self.top_expr()?; + self.expect_kw("then")?; + let b = self.block(&["elseif", "else", "endif"])?; + arms.push((c, b)); + } else if self.eat_kw("else") { + otherwise = Some(self.block(&["endif"])?); + } else { + self.expect_kw("endif")?; + break; + } + } + Ok(Stmt::If(arms, otherwise)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { + self.at += 1; + let cond = self.top_expr()?; + self.expect_kw("do")?; + let body = self.block(&["endwhile"])?; + self.expect_kw("endwhile")?; + Ok(Stmt::While(cond, body)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; + self.expect_op("=")?; + let from = self.top_expr()?; + let up = if self.eat_kw("upto") { + true + } else if self.eat_kw("downto") { + false + } else { + return Err(format!("line {line}: expected upto or downto")); + }; + let to = self.top_expr()?; + let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::For { var, from, to, up, step, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; + self.expect_kw("in")?; + self.expect_op("(")?; + let mut list = Vec::new(); + if !self.is_op(")") { + loop { + list.push(self.top_expr()?); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::Foreach { var, list, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; + self.expect_op("(")?; + let mut params = Vec::new(); + if !self.is_op(")") { + loop { + let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; + params.push(p); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfunc"])?; + self.expect_kw("endfunc")?; + Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { + self.at += 1; + let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; + Ok(Stmt::Return(v)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { + self.at += 1; + Ok(Stmt::Break) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { + self.at += 1; + Ok(Stmt::Continue) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { + self.at += 1; + Ok(Stmt::Exit) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { + self.at += 1; + Ok(Stmt::Throw(self.top_expr()?)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { + // `do … end`: a block as one statement. + self.at += 1; + let body = self.block(&["end"])?; + self.expect_kw("end")?; + Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) + } + _ => { + let e = self.top_expr()?; + if self.eat_op("=") { + let v = self.top_expr()?; + return Ok(Stmt::Assign(e, v)); + } + Ok(Stmt::Expr(e)) + } + } + } + + /// An expression that starts a tree of its own (a condition, a bound, a statement's + /// expression): its operator chain is counted from zero, whatever came before it. + fn top_expr(&mut self) -> Result { + self.chain = 0; + self.expr() + } + + fn expr(&mut self) -> Result { + self.enter()?; + let e = self.or_expr(); + self.leave(); + e + } + + fn or_expr(&mut self) -> Result { + let mut l = self.and_expr()?; + loop { + if self.eat_op("|") || self.eat_kw("or") { + self.link()?; + let r = self.and_expr()?; + l = Expr::Bin("|", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn and_expr(&mut self) -> Result { + let mut l = self.cmp_expr()?; + loop { + if self.eat_op("&") || self.eat_kw("and") { + self.link()?; + let r = self.cmp_expr()?; + l = Expr::Bin("&", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn cmp_expr(&mut self) -> Result { + let mut l = self.add_expr()?; + loop { + let op = match self.peek() { + Tok::Op("==") => "==", + Tok::Op("<>") => "<>", + Tok::Op("<=") => "<=", + Tok::Op(">=") => ">=", + Tok::Op("<") => "<", + Tok::Op(">") => ">", + Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", + Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", + Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", + Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", + Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", + Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.add_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn add_expr(&mut self) -> Result { + let mut l = self.mul_expr()?; + loop { + let op = match self.peek() { + Tok::Op("+") => "+", + Tok::Op("-") => "-", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.mul_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn mul_expr(&mut self) -> Result { + let mut l = self.unary()?; + loop { + let op = match self.peek() { + Tok::Op("*") => "*", + Tok::Op("/") => "/", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.unary()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn unary(&mut self) -> Result { + self.enter()?; + let e = if self.eat_op("-") { + Ok(Expr::Neg(Box::new(self.unary()?))) + } else if self.eat_op("+") { + self.unary() + } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { + if matches!(self.peek(), Tok::Ident(w) if w == "!") { + self.at += 1; + } + Ok(Expr::Not(Box::new(self.unary()?))) + } else { + self.postfix() + }; + self.leave(); + e + } + + fn postfix(&mut self) -> Result { + let mut e = self.primary()?; + loop { + if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { + self.link()?; + } + if self.eat_op(".") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; + e = Expr::Member(Box::new(e), name); + } else if self.eat_op("..") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; + e = Expr::Descend(Box::new(e), name); + } else if self.eat_op("[") { + let sign = match self.peek() { + Tok::Op("-") => Some(-1), + Tok::Op("+") => Some(1), + _ => None, + }; + let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { + (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), + _ => None, + }); + let idx = if let Some(k) = relative { + self.at += 2; + Idx::Rel(k) + } else if self.eat_op("*") { + Idx::All + } else { + Idx::Abs(Box::new(self.expr()?)) + }; + self.expect_op("]")?; + e = Expr::Index(Box::new(e), idx); + } else if self.eat_op("(") { + let mut args = Vec::new(); + self.skip_newlines(); + if !self.is_op(")") { + loop { + args.push(self.expr()?); + self.skip_newlines(); + if !self.eat_op(",") { + break; + } + self.skip_newlines(); + } + } + self.expect_op(")")?; + e = Expr::Call(Box::new(e), args); + } else { + return Ok(e); + } + } + } + + fn primary(&mut self) -> Result { + let line = self.line(); + match self.next() { + Tok::Num(n) => Ok(Expr::Num(n)), + Tok::Str(s) => Ok(Expr::Str(s)), + Tok::Op("(") => { + self.skip_newlines(); + let e = self.expr()?; + self.skip_newlines(); + self.expect_op(")")?; + Ok(e) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), + Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), + Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), + Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), + Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), + Tok::Ident(w) => Ok(Expr::Ident(w)), + t => Err(format!("line {line}: unexpected {}", describe(&t))), + } + } +} + +fn describe(t: &Tok) -> String { + match t { + Tok::Num(n) => format!("number {n}"), + Tok::Str(s) => format!("string {s:?}"), + Tok::Ident(s) => s.clone(), + Tok::Op(o) => format!("{o:?}"), + Tok::Newline => "end of line".into(), + Tok::Eof => "end of script".into(), + } +} + +fn parse(src: &str) -> Result, String> { + let toks = lex(src)?; + let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; + let body = p.block(&[])?; + match p.peek() { + Tok::Eof => Ok(body), + t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), + } +} + +// ── values ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +enum Val { + Null, + Num(f64), + Str(String), + /// A form object, by index in the host. + Node(usize), + /// Several objects or values (`Row[*]`, `Row[*].Amount`). + List(Vec), + Host, + Layout, + Event, + /// `xfa` itself (`xfa.form`, `xfa.host`, …). + Xfa, + /// The instance manager of `name` under `parent`. + Im(usize, String), + /// A property object whose contents are not modelled (`$.border.fill…`): reads give + /// another sink, writes change nothing. + Sink, +} + +/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. +fn num_text(n: f64) -> String { + if !n.is_finite() { + return if n.is_nan() { + "NaN".into() + } else if n > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + }; + } + if n.fract() == 0.0 && n.abs() < 1e15 { + return format!("{}", n as i64); + } + let s = format!("{n:.12}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } +} + +/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. +fn parse_num(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + return None; + } + let mut end = 0; + let b = t.as_bytes(); + let mut seen_digit = false; + let mut seen_dot = false; + let mut seen_e = false; + let mut i = 0; + if i < b.len() && (b[i] == b'-' || b[i] == b'+') { + i += 1; + } + while i < b.len() { + let c = b[i]; + if c.is_ascii_digit() { + seen_digit = true; + end = i + 1; + } else if c == b'.' && !seen_dot && !seen_e { + seen_dot = true; + } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { + seen_e = true; + if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { + i += 1; + } + } else { + break; + } + i += 1; + } + if !seen_digit { + return None; + } + t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) +} + +fn is_numeric_text(s: &str) -> bool { + s.trim().parse::().is_ok_and(|v| v.is_finite()) +} + +// ── the evaluator ─────────────────────────────────────────────────────────────────────────── + +enum Flow { + Next, + Break, + Continue, + Return(Val), + Exit, +} + +#[derive(Debug, PartialEq)] +struct Func { + params: Vec, + body: Vec, +} + +struct Interp<'h> { + h: &'h mut XHost, + scopes: Vec>, + /// User functions by lower-case name (FormCalc names are case-insensitive). + funcs: HashMap>, + event: XfaEvent, + steps: u64, + loops_left: u64, + depth: usize, + max_depth: usize, + /// `exit` ran: every block ends at once. + exited: bool, + /// When the caller stops waiting: the script stops itself soon after (no thread is left + /// burning a core to the loop limit). + #[cfg(not(target_arch = "wasm32"))] + deadline: Option, +} + +type R = Result; + +impl Interp<'_> { + fn step(&mut self) -> R<()> { + self.charge(1) + } + + /// `n` units of work done (a step per statement or expression, more for work on long + /// strings and lists). + fn charge(&mut self, n: u64) -> R<()> { + let before = self.steps; + self.steps = self.steps.saturating_add(n); + if self.steps > MAX_STEPS { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(not(target_arch = "wasm32"))] + if self.steps / CLOCK_EVERY != before / CLOCK_EVERY + && let Some(d) = self.deadline + && std::time::Instant::now() > d + { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(target_arch = "wasm32")] + let _ = before; + Ok(()) + } + + /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. + fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { + for i in more { + if seen.insert(i) { + out.push(i); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + } + self.charge(1) + } + + /// The nodes of `v`, each once. + fn nodes_list(&self, v: &Val) -> Vec { + let mut seen = HashSet::new(); + self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() + } + + fn node(&self, i: usize) -> Option<&HNode> { + self.h.nodes.get(i).filter(|n| !n.gone) + } + + // ── coercions + + /// A node's value as a FormCalc value. + fn node_value(&self, i: usize) -> Val { + let Some(n) = self.node(i) else { return Val::Null }; + if n.value.is_empty() { + return Val::Null; + } + if n.numeric + && let Some(v) = parse_num(&n.value) + { + return Val::Num(v); + } + Val::Str(n.value.clone()) + } + + /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so + /// copying long strings over and over runs out of steps even where there is no clock + /// (wasm). A form object counts its value; a list its items too. + fn cost(&self, v: &Val) -> u64 { + let mut bytes = 0usize; + let mut todo = vec![v]; + while let Some(v) = todo.pop() { + match v { + Val::Str(s) => bytes = bytes.saturating_add(s.len()), + Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), + Val::List(items) => { + bytes = bytes.saturating_add(items.len().saturating_mul(16)); + todo.extend(items.iter()); + } + _ => {} + } + } + (bytes / 256) as u64 + } + + /// [`Self::deref`], charged for the copy. + fn take(&mut self, v: Val) -> R { + self.charge(self.cost(&v))?; + Ok(self.deref(v)) + } + + /// Objects become their values; lists of objects become lists of values. + fn deref(&self, v: Val) -> Val { + match v { + Val::Node(i) => self.node_value(i), + Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), + other => other, + } + } + + fn to_num(&self, v: &Val) -> f64 { + match v { + Val::Null => 0.0, + Val::Num(n) => *n, + Val::Str(s) => parse_num(s).unwrap_or(0.0), + Val::Node(i) => self.to_num(&self.node_value(*i)), + Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), + _ => 0.0, + } + } + + fn to_str(&self, v: &Val) -> String { + match v { + Val::Null => String::new(), + Val::Num(n) => num_text(*n), + Val::Str(s) => s.clone(), + Val::Node(i) => self.to_str(&self.node_value(*i)), + Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), + Val::Host => "host".into(), + Val::Layout => "layout".into(), + Val::Event => "event".into(), + Val::Xfa => "xfa".into(), + Val::Im(..) => "instanceManager".into(), + Val::Sink => String::new(), + } + } + + fn truthy(&self, v: &Val) -> bool { + match v { + Val::Null => false, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), + Val::Node(i) => { + let d = self.node_value(*i); + self.truthy(&d) + } + Val::List(items) => !items.is_empty(), + _ => true, + } + } + + fn is_null(&self, v: &Val) -> bool { + match v { + Val::Null => true, + Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), + Val::List(items) => items.is_empty(), + _ => false, + } + } + + // ── scopes + + fn lookup(&self, name: &str) -> Option { + self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) + } + + fn set_var(&mut self, name: &str, v: Val) -> bool { + for s in self.scopes.iter_mut().rev() { + if let Some(slot) = s.get_mut(name) { + *slot = v; + return true; + } + } + false + } + + fn declare(&mut self, name: &str, v: Val) { + if let Some(s) = self.scopes.last_mut() { + s.insert(name.to_string(), v); + } + } + + // ── accessors + + /// The objects an identifier names from the current node: `$`, the special objects, an + /// instance manager (`_name`), a variable, or a form object by name (children first, then + /// the containing subforms). + fn ident(&self, name: &str) -> R { + if let Some(v) = self.lookup(name) { + return Ok(v); + } + let lower = name.to_ascii_lowercase(); + Ok(match lower.as_str() { + "$" | "this" => Val::Node(self.h.current), + "$host" => Val::Host, + "$layout" => Val::Layout, + "$event" => Val::Event, + "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), + "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "$template" | "$xfa" => Val::Xfa, + "xfa" => Val::Xfa, + _ => { + if let Some(child) = name.strip_prefix('_') { + // The instance manager of `child`, looked for like a name. + let found = resolve_som(self.h, self.h.current, child); + let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; + let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; + return Ok(Val::Im(parent, child.to_string())); + } + let found = resolve_som(self.h, self.h.current, name); + match found.as_slice() { + [] => return Err(format!("unknown name {name}")), + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), + } + } + }) + } + + /// Every object a value stands for. + fn nodes_of(&self, v: &Val) -> Vec { + match v { + Val::Node(i) => vec![*i], + Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), + _ => Vec::new(), + } + } + + fn member(&mut self, base: Val, name: &str) -> R { + match base { + Val::Node(i) => self.node_member(i, name), + Val::List(items) => { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for it in items { + match self.member(it, name)? { + Val::List(more) => { + for m in more { + self.list_push(&mut out, &mut seen, m)?; + } + } + Val::Null => {} + v => self.list_push(&mut out, &mut seen, v)?, + } + } + Ok(match out.len() { + 0 => Val::Null, + 1 => out.remove(0), + _ => Val::List(out), + }) + } + Val::Host => Ok(match name.to_ascii_lowercase().as_str() { + "numpages" => Val::Num(self.h.doc.page_count as f64), + "currentpage" => Val::Num(self.h.doc.page as f64), + "name" => Val::Str("Acrobat".into()), + "apptype" => Val::Str("Exchange-Pro".into()), + "version" => Val::Str("11".into()), + "variation" => Val::Str("Full".into()), + "language" => Val::Str("en".into()), + "platform" => Val::Str(crate::platform().into()), + "title" => Val::Str(self.h.doc.file_name.clone()), + "calculationsenabled" | "validationsenabled" => Val::Num(1.0), + _ => Val::Sink, + }), + Val::Layout => Ok(Val::Sink), + Val::Event => Ok(match name.to_ascii_lowercase().as_str() { + "name" => Val::Str(self.event.activity.clone()), + "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), + "prevtext" => Val::Str(self.event.prev_text.clone()), + "change" => Val::Str(String::new()), + "target" => Val::Node(self.h.current), + "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), + _ => Val::Sink, + }), + Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { + "form" | "datasets" | "data" => Val::Node(0), + "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "host" => Val::Host, + "layout" => Val::Layout, + "event" => Val::Event, + _ => Val::Sink, + }), + Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { + "count" => Val::Num(instances(self.h, parent, &child).len() as f64), + "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), + "max" => Val::Num( + instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), + ), + "classname" => Val::Str("instanceManager".into()), + _ => Val::Sink, + }), + Val::Sink => Ok(Val::Sink), + Val::Null => Err(format!("no object to take {name} from")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), + } + } + + /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. + fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { + if let Val::Node(i) = v + && !seen.insert(i) + { + return Ok(()); + } + out.push(v); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + self.charge(1) + } + + fn node_member(&mut self, i: usize, name: &str) -> R { + let Some(n) = self.node(i) else { return Ok(Val::Null) }; + Ok(match name { + "rawValue" | "value" | "formattedValue" => self.node_value(i), + "name" => Val::Str(n.name.clone()), + "somExpression" => Val::Str(n.som.clone()), + "className" => Val::Str(n.kind.class_name().into()), + "index" | "instanceIndex" => Val::Num(n.index as f64), + "presence" => Val::Str(n.presence.clone()), + "access" => Val::Str(n.access.clone()), + "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), + "mandatory" => Val::Str("disabled".into()), + "parent" => n.parent.map_or(Val::Null, Val::Node), + "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), + "all" => match n.parent { + Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), + None => Val::Node(i), + }, + "instanceManager" => match n.parent { + Some(p) => Val::Im(p, n.name.clone()), + None => Val::Null, + }, + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" + | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" + | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" + | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, + other => { + if let Some(child) = other.strip_prefix('_') { + return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); + } + let found = instances(self.h, i, other); + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + }) + } + + /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. + fn index(&mut self, base: &Expr, idx: &Idx) -> R { + let all = self.all_of(base)?; + match idx { + Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), + Idx::Abs(e) => { + let v = self.eval(e)?; + let n = self.to_num(&v); + if !n.is_finite() || n < 0.0 { + return Ok(Val::Null); + } + Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) + } + Idx::Rel(k) => { + // From the instance the script runs in (`row[-1]` in a row is the row above), + // else from the first. + let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); + let at = (here as i64).saturating_add(*k); + Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) + } + } + } + + /// Whether `node` is `inner` or one of its containers. + fn encloses(&self, node: usize, inner: usize) -> bool { + let mut at = Some(inner); + let mut guard = 0; + while let Some(i) = at + && guard < MAX_PARSE_DEPTH + { + if i == node { + return true; + } + at = self.h.nodes.get(i).and_then(|n| n.parent); + guard += 1; + } + false + } + + /// Every instance an accessor names (`Row` → all rows, not just the first), each once. + fn all_of(&mut self, e: &Expr) -> R> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + match e { + Expr::Ident(name) => { + if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { + let v = self.eval(e)?; + return Ok(self.nodes_list(&v)); + } + // The name resolves to its first instance; `row[*]` means every sibling of it. + for first in resolve_som(self.h, self.h.current, name) { + match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { + Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, + None => self.gather(&mut out, &mut seen, [first])?, + } + } + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, instances(self.h, i, name))?; + } + if out.is_empty() { + let v = self.member(b, name)?; + out = self.nodes_list(&v); + } + } + Expr::Descend(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + } + other => { + let v = self.eval(other)?; + out = self.nodes_list(&v); + } + } + Ok(out) + } + + // ── expressions + + fn eval(&mut self, e: &Expr) -> R { + self.step()?; + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.eval_inner(e); + self.depth -= 1; + r + } + + fn eval_inner(&mut self, e: &Expr) -> R { + Ok(match e { + Expr::Num(n) => Val::Num(*n), + Expr::Str(s) => { + self.charge((s.len() / 256) as u64)?; + Val::Str(s.clone()) + } + Expr::Null => Val::Null, + Expr::Ident(name) => { + let v = self.ident(name)?; + self.charge(self.cost(&v))?; + v + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + self.member(b, name)? + } + Expr::Index(base, idx) => self.index(base, idx)?, + Expr::Descend(base, name) => { + let b = self.eval(base)?; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + match out.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + Expr::Call(callee, args) => self.call(callee, args)?, + Expr::Neg(x) => { + let v = self.eval(x)?; + if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } + } + Expr::Not(x) => { + let v = self.eval(x)?; + Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) + } + Expr::Bin(..) => { + // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked + // from its leftmost operand and folded without recursing per operator. + let mut links = Vec::new(); + let mut leftmost = e; + while let Expr::Bin(op, l, r) = leftmost { + links.push((*op, r.as_ref())); + leftmost = l; + } + let mut acc = self.eval(leftmost)?; + for (op, r) in links.into_iter().rev() { + self.step()?; + acc = match op { + "&" => { + if !self.truthy(&acc) { + // Not evaluated: the result is settled. + Val::Num(0.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + "|" => { + if self.truthy(&acc) { + Val::Num(1.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + _ => { + let b = self.eval(r)?; + // Comparing (and dereferencing) long strings is work too. + self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; + self.binary(op, acc, b)? + } + }; + } + acc + } + }) + } + + fn binary(&self, op: &str, a: Val, b: Val) -> R { + let (a, b) = (self.deref(a), self.deref(b)); + // Null with an arithmetic operator is null only when both sides are null. + match op { + "+" | "-" | "*" | "/" => { + if self.is_null(&a) && self.is_null(&b) { + return Ok(Val::Null); + } + let (x, y) = (self.to_num(&a), self.to_num(&b)); + let v = match op { + "+" => x + y, + "-" => x - y, + "*" => x * y, + _ => { + if y == 0.0 { + return Err("division by zero".into()); + } + x / y + } + }; + Ok(Val::Num(v)) + } + _ => { + let ord = match (&a, &b) { + (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), + (Val::Null, _) | (_, Val::Null) => None, + (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), + (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), + (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), + _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), + }; + let r = match (op, ord) { + ("==", Some(o)) => o.is_eq(), + ("<>", Some(o)) => !o.is_eq(), + ("<>", None) => true, + ("<", Some(o)) => o.is_lt(), + ("<=", Some(o)) => o.is_le(), + (">", Some(o)) => o.is_gt(), + (">=", Some(o)) => o.is_ge(), + _ => false, + }; + Ok(Val::Num(if r { 1.0 } else { 0.0 })) + } + } + } + + // ── calls + + fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { + match callee { + Expr::Ident(name) => { + if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { + let mut vals = Vec::new(); + for a in args { + let v = self.eval(a)?; + vals.push(self.take(v)?); + } + return self.call_user(&f, vals); + } + self.builtin(name, args) + } + Expr::Member(base, method) => { + let b = self.eval(base)?; + self.method(b, method, args) + } + _ => Err("not a function".into()), + } + } + + fn call_user(&mut self, f: &Func, vals: Vec) -> R { + if self.scopes.len() > self.max_depth { + return Err(CALLS_TOO_DEEP.into()); + } + let mut scope = HashMap::new(); + for (i, p) in f.params.iter().enumerate() { + scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); + } + self.scopes.push(scope); + let r = self.block(&f.body); + self.scopes.pop(); + match r? { + (Flow::Return(v), _) => Ok(v), + (Flow::Exit, last) => { + self.exited = true; + Ok(last) + } + (_, last) => Ok(last), + } + } + + fn arg(&mut self, args: &[Expr], i: usize) -> R { + match args.get(i) { + Some(e) => { + let v = self.eval(e)?; + self.take(v) + } + None => Ok(Val::Null), + } + } + + fn arg_num(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_num(&v)) + } + + fn arg_str(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_str(&v)) + } + + /// Every value the arguments hold, lists flattened, nulls left out. + fn arg_values(&mut self, args: &[Expr]) -> R> { + self.arg_values_keeping(args, false) + } + + /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so + /// `Count(row[*])` counts rows rather than filled-in values. + fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { + let mut out = Vec::new(); + for a in args { + let v = self.eval(a)?; + let v = if objects { v } else { self.take(v)? }; + match v { + Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), + Val::Null => {} + other => out.push(other), + } + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + Ok(out) + } + + fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { + let m = method.to_ascii_lowercase(); + match base { + Val::Host => match m.as_str() { + "messagebox" => { + let t = self.arg_str(args, 0)?; + self.h.push(XfaEffect::MessageBox(t)); + Ok(Val::Num(1.0)) + } + "resetdata" => { + let list = self.arg_str(args, 0)?; + let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); + self.h.push(XfaEffect::ResetData(names)); + Ok(Val::Null) + } + "print" => { + self.h.push(XfaEffect::Print); + Ok(Val::Null) + } + "beep" => { + self.h.push(XfaEffect::Beep); + Ok(Val::Null) + } + "gotourl" => { + let u = self.arg_str(args, 0)?; + self.h.push(XfaEffect::LaunchUrl(u)); + Ok(Val::Null) + } + "setfocus" => { + let target = match args.first() { + Some(e) => { + let raw = self.eval(e)?; + match self.nodes_of(&raw).first() { + Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), + None => { + let s = self.to_str(&raw); + let found = resolve_som(self.h, self.h.current, &s); + found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) + } + } + } + None => String::new(), + }; + self.h.push(XfaEffect::SetFocus(target)); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Layout => match m.as_str() { + "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), + "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), + "relayout" | "relayoutpagearea" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Num(0.0)), + }, + Val::Xfa => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, self.h.current, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Node(i) => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, i, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Im(parent, name) => match m.as_str() { + "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), + "removeinstance" => { + let i = self.arg_num(args, 0)?; + if i.is_finite() && i >= 0.0 { + self.h.remove_instance(parent, &name, i as usize); + } + Ok(Val::Null) + } + "setinstances" => { + let want = self.arg_num(args, 0)?; + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), + Val::Null => Err(format!("no object to call {method} on")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), + } + } + + fn set_val(&self, found: Vec, many: bool) -> Val { + if many { + return Val::List(found.into_iter().map(Val::Node).collect()); + } + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), + } + } + + // ── assignment + + fn assign(&mut self, target: &Expr, v: Val) -> R<()> { + let v = self.deref(v); + match target { + Expr::Ident(name) => { + if self.set_var(name, v.clone()) { + return Ok(()); + } + let t = self.ident(name)?; + self.assign_to(t, "rawValue", v) + } + Expr::Member(base, prop) => { + let b = self.eval(base)?; + self.assign_to(b, prop, v) + } + Expr::Index(..) | Expr::Descend(..) => { + let t = self.eval(target)?; + self.assign_to(t, "rawValue", v) + } + _ => Err("cannot assign to that".into()), + } + } + + fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { + match target { + Val::Node(i) => { + let Some(n) = self.node(i) else { return Ok(()) }; + let som = n.som.clone(); + match prop { + "rawValue" | "value" | "formattedValue" => { + let text = self.to_str(&v); + if !self.h.set_value(i, text) { + return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); + } + } + "presence" => { + let p = self.to_str(&v); + if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { + if let Some(n) = self.h.nodes.get_mut(i) { + n.presence = p.clone(); + } + self.h.push(XfaEffect::SetPresence { som, presence: p }); + } + } + "access" => { + let a = self.to_str(&v); + if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { + if let Some(n) = self.h.nodes.get_mut(i) { + n.access = a.clone(); + } + self.h.push(XfaEffect::SetAccess { som, access: a }); + } + } + other => { + // `page1.qty = 7`: the child object named by the last step takes the value. + if let Some(child) = other.strip_prefix('_') { + if child_named(self.h, i, child).is_some() { + return self.assign_to(Val::Im(i, child.to_string()), "count", v); + } + return Ok(()); + } + let kids = instances(self.h, i, other); + if kids.is_empty() { + return Err(format!("{som} has no {other} to assign to")); + } + for k in kids { + self.assign_to(Val::Node(k), "rawValue", v.clone())?; + } + } + } + Ok(()) + } + Val::List(items) => { + for it in items { + self.assign_to(it, prop, v.clone())?; + } + Ok(()) + } + Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { + let want = self.to_num(&v); + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(()) + } + _ => Ok(()), + } + } + + // ── statements + + /// Run a block; returns how it ended and the value of its last expression statement. + fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + // Nesting of statements counts like nesting of expressions: both are stack. + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.block_inner(body); + self.depth -= 1; + r + } + + fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + let mut last = Val::Null; + for s in body { + self.step()?; + match self.exec(s)? { + (Flow::Next, v) => { + if let Some(v) = v { + last = v; + } + if self.exited { + return Ok((Flow::Exit, last)); + } + } + (flow, _) => return Ok((flow, last)), + } + } + Ok((Flow::Next, last)) + } + + fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { + match s { + Stmt::Var(name, init) => { + let v = match init { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + self.declare(name, v.clone()); + Ok((Flow::Next, Some(v))) + } + Stmt::Assign(target, e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + self.assign(target, v.clone())?; + Ok((Flow::Next, Some(v))) + } + Stmt::Expr(e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + Ok((Flow::Next, Some(v))) + } + Stmt::If(arms, otherwise) => { + for (cond, body) in arms { + let c = self.eval(cond)?; + if self.truthy(&c) { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + } + if let Some(body) = otherwise { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + Ok((Flow::Next, None)) + } + Stmt::While(cond, body) => { + loop { + let c = self.eval(cond)?; + if !self.truthy(&c) { + break; + } + self.iterate()?; + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::For { var, from, to, up, step, body } => { + let start = { + let v = self.eval(from)?; + self.to_num(&v) + }; + let end = { + let v = self.eval(to)?; + self.to_num(&v) + }; + let by = match step { + Some(e) => { + let v = self.eval(e)?; + self.to_num(&v).abs() + } + None => 1.0, + }; + if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { + return Err("bad for loop bounds".into()); + } + self.declare(var, Val::Num(start)); + let mut i = start; + while if *up { i <= end } else { i >= end } { + self.iterate()?; + self.set_var(var, Val::Num(i)); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + i = if *up { i + by } else { i - by }; + } + Ok((Flow::Next, None)) + } + Stmt::Foreach { var, list, body } => { + let items = self.arg_values_keeping(list, true)?; + self.declare(var, Val::Null); + for it in items { + self.iterate()?; + self.set_var(var, it); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::Func { name, f } => { + self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); + Ok((Flow::Next, None)) + } + Stmt::Return(e) => { + let v = match e { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + Ok((Flow::Return(v), None)) + } + Stmt::Break => Ok((Flow::Break, None)), + Stmt::Continue => Ok((Flow::Continue, None)), + Stmt::Exit => Ok((Flow::Exit, None)), + Stmt::Throw(e) => { + let v = self.eval(e)?; + self.charge(self.cost(&v))?; + // The thrown value becomes the error message the caller keeps: cut it as + // messages are. + Err(clip(self.to_str(&v))) + } + } + } + + fn iterate(&mut self) -> R<()> { + if self.loops_left == 0 { + return Err(LOOPED_TOO_MUCH.into()); + } + self.loops_left -= 1; + Ok(()) + } +} + +// ── built-in functions ────────────────────────────────────────────────────────────────────── + +/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + // Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1. + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (i64::from(m) + 9) % 12; + let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let unix = era * 146_097 + doe - 719_468; + unix + 25_568 +} + +fn civil_from_days(n: i64) -> (i64, u32, u32) { + let z = n - 25_568 + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Day numbers (and years) beyond this are refused rather than computed. +const MAX_DAYS: f64 = 1e8; + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). +fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { + let pic: Vec = picture.chars().collect(); + let txt: Vec = text.trim().chars().collect(); + let (mut y, mut m, mut d) = (None, None, None); + let (mut i, mut j) = (0, 0); + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + if c == 'M' && run >= 3 { + // Month name. + let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); + let lower = rest.to_ascii_lowercase(); + let found = MONTHS + .iter() + .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); + let k = found?; + m = Some(k as u32 + 1); + let full = MONTHS[k].to_ascii_lowercase(); + j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; + continue; + } + let width = if run == 1 { 2 } else { run }; + let mut digits = String::new(); + while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { + digits.push(txt[j]); + j += 1; + } + if digits.is_empty() { + return None; + } + let v: i64 = digits.parse().ok()?; + match c { + 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), + 'M' => m = Some(v as u32), + _ => d = Some(v as u32), + } + } else { + if txt.get(j) != Some(&pic[i]) { + return None; + } + i += 1; + j += 1; + } + } + let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); + ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=31).contains(&d)).then_some((y, m, d)) +} + +fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { + let pic: Vec = picture.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + match (c, run) { + ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), + ('Y', _) => out.push_str(&format!("{y:04}")), + ('M', 1) => out.push_str(&m.to_string()), + ('M', 2) => out.push_str(&format!("{m:02}")), + ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), + ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), + ('D', 1) => out.push_str(&d.to_string()), + (_, _) => out.push_str(&format!("{d:02}")), + } + } else { + out.push(pic[i]); + i += 1; + } + } + out +} + +const ONES: [&str; 20] = [ + "Zero", + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + "Thirteen", + "Fourteen", + "Fifteen", + "Sixteen", + "Seventeen", + "Eighteen", + "Nineteen", +]; +const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; + +fn words_below_1000(n: u64) -> String { + let mut parts = Vec::new(); + if n >= 100 { + parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); + } + let r = n % 100; + if r >= 20 { + parts.push(if r.is_multiple_of(10) { + TENS[(r / 10) as usize].to_string() + } else { + format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) + }); + } else if r > 0 || n == 0 { + parts.push(ONES[r as usize].to_string()); + } + parts.join(" ") +} + +/// `WordNum`: an integer in English words (up to the billions). +fn word_num(n: f64, cents: bool) -> String { + if !n.is_finite() || !(0.0..1e12).contains(&n) { + return String::new(); + } + let cents_total = (n * 100.0).round() as u64; + let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; + let mut parts = Vec::new(); + let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; + let mut rest = whole; + for (unit, name) in groups { + if rest >= unit { + parts.push(format!("{} {name}", words_below_1000(rest / unit))); + rest %= unit; + } + } + if rest > 0 || parts.is_empty() { + parts.push(words_below_1000(rest)); + } + let mut out = parts.join(" "); + if cents { + let c = cents_total % 100; + let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; + let cent = if c == 1 { "Cent" } else { "Cents" }; + out.push_str(&format!(" {dollars} And {c:02} {cent}")); + } + out +} + +impl Interp<'_> { + /// A built-in function: its result is never a string past [`MAX_STRING`], and work on + /// long strings is charged to the step budget. + fn builtin(&mut self, name: &str, args: &[Expr]) -> R { + let v = self.builtin_inner(name, args)?; + if let Val::Str(s) = &v { + if s.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + self.charge((s.len() / 256) as u64)?; + } + Ok(v) + } + + fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { + let lower = name.to_ascii_lowercase(); + let n = |v: f64| Ok(Val::Num(v)); + Ok(match lower.as_str() { + // ── arithmetic + "abs" => return n(self.arg_num(args, 0)?.abs()), + "ceil" => return n(self.arg_num(args, 0)?.ceil()), + "floor" => return n(self.arg_num(args, 0)?.floor()), + "mod" => { + let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); + if b == 0.0 { + return Err("division by zero".into()); + } + Val::Num(a - b * (a / b).trunc()) + } + "round" => { + let a = self.arg_num(args, 0)?; + let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; + let k = 10f64.powi(places); + Val::Num((a * k).round() / k) + } + "count" => { + // Non-null values and objects: an empty field doesn't count, a subform does. + let vals = self.arg_values_keeping(args, true)?; + let counts = |v: &Val| match v { + Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), + other => !self.is_null(other), + }; + Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) + } + "sum" | "avg" | "max" | "min" => { + let vals = self.arg_values(args)?; + let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); + match lower.as_str() { + "sum" => Val::Num(nums.iter().sum()), + "avg" => { + if nums.is_empty() { + Val::Null + } else { + Val::Num(nums.iter().sum::() / nums.len() as f64) + } + } + "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), + _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), + } + } + // ── logical + "choose" => { + let i = self.arg_num(args, 0)?; + if !i.is_finite() || i < 1.0 { + return Ok(Val::Null); + } + self.arg(args, i as usize)? + } + "exists" => { + let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; + // A name that resolves to nothing is the answer "no"; a limit the lookup ran + // into (time, steps, depth, size) still stops the script. + match self.all_of(e) { + Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), + Err(e) if is_limit(&e) => return Err(e), + Err(_) => Val::Num(0.0), + } + } + "hasvalue" => { + let v = self.arg(args, 0)?; + Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) + } + "oneof" => { + let v = self.arg(args, 0)?; + let rest = self.arg_values(args.get(1..).unwrap_or_default())?; + let mut hit = false; + for x in &rest { + // Each comparison copies and compares `v`: charged per item. + self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; + if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { + hit = true; + break; + } + } + Val::Num(if hit { 1.0 } else { 0.0 }) + } + "within" => { + let v = self.arg(args, 0)?; + let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); + let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); + Val::Num(if ok { 1.0 } else { 0.0 }) + } + // ── strings + "at" => { + let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) + } + "concat" => { + let mut out = String::new(); + for i in 0..args.len() { + out.push_str(&self.arg_str(args, i)?); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + } + Val::Str(out) + } + "left" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + Val::Str(s.chars().take(k).collect()) + } + "right" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + let len = s.chars().count(); + Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) + } + "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), + "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), + "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), + "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), + "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), + "space" => { + let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; + Val::Str(" ".repeat(k)) + } + "str" => { + let v = self.arg_num(args, 0)?; + let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; + let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; + let text = format!("{v:.prec$}"); + Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) + } + "substr" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let len = self.arg_num(args, 2)?.max(0.0) as usize; + Val::Str(s.chars().skip(start - 1).take(len).collect()) + } + "stuff" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let del = self.arg_num(args, 2)?.max(0.0) as usize; + let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; + let chars: Vec = s.chars().collect(); + let a = start.saturating_sub(1).min(chars.len()); + let b = a.saturating_add(del).min(chars.len()); + let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(out) + } + "replace" => { + let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; + if old.is_empty() { + return Ok(Val::Str(s)); + } + let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); + if grown > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(s.replace(&old, &new)) + } + "wordnum" => { + let v = self.arg_num(args, 0)?; + let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; + Val::Str(word_num(v, kind == 2.0)) + } + "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), + "encode" | "decode" => { + let s = self.arg_str(args, 0)?; + let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; + Val::Str(match (lower.as_str(), kind.as_str()) { + ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), + ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), + ("encode", _) => s + .bytes() + .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) + .collect(), + (_, _) => { + let mut out = Vec::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && let Some(h) = + b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(h); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() + } + }) + } + "format" => { + // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. + let pic = self.arg_str(args, 0)?; + let v = self.arg(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { + let text = self.to_str(&v); + let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); + return Ok(match days { + Some(d) => { + let (y, m, dd) = civil_from_days(d); + Val::Str(format_date(y, m, dd, &pic)) + } + None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), + }); + } + let num = self.to_num(&v); + let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); + let grouped = pic.contains(','); + let mut text = format!("{:.decimals$}", num.abs()); + if grouped { + let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); + let mut g = String::new(); + for (k, c) in int.chars().enumerate() { + if k > 0 && (int.len() - k) % 3 == 0 { + g.push(','); + } + g.push(c); + } + text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; + } + if pic.contains('$') { + text = format!("${text}"); + } + if num < 0.0 { + text = format!("-{text}"); + } + Val::Str(text) + } + "parse" => { + let pic = self.arg_str(args, 0)?; + let s = self.arg_str(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") { + // The canonical form of a date is ISO. + return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); + } + let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); + parse_num(&cleaned).map_or(Val::Null, Val::Num) + } + // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) + "date" => { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + Val::Num((secs.div_euclid(86_400) + 25_568) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(25_568.0) + } + } + "time" => { + #[cfg(not(target_arch = "wasm32"))] + { + let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); + Val::Num(ms.rem_euclid(86_400_000) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(0.0) + } + } + "date2num" | "isodate2num" => { + let s = self.arg_str(args, 0)?; + let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; + let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); + parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) + } + "num2date" => { + let days = self.arg_num(args, 0)?; + let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; + if !days.is_finite() || days.abs() >= MAX_DAYS { + return Ok(Val::Null); + } + let (y, m, d) = civil_from_days(days as i64); + Val::Str(format_date(y, m, d, &pic)) + } + "datefmt" | "localdatefmt" => { + let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; + Val::Str( + match style as i32 { + 1 => "M/D/YY", + 2 => "MMM D, YYYY", + 3 => "MMMM D, YYYY", + 4 => "EEEE, MMMM D, YYYY", + _ => "MMM D, YYYY", + } + .into(), + ) + } + "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), + "num2time" | "num2gmtime" => { + let ms = self.arg_num(args, 0)?; + if !ms.is_finite() || ms < 0.0 { + return Ok(Val::Null); + } + let s = (ms / 1000.0) as u64; + Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) + } + "time2num" | "isotime2num" => { + let s = self.arg_str(args, 0)?; + let parts: Vec = s.split(':').filter_map(|p| p.trim().parse::().ok()).map(|v| v.min(1_000_000)).collect(); + let (h, m, sec) = (parts.first().copied().unwrap_or(0), parts.get(1).copied().unwrap_or(0), parts.get(2).copied().unwrap_or(0)); + Val::Num(((h * 3600 + m * 60 + sec) * 1000) as f64) + } + // ── financial + "fv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) + } + "pv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) + } + "pmt" => { + let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if k <= 0.0 { + return Err("Pmt needs a positive number of periods".into()); + } + Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) + } + "term" => { + let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if p <= 0.0 || r <= 0.0 || fv <= 0.0 { + return Err("Term needs positive arguments".into()); + } + Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) + } + "cterm" => { + let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { + return Err("CTerm needs positive arguments".into()); + } + Val::Num((fv / pv).ln() / (1.0 + r).ln()) + } + "rate" => { + let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { + return Err("Rate needs positive arguments".into()); + } + Val::Num((fv / pv).powf(1.0 / k) - 1.0) + } + "npv" => { + let r = self.arg_num(args, 0)?; + let flows = self.arg_values(args.get(1..).unwrap_or_default())?; + Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) + } + "ipmt" | "ppmt" => { + let (pv, r, pmt, first, count) = ( + self.arg_num(args, 0)?, + self.arg_num(args, 1)?, + self.arg_num(args, 2)?, + self.arg_num(args, 3)?.max(1.0), + self.arg_num(args, 4)?.max(1.0), + ); + let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); + for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { + self.step()?; + let i = balance * r; + let p = (pmt - i).min(balance); + if period >= first as u32 { + interest += i; + principal += p; + } + balance -= p; + if balance <= 0.0 { + break; + } + } + Val::Num(if lower == "ipmt" { interest } else { principal }) + } + "apr" => { + let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { + return Err("Apr needs positive arguments".into()); + } + // Bisection on the monthly rate. + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..200 { + let mid = (lo + hi) / 2.0; + let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; + if pmt > payment { + hi = mid; + } else { + lo = mid; + } + } + Val::Num((lo + hi) / 2.0 * 12.0) + } + // ── units and references + "unittype" => { + let s = self.arg_str(args, 0)?; + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + Val::Str( + match unit.trim().to_ascii_lowercase().as_str() { + "in" | "inch" | "inches" => "in", + "mm" | "millimeter" | "millimeters" => "mm", + "cm" | "centimeter" | "centimeters" => "cm", + "pt" | "point" | "points" => "pt", + "pc" | "pica" => "pc", + _ => "in", + } + .into(), + ) + } + "unitvalue" => { + let s = self.arg_str(args, 0)?; + let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; + let v = parse_num(&s).unwrap_or(0.0); + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { + "mm" => 25.4, + "cm" => 2.54, + "pt" => 72.0, + "pc" => 6.0, + _ => 1.0, + }; + let target = if to.trim().is_empty() { unit.clone() } else { to }; + Val::Num(v / per_in(&unit) * per_in(&target)) + } + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } +} + +// ── entry points ──────────────────────────────────────────────────────────────────────────── + +/// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a +/// JavaScript one. +pub fn run_formcalc(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + run_formcalc_at(script, event, doc, root.clone(), limits, None) +} + +/// [`run_formcalc`] on an owned form, giving up at `deadline` (the clock is read every +/// [`CLOCK_EVERY`] steps; unused on wasm, which has no clock). +pub(crate) fn run_formcalc_at( + script: &str, + event: &XfaEvent, + doc: &XfaDoc, + root: XfaNode, + limits: Limits, + deadline: Option, +) -> XfaOutcome { + // FormCalc has its own nesting limits (the shared `refuse` reads JavaScript syntax). + if script.len() > crate::MAX_SCRIPT_BYTES { + return XfaOutcome { + error: Some(format!("the script is too long ({} KiB; the limit is {} KiB)", script.len() / 1024, crate::MAX_SCRIPT_BYTES / 1024)), + ..Default::default() + }; + } + let body = match parse(script) { + Ok(b) => b, + Err(e) => return XfaOutcome { error: Some(e), ..Default::default() }, + }; + let mut host = XHost::new(root, &event.target, doc); + #[cfg(target_arch = "wasm32")] + let _ = deadline; + let mut interp = Interp { + h: &mut host, + scopes: vec![HashMap::new()], + funcs: HashMap::new(), + event: event.clone(), + steps: 0, + loops_left: limits.loop_iterations, + depth: 0, + // The JavaScript recursion limit, kept to 8..=128: statement nesting counts against it + // too, and the interpreter's frames are large. + max_depth: limits.recursion.clamp(8, 128), + exited: false, + #[cfg(not(target_arch = "wasm32"))] + deadline: deadline.and_then(|d| std::time::Instant::now().checked_add(d)), + }; + let mut out = XfaOutcome::default(); + match interp.block(&body) { + Ok((_, last)) => { + // As the JavaScript path reports: null is an empty result (a calculate clears the + // field) with no truth value. + let last = interp.deref(last); + out.result = Some(interp.to_str(&last)); + if !matches!(last, Val::Null) { + out.result_bool = Some(interp.truthy(&last)); + } + } + Err(e) => out.error = Some(e), + } + out.notes = host.notes(); + out.effects = host.effects; + out.console = host.console; + clip_result(&mut out); + out +} + +/// [`run_formcalc`] on its own thread, abandoned after `timeout` (and told to stop itself then). +pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + run_within("pdfcraft-formcalc", timeout, move || run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates_round_trip_through_formcalc_day_numbers() { + assert_eq!(days_from_civil(1900, 1, 1), 1); + assert_eq!(days_from_civil(1970, 1, 1), 25_568); + assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29)); + assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3))); + assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3))); + assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3))); + assert_eq!(format_date(2001, 2, 3, "MMMM D, YYYY"), "February 3, 2001"); + assert_eq!(word_num(1234.5, true), "One Thousand Two Hundred Thirty-Four Dollars And 50 Cents"); + assert_eq!(num_text(3.0), "3"); + assert_eq!(num_text(2.5), "2.5"); + assert_eq!(num_text(0.1 + 0.2), "0.3"); + assert_eq!(parse_num("12abc"), Some(12.0)); + assert_eq!(parse_num("abc"), None); + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 560eec83..d265a8b8 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -991,7 +991,7 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject { // construct (a few KiB per level, several bytes of source each) by the length limit. /// Longest script, in bytes. -const MAX_SCRIPT_BYTES: usize = 256 * 1024; +pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; /// Deepest nesting of `(`, `[` and `{`. const MAX_BRACKET_DEPTH: usize = 64; /// Longest run of prefix operators (`!`, `~`, `+`, `-`). @@ -1140,6 +1140,7 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], d out } +pub mod formcalc; pub mod xfa; #[cfg(test)] diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index ddbd6b7b..e23915ea 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -188,7 +188,7 @@ mod xfa_model { use crate::Limits; use crate::xfa::*; - fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode { + pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode { XfaNode { name: name.into(), som: som.into(), @@ -201,7 +201,7 @@ mod xfa_model { } /// form1 > page1 > { qty, price, total, details (subform, hidden) > note, table > row[0..2] > { what, amount }, go (button) } - fn form() -> XfaNode { + pub(super) fn form() -> XfaNode { let row = |i: usize| XfaNode { name: "row".into(), som: format!("form1[0].page1[0].table[0].row[{i}]"), @@ -266,6 +266,19 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + #[test] + fn long_values_and_results_are_cut_where_they_are_stored() { + // The value kept for later reads is the one the effect saves. + let o = go("details.note.rawValue = new Array(100001).join('x'); details.note.rawValue.length", "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("65536")); + assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + // A calculate's result is cut like a value set, and noted. + let o = go("new Array(100001).join('x')", "form1[0].page1[0].details[0].note[0]", "calculate"); + assert_eq!(o.result.map(|r| r.chars().count()), Some(65_536)); + assert!(o.notes.iter().any(|n| n.contains("result")), "{:?}", o.notes); + } + #[test] fn values_read_as_numbers_or_strings_and_null_when_empty() { let o = go( @@ -414,3 +427,407 @@ mod xfa_model { assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); } } + +mod formcalc { + use super::xfa_model::form; + use crate::Limits; + use crate::formcalc::run_formcalc; + use crate::xfa::*; + + fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { + let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; + let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 }; + run_formcalc(script, &ev, &doc, &form(), Limits::default()) + } + + /// The value of a script run as a calculate on `total`. + fn calc(script: &str) -> String { + let o = go(script, "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.error, None, "{script}"); + o.result.unwrap_or_default() + } + + #[test] + fn arithmetic_strings_and_comparisons_follow_formcalc_rules() { + assert_eq!(calc("1 + 2 * 3"), "7"); + assert_eq!(calc("(1 + 2) * 3"), "9"); + assert_eq!(calc("10 / 4"), "2.5"); + assert_eq!(calc("-3 + +4"), "1"); + assert_eq!(calc(r#""12" + 3"#), "15"); + assert_eq!(calc(r#""abc" + 3"#), "3"); + assert_eq!(calc(r#"Concat("a", "b", 1.50)"#), "ab1.5"); + assert_eq!(calc(r##""say ""hi""""##), "say \"hi\""); + assert_eq!(calc("1 < 2 and 2 <= 2"), "1"); + assert_eq!(calc("1 == 1.0 & 3 <> 4"), "1"); + assert_eq!(calc(r#""a" lt "b" | 0"#), "1"); + assert_eq!(calc("not 0"), "1"); + assert_eq!(calc("!0 + !1"), "1"); + assert_eq!(calc("table.row[0]..amount"), "10"); + assert_eq!(calc(r#""abc" == "ABC""#), "0"); + // Logical operands are numbers: text that is not a number is false. + assert_eq!(calc(r#"if ("abc") then 1 else 0 endif"#), "0"); + assert_eq!(calc(r#"if ("2") then 1 else 0 endif"#), "1"); + assert_eq!(calc("null + 1"), "1"); + assert_eq!(calc("null == null"), "1"); + assert_eq!(calc("1 == null"), "0"); + assert_eq!(calc("1 <> null"), "1"); + assert!(go("1 / 0", "form1[0].page1[0].total[0]", "calculate").error.is_some()); + // Comments and newlines inside brackets. + assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6"); + } + + #[test] + fn built_in_functions_cover_the_common_set() { + assert_eq!(calc("Abs(-2.5)"), "2.5"); + assert_eq!(calc("Ceil(2.1)"), "3"); + assert_eq!(calc("Floor(-2.1)"), "-3"); + assert_eq!(calc("Mod(7, 3)"), "1"); + assert_eq!(calc("Round(2.345, 2)"), "2.35"); + assert_eq!(calc("Sum(1, 2, 3)"), "6"); + assert_eq!(calc("Avg(1, 2, 3, null)"), "2"); + assert_eq!(calc("Count(1, null, 3)"), "2"); + assert_eq!(calc("Max(1, 9, 3)"), "9"); + assert_eq!(calc("Min(4, 2, 3)"), "2"); + assert_eq!(calc(r#"Choose(2, "a", "b", "c")"#), "b"); + assert_eq!(calc(r#"Oneof(3, 1, 2, 3)"#), "1"); + assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); + assert_eq!(calc(r#"HasValue("")"#), "0"); + assert_eq!(calc(r#"HasValue("x")"#), "1"); + assert_eq!(calc(r#"At("abcd", "c")"#), "3"); + assert_eq!(calc(r#"Left("hello", 2)"#), "he"); + assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); + assert_eq!(calc(r#"Len("héllo")"#), "5"); + assert_eq!(calc(r#"Lower("ABC")"#), "abc"); + assert_eq!(calc(r#"Upper("abc")"#), "ABC"); + assert_eq!(calc(r#"Ltrim(" a ")"#), "a "); + assert_eq!(calc(r#"Rtrim(" a ")"#), " a"); + assert_eq!(calc(r#"Space(3)"#), " "); + assert_eq!(calc(r#"Str(3.14159, 6, 2)"#), " 3.14"); + assert_eq!(calc(r#"Substr("abcdef", 2, 3)"#), "bcd"); + assert_eq!(calc(r#"Stuff("abcdef", 2, 3, "XY")"#), "aXYef"); + assert_eq!(calc(r#"Replace("a-b-c", "-", "+")"#), "a+b+c"); + assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); + assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); + assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); + assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); + assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); + assert_eq!(calc(r#"Parse("zzz,zz9.99", "1,234.50")"#), "1234.5"); + assert_eq!(calc(r#"Parse("MMM D, YYYY", "Mar 15, 1996")"#), "1996-03-15"); + assert_eq!(calc(r#"Date2Num("1900-01-01")"#), "1"); + assert_eq!(calc(r#"Date2Num("2024-02-29")"#), "45350"); + assert_eq!(calc(r#"Date2Num("29/02/2024", "DD/MM/YYYY")"#), "45350"); + assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD")"#), "2024-02-29"); + assert_eq!(calc(r#"Num2Date(Date2Num("2024-02-29") + 1, "MMMM D, YYYY")"#), "March 1, 2024"); + assert_eq!(calc(r#"Time2Num("01:00:00")"#), "3600000"); + assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); + assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); + assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); + assert_eq!(calc(r#"UnitValue("2in", "mm")"#), "50.8"); + assert_eq!(calc(r#"UnitType("2.5cm")"#), "cm"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); + assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); + } + + #[test] + fn control_flow_variables_and_functions_work() { + assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10"); + // An assignment is an expression: its value is the script's result. + assert_eq!(calc("qty = 7"), "7"); + assert_eq!(calc("var y = 8"), "8"); + assert_eq!(calc("var x = 1\nif (x > 2) then 10 elseif (x > 0) then 20 else 30 endif"), "20"); + assert_eq!(calc("var i = 0\nvar s = 0\nwhile (i < 5) do\n i = i + 1\n if (i == 3) then continue endif\n s = s + i\nendwhile\ns"), "12"); + assert_eq!(calc("var s = 0\nfor i = 1 upto 10 step 3 do s = s + i endfor\ns"), "22"); + assert_eq!(calc("var s = 0\nfor i = 5 downto 1 do\n if (i == 2) then break endif\n s = s + i\nendfor\ns"), "12"); + assert_eq!( + calc( + r#"var s = "" +foreach w in ("a", "b", "c") do s = Concat(s, w) endfor +s"# + ), + "abc" + ); + assert_eq!(calc("func sq(n) do n * n endfunc\nsq(4) + sq(3)"), "25"); + assert_eq!(calc("func Sq(n) do n * n endfunc\nSQ(4)"), "16"); + // `exit` inside a function ends the whole script. + let o = go("func f() do exit endfunc\nf()\n$host.messageBox(\"still ran\")\n5", "form1[0].page1[0].go[0]", "click"); + assert_eq!(o.error, None); + assert!(o.effects.is_empty(), "{:?}", o.effects); + // Rows can be walked as objects. + assert_eq!(calc("var s = 0\nforeach r in (table.row[*]) do s = s + r.amount endfor\ns"), "15"); + // A long sum is not deep nesting. + let long = format!("1{}", " + 1".repeat(500)); + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let o = run_formcalc(&long, &ev, &XfaDoc::default(), &form(), Limits { loop_iterations: 1000, recursion: 64 }); + assert_eq!(o.result.as_deref(), Some("501"), "{o:?}"); + // Null is an empty result with no truth value, as in JavaScript. + let o = go("if (0) then 5 endif", "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.result.as_deref(), Some("")); + assert_eq!(o.result_bool, None); + assert_eq!(calc("func f(n) do if (n < 2) then return 1 endif\n return n * f(n - 1) endfunc\nf(5)"), "120"); + assert_eq!(calc("IF (1) THEN 5 ENDIF"), "5"); + let o = go(r#"throw "stop here""#, "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.error.as_deref(), Some("stop here")); + let o = go("if (1) then 1", "form1[0].page1[0].total[0]", "calculate"); + assert!(o.error.unwrap().contains("endif")); + } + + #[test] + fn form_objects_resolve_assign_and_record_effects() { + // Fields deref to their values; `$` is the current field. + assert_eq!(calc("qty * price"), "10"); + assert_eq!(calc("$.name"), "total"); + assert_eq!(calc("$form.form1.page1.qty + 1"), "5"); + assert_eq!(calc("xfa.form.form1.page1.qty"), "4"); + assert_eq!(calc("$record.page1.qty"), "4"); + assert_eq!(calc("Sum(table.row[*].amount)"), "15"); + assert_eq!(calc("Count(table.row[*])"), "2"); + assert_eq!(calc("Count(table.row[*].what)"), "1"); + // Lists hold each object once, however they were reached. + assert_eq!(calc("Count((table.row[*].parent..row).parent..row)"), "2"); + assert_eq!(calc("Count(xfa.resolveNodes(\"table.row[*].parent.row[*].parent.row[*]\"))"), "2"); + // Bare names in a row see every sibling row, and `[-1]`/`[+1]` are relative to the row + // the script runs in. + let in_row = |s: &str| { + let o = go(s, "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); + assert_eq!(o.error, None, "{s}"); + o.result.unwrap_or_default() + }; + assert_eq!(in_row("Count(row[*])"), "2"); + assert_eq!(in_row("Sum(row[*].amount)"), "15"); + assert_eq!(in_row("row[1].amount"), "5"); + assert_eq!(in_row("row[-1].amount"), "10"); + // Past the ends there is no object, so a property of it is an error (as in Acrobat). + let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); + assert!(past.error.unwrap().contains("no object")); + assert_eq!(in_row("Exists(row[-5])"), "0"); + let o = go("row[+1].amount", "form1[0].page1[0].table[0].row[0].amount[0]", "calculate"); + assert_eq!(o.result.as_deref(), Some("5")); + assert_eq!(calc("table.row[1].amount"), "5"); + assert_eq!(calc("table.row.amount"), "10"); + assert_eq!(calc("$form..amount[1]"), "5"); + assert_eq!(calc("table._row.count"), "2"); + assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); + assert_eq!(calc("HasValue(total)"), "0"); + assert_eq!(calc("$host.numPages"), "3"); + assert_eq!(calc("$layout.pageCount()"), "3"); + assert_eq!(calc("$event.name"), "calculate"); + assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); + assert_eq!(calc("$.border.fill.color.value"), ""); + assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + // Assignment to a field (by name and by path), a property and a variable. + let o = go( + r#"page1.qty = 6 +xfa.form.form1.page1.qty = 7 +details.presence = "visible" +price.access = "readOnly" +$.border.fill.color.value = "255,0,0" +$host.messageBox(Concat("Total ", qty * price)) +$host.setFocus("qty") +table._row.addInstance(1) +table._row.count"#, + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("3")); + assert_eq!( + o.effects, + vec![ + XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() }, + XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() }, + XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() }, + XfaEffect::MessageBox("Total 17.5".into()), + XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()), + XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, + ] + ); + // Instance managers: remove, setInstances, count assignment, and the floor of one. + let o = go( + "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("1")); + // Validate scripts give a truth value; a value the script sets wins over the result. + let o = go("$ == null or $ <= 100", "form1[0].page1[0].qty[0]", "validate"); + assert_eq!(o.result_bool, Some(true)); + let o = go("$ > 100", "form1[0].page1[0].qty[0]", "validate"); + assert_eq!(o.result_bool, Some(false)); + // Reset, print and URLs are recorded as requests, not performed. + let o = go( + r#"$host.resetData("qty, price") +$host.print() +$host.gotoURL("https://example.org")"#, + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!( + o.effects, + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + ); + } + + #[test] + fn formcalc_values_results_and_errors_stay_bounded() { + let total = "form1[0].page1[0].total[0]"; + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); + // A long value is cut where it is stored, so it reads back as saved. + let o = run("details.note = Space(100000)\nLen(details.note)"); + assert_eq!(o.error, None); + assert_eq!(o.result.as_deref(), Some("65536")); + // A calculate's result is cut as values are, with a note. + let o = run("Space(100000)"); + assert_eq!(o.result.map(|r| r.chars().count()), Some(65_536)); + assert!(o.notes.iter().any(|n| n.contains("result")), "{:?}", o.notes); + // A thrown value is the error message: cut like one. + let e = run("throw Space(100000)").error.unwrap(); + assert!(e.chars().count() <= 4_097, "{}", e.len()); + // Copying and comparing long strings costs steps, so it ends without a clock (wasm). + let e = run("var s = Space(1000000)\nfor i = 1 upto 3000 do s endfor").error; + assert!(e.is_some_and(|e| e.contains("too long"))); + let e = run("var s = Space(1000000)\nfor i = 1 upto 3000 do s == \"x\" endfor").error; + assert!(e.is_some_and(|e| e.contains("too long"))); + // Each period IPmt and PPmt walk costs a step. + let e = run("for i = 1 upto 600 do IPmt(1e9, 0.01, 1, 1, 10000) endfor").error; + assert!(e.is_some_and(|e| e.contains("too long"))); + // Exists answers no for a name that isn't there, but a limit inside it still stops the script. + assert_eq!(run("Exists(nothing.here)").result.as_deref(), Some("0")); + assert_eq!(run("Exists(qty)").result.as_deref(), Some("1")); + assert!(run("func f(n) do Exists(f(n + 1)) endfunc\nf(1)").error.unwrap().contains("nested")); + // An elseif condition's chain counts from zero, not from the last statement of the arm before. + let script = format!("if 0 then\nvar x = 1{}\nelseif 1{} then\n2\nendif", "+1".repeat(900), "+1".repeat(200)); + assert_eq!(run(&script).result.as_deref(), Some("2")); + } + + #[test] + fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { + let total = "form1[0].page1[0].total[0]"; + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); + assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). + assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + // Values go into fields only: a subform or the form itself is refused, nothing is recorded. + let o = run("details = \"junk\""); + assert!(o.error.unwrap().contains("not a field")); + assert!(o.effects.is_empty()); + assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + // Hostile numbers into the string functions. + assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + // Strings can't grow past the cap through any function, and messages and values are clipped. + assert!( + run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + .error + .unwrap() + .contains("too long") + ); + assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); + let o = run("$host.messageBox(Space(100000))"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); + let o = run("qty = Space(100000)"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); + assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + // A comment of dashes is not deep nesting. + assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + // Parentheses don't reset the chain count: a tree is never deeper than the cap. + let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); + for _ in 0..30 { + nested = format!("({nested}{})", "+1".repeat(40)); + } + assert!(run(&nested).error.unwrap().contains("too long")); + assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + // Nested statements inside recursion count against the depth limit. + let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); + assert!(run(&deep).error.unwrap().contains("nested")); + // A deadline already passed stops the script at its first look at the clock. + let o = crate::formcalc::run_formcalc_at( + "for i = 1 upto 100000 do Eval(\"1\") endfor", + &ev, + &doc, + form(), + Limits { loop_iterations: u64::MAX, recursion: 32 }, + Some(std::time::Duration::ZERO), + ); + assert!(o.error.unwrap().contains("too long")); + assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run("Space(1e12)").error.is_none()); + assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run("1 ) 2").error.is_some()); + assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + // Adding rows forever stops at the instance cap with the effects it managed. + let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + assert!(o.error.unwrap().contains("looped")); + assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); + let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + assert_eq!(o.error, None); + assert_eq!(o.result.as_deref(), Some("1002")); + // Message boxes past the cap are dropped and noted. + let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); + assert!(!o.notes.is_empty()); + // On a small stack (the wasm build's), the limits stop the script before the stack does. + let small = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let fast = Limits { loop_iterations: 10_000, recursion: 256 }; + let ev = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); + assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); + assert!(run(&deep).error.unwrap().contains("nested")); + let mut nested = "1".to_string(); + for _ in 0..30 { + nested = format!("({nested}{})", "+1".repeat(40)); + } + assert!(run(&nested).error.is_some()); + let chain = format!("1{}", "+1".repeat(999)); + assert_eq!(run(&chain).result.as_deref(), Some("1000")); + let dots = format!("$form{}", ".parent".repeat(60)); + assert!(run(&dots).error.unwrap().contains("no object")); + let dots = format!("${}", ".parent".repeat(3)); + assert_eq!(run(&dots).result.as_deref(), Some("")); + }) + .unwrap(); + small.join().expect("no stack overflow on a 1 MiB stack"); + // Abandoned when it runs past the deadline. + let o = crate::formcalc::run_formcalc_within( + "while (1) do endwhile", + &ev, + &doc, + form(), + Limits { loop_iterations: u64::MAX, recursion: 32 }, + std::time::Duration::from_millis(200), + ); + assert!(o.abandoned || o.error.is_some(), "{o:?}"); + } +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 94801f32..c6995ba2 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -30,7 +30,7 @@ pub enum XfaKind { } impl XfaKind { - fn class_name(self) -> &'static str { + pub(crate) fn class_name(self) -> &'static str { match self { XfaKind::Form => "form", XfaKind::Subform => "subform", @@ -145,7 +145,7 @@ pub struct XfaOutcome { /// Most nodes a snapshot may hold. const MAX_NODES: usize = 200_000; /// Most instances a script may add in one run. -const MAX_ADDED: usize = 1_000; +pub(crate) const MAX_ADDED: usize = 1_000; /// Deepest tree followed. const MAX_DEPTH: usize = 64; /// Most effects one run may produce (after values set again on the same object are merged). @@ -157,40 +157,65 @@ pub const MAX_CONSOLE: usize = 1_000; /// Longest message or console line kept, in characters. const MAX_LINE: usize = 4_096; +/// Longest value a script may put in a field (characters); longer ones are cut and noted. +const MAX_VALUE: usize = 65_536; + /// `m` cut to [`MAX_LINE`] characters. -fn clip(m: String) -> String { +pub(crate) fn clip(m: String) -> String { match m.char_indices().nth(MAX_LINE) { Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), None => m, } } -struct HNode { - name: String, - som: String, - kind: XfaKind, - value: String, - numeric: bool, - presence: String, - access: String, - repeatable: bool, - occur_min: usize, - occur_max: Option, - index: usize, - parent: Option, - children: Vec, - /// Removed by `removeInstance`: skipped everywhere. - gone: bool, +/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. +pub(crate) fn clip_value(v: String) -> (String, bool) { + match v.char_indices().nth(MAX_VALUE) { + Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), + None => (v, false), + } } -struct XHost { - nodes: Vec, - effects: Vec, - console: Vec, - doc: XfaDoc, +/// A script's completion value (what a calculate script puts in its field) cut to +/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. +pub(crate) fn clip_result(out: &mut XfaOutcome) { + if let Some(r) = out.result.take() { + let (r, cut) = clip_value(r); + if cut { + out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); + } + out.result = Some(r); + } +} + +/// One node of the flattened form, shared by the JavaScript and FormCalc engines. +pub(crate) struct HNode { + pub(crate) name: String, + pub(crate) som: String, + pub(crate) kind: XfaKind, + pub(crate) value: String, + pub(crate) numeric: bool, + pub(crate) presence: String, + pub(crate) access: String, + pub(crate) repeatable: bool, + pub(crate) occur_min: usize, + pub(crate) occur_max: Option, + pub(crate) index: usize, + pub(crate) parent: Option, + pub(crate) children: Vec, + /// Removed by `removeInstance`: skipped everywhere. + pub(crate) gone: bool, +} + +/// The form as a script sees and changes it, with the effects it records. +pub(crate) struct XHost { + pub(crate) nodes: Vec, + pub(crate) effects: Vec, + pub(crate) console: Vec, + pub(crate) doc: XfaDoc, added: usize, /// Index of `this`. - current: usize, + pub(crate) current: usize, /// Where the effect setting a property of an object is in `effects`, by (property, SOM): /// setting it again replaces that effect (the last value wins) unless something that /// depends on order (rows added or removed, a reset) came after it. @@ -199,15 +224,51 @@ struct XHost { barrier: usize, alerts: usize, dropped_effects: usize, + clipped_values: usize, dropped_alerts: usize, dropped_console: usize, } impl XHost { + /// The host for a script on `root` (the template's root subform), with `this` at the node + /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. + pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { + let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; + let mut nodes = Vec::new(); + flatten(&form, None, &mut nodes, 0); + let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); + XHost { + nodes, + effects: Vec::new(), + console: Vec::new(), + doc: doc.clone(), + added: 0, + current, + set_at: HashMap::new(), + barrier: 0, + alerts: 0, + dropped_effects: 0, + clipped_values: 0, + dropped_alerts: 0, + dropped_console: 0, + } + } + /// Record an effect: values, presence and access set again on the same object replace the /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted /// and dropped. - fn push(&mut self, e: XfaEffect) { + pub(crate) fn push(&mut self, e: XfaEffect) { + let e = match e { + XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::SetValue { som, value } => { + let (value, cut) = clip_value(value); + if cut { + self.clipped_values += 1; + } + XfaEffect::SetValue { som, value } + } + other => other, + }; let key = match &e { XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), @@ -247,7 +308,7 @@ impl XHost { self.effects.push(e); } - fn print(&mut self, line: String) { + pub(crate) fn print(&mut self, line: String) { if self.console.len() >= MAX_CONSOLE { self.dropped_console += 1; } else { @@ -256,7 +317,7 @@ impl XHost { } /// What the limits left out. - fn notes(&self) -> Vec { + pub(crate) fn notes(&self) -> Vec { let mut out = Vec::new(); if self.dropped_effects > 0 { out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); @@ -267,8 +328,31 @@ impl XHost { if self.dropped_console > 0 { out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); } + if self.clipped_values > 0 { + out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); + } out } + + /// Give node `i` the value `text` when it is a field or exclusion group (nothing else + /// holds a value; writing one into a subform would put text where another viewer expects + /// a data group). Returns whether it was. + pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { + let Some(n) = self.nodes.get_mut(i) else { return false }; + if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { + return false; + } + // Cut here, not only in the effect: later reads in the script see what is saved, and + // the snapshot stays bounded however often a long value is written. + let (text, cut) = clip_value(text); + n.value = text.clone(); + let som = n.som.clone(); + if cut { + self.clipped_values += 1; + } + self.push(XfaEffect::SetValue { som, value: text }); + true + } } type Shared = RefCell; @@ -371,13 +455,13 @@ fn value_js(h: &HNode) -> JsValue { } /// `(idx, name)` of the child of `parent` named `name`, first instance. -fn child_named(h: &XHost, parent: usize, name: &str) -> Option { +pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { let p = h.nodes.get(parent)?; p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) } /// Every live instance named `name` under `parent`, in order. -fn instances(h: &XHost, parent: usize, name: &str) -> Vec { +pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { h.nodes .get(parent) .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) @@ -449,24 +533,7 @@ fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult want && have > 0 { - remove_instance_of(ctx, p, &n, have - 1)?; - if instances(&host(ctx)?.borrow(), p, &n).len() == have { - break; - } - } else { - break; - } - } + host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); Ok(JsValue::undefined()) } fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { @@ -576,55 +643,93 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { let mut h = host(ctx)?.borrow_mut(); - let live = instances(&h, parent, name); - let Some(&first) = live.first() else { return Ok(None) }; - let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); - if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED { - return Ok(None); - } - let Some(new) = clone_subtree(&mut h, first, Some(parent), 0) else { return Ok(None) }; - let last = live.last().copied().unwrap_or(first); - if let Some(n) = h.nodes.get_mut(new) { - n.index = live.len(); - } - // Keep instances together: right after the last one. - if let Some(p) = h.nodes.get_mut(parent) { - let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); - p.children.insert(pos, new); - } - renumber(&mut h, new, 0); - h.added += 1; - let som = h.nodes.get(new).map(|n| n.som.clone()).unwrap_or_default(); - h.push(XfaEffect::AddInstance { som }); - Ok(Some(new)) + Ok(h.add_instance(parent, name)) } fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { let mut h = host(ctx)?.borrow_mut(); - let live = instances(&h, parent, name); - let Some(&gone) = live.get(i) else { return Ok(()) }; - let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); - if live.len() <= min { - return Ok(()); - } - let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); - if let Some(n) = h.nodes.get_mut(gone) { - n.gone = true; - } - // Later instances move down one. - for (k, &inst) in live.iter().enumerate().skip(i + 1) { - if let Some(n) = h.nodes.get_mut(inst) { - n.index = k - 1; - } - renumber(&mut h, inst, 0); - } - h.push(XfaEffect::RemoveInstance { som }); + h.remove_instance(parent, name, i); Ok(()) } +impl XHost { + /// A new instance of the subform `name` under `parent`, after the last one; `None` when the + /// subform doesn't repeat, is at its maximum, or the run added its limit. + pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { + let h = self; + let live = instances(h, parent, name); + let &first = live.first()?; + let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); + if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED { + return None; + } + let new = clone_subtree(h, first, Some(parent), 0)?; + let last = live.last().copied().unwrap_or(first); + if let Some(n) = h.nodes.get_mut(new) { + n.index = live.len(); + } + // Keep instances together: right after the last one. + if let Some(p) = h.nodes.get_mut(parent) { + let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); + p.children.insert(pos, new); + } + renumber(h, new, 0); + h.added += 1; + let som = h.nodes.get(new).map(|n| n.som.clone()).unwrap_or_default(); + h.push(XfaEffect::AddInstance { som }); + Some(new) + } + + /// Add or remove instances of the subform `name` under `parent` until there are `want`. + /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that + /// changes nothing ends the loop. + pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { + let want = want.min(MAX_ADDED); + for _ in 0..=MAX_ADDED { + let have = instances(self, parent, name).len(); + if have < want { + if self.add_instance(parent, name).is_none() { + break; + } + } else if have > want && have > 0 { + self.remove_instance(parent, name, have - 1); + if instances(self, parent, name).len() == have { + break; + } + } else { + break; + } + } + } + + /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or + /// below one). + pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { + let h = self; + let live = instances(h, parent, name); + let Some(&gone) = live.get(i) else { return }; + let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); + if live.len() <= min { + return; + } + let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + if let Some(n) = h.nodes.get_mut(gone) { + n.gone = true; + } + // Later instances move down one. + for (k, &inst) in live.iter().enumerate().skip(i + 1) { + if let Some(n) = h.nodes.get_mut(inst) { + n.index = k - 1; + } + renumber(h, inst, 0); + } + h.push(XfaEffect::RemoveInstance { som }); + } +} + /// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, /// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. -fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { +pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { let expr = expr.trim(); if expr.is_empty() { return vec![from]; @@ -700,6 +805,10 @@ fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { } } } + // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the + // number of rows at every step. + let mut seen = std::collections::HashSet::with_capacity(next.len()); + next.retain(|i| seen.insert(*i)); cur = next; if cur.is_empty() { break; @@ -708,7 +817,7 @@ fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { cur } -fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { +pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { let mut out = Vec::new(); if depth > MAX_DEPTH { return out; @@ -825,10 +934,8 @@ fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - if let Some(n) = h.nodes.get_mut(idx) { - n.value = value.clone(); - } - h.push(XfaEffect::SetValue { som, value }); + // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. + h.set_value(idx, value); } "presence" => { let p = match value.as_str() { @@ -1238,7 +1345,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim let spawned = std::thread::Builder::new() .name("pdfcraft-xfa-js".into()) .stack_size(crate::SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, root, limits)); + .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits)); match spawned { Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), @@ -1246,7 +1353,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim }) } #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, root, limits) + run_here(script, event, doc, root.clone(), limits) } /// [`run_xfa`] with a time limit: a script still running after `timeout` (loops inside @@ -1255,17 +1362,24 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim /// `abandoned` set comes back at once. In the browser build there are no threads: the script /// runs to its limits. pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; if let Some(why) = refuse(script) { - return failed(why); + return XfaOutcome { error: Some(why), ..Default::default() }; } + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + run_within("pdfcraft-xfa-js", timeout, move || run_here(&script, &event, &doc, root, limits)) +} + +/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its +/// outcome; past that the thread is abandoned and the outcome says so. On wasm there are no +/// threads: the script runs here. +pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce() -> XfaOutcome + Send + 'static) -> XfaOutcome { #[cfg(not(target_arch = "wasm32"))] { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; let (tx, rx) = std::sync::mpsc::channel(); - let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - let spawned = std::thread::Builder::new().name("pdfcraft-xfa-js".into()).stack_size(crate::SCRIPT_STACK).spawn(move || { + let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { // The receiver is gone when the caller stopped waiting: nothing to report then. - let _ = tx.send(run_here(&script, &event, &doc, &root, limits)); + let _ = tx.send(script()); }); if let Err(e) = spawned { return failed(format!("the script engine could not start: {e}")); @@ -1282,34 +1396,18 @@ pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNod } #[cfg(target_arch = "wasm32")] { - let _ = timeout; - run_here(script, event, doc, &root, limits) + let _ = (name, timeout); + script() } } -fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { +fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits) -> XfaOutcome { let mut ctx = Context::default(); ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - // `xfa.form` is a synthetic root whose child is the template's root subform. - let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root.clone()], ..Default::default() }; - let mut nodes = Vec::new(); - flatten(&form, None, &mut nodes, 0); - let current = nodes.iter().position(|n| n.som == event.target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); - ctx.insert_data(RefCell::new(XHost { - nodes, - effects: Vec::new(), - console: Vec::new(), - doc: doc.clone(), - added: 0, - current, - set_at: HashMap::new(), - barrier: 0, - alerts: 0, - dropped_effects: 0, - dropped_alerts: 0, - dropped_console: 0, - })); + let host_state = XHost::new(root, &event.target, doc); + let current = host_state.current; + ctx.insert_data(RefCell::new(host_state)); let mut out = XfaOutcome::default(); let result = (|| -> JsResult<()> { install(&mut ctx, event, doc, current)?; @@ -1337,5 +1435,6 @@ fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits out.effects = h.effects; out.console = h.console; } + clip_result(&mut out); out } diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 32bea94f..cfb2dacb 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -83,8 +83,11 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for - **Data binding** is the default one only: explicit `bind ref` expressions, global binding and data descriptions are not followed, and no standalone XML or XDP data file is imported or exported. -- **Scripting.** JavaScript only: FormCalc scripts (the default language of older forms) are - detected and reported, not run. The object model covers what forms commonly use (`xfa.form` +- **Scripting.** JavaScript (boa) and FormCalc (a native interpreter, `pdfcraft_js::formcalc`) + share one object model, one set of effects and one set of budgets. FormCalc covers the + language and the common built-ins (arithmetic, logical, string, date/time, financial, unit); + not locale-aware pictures beyond simple number and date ones, or `Get`/`Post`/`Put` (refused). + The object model covers what forms commonly use (`xfa.form` navigation and SOM resolution, `rawValue`, `presence`, `access`, instance managers, `xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page numbers, `xfa.event`); not `xfa.template`, data descriptions, `xfa.connectionSet`, `border`/`font`/`ui` properties diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 199f2632..64b88469 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -160,6 +160,54 @@ pub fn scripted_template() -> String { .to_string() } +/// [`scripted_template`] with every script in FormCalc (no `contentType`, as Designer writes +/// the default language): the same fields, plus `words`, the total in English words. +pub fn formcalc_template() -> String { + r##" + + + +"## + .to_string() +} + /// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields. pub fn shell(xdp: &str) -> Vec { let objs: Vec> = vec![ diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cc5448bb..e8514085 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5131,7 +5131,7 @@ status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript runs in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; FormCalc (reported, not run); the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" @@ -5139,11 +5139,11 @@ title = "FormCalc scripting" area = "F" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["file.open"] +tools = ["doc_open", "form_fill", "js_run"] +evidence = ["crates/js/src/tests.rs::arithmetic_strings_and_comparisons_follow_formcalc_rules", "crates/js/src/tests.rs::built_in_functions_cover_the_common_set", "crates/js/src/tests.rs::control_flow_variables_and_functions_work", "crates/js/src/tests.rs::form_objects_resolve_assign_and_record_effects", "crates/js/src/tests.rs::hostile_formcalc_scripts_end_with_an_error_and_bounded_output", "crates/js/src/tests.rs::formcalc_values_results_and_errors_stay_bounded", "crates/engine/src/tests.rs::dynamic_xfa_forms_run_formcalc_scripts", "crates/engine/src/tests.rs::hostile_formcalc_scripts_cannot_hang_or_bloat_the_document", "crates/engine/src/tests.rs::a_formcalc_calculate_result_is_cut_before_it_is_saved"] +notes = "a native FormCalc interpreter (crates/js/src/formcalc.rs) over the same form table, effects and budgets as the XFA JavaScript model: expressions and FormCalc coercions, var, if/while/for/foreach/func, accessors ($, $host, $form, $record, $layout, $event, xfa., .., [n], [*], _name instance managers) and the arithmetic, logical, string, date/time, financial and unit built-ins. Bounded: steps and loop iterations, statement and expression nesting, operator/accessor chain length, strings (1 MiB; messages and thrown errors clipped to 4 KiB, field values and calculate results to 64 KiB, noted; copying and comparing long strings is charged to the step budget, so it ends on wasm, which has no clock), lists (each object once), clamped dates, the instance cap, and a deadline the script itself honours after the engine stops waiting. Missing: locale-aware pictures beyond simple number and date ones, Format/Parse time and text pictures, Get/Post/Put (refused), $template/$connectionSet, user-defined functions across scripts, a cap on a script's total memory (each string and list is capped, but many variables can each hold one), real Uuid() values (deterministic, from the step count) and the wall clock on wasm (Date() reads 1970-01-01 and Time() midnight there), and checking against Acrobat on real Designer forms beyond the fixtures" [[feature]] id = "form.xfa-data"