diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 833fa16b9f..1bcf3801c3 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -6626,6 +6626,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]
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index f20c82f6c6..ba8e45b83c 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -169,6 +169,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())
@@ -338,7 +342,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!(
@@ -658,6 +663,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();
@@ -752,7 +763,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);
@@ -908,11 +920,11 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
/// The SOM path of a generated field whose template has a click script.
pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> 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 clicked field itself.
+ // The clicked 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 == "click").then_some(som)
}
diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs
index d3568817d2..a856ad2eac 100644
--- a/crates/xfa/src/data/path.rs
+++ b/crates/xfa/src/data/path.rs
@@ -50,7 +50,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,
@@ -75,6 +79,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/pages.rs b/crates/xfa/src/layout/pages.rs
index dac17c1169..6b0ba7820e 100644
--- a/crates/xfa/src/layout/pages.rs
+++ b/crates/xfa/src/layout/pages.rs
@@ -100,8 +100,12 @@ impl Layouter<'_> {
self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() });
let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height));
self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false });
- // Master page content: positioned draws and fields.
- let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() };
+ // Master page content: positioned draws and fields. The page area's own SOM index counts
+ // the pages made from it, so a field repeated on each page (a page number, an office-use
+ // box) has a path of its own per page rather than every page sharing `[0]`.
+ let instance = self.uses.get(idx).map_or(0, |u| u.saturating_sub(1));
+ let ctx =
+ Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[{instance}]")).unwrap_or_default() };
let page_rect = Rect::new(0.0, 0.0, area.width, area.height);
let sibs = SomIndices::of(&area.items);
for (i, item) in area.items.iter().enumerate() {
diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs
index 7e63218c90..202fa37975 100644
--- a/crates/xfa/src/lib.rs
+++ b/crates/xfa/src/lib.rs
@@ -103,7 +103,7 @@ pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, exis
pub use picture::typed_date;
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text,
- fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,
+ fields_by_som, form_tree, form_tree_paged, has_scripts, overrides, page_area_uses, refresh_value, rerender, set_overrides, template_of,
};
#[derive(Debug, thiserror::Error)]
diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs
index 0fac8db18e..1b844ede51 100644
--- a/crates/xfa/src/model.rs
+++ b/crates/xfa/src/model.rs
@@ -539,6 +539,10 @@ pub struct Field {
/// The display picture clause (``), e.g. `date{YYYYMMDD}`: how the value
/// shows when not being edited, and what scripts read as `formattedValue`.
pub display_picture: Option,
+ /// Its bind shares one data node among every field that names it: `match="global"`, or an
+ /// explicit data reference (`ref`). On a page area, such a field shows the same value on each
+ /// page made from it (XFA 3.3, "Page Background").
+ pub data_shared: bool,
}
impl Field {
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index fc114aebac..cacc061ffb 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -543,6 +543,8 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field {
},
picture: ui.and_then(|u| child(u, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
data_picture: child(n, "bind").and_then(|b| child(b, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
+ data_shared: child(n, "bind")
+ .is_some_and(|b| b.attribute("match").is_some_and(|m| m.trim() == "global") || b.attribute("ref").is_some_and(|r| !r.trim().is_empty())),
scripts,
calculate: child(n, "calculate").and_then(|c| child(c, "script")).map(|s| parse_script(s, "calculate")),
validate: validate_el.and_then(|v| child(v, "script")).map(|s| parse_script(s, "validate")),
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 6946aa9b1c..56be18bb9a 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -406,32 +406,48 @@ impl Walker<'_> {
/// from the template and `ov`) and every scripted event, in document order, within
/// [`MAX_FORM_NODES`] objects.
pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm {
+ form_tree_paged(tpl, data, ov, &HashMap::new())
+}
+
+/// [`form_tree`] for a laid-out form whose page areas made `uses` pages each, by name
+/// ([`page_area_uses`]): the master content of each of those pages is a node of its own
+/// (`master[0]`, `master[1]`, …), as the layout names its fields, so a button repeated on every
+/// page runs its click script as itself. A page area not in `uses` gets one node.
+pub fn form_tree_paged(tpl: &Template, data: Option<&DataNode>, ov: &Overrides, uses: &HashMap) -> LiveForm {
let mut w = Walker { tpl, data, ov, events: Vec::new(), script_bytes: 0, nodes: 0, truncated: false };
let root = Node::Subform(Box::new(tpl.root.clone()));
let mut nodes = w.children(std::slice::from_ref(&root), "", 0);
let mut root_node = nodes.pop().unwrap_or_default();
// Page masters' fields (Print and Save buttons, page numbers) belong to the layout model
- // in XFA; they hang off the root here, under their page area's name, as the layouter
- // names them, so their click scripts can run.
+ // in XFA; they hang off the root here, under their page area's name, one node per page made
+ // from it, as the layouter names them, so their click scripts can run.
if let Some(ps) = &tpl.root.page_set {
for (i, area) in ps.areas.iter().enumerate() {
- let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
- let children = w.children(&area.items, &som, 1);
- if children.is_empty() {
- continue;
+ // A name two areas share stays one node: its pages can't be told apart by it.
+ let unique = area.name.as_ref().is_some_and(|n| ps.areas.iter().filter(|a| a.name.as_ref() == Some(n)).count() == 1);
+ let pages = match &area.name {
+ Some(n) if unique => uses.get(n).copied().unwrap_or(1).clamp(1, crate::layout::MAX_PAGES),
+ _ => 1,
+ };
+ for page in 0..pages {
+ let som = area.name.clone().map(|n| format!("{n}[{page}]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
+ let children = w.children(&area.items, &som, 1);
+ if children.is_empty() {
+ break;
+ }
+ root_node.children.push(FormNode {
+ name: area.name.clone().unwrap_or_else(|| "#pageArea".into()),
+ som,
+ kind: Some(NodeKind::Area),
+ presence: "visible".into(),
+ access: "open".into(),
+ occur_min: 1,
+ occur_max: Some(1),
+ index: page,
+ children,
+ ..Default::default()
+ });
}
- root_node.children.push(FormNode {
- name: area.name.clone().unwrap_or_else(|| "#pageArea".into()),
- som,
- kind: Some(NodeKind::Area),
- presence: "visible".into(),
- access: "open".into(),
- occur_min: 1,
- occur_max: Some(1),
- index: 0,
- children,
- ..Default::default()
- });
}
}
LiveForm { root: root_node, events: w.events, truncated: w.truncated }
@@ -575,6 +591,25 @@ pub fn fields_by_som(doc: &Document) -> HashMap {
out
}
+/// How many pages the layout made from each named page area of `tpl`, as the generated fields
+/// of `doc` show it: the master content of the n-th page from area `master` is `master[n-1]`.
+/// Areas no generated field names are left out (one page's worth, to [`form_tree_paged`]).
+pub fn page_area_uses(doc: &Document, tpl: &Template) -> HashMap {
+ let mut out: HashMap = HashMap::new();
+ let Some(ps) = &tpl.root.page_set else { return out };
+ for som in fields_by_som(doc).keys() {
+ let first = som.split('.').next().unwrap_or_default();
+ let Some((name, rest)) = first.split_once('[') else { continue };
+ let Some(index) = rest.strip_suffix(']').and_then(|i| i.parse::().ok()) else { continue };
+ if tpl.root.common.name.as_deref() == Some(name) || !ps.areas.iter().any(|a| a.name.as_deref() == Some(name)) {
+ continue;
+ }
+ let pages = out.entry(name.to_string()).or_insert(1);
+ *pages = (*pages).max(index.saturating_add(1)).min(crate::layout::MAX_PAGES);
+ }
+ out
+}
+
/// Lay the form out again from its template, data and overrides, replacing the pages and the
/// generated fields (a script added a row or hid a subform).
pub fn rerender(doc: &mut Document, tpl: &Template) -> Result {
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index e424be5441..16528fae3a 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -2102,6 +2102,47 @@ fn page_area_without_occur_serves_every_body_page() {
assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}");
}
+#[test]
+fn page_master_content_repeated_on_each_page_has_its_own_som_index() {
+ // One page area (no occur limit) serves three body pages and carries a field in its own
+ // master content. Each page's copy of that field gets the page area's instance index, so the
+ // three do not share one SOM path (and one data node).
+ let section = |n: u32| {
+ format!(
+ r#""#
+ )
+ };
+ let xdp = format!(
+ r##"
+
+
+
+
+
+
+
+
+
+ {}{}{}
+
+
+
+"##,
+ section(1),
+ section(2),
+ section(3)
+ );
+ let form = layout_xml(&xdp).unwrap();
+ let page_nos: Vec = form.pages.iter().flat_map(|p| widgets(p)).filter(|w| w.som.contains("pageNo")).map(|w| w.som.clone()).collect();
+ assert_eq!(page_nos, ["Body[0].pageNo[0]", "Body[1].pageNo[0]", "Body[2].pageNo[0]"], "each page's master field has its own index");
+ // Its data path keeps that index, so each page binds apart; a field whose bind is global
+ // shares one data node, so every page binds it as instance 0 (XFA 3.3, "Page Background").
+ let (tpl, _) = crate::parse(&xdp).unwrap();
+ let dp = |som: &str| crate::data_path(&tpl, som);
+ assert_eq!(dp("Body[2].pageNo[0]"), crate::som_to_path("Body[2].pageNo[0]"));
+ assert_eq!(dp("Body[2].total[0]"), crate::som_to_path("Body[0].total[0]"), "a global field binds as instance 0 on each page");
+}
+
/// A page area's `occur min` is a page it must have even when the body never reaches it, and a
/// body that already used it does not get another. An area with no `` needs none.
#[test]