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 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d770f649b4
commit
87f635cf65
|
|
@ -775,7 +775,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
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." } }),
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
// 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(
|
||||
"<calculate><script>qty * price</script></calculate>",
|
||||
"<calculate><script>table._row.addInstance(1)
|
||||
table._row.count</script></calculate>",
|
||||
)
|
||||
.replace("<script>table._row.addInstance(1)</script>", "<script>while (1) do table._row.addInstance(1) endwhile</script>")
|
||||
.replace(
|
||||
"<script>WordNum(total)</script>",
|
||||
"<script>func f(n) do f(n + 1) endfunc
|
||||
f(1)</script>",
|
||||
);
|
||||
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("<script>WordNum(total)</script>", "<script>Replace(Space(100000), \" \", \"x\")</script>");
|
||||
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()));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
index: usize,
|
||||
parent: Option<usize>,
|
||||
children: Vec<usize>,
|
||||
/// 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<HNode>,
|
||||
effects: Vec<XfaEffect>,
|
||||
console: Vec<String>,
|
||||
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<usize>,
|
||||
pub(crate) index: usize,
|
||||
pub(crate) parent: Option<usize>,
|
||||
pub(crate) children: Vec<usize>,
|
||||
/// 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<HNode>,
|
||||
pub(crate) effects: Vec<XfaEffect>,
|
||||
pub(crate) console: Vec<String>,
|
||||
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<String> {
|
||||
pub(crate) fn notes(&self) -> Vec<String> {
|
||||
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<XHost>;
|
||||
|
|
@ -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<usize> {
|
||||
pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option<usize> {
|
||||
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<usize> {
|
||||
pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec<usize> {
|
||||
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<Js
|
|||
if !want.is_finite() || want < 0.0 {
|
||||
return Ok(JsValue::undefined());
|
||||
}
|
||||
let want = (want as usize).min(MAX_ADDED);
|
||||
// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step
|
||||
// that changes nothing ends the loop.
|
||||
for _ in 0..=MAX_ADDED {
|
||||
let have = instances(&host(ctx)?.borrow(), p, &n).len();
|
||||
if have < want {
|
||||
if add_instance_of(ctx, p, &n)?.is_none() {
|
||||
break;
|
||||
}
|
||||
} else if have > 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<JsValue> {
|
||||
|
|
@ -576,55 +643,93 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
|||
|
||||
fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult<Option<usize>> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec<usize> {
|
||||
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<usize> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// 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<usize> {
|
|||
cur
|
||||
}
|
||||
|
||||
fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec<usize> {
|
||||
pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec<usize> {
|
||||
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<JsValu
|
|||
let som = n.som.clone();
|
||||
match key.as_str() {
|
||||
"rawValue" | "value" | "formattedValue" => {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="page1" layout="tb">
|
||||
<event activity="initialize"><script>if (HasValue(qty) == 0) then qty = 2 endif</script></event>
|
||||
<field name="qty" w="2in" h="0.3in"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Qty</text></value></caption>
|
||||
<validate><script>HasValue($) == 0 or $ <= 100</script><message><text name="scriptTest">Quantity must be 100 or less</text></message></validate></field>
|
||||
<field name="price" w="2in" h="0.3in"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Price</text></value></caption><value><integer>5</integer></value></field>
|
||||
<field name="total" w="2in" h="0.3in" access="readOnly"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Total</text></value></caption>
|
||||
<calculate><script>qty * price</script></calculate></field>
|
||||
<field name="words" w="4in" h="0.3in" access="readOnly"><ui><textEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Words</text></value></caption>
|
||||
<calculate><script>WordNum(total)</script></calculate></field>
|
||||
<field name="more" w="2in" h="0.3in"><ui><checkButton/></ui><caption placement="right" reserve="1.5in"><value><text>More details</text></value></caption><items><integer>1</integer><integer>0</integer></items>
|
||||
<event activity="change"><script>if ($ == 1) then
|
||||
details.presence = "visible"
|
||||
else
|
||||
details.presence = "hidden"
|
||||
endif</script></event></field>
|
||||
<subform name="details" layout="tb" presence="hidden">
|
||||
<field name="note" w="4in" h="0.3in"><ui><textEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Note</text></value></caption></field>
|
||||
</subform>
|
||||
<subform name="table" layout="table" columnWidths="3in 2in">
|
||||
<subform name="row" layout="row"><occur min="1" max="-1"/>
|
||||
<field name="item" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="amount" h="0.3in"><ui><numericEdit/></ui></field>
|
||||
</subform>
|
||||
</subform>
|
||||
<field name="grand" w="2in" h="0.3in" access="readOnly"><ui><numericEdit/></ui><caption placement="left" reserve="0.8in"><value><text>Sum</text></value></caption>
|
||||
<calculate><script>Sum(table.row[*].amount)</script></calculate></field>
|
||||
<field name="addRow" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Add row</text></value></caption>
|
||||
<event activity="click"><script>table._row.addInstance(1)</script></event></field>
|
||||
<field name="removeRow" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Remove row</text></value></caption>
|
||||
<event activity="click"><script>if (table._row.count > 1) then table._row.removeInstance(table._row.count - 1) endif</script></event></field>
|
||||
<field name="hello" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Hello</text></value></caption>
|
||||
<event activity="click"><script>$host.messageBox(Concat("Hello ", qty))</script></event></field>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields.
|
||||
pub fn shell(xdp: &str) -> Vec<u8> {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue