diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 45aa754d60..0715102152 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -8423,6 +8423,57 @@ fn repeated_xfa_print_buttons_run_their_click_script() {
}
}
+/// A button on the page master runs its click script as the copy on the page that was clicked:
+/// the field it writes next to it changes on that page only.
+#[test]
+fn a_page_master_button_writes_the_field_on_its_own_page() {
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template().replace(
+ "",
+ r#"
+
+
+
+ "#,
+ );
+ let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&template));
+ let mut s = Session::new();
+ let id = s.open("stamp.pdf", None, bytes, None).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
+ let out = s.run_javascript(id, "throw new Error('AcroForm fallback ran')", Some("mark_2")).unwrap();
+ assert_eq!(out.error, None, "{out:?}");
+ let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone();
+ assert_eq!(value(&s, "stamp_2"), vec!["clicked".to_string()], "the page clicked");
+ for other in ["stamp", "stamp_3"] {
+ assert_ne!(value(&s, other), vec!["clicked".to_string()], "{other} is on another page");
+ }
+}
+
+/// An initialize script that hides the last page's subform takes a page away; the page-master
+/// button of that page, listed to initialize before it went, doesn't run with `this` at the
+/// form instead and hide all of it.
+#[test]
+fn a_page_master_script_of_a_page_gone_does_not_hide_the_form() {
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template()
+ .replace(
+ r#"
Print form
"#,
+ r#"
Print form
+ "#,
+ )
+ .replace(
+ r#""#,
+ r#"
+ "#,
+ );
+ let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&template));
+ let mut s = Session::new();
+ let id = s.open("gone.pdf", None, bytes, None).unwrap();
+ let doc = s.get(id).unwrap();
+ assert_eq!(doc.info.pages.len(), 2, "page 3 is hidden");
+ for note in ["note1", "note2"] {
+ assert!(doc.form.iter().any(|f| f.name == note), "{note} is still shown");
+ }
+}
+
/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows,
/// with undo and save/reopen.
#[test]
@@ -12363,6 +12414,164 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes()
black(&s, rewritten);
}
+/// A page still exists after its only master widget is hidden: its master nodes stay in the
+/// script model, so a later script can address them again (test from wireless89's review of
+/// #5368).
+#[test]
+fn xfa_page_master_instance_survives_hiding_its_last_widget() {
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template()
+ .replace(r#"xfa.host.print(1, "0", (xfa.host.numPages -1).toString(), 0, 0, 0, 0, 0);"#, r#"this.presence = "hidden";"#)
+ .replace(
+ "",
+ r#"
+"#,
+ );
+ let (tpl, _) = pdfcraft_xfa::parse(&template).unwrap();
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let id = s.open("hidden-master.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ let out = s.run_javascript(id, "", Some("PrintButton1_3")).unwrap();
+ assert_eq!(out.error, None, "{out:?}");
+ let cos = &s.get(id).unwrap().editor.as_ref().unwrap().cos;
+ assert_eq!(pdfcraft_organize::page_count(cos).unwrap(), 3, "the body still fills three pages");
+ assert!(!s.get(id).unwrap().form.iter().any(|f| f.name == "PrintButton1_3"));
+ let uses = pdfcraft_xfa::page_area_uses(cos, &tpl);
+ let restored = s.run_javascript(id, "", Some("restore")).unwrap();
+ assert_eq!((uses.get("master").copied(), restored.error), (Some(3), None), "the third page master remains addressable by scripts");
+}
+
+/// Page-local values and a globally bound control survive relayout, disappearing and
+/// reappearing pages, incremental saves and vector printing (test from wireless89's review of #5368).
+#[test]
+fn xfa_master_values_survive_page_changes_save_reopen_and_print() {
+ fn name(s: &Session, id: DocId, som: &str) -> String {
+ let d = s.get(id).unwrap();
+ pdfcraft_xfa::fields_by_som(&d.editor.as_ref().unwrap().cos).get(som).unwrap_or_else(|| panic!("missing {som}")).clone()
+ }
+ fn set(s: &mut Session, id: DocId, som: &str, text: &str) {
+ let n = name(s, id, som);
+ s.apply(id, Edit::SetFieldValue { name: n, value: FieldValue::Text(text.into()) }).unwrap();
+ }
+ fn check(s: &Session, id: DocId, expected: &[&str]) {
+ let d = s.get(id).unwrap();
+ // Inspect the actual PDF tree: refreshing the cached view after XFA relayout is
+ // a separate issue (#4604), not part of this master-value regression.
+ assert_eq!(pdfcraft_organize::page_count(&d.editor.as_ref().unwrap().cos).unwrap(), expected.len());
+ let by_som = pdfcraft_xfa::fields_by_som(&d.editor.as_ref().unwrap().cos);
+ assert_eq!(by_som.keys().filter(|som| som.ends_with(".stamp[0]")).count(), expected.len());
+ for (page, value) in expected.iter().enumerate() {
+ for (field, expected) in [("stamp", *value), ("shared", "GLOBAL_VALUE")] {
+ let n = name(s, id, &format!("master[{page}].{field}[0]"));
+ assert_eq!(d.form.iter().find(|f| f.name == n).unwrap().value, [expected], "page {page}: {field}");
+ }
+ }
+ }
+ let template = master_round_trip_review_template();
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let mut id = s.open("masters.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
+ for (page, value) in ["MASTER_A", "MASTER_B", "MASTER_C"].iter().enumerate() {
+ set(&mut s, id, &format!("master[{page}].stamp[0]"), value);
+ }
+ check(&s, id, &["MASTER_A", "MASTER_B", "MASTER_C"]);
+ let mark = name(&s, id, "master[1].mark[0]");
+ let out = s.run_javascript(id, "throw new Error('AcroForm fallback ran')", Some(&mark)).unwrap();
+ assert_eq!(out.error, None, "{out:?}");
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ let more = name(&s, id, "form[0].page1[0].more[0]");
+ s.apply(id, Edit::SetFieldValue { name: more.clone(), value: FieldValue::Check(false) }).unwrap();
+ assert!(s.take_js_output(id).errors.is_empty());
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK"]);
+ s.undo(id).unwrap();
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ s.redo(id).unwrap();
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK"]);
+ s.apply(id, Edit::SetFieldValue { name: more, value: FieldValue::Check(true) }).unwrap();
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ for cycle in 0..3 {
+ let saved = s.save_bytes(id).unwrap();
+ let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ for (page, value) in ["MASTER_A", "MASTER_B_CLICK", "MASTER_C"].iter().enumerate() {
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path(&format!("master[{page}].stamp[0]"))), Some(*value), "cycle {cycle}: page {page}");
+ }
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("master[0].shared[0]")), Some("GLOBAL_VALUE"));
+ id = s.open("again.pdf", None, saved, None).unwrap();
+ assert!(s.take_js_output(id).errors.is_empty());
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ }
+ let printed = s.print_pdf(id, &print::Settings { pages: vec![0, 1, 2], ..Default::default() }).unwrap();
+ let pid = s.open("printed.pdf", None, Arc::new(printed), None).unwrap();
+ let texts = page_texts(&s, pid);
+ assert_eq!(texts.len(), 3);
+ for (page, value) in ["MASTER_A", "MASTER_B_CLICK", "MASTER_C"].iter().enumerate() {
+ assert!(texts[page].contains(value) && texts[page].contains("GLOBAL_VALUE"), "page {page}: {:?}", texts[page]);
+ for other in ["MASTER_A", "MASTER_B_CLICK", "MASTER_C"].iter().filter(|other| *other != value) {
+ assert!(!texts[page].contains(other), "page {page} contains another page's value: {other}");
+ }
+ }
+}
+fn master_round_trip_review_template() -> String {
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template()
+ .replace(r#"name="PrintButton1" x="36pt""#, r#"name="PrintButton1" x="480pt""#)
+ .replace("", r#"
+
+
+
+
+
+ "#)
+ .replace(r#""#, r#"
+ 101
+
+ "#);
+ format!(
+ r#"{template}
+GLOBAL_VALUE"#
+ )
+}
+
+/// Hiding the first, middle, last or every page-master widget keeps every master
+/// instance addressable, live and after save and reopen (wireless89, #5368).
+#[test]
+fn xfa_hidden_master_instances_remain_addressable_across_reopen_matrix() {
+ let mut failures = Vec::new();
+ for targets in [vec![0usize], vec![1], vec![2], vec![0, 1, 2]] {
+ let hide =
+ targets.iter().map(|page| format!("xfa.resolveNode('master[{page}].PrintButton1').presence = 'hidden';")).collect::>().join(" ");
+ let restore =
+ targets.iter().map(|page| format!("xfa.resolveNode('master[{page}].PrintButton1').presence = 'visible';")).collect::>().join(" ");
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template().replace(
+ r#"xfa.host.print(1, "0", (xfa.host.numPages -1).toString(), 0, 0, 0, 0, 0);"#, &hide,
+ ).replace("", &format!(r#""#));
+ let (tpl, _) = pdfcraft_xfa::parse(&template).unwrap();
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let id = s.open("hidden.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ assert_eq!(s.run_javascript(id, "", Some("PrintButton1")).unwrap().error, None);
+ s.undo(id).unwrap();
+ assert_eq!(
+ pdfcraft_xfa::fields_by_som(&s.get(id).unwrap().editor.as_ref().unwrap().cos).keys().filter(|name| name.contains("PrintButton1")).count(),
+ 3
+ );
+ s.redo(id).unwrap();
+ let saved = s.save_bytes(id).unwrap();
+ for (stage, current) in [("live", id), ("reopened", s.open("saved.pdf", None, saved, None).unwrap())] {
+ let cos = &s.get(current).unwrap().editor.as_ref().unwrap().cos;
+ assert_eq!(pdfcraft_organize::page_count(cos).unwrap(), 3);
+ let count = pdfcraft_xfa::page_area_uses(cos, &tpl).get("master").copied();
+ let error = s.run_javascript(current, "", Some("restore")).unwrap().error;
+ let restored = targets.iter().all(|page| {
+ pdfcraft_xfa::fields_by_som(&s.get(current).unwrap().editor.as_ref().unwrap().cos)
+ .contains_key(&format!("master[{page}].PrintButton1[0]"))
+ });
+ eprintln!("targets={targets:?} stage={stage} count={count:?} error={error:?} restored={restored}");
+ if count != Some(3) || error.is_some() || !restored {
+ failures.push(format!("{targets:?}/{stage}: count={count:?}, error={error:?}, restored={restored}"));
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
+
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 0c4b19bbd7..b439651c7c 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -308,6 +308,10 @@ fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a Fo
n.children.iter().find_map(|c| find_container(c, som, depth + 1))
}
+fn has_node(n: &FormNode, som: &str, depth: usize) -> bool {
+ depth <= 64 && (n.som == som || n.children.iter().any(|c| has_node(c, som, depth + 1)))
+}
+
fn count_instances(root: &FormNode, parent_som: &str, name: &str) -> usize {
let parent = if parent_som.is_empty() { Some(root) } else { find_container(root, parent_som, 0) };
parent.map_or(0, |p| p.children.iter().filter(|c| c.name == name).count())
@@ -538,7 +542,8 @@ impl<'a, 'b> Runner<'a, 'b> {
fn live(&mut self) -> &LiveForm {
if self.cached.is_none() {
- let live = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
+ let uses = pdfcraft_xfa::page_area_uses(self.doc, self.tpl);
+ let live = pdfcraft_xfa::form_tree_paged(self.tpl, self.data.as_ref(), &self.ov, &uses);
if live.truncated && !self.truncated_warned {
self.truncated_warned = true;
self.error(format!(
@@ -933,6 +938,12 @@ impl<'a, 'b> Runner<'a, 'b> {
self.skipped += 1;
return Ok(None);
}
+ // An event listed before a script relaid the form out can name an object that is gone
+ // (a page the form no longer fills, a row removed): it isn't run, rather than run with
+ // `this` at some other object.
+ if !has_node(&self.live().root, &ev.som, 0) {
+ return Ok(None);
+ }
self.runs += 1;
#[cfg(test)]
self.check_live();
@@ -1039,7 +1050,8 @@ impl Runner<'_, '_> {
#[cfg(test)]
fn check_live(&mut self) {
let Some(live) = self.cached.as_ref() else { return };
- let fresh = pdfcraft_xfa::form_tree(self.tpl, self.data.as_ref(), &self.ov);
+ let uses = pdfcraft_xfa::page_area_uses(self.doc, self.tpl);
+ let fresh = pdfcraft_xfa::form_tree_paged(self.tpl, self.data.as_ref(), &self.ov, &uses);
let (mut have, mut want) = (Vec::new(), Vec::new());
collect_fields(&live.root, &mut have, 0);
collect_fields(&fresh.root, &mut want, 0);
@@ -1344,11 +1356,11 @@ pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field:
/// The SOM path of a generated field whose template has an `activity` script.
pub(crate) fn scripted_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field, activity: &str) -> Option {
- // Page-master copies have distinct PDF field names but share a SOM path. Reversing
- // fields_by_som would lose every copy except the last: use the field itself.
+ // The field's own SOM path: each page-master copy has one of its own (`master[1]…`).
let object = doc.get(field.obj);
let som = object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string()?.to_text();
- let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc));
+ let uses = pdfcraft_xfa::page_area_uses(doc, tpl);
+ let live = pdfcraft_xfa::form_tree_paged(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc), &uses);
live.events.iter().any(|e| e.som == som && e.activity == activity).then_some(som)
}
diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs
index 44e9cd43b7..23c5e5ba5d 100644
--- a/crates/xfa/src/data/path.rs
+++ b/crates/xfa/src/data/path.rs
@@ -52,7 +52,11 @@ pub fn data_path(tpl: &Template, som: &str) -> DataPath {
// The template children the next segment is looked up in; `None` until the root matched,
// and again once a segment is not a container the template has.
let mut level: Option<&[Node]> = None;
- for (i, (name, index)) in som_to_path(som).into_iter().enumerate() {
+ let segments = som_to_path(som);
+ let page_area_index = page_area_index(tpl, &segments);
+ for (i, (name, index)) in segments.into_iter().enumerate() {
+ // A page area's SOM index is the page made from it; see [`page_area_index`].
+ let index = if i == 0 { page_area_index.unwrap_or(index) } else { index };
let found = match level {
None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)),
None => None,
@@ -77,6 +81,36 @@ pub fn data_path(tpl: &Template, som: &str) -> DataPath {
out
}
+/// For a SOM path that starts at a page area (`Area[n].…`), the index its first segment takes in
+/// the data path, `None` for any other path. Each page made from a page area has its own SOM
+/// index, so the master content repeated on each page (a page number, an office-use box) binds
+/// apart on every page. A field whose bind shares one data node (`match="global"`, or an
+/// explicit data reference) binds as instance 0 on each page instead, so it shows the same value
+/// on all of them (XFA 3.3, "Page Background": a page-background field binds only through an
+/// explicit data reference).
+fn page_area_index(tpl: &Template, segments: &[(String, usize)]) -> Option {
+ let ((first, instance), rest) = segments.split_first()?;
+ if tpl.root.common.name.as_deref() == Some(first.as_str()) {
+ return None;
+ }
+ let areas = tpl.root.page_set.as_ref()?;
+ let area = areas.areas.iter().find(|a| a.name.as_deref() == Some(first.as_str()))?;
+ // The object the rest of the path names, inside the page area's own content.
+ let mut nodes: &[Node] = &area.items;
+ let mut target = None;
+ for (name, index) in rest {
+ let found = named_at(nodes, name, *index, 0)?;
+ target = Some(found);
+ nodes = match found {
+ Node::Subform(sf) => &sf.children,
+ Node::Area(a) => &a.children,
+ _ => &[],
+ };
+ }
+ let shared = matches!(target, Some(Node::Field(f)) if f.data_shared);
+ Some(if shared { 0 } else { *instance })
+}
+
enum Container<'a> {
Children(&'a [Node]),
/// Contents bind under the area's parent.
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 2e61338b67..6eac6757e0 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -200,6 +200,11 @@ struct Layouter<'t> {
/// The flowed subforms being laid out, outermost first: a break between their children
/// closes their parts on one content area and opens new ones on the next.
frames: Vec,
+ /// What scripts set on page-master content, applied to each page's copy by its own SOM path.
+ overrides: Option<&'t crate::script::Overrides>,
+ /// The pages' copies of master content that overrides changed, kept for the whole layout so
+ /// their addresses stay unique (the measurement cache keys on them).
+ master_copies: Vec>>,
}
/// How many instances of a repeating subform to lay out: what the data holds once it holds
@@ -212,6 +217,17 @@ pub fn instance_count(occur: &Occur, from_data: usize) -> usize {
/// Lay a template out, with the values and repeat counts of `data` (the `xfa:data` element).
pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result