diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ce5b221834..ed53ad2a50 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -6576,6 +6576,57 @@ fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() {
assert_eq!(names(&s, again), opened);
}
+/// 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#""#);
+ 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");
+}
+
/// 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.
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 55e31e187f..ae56646986 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -159,6 +159,23 @@ fn to_js(n: &FormNode) -> XfaNode {
}
}
+/// Add the names of a `resetData` call to the run of resets before it. No names means the
+/// whole form, which takes in every other.
+fn merge_reset(merged: &mut Vec, names: &[String]) {
+ if merged.is_empty() {
+ return;
+ }
+ if names.is_empty() {
+ merged.clear();
+ return;
+ }
+ for n in names {
+ if !merged.contains(n) {
+ merged.push(n.clone());
+ }
+ }
+}
+
fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a FormNode> {
if depth > 64 {
return None;
@@ -275,6 +292,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).
@@ -309,6 +329,7 @@ impl<'a, 'b> Runner<'a, 'b> {
by_path: None,
fields: None,
cached: None,
+ js_root: None,
session: None,
changes: Changes::default(),
}
@@ -318,6 +339,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.
@@ -334,6 +356,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,
@@ -591,7 +615,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)
@@ -640,7 +666,19 @@ 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.
+ if let Some(merged) = pending_reset.as_mut() {
+ merge_reset(merged, names);
+ } else {
+ pending_reset = Some(names.clone());
+ }
+ let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_)));
+ if !next_is_reset && let Some(names) = pending_reset.take() {
+ self.reset(&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())),
@@ -669,7 +707,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,
diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs
index 3e68ce081d..ff43bf0837 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 0b2dfe4b94..216e26cba5 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)
}
}
}
@@ -396,7 +396,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 2d3877efe3..c933097330 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -1542,7 +1542,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),
);
@@ -1660,12 +1661,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.
@@ -1683,7 +1725,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 971cf12fc1..d166b7147b 100644
--- a/crates/js/src/xfa/globals.rs
+++ b/crates/js/src/xfa/globals.rs
@@ -69,13 +69,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))?;
@@ -329,7 +329,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