mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 15:22:54 +00:00
M12: an XFA page master's content is its own on every page, in the layout and in scripts
Master-page content repeated on each page shared one SOM path (`master[0]`), so its fields shared one data node and one script node. Each page's copy is now `master[n]`: - the layout names it so; - data binds it per page, except a field bound globally or by explicit ref, which stays shared (XFA 3.3, "Page Background"); - the live form has one page-area node per page laid out from it, counted from the generated fields, so a button on page 2 runs its click script as itself. - an event listed before a script laid the form out again can name an object that is gone (a page the form no longer fills): it is not run, rather than run with `this` at the form's root, where `this.presence = "hidden"` hid the whole form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
This commit is contained in:
1 parent
fa5f9f8c3b
commit
63a77901e6
9 files changed
+210
-27
No files matched your search
@@ -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(
|
||||
"</pageArea>",
|
||||
r#"<field name="stamp" x="200pt" y="24pt" w="100pt" h="25pt"><ui><textEdit/></ui></field>
|
||||
<field name="mark" x="320pt" y="24pt" w="80pt" h="25pt"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">stamp.rawValue = "clicked";</script></event>
|
||||
</field>
|
||||
</pageArea>"#,
|
||||
);
|
||||
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#"<caption><value><text>Print form</text></value></caption>"#,
|
||||
r#"<caption><value><text>Print form</text></value></caption>
|
||||
<event activity="initialize"><script contentType="application/x-javascript">this.presence = "hidden";</script></event>"#,
|
||||
)
|
||||
.replace(
|
||||
r#"<subform name="page3" layout="tb"><breakBefore targetType="pageArea"/>"#,
|
||||
r#"<subform name="page3" layout="tb"><breakBefore targetType="pageArea"/>
|
||||
<event activity="initialize"><script contentType="application/x-javascript">this.presence = "hidden";</script></event>"#,
|
||||
);
|
||||
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]
|
||||
|
||||
@@ -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<String> {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize> {
|
||||
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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -539,6 +539,10 @@ pub struct Field {
|
||||
/// The display picture clause (`<format><picture>`), e.g. `date{YYYYMMDD}`: how the value
|
||||
/// shows when not being edited, and what scripts read as `formattedValue`.
|
||||
pub display_picture: Option<String>,
|
||||
/// 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 {
|
||||
|
||||
@@ -543,6 +543,8 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> 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")),
|
||||
|
||||
+53
-18
@@ -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<String, usize>) -> 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<String, String> {
|
||||
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<String, usize> {
|
||||
let mut out: HashMap<String, usize> = 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::<usize>().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<Report, XfaError> {
|
||||
|
||||
@@ -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#"<subform name="s{n}" layout="tb"><breakBefore targetType="pageArea" startNew="1"/><field name="f{n}" w="2in" h="0.3in"><ui><textEdit/></ui></field></subform>"#
|
||||
)
|
||||
};
|
||||
let xdp = format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet>
|
||||
<pageArea name="Body"><contentArea x="0.5in" y="0.5in" w="7.5in" h="2in"/><medium short="8.5in" long="11in"/>
|
||||
<field name="pageNo" x="7in" y="10in" w="1in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="total" x="1in" y="10in" w="1in" h="0.3in"><ui><textEdit/></ui><bind match="global"/></field>
|
||||
</pageArea>
|
||||
</pageSet>
|
||||
{}{}{}
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##,
|
||||
section(1),
|
||||
section(2),
|
||||
section(3)
|
||||
);
|
||||
let form = layout_xml(&xdp).unwrap();
|
||||
let page_nos: Vec<String> = 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 `<occur>` needs none.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user