diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index aa3505d4b9..45aa754d60 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -8139,6 +8139,119 @@ fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() {
assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started");
}
+/// Opening lays the form out once after its initialize scripts, not after each one that
+/// hides a subform (XFA 3.3 pp. 388, 390: initialize fires once data binding is complete;
+/// the layout process has not run yet). Laying it out after every script spent the opening's
+/// time on the first few, and a form whose initialize scripts hide its optional pages was
+/// saved half initialized: the next opening hid more of it, and reset the data typed into
+/// what it hid.
+#[test]
+fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() {
+ // Shaped like a form with optional extra pages: a check box, then more sections than one
+ // event may lay the form out again for, each hiding itself while the box is clear.
+ let sections = 60;
+ let mut body = String::from(
+ r#"100"#,
+ );
+ for n in 0..sections {
+ body.push_str(&format!(
+ r#""#
+ ));
+ }
+ let tpl = format!(
+ r#"
+
+
+
+
+ {body}
+
+
+
+"#
+ );
+ let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let id = s.open("extras.pdf", None, bytes, None).expect("opens");
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "{:?}", out.errors);
+ let names = |s: &Session, id| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>();
+ let opened = names(&s, id);
+ assert!(opened.iter().all(|n| !n.contains("note")), "every extra section is hidden on opening: {opened:?}");
+ assert!(opened.iter().any(|n| n.contains("wanted")), "{opened:?}");
+ // User input survives repeated saves and openings, with stable field and page counts
+ // (strengthened by wireless89 in the review of #4791).
+ let wanted = opened.iter().find(|n| n.contains("wanted")).unwrap().clone();
+ s.apply(id, Edit::SetFieldValue { name: wanted.clone(), value: FieldValue::Check(true) }).unwrap();
+ let pages = s.get(id).unwrap().info.pages.len();
+ let mut saved = s.save_bytes(id).unwrap();
+ for cycle in 0..3 {
+ let again = s.open("again.pdf", None, saved, None).expect("reopens");
+ let out = s.take_js_output(again);
+ assert!(out.errors.is_empty(), "cycle {cycle}: {out:?}");
+ assert_eq!(names(&s, again), opened, "cycle {cycle}: stable fields");
+ let doc = s.get(again).unwrap();
+ assert_eq!(doc.info.pages.len(), pages, "cycle {cycle}: stable pages");
+ assert_eq!(doc.form.iter().find(|f| f.name == wanted).unwrap().value, ["1"], "cycle {cycle}: input survives");
+ saved = s.save_bytes(again).unwrap();
+ }
+}
+
+/// A script that resets several subforms one after another (`xfa.host.resetData` once for
+/// each, as forms with optional pages do) resets every one of them, and nothing else; resets
+/// in a row are applied together.
+#[test]
+fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() {
+ let sections = 5;
+ let mut body = String::from(
+ r#"100"#,
+ );
+ let resets: String = (0..sections).map(|n| format!("xfa.host.resetData('form.extra{n}');")).collect();
+ body.push_str(&format!(
+ r#""#
+ ));
+ body.push_str(r#""#);
+ // A field no script touches: the resets must leave what was typed in it.
+ body.push_str(r#"default"#);
+ for n in 0..sections {
+ body.push_str(&format!(
+ r#"default"#
+ ));
+ }
+ let notes: String = (0..sections).map(|n| format!("typed {n}")).collect();
+ let tpl = format!(
+ r#"
+
+
+
+
+ {body}
+
+
+
+
+"#
+ );
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ // Without the resets the sections show what was typed.
+ let untouched = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(&resets, "")));
+ let plain = s.open("plain.pdf", None, untouched, None).expect("opens");
+ let typed: Vec<_> = s.get(plain).unwrap().form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect();
+ assert_eq!(typed, (0..sections).map(|n| vec![format!("typed {n}")]).collect::>());
+ let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
+ let id = s.open("resets.pdf", None, bytes, None).expect("opens");
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "{:?}", out.errors);
+ let form = &s.get(id).unwrap().form;
+ let notes: Vec<_> = form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect();
+ assert_eq!(notes.len(), sections, "{form:?}");
+ assert!(notes.iter().all(|v| v == &vec!["default".to_string()]), "every section is reset: {notes:?}");
+ let keep = form.iter().find(|f| f.name.contains("keep")).map(|f| f.value.clone());
+ assert_eq!(keep, Some(vec!["kept".to_string()]), "the script's later write stands");
+ let other = form.iter().find(|f| f.name.contains("other")).map(|f| f.value.clone());
+ assert_eq!(other, Some(vec!["typed other".to_string()]), "a field outside the named subforms keeps its value");
+}
+
/// The scripts of one event share the form as the engine holds it: a failed script's
/// half-done writes reach nobody, and a script reads the saved value a choice list was given
/// by its shown text.
@@ -12341,6 +12454,179 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() {
}
}
+/// A reset in an opening script reaches a field of a row an earlier opening script added, before
+/// the form is laid out again with the row's widgets: its data goes back to its default and stays
+/// so when the form is saved.
+#[test]
+fn an_opening_reset_reaches_a_row_added_while_opening() {
+ let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(
+ "if (qty.rawValue === null) qty.rawValue = 2;",
+ r#"if (qty.rawValue === null) qty.rawValue = 2; table._row.addInstance(1);"#,
+ )
+ .replace(
+ r#""#,
+ r#""#,
+ );
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "{:?}", out.errors);
+ let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added");
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].item[0]")).unwrap_or(""), "", "reset in the data");
+}
+
+/// A reset in an opening script reaches an exclusion group of a row an earlier opening script
+/// added, before the row has widgets: the group's data goes back to the on value of its default
+/// button, and stays so when the form is saved.
+#[test]
+fn an_opening_reset_reaches_an_exclusion_group_of_a_row_added_while_opening() {
+ let group = r#"AAB"#;
+ let tpl = pdfcraft_xfa::fixtures::scripted_template()
+ .replace(
+ "if (qty.rawValue === null) qty.rawValue = 2;",
+ r#"if (qty.rawValue === null) qty.rawValue = 2; table._row.addInstance(1);"#,
+ )
+ .replace(r#""#, &format!(r#"{group}"#))
+ .replace(
+ r#""#,
+ r#""#,
+ );
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "{:?}", out.errors);
+ let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added");
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].pick[0]")).unwrap_or(""), "A", "group reset in the data");
+}
+
+/// A reset in an opening script clears a multi-select list of a hidden subform (no widget)
+/// whose picks the data holds as `value` elements: its empty default leaves no pick in the
+/// saved data, and the rest of the data stays as it was.
+#[test]
+fn an_opening_reset_clears_structured_picks_of_a_field_without_a_widget() {
+ let tpl = pdfcraft_xfa::fixtures::scripted_template()
+ .replace(
+ r#""#,
+ r#"abc"#,
+ )
+ .replace(
+ "if (qty.rawValue === null) qty.rawValue = 2;",
+ r#"if (qty.rawValue === null) qty.rawValue = 2; xfa.host.resetData("form.page1.details.tags");"#,
+ )
+ .replace(
+ "",
+ r#"
+"#,
+ );
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("tags.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "{:?}", out.errors);
+ let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ assert_eq!(data.value_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].tags[0]")).unwrap_or_default(), "", "picks reset in the data");
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].note[0]")), Some("kept"));
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].qty[0]")), Some("3"));
+}
+
+/// A later opening script resets a section shown by an earlier one, including its datasets
+/// value: the shown field and the saved form hold the template default, not the pre-reset
+/// value, through three save/reopen cycles. Test by wireless89 (review of #4791, 2026-10-27).
+#[test]
+fn xfa_opening_reset_reaches_a_section_shown_by_an_earlier_script() {
+ let tpl = r#"
+
+
+
+
+
+
+
+
+
+
+
+ default
+
+
+
+
+
+"#;
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let mut bytes = Arc::new(pdfcraft_xfa::fixtures::shell(tpl));
+ for opening in 0..3 {
+ let id = s.open("reset.pdf", None, bytes, None).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "opening {opening}: {out:?}");
+ let doc = s.get(id).unwrap();
+ let note = doc.form.iter().find(|f| f.name == "note").expect("shown section has a widget");
+ let shown = note.value.clone();
+ assert_eq!(doc.form.iter().find(|f| f.name == "keep").unwrap().value, ["unrelated input"]);
+ assert_eq!(doc.info.pages.len(), 1);
+ bytes = s.save_bytes(id).unwrap();
+ let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ let saved = data.text_at(&pdfcraft_xfa::som_to_path("form[0].extra[0].note[0]"));
+ assert_eq!((shown, saved), (vec!["default".to_string()], Some("default")), "opening {opening}: displayed and saved values reset");
+ }
+}
+
+/// A reset of a choice list in a section an earlier opening script showed (so it has no widget
+/// yet when the reset runs) puts the template default back in the shown widget, the open
+/// document's datasets and the saved datasets alike, through three openings: a multi-select
+/// with structured `` picks and an empty or set default, and a single choice whose
+/// default is a display label saved as its export value. Test by wireless89 (review of #4791,
+/// 2026-10-30).
+#[test]
+fn xfa_deferred_choice_resets_preserve_the_reset_value_through_reopen() {
+ let cases = [
+ ("multi_empty", r#""#, "", "AB", ""),
+ ("multi_default", r#""#, "A", "B", "A"),
+ ("choice_export", "", "Alpha", "B", "A"),
+ ];
+ let mut failures = Vec::new();
+ for (case, ui, default, initial, expected) in cases {
+ let xml = format!(
+ r#"
+
+
+
+
+{ui}
+AlphaBetaAB{default}
+
+"#
+ );
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let mut bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&xml));
+ for cycle in 0..3 {
+ let id = s.open("choices.pdf", None, bytes, None).unwrap();
+ assert!(s.take_js_output(id).errors.is_empty(), "{case}, cycle {cycle}: reset script must execute");
+ let doc = s.get(id).unwrap();
+ let shown = doc.form.iter().find(|f| f.name == "choice").expect("shown choice has a widget").value.clone();
+ assert_eq!(doc.form.iter().find(|f| f.name == "keep").unwrap().value, ["KEEP"]);
+ let opened_data = pdfcraft_xfa::data_of(&doc.editor.as_ref().unwrap().cos).unwrap();
+ let path = pdfcraft_xfa::som_to_path("form[0].extra[0].choice[0]");
+ let opened = opened_data.value_at(&path).unwrap_or_default();
+ bytes = s.save_bytes(id).unwrap();
+ let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ let stored = data.value_at(&path).unwrap_or_default();
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].keep[0]")), Some("KEEP"));
+ let expected_shown: Vec = if expected.is_empty() { Vec::new() } else { vec![expected.into()] };
+ if shown != expected_shown || opened != expected || stored != expected {
+ failures.push(format!("{case}, cycle {cycle}: shown={shown:?}, opened={opened:?}, stored={stored:?}, expected={expected:?}"));
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
+
/// Optimize has JBIG2 images share a symbol dictionary only where redaction can store each again
/// without it. Of four pages of the same scan, the two in DeviceGray and the image mask share one;
/// the one in a DeviceN of one colourant, which redaction doesn't store again, is coded alone.
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 66c4381187..2bb6a0fcc7 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -161,6 +161,41 @@ fn to_js(n: &FormNode) -> XfaNode {
}
}
+/// The names of a run of `resetData` calls, applied together: each name once, in the order
+/// first given. A call without names resets the whole form, which takes in every other.
+#[derive(Default)]
+struct ResetRun {
+ names: Vec,
+ /// `names` as a set, so adding a name costs the same however many there are (a script
+ /// may pass hundreds of thousands).
+ seen: HashSet,
+ whole: bool,
+}
+
+impl ResetRun {
+ fn add(&mut self, names: &[String]) {
+ if self.whole {
+ return;
+ }
+ if names.is_empty() {
+ self.whole = true;
+ self.names.clear();
+ self.seen.clear();
+ return;
+ }
+ for n in names {
+ if self.seen.insert(n.clone()) {
+ self.names.push(n.clone());
+ }
+ }
+ }
+
+ /// The names to reset; none for the whole form.
+ fn names(&self) -> &[String] {
+ if self.whole { &[] } else { &self.names }
+ }
+}
+
/// The SOM paths of the live form's fields under `n`, at most 64 levels deep, into `out`.
fn field_soms(n: &FormNode, out: &mut HashSet, depth: usize) {
if depth > 64 {
@@ -215,15 +250,52 @@ fn plain_som(som: &str) -> String {
out
}
-/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container
-/// such as `table`) name this SOM or one of its ancestors?
-fn som_matches(som: &str, name: &str) -> bool {
- let plain = plain_som(som);
- let n = plain_som(name.trim());
- let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
- let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
- let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
- !want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice())
+/// The names a script passed to a reset (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a
+/// container such as `table`), indexed, so matching an object against them costs the same
+/// however many names there are.
+struct ResetTargets {
+ /// Each name's steps without indexes, joined by `.`.
+ wants: HashSet,
+ /// The most steps of a name.
+ longest: usize,
+}
+
+impl ResetTargets {
+ fn new(names: &[String]) -> Self {
+ let mut wants = HashSet::new();
+ let mut longest = 0;
+ for name in names {
+ let n = plain_som(name.trim());
+ let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
+ let steps: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
+ if !steps.is_empty() {
+ longest = longest.max(steps.len());
+ wants.insert(steps.join("."));
+ }
+ }
+ ResetTargets { wants, longest }
+ }
+
+ /// Does a name name this SOM or one of its ancestors: is it a run of the SOM's steps? The
+ /// runs are looked up, at most [`Self::longest`] steps each, so the work depends on the
+ /// SOM's depth and not on how many names there are.
+ fn matches(&self, som: &str) -> bool {
+ let plain = plain_som(som);
+ let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
+ for start in 0..have.len() {
+ let mut run = String::new();
+ for (k, step) in have.iter().skip(start).take(self.longest).enumerate() {
+ if k > 0 {
+ run.push('.');
+ }
+ run.push_str(step);
+ if self.wants.contains(&run) {
+ return true;
+ }
+ }
+ }
+ false
+ }
}
/// A generated field as the runner tracks it: kind, value and on state.
@@ -273,6 +345,11 @@ struct Runner<'a, 'b> {
skipped: usize,
relayouts: usize,
relayouts_skipped: bool,
+ /// Lay the form out once, when the event ends, rather than after every script that changed
+ /// its structure (opening: see [`on_open`]).
+ defer_relayout: bool,
+ /// A deferred layout is owed.
+ relayout_owed: bool,
dropped_output: usize,
truncated_warned: bool,
data: Option,
@@ -287,6 +364,9 @@ struct Runner<'a, 'b> {
fields: Option>,
/// The live form as last built; dropped when a script changed the form.
cached: Option,
+ /// `cached` as the scripts get it, made once and sent to each script until the live form
+ /// changes in any way (a value too): the script engine reuses what it built from it.
+ js_root: Option>,
/// The script engine for this event: one thread for all its scripts, each run in a fresh
/// context on the form as it is then (`cached`, kept up to date in place for values and
/// rebuilt when the structure changed).
@@ -316,6 +396,8 @@ impl<'a, 'b> Runner<'a, 'b> {
skipped: 0,
relayouts: 0,
relayouts_skipped: false,
+ defer_relayout: false,
+ relayout_owed: false,
dropped_output: 0,
truncated_warned: false,
data,
@@ -327,6 +409,7 @@ impl<'a, 'b> Runner<'a, 'b> {
by_path: None,
fields: None,
cached: None,
+ js_root: None,
session: None,
changes: Changes::default(),
numbered: HashSet::new(),
@@ -338,6 +421,7 @@ impl<'a, 'b> Runner<'a, 'b> {
/// script.
fn invalidate(&mut self) {
self.cached = None;
+ self.js_root = None;
}
/// The script engine for this event.
@@ -354,6 +438,8 @@ impl<'a, 'b> Runner<'a, 'b> {
let op = pdfcraft_xfa::DataOp::Value { path, text };
self.data.get_or_insert_with(Default::default).apply(&op);
self.pending.push(op);
+ // The value changes in the live form below: the scripts' copy is made again.
+ self.js_root = None;
let refreshed = match self.cached.as_mut() {
Some(live) => pdfcraft_xfa::refresh_value(&mut live.root, self.tpl, self.data.as_ref(), som).is_some(),
None => true,
@@ -568,11 +654,37 @@ impl<'a, 'b> Runner<'a, 'b> {
fn reset(&mut self, names: &[String]) -> Result<(), String> {
self.flush()?;
let by_som = self.by_som().clone();
- let listed: Option> = if names.is_empty() {
- None
- } else {
- Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
+ let targets = (!names.is_empty()).then(|| ResetTargets::new(names));
+ let wanted = |som: &str| targets.as_ref().is_none_or(|t| t.matches(som));
+ let listed: Option> = targets.as_ref().map(|_| by_som.iter().filter(|(som, _)| wanted(som)).map(|(_, f)| f.clone()).collect());
+ // Fields and exclusion groups of the live form with no widget yet (a row an opening
+ // script added, before the form is laid out again): their data goes back to the
+ // template's default too, or the value would stay and be laid out and saved.
+ let unshown: Vec = {
+ let live = self.live();
+ let mut out = Vec::new();
+ let mut stack: Vec<&pdfcraft_xfa::FormNode> = vec![&live.root];
+ while let Some(n) = stack.pop() {
+ if matches!(n.kind, Some(pdfcraft_xfa::NodeKind::Field | pdfcraft_xfa::NodeKind::ExclGroup))
+ && !by_som.contains_key(&n.som)
+ && wanted(&n.som)
+ {
+ out.push(n.som.clone());
+ }
+ stack.extend(n.children.iter());
+ }
+ out
};
+ for som in unshown {
+ // A floating field rich text embeds goes back to its default with every row that
+ // holds it (below).
+ if pdfcraft_xfa::field_is_embedded(self.tpl, &som) {
+ continue;
+ }
+ let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue };
+ let path = pdfcraft_xfa::data_path(self.tpl, &som);
+ self.reset_data(path, text);
+ }
// Names no widget answers to may still name a floating field (below).
let widgets = !listed.as_ref().is_some_and(Vec::is_empty);
if widgets {
@@ -580,6 +692,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
self.fields = None;
let infos = self.fields().clone();
+ let listed: Option> = listed.as_ref().map(|l| l.iter().collect());
for (som, name) in by_som.iter().filter(|_| widgets) {
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
continue;
@@ -589,17 +702,12 @@ impl<'a, 'b> Runner<'a, 'b> {
continue;
}
let path = pdfcraft_xfa::data_path(self.tpl, som);
- let text = data_text(info);
- if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
- self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
- self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
- self.changes.values += 1;
- }
+ self.reset_data(path, data_text(info));
}
// A floating field without a widget (a hidden one, as forms place them) goes back to
// its default too, and the text that embeds it shows that.
for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) {
- if by_som.contains_key(&som) || (!names.is_empty() && !names.iter().any(|n| som_matches(&som, n))) {
+ if by_som.contains_key(&som) || !wanted(&som) {
continue;
}
// Every row that holds it, not only the first.
@@ -617,6 +725,16 @@ impl<'a, 'b> Runner<'a, 'b> {
Ok(())
}
+ /// Put a reset's `text` in the data at `path`, unless it already holds it: a multi-select
+ /// list's picks (`value` elements) are compared as its lines (the write replaces them).
+ fn reset_data(&mut self, path: pdfcraft_xfa::DataPath, text: String) {
+ if self.data.as_ref().and_then(|d| d.value_at(&path)).unwrap_or_default() != text {
+ self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
+ self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
+ self.changes.values += 1;
+ }
+ }
+
/// Apply what a script of `ev` did. Returns whether the form has to be laid out again.
fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result {
let mut relayout = false;
@@ -635,7 +753,9 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
}
- for e in effects {
+ // Resets in a row, applied together when the run of them ends.
+ let mut pending_reset: Option = None;
+ for (at, e) in effects.iter().enumerate() {
// Only a click script may print, save, open links or move the focus (not the
// calculations a click sets off, nor anything run on open or on a change).
if let Some(what) = click_only(e)
@@ -686,7 +806,15 @@ impl<'a, 'b> Runner<'a, 'b> {
relayout = true;
}
XfaEffect::MessageBox(m) => self.alert(m.clone()),
- XfaEffect::ResetData(names) => self.reset(names)?,
+ XfaEffect::ResetData(names) => {
+ // A script resetting several subforms in a row (`resetData` once for each)
+ // resets them together: the same fields end up at their defaults.
+ pending_reset.get_or_insert_with(ResetRun::default).add(names);
+ let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_)));
+ if !next_is_reset && let Some(run) = pending_reset.take() {
+ self.reset(run.names())?;
+ }
+ }
XfaEffect::Print => self.run.out.requests.push(Request::Print),
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
@@ -722,7 +850,14 @@ impl<'a, 'b> Runner<'a, 'b> {
#[cfg(test)]
self.check_live();
let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() };
- let root = to_js(&self.live().root);
+ let root = match &self.js_root {
+ Some(r) => Arc::clone(r),
+ None => {
+ let r = Arc::new(to_js(&self.live().root));
+ self.js_root = Some(Arc::clone(&r));
+ r
+ }
+ };
let script = Script {
source: ev.script.clone(),
formcalc: ev.formcalc,
@@ -749,7 +884,11 @@ impl<'a, 'b> Runner<'a, 'b> {
self.error(format!("{} ({}): {e}", ev.som, ev.activity));
}
if self.apply_effects(ev, &o.effects)? {
- self.relayout()?;
+ if self.defer_relayout {
+ self.relayout_owed = true;
+ } else {
+ self.relayout()?;
+ }
}
Ok(Some(o))
}
@@ -779,9 +918,10 @@ impl<'a, 'b> Runner<'a, 'b> {
fn finish(mut self) -> Result {
#[cfg(test)]
self.check_live();
- if self.relayouts_skipped || self.embedded_changed {
+ if self.relayouts_skipped || self.relayout_owed || self.embedded_changed {
self.relayouts = 0;
self.relayouts_skipped = false;
+ self.relayout_owed = false;
self.relayout()?;
}
self.flush()?;
@@ -949,6 +1089,12 @@ fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: O
/// dropped (one value would put the same number on every page).
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
+ // The scripts read the live form (template, data and overrides), not the pages, so the
+ // pages are laid out once, after the last of them. Laying a large form out after every
+ // script that hides a subform spent the time limit on the first few, and the form was kept
+ // half initialized: a page its initialize scripts hide stayed open to typing until the
+ // next opening hid it and reset its data.
+ r.defer_relayout = true;
let events: Vec = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect();
for ev in &events {
r.run(ev, "")?;
@@ -1144,14 +1290,70 @@ pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pd
mod tests {
use super::*;
+ /// A run of resets keeps each name once, in the order first given, and a reset of the whole
+ /// form takes in the rest, before or after it.
+ #[test]
+ fn resets_in_a_row_keep_each_name_once_and_a_whole_form_reset_wins() {
+ let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::>();
+ let mut run = ResetRun::default();
+ run.add(&v(&["a", "b", "a"]));
+ run.add(&v(&["c", "b"]));
+ assert_eq!(run.names(), v(&["a", "b", "c"]).as_slice());
+ run.add(&[]);
+ assert!(run.names().is_empty());
+ run.add(&v(&["d"]));
+ assert!(run.names().is_empty(), "still the whole form");
+ let mut first = ResetRun::default();
+ first.add(&[]);
+ first.add(&v(&["a"]));
+ assert!(first.names().is_empty());
+ }
+
+ /// Adding a name to a run costs the same however many it holds: two runs of 60,000
+ /// distinct names merge at once (comparing each name with every kept one is 3.6 billion
+ /// comparisons).
+ #[test]
+ fn a_long_run_of_reset_names_merges_in_linear_time() {
+ let a: Vec = (0..60_000).map(|i| format!("form1[0].a{i}")).collect();
+ let b: Vec = (0..60_000).map(|i| format!("form1[0].b{i}")).collect();
+ let started = std::time::Instant::now();
+ let mut run = ResetRun::default();
+ run.add(&a);
+ run.add(&b);
+ assert_eq!(run.names().len(), 120_000);
+ assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
+ }
+
+ /// Matching objects against a reset's names costs the same however many names there are:
+ /// 5,000 objects against 50,000 names that miss them all is 250 million comparisons when
+ /// each object is compared with every name.
+ #[test]
+ fn matching_many_objects_against_many_reset_names_is_bounded() {
+ let names: Vec = (0..50_000).map(|i| format!("form1.page1.other{i}")).collect();
+ let started = std::time::Instant::now();
+ let targets = ResetTargets::new(&names);
+ let hits = (0..5_000).filter(|i| targets.matches(&format!("form1[0].page1[0].table[0].row[{i}].item[0]"))).count();
+ assert_eq!(hits, 0);
+ assert!(targets.matches("form1[0].page1[0].other49999[0]"));
+ assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
+ }
+
#[test]
fn script_names_match_indexed_som_paths() {
+ let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som);
assert!(som_matches("form[0].page1[0].qty[0]", "qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty"));
+ assert!(som_matches("form[0].page1[0].qty[0]", "$form.form.page1.qty"));
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table"));
+ assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table.row[0].amount"));
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
+ assert!(!som_matches("form[0].page1[0].qty[0]", "page1.page1.qty"));
+ assert!(!som_matches("form[0].page1[0].qty[0]", " . "));
+ assert!(!ResetTargets::new(&[]).matches("form[0].qty[0]"));
+ let many = ResetTargets::new(&["price".into(), "page1.qty".into()]);
+ assert!(many.matches("form[0].page1[0].qty[0]") && many.matches("form[0].page2[0].price[0]"));
assert_eq!(
split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
Some((2, "form[0].table[0]".into(), "row".into(), 2))
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
index 2e66f7ffda..28ae792e26 100644
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -6572,6 +6572,26 @@ fn a_read_only_button_is_not_turned_on_by_number() {
assert_eq!(on_widgets(&doc, "r").0, [false, false, true]);
}
+/// A reset of named fields costs the same per field however many are named: resetting 60,000
+/// fields by name is not 3.6 billion name comparisons.
+#[test]
+fn resetting_many_named_fields_is_not_quadratic() {
+ const N: usize = 60_000;
+ let kids: String = (0..N).map(|i| format!("{} 0 R ", i + 4)).collect();
+ let mut objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >>".into(),
+ "<< /Type /Pages /Kids [] /Count 0 >>".into(),
+ format!("<< /Fields [{kids}] >>"),
+ ];
+ objs.extend((0..N).map(|i| format!("<< /FT /Tx /T (f{i}) /V (a) /DV (a) >>")));
+ let mut doc = document(&objs);
+ let names: Vec = (0..N).map(|i| format!("f{i}")).collect();
+ let started = std::time::Instant::now();
+ assert_eq!(reset(&mut doc, Some(&names)).unwrap(), 0);
+ assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
+ assert!(matches!(reset(&mut doc, Some(&["f0".to_string(), "nope".to_string()])), Err(FormError::NoSuchField(n)) if n == "nope"));
+}
+
/// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C`
/// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB.
#[test]
diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs
index d37be9711c..3c03ea4434 100644
--- a/crates/forms/src/values.rs
+++ b/crates/forms/src/values.rs
@@ -758,13 +758,16 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result = names.map(|_| all.iter().map(|f| f.name.as_str()).collect()).unwrap_or_default();
if let Some(n) = names
- && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n))
+ && let Some(missing) = n.iter().find(|n| !have.contains(n.as_str()))
{
return Err(FormError::NoSuchField(missing.clone()));
}
+ let named: Option> = names.map(|n| n.iter().map(String::as_str).collect());
let mut changed = 0;
- for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) {
+ for f in all.iter().filter(|f| named.as_ref().is_none_or(|n| n.contains(f.name.as_str()))) {
// A default the reading didn't decode (its budget spent) is unknown, not empty: clearing
// the field to nothing would lose the value it should go back to. A value it didn't decode
// isn't known to be the default, so it is reset.
diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs
index 06ff21f438..f4792ba64b 100644
--- a/crates/js/src/formcalc.rs
+++ b/crates/js/src/formcalc.rs
@@ -109,7 +109,7 @@ type R = Result;
/// 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_at(script, event, doc, std::sync::Arc::new(root.clone()), false, limits, None)
}
/// [`run_formcalc`] on an owned form, giving up at `deadline` (the clock is read every
@@ -118,7 +118,8 @@ pub(crate) fn run_formcalc_at(
script: &str,
event: &XfaEvent,
doc: &XfaDoc,
- root: XfaNode,
+ root: std::sync::Arc,
+ reuse: bool,
limits: Limits,
deadline: Option,
) -> XfaOutcome {
@@ -133,7 +134,7 @@ pub(crate) fn run_formcalc_at(
Ok(b) => b,
Err(e) => return XfaOutcome { error: Some(e), ..Default::default() },
};
- let mut host = XHost::new(root, &event.target, doc);
+ let mut host = XHost::new(root, reuse, &event.target, doc);
#[cfg(target_arch = "wasm32")]
let _ = deadline;
let mut interp = Interp {
@@ -177,7 +178,7 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
run_within("pdfcraft-formcalc", timeout, move |started| {
started();
- run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout))
+ run_formcalc_at(&script, &event, &doc, std::sync::Arc::new(root), false, limits, Some(timeout))
})
}
diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs
index bea902c752..5f279ea4f6 100644
--- a/crates/js/src/formcalc/access.rs
+++ b/crates/js/src/formcalc/access.rs
@@ -313,16 +313,16 @@ impl Interp<'_> {
let Some(n) = self.node(i) else { return Ok(Val::Null) };
Ok(match name {
"formattedValue" => match &n.formatted {
- Some(f) => Val::Str(f.clone()),
+ Some(f) => Val::Str(f.to_string()),
None => self.node_value(i),
},
"rawValue" | "value" => self.node_value(i),
- "name" => Val::Str(n.name.clone()),
- "somExpression" => Val::Str(n.som.clone()),
+ "name" => Val::Str(n.name.to_string()),
+ "somExpression" => Val::Str(n.som.to_string()),
"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()),
+ "presence" => Val::Str(n.presence.to_string()),
+ "access" => Val::Str(n.access.to_string()),
"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),
@@ -332,7 +332,7 @@ impl Interp<'_> {
None => Val::Node(i),
},
"instanceManager" => match n.parent {
- Some(p) => Val::Im(p, n.name.clone()),
+ Some(p) => Val::Im(p, n.name.to_string()),
None => Val::Null,
},
"border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate"
diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs
index c5b9cb49db..c146fd65b9 100644
--- a/crates/js/src/formcalc/eval.rs
+++ b/crates/js/src/formcalc/eval.rs
@@ -277,11 +277,11 @@ impl Interp<'_> {
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(),
+ Some(i) => self.node(*i).map(|n| n.som.to_string()).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)
+ found.first().and_then(|i| self.node(*i)).map(|n| n.som.to_string()).unwrap_or(s)
}
}
}
@@ -404,7 +404,7 @@ impl Interp<'_> {
match target {
Val::Node(i) => {
let Some(n) = self.node(i) else { return Ok(()) };
- let som = n.som.clone();
+ let som = n.som.to_string();
match prop {
"rawValue" | "value" | "formattedValue" => {
let text = self.to_str(&v);
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index 7635d69e44..72a88ac9f3 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -1632,7 +1632,8 @@ $host.gotoURL("https://example.org")"#,
"for i = 1 upto 100000 do Eval(\"1\") endfor",
&ev,
&doc,
- form(),
+ std::sync::Arc::new(form()),
+ false,
Limits { loop_iterations: u64::MAX, recursion: 32 },
Some(std::time::Duration::ZERO),
);
@@ -1750,12 +1751,53 @@ mod session {
source: source.into(),
formcalc,
event: XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() },
- root: form(),
+ root: std::sync::Arc::new(form()),
limits: Limits::default(),
timeout: std::time::Duration::from_secs(3),
}
}
+ /// The scripts of an event given the same form start from one flattened copy of it: the
+ /// second host shares the first one's text, and each host changes only its own copy.
+ #[test]
+ fn hosts_given_the_same_form_reuse_its_flattened_copy_and_change_only_their_own() {
+ let root = std::sync::Arc::new(form());
+ let doc = XfaDoc::default();
+ let mut first = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
+ let second = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
+ let shared = std::sync::Arc::clone(&first.nodes.get(1).expect("form1").som);
+ assert!(std::sync::Arc::ptr_eq(&shared, &second.nodes.get(1).expect("form1").som), "the second host starts from the first one's copy");
+ let qty = first.current;
+ let before = second.nodes.get(qty).map(|n| n.value.clone());
+ first.set_state(qty, XfaEffect::SetPresence { som: "form1[0].page1[0].qty[0]".into(), presence: "hidden".into() });
+ if let Some(n) = first.nodes.get_mut(qty) {
+ n.value = "99".into();
+ }
+ let third = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc);
+ for h in [&second, &third] {
+ assert_eq!(h.nodes.get(qty).map(|n| n.value.clone()), before);
+ assert_eq!(h.nodes.get(qty).map(|n| n.presence.to_string()).as_deref(), Some("visible"));
+ }
+ // Another form (a new `Arc`, even with the same content) is flattened again.
+ let other = crate::xfa::XHost::new(std::sync::Arc::new(form()), true, "form1[0].page1[0].qty[0]", &doc);
+ assert!(!std::sync::Arc::ptr_eq(&other.nodes.get(1).expect("form1").som, &shared));
+ }
+
+ /// One session given the same form for two scripts: what the first changed on its copy is
+ /// not in the second's.
+ #[test]
+ fn a_form_sent_again_does_not_carry_the_last_scripts_changes() {
+ let mut s = XfaSession::new(XfaDoc::default());
+ let root = std::sync::Arc::new(form());
+ let mut a = script("qty.rawValue = 10; details.presence = 'visible'; qty.rawValue", false, "form1[0].page1[0].total[0]", "calculate");
+ a.root = std::sync::Arc::clone(&root);
+ assert_eq!(s.run(a).result.as_deref(), Some("10"));
+ let mut b = script("qty.rawValue + ':' + details.presence", false, "form1[0].page1[0].total[0]", "calculate");
+ b.root = std::sync::Arc::clone(&root);
+ let o = s.run(b);
+ assert_eq!(o.result.as_deref(), Some("4:hidden"), "{o:?}");
+ }
+
/// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller
/// sends: nothing an earlier script changed or defined leaks into the next, a failing script
/// doesn't end the session, and hundreds of scripts run on its one thread.
@@ -1773,7 +1815,7 @@ mod session {
let o = s.run(script("qty * price", true, "form1[0].page1[0].total[0]", "calculate"));
assert_eq!(o.result.as_deref(), Some("10"), "{o:?}");
let mut with = script("qty * price", true, "form1[0].page1[0].total[0]", "calculate");
- with.root.children[0].children[0].value = "10".into();
+ std::sync::Arc::make_mut(&mut with.root).children[0].children[0].value = "10".into();
assert_eq!(s.run(with).result.as_deref(), Some("25"));
// Nor do variables, globals, prototype changes or kept objects.
s.run(script("leak = 5; String.prototype.trim = function () { return 'P' }; kept = qty; 1", false, "form1[0].page1[0].qty[0]", "calculate"));
diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs
index eb275d1822..c623d0766f 100644
--- a/crates/js/src/xfa.rs
+++ b/crates/js/src/xfa.rs
@@ -24,6 +24,8 @@ mod model;
mod nodes;
mod run;
+#[cfg(target_arch = "wasm32")]
+pub(crate) use host::forget_base;
pub(crate) use host::{HNode, XHost};
pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome};
pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names};
diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs
index cfbade0947..50647a3426 100644
--- a/crates/js/src/xfa/globals.rs
+++ b/crates/js/src/xfa/globals.rs
@@ -88,13 +88,13 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
let i = node_index(&target, ctx)?;
- host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default()
+ host(ctx)?.borrow().nodes.get(i).map(|n| n.som.to_string()).unwrap_or_default()
}
_ => {
let expr = text(&target, ctx)?;
let h = host(ctx)?.borrow();
let found = resolve_som(&h, h.current, &expr);
- found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr)
+ found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.to_string()).unwrap_or(expr)
}
};
effect(ctx, XfaEffect::SetFocus(som))?;
@@ -356,7 +356,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
let h = host(ctx)?.borrow();
h.nodes
.get(scope)
- .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect())
+ .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.to_string(), k.repeatable && !k.gone))).collect())
.unwrap_or_default()
};
for (c, name, repeatable) in kids {
diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs
index ec222a4ca8..ffc8352005 100644
--- a/crates/js/src/xfa/host.rs
+++ b/crates/js/src/xfa/host.rs
@@ -2,27 +2,71 @@ use super::model::{
MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value,
effect_key, payload,
};
+use std::sync::Arc;
+
use super::{Context, HashMap, JsResult, Message, RefCell, error};
-/// One node of the flattened form, shared by the JavaScript and FormCalc engines.
+/// One node of the flattened form, shared by the JavaScript and FormCalc engines. Its text is
+/// shared (`Arc`), so the copy each script starts from costs little: the scripts of one
+/// event start from the same flattened form until it changes (see [`XHost::new`]).
+#[derive(Clone)]
pub(crate) struct HNode {
- pub(crate) name: String,
- pub(crate) som: String,
+ pub(crate) name: Arc,
+ pub(crate) som: Arc,
pub(crate) kind: XfaKind,
pub(crate) value: String,
pub(crate) numeric: bool,
- pub(crate) presence: String,
- pub(crate) access: String,
+ pub(crate) presence: Arc,
+ pub(crate) access: Arc,
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,
+ pub(crate) children: Arc<[usize]>,
/// Removed by `removeInstance`: skipped everywhere.
pub(crate) gone: bool,
/// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows.
- pub(crate) formatted: Option,
+ pub(crate) formatted: Option>,
+}
+
+thread_local! {
+ /// The form the last script on this thread started from, flattened: the next script of the
+ /// event starts from a copy of it while the engine sends the same form.
+ static BASE: std::cell::RefCell