Merge #5368: M12: XFA master-page fields keep their own value on each page (#5365)

# Conflicts:
#	crates/engine/src/xfa.rs
#	crates/xfa/src/layout/pages.rs
#	crates/xfa/src/lib.rs
#	crates/xfa/src/script.rs
#	crates/xfa/src/tests.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:38:21 +07:00
commit ab2d4382ff
12 files changed
+568 -99

No files matched your search

+209
View File
@@ -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(
"</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]
@@ -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(
"</subform></template>",
r#"<field name="restore" w="100pt" h="25pt"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">xfa.resolveNode("master[2].PrintButton1").presence = "visible";</script></event></field></subform></template>"#,
);
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("</pageArea>", r#"
<field name="stamp" x="36pt" y="24pt" w="160pt" h="25pt"><ui><textEdit/></ui></field>
<field name="shared" x="200pt" y="24pt" w="150pt" h="25pt"><ui><textEdit/></ui><bind match="global"/></field>
<field name="mark" x="360pt" y="24pt" w="80pt" h="25pt"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">stamp.rawValue = "MASTER_B_CLICK";</script></event>
</field>
</pageArea>"#)
.replace(r#"<subform name="page1" layout="tb">"#, r#"<subform name="page1" layout="tb">
<field name="more" w="20pt" h="20pt"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items><value><integer>1</integer></value>
<event activity="change"><script contentType="application/x-javascript">xfa.resolveNode("form.page3").presence = this.rawValue == 1 ? "visible" : "hidden";</script></event>
</field>"#);
format!(
r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">{template}
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><master><shared>GLOBAL_VALUE</shared></master></xfa:data></xfa:datasets></xdp:xdp>"#
)
}
/// 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::<Vec<_>>().join(" ");
let restore =
targets.iter().map(|page| format!("xfa.resolveNode('master[{page}].PrintButton1').presence = 'visible';")).collect::<Vec<_>>().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("</subform></template>", &format!(r#"<field name="restore" w="100pt" h="25pt"><ui><button/></ui><event activity="click"><script contentType="application/x-javascript">{restore}</script></event></field></subform></template>"#));
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)
}
+17 -5
View File
@@ -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<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 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)
}
+35 -1
View File
@@ -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<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.
+18
View File
@@ -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<Frame>,
/// 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<Rc<Vec<Node>>>,
}
/// 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<Form, XfaError> {
layout_inner(tpl, data, None)
}
/// [`layout`], with what scripts set (`ov`) applied to page-master content page by page: a
/// field hidden on the second page made from a page area is hidden there only. The rest of the
/// template takes `ov` before this ([`crate::script::apply_overrides`]).
pub fn layout_with_overrides(tpl: &Template, data: Option<&DataNode>, ov: &crate::script::Overrides) -> Result<Form, XfaError> {
layout_inner(tpl, data, Some(ov))
}
fn layout_inner(tpl: &Template, data: Option<&DataNode>, overrides: Option<&crate::script::Overrides>) -> Result<Form, XfaError> {
let areas = match &tpl.root.page_set {
Some(ps) if !ps.areas.is_empty() => ps.areas.clone(),
_ => vec![PageArea {
@@ -253,6 +269,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
field_border: None,
views: Views::ALL,
frames: Vec::new(),
overrides,
master_copies: Vec::new(),
};
let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() };
l.new_page()?;
+2
View File
@@ -161,6 +161,8 @@ pub struct Page {
pub width: f64,
pub height: f64,
pub items: Vec<Item>,
/// The name of the page area the page was made from (`None` for an unnamed one).
pub area: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
+22 -6
View File
@@ -2,7 +2,7 @@
use super::EMBED_COPIED;
use super::items::{Item, Page, UNDERLINE_DROP};
use super::{
Block, Border, Common, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK,
Block, Border, Common, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, Node, PAGE_COUNT_MARK,
PageRole, Para, Rc, Rect, SomIndices, Views, XfaError, data_path, text,
};
@@ -144,17 +144,33 @@ impl Layouter<'_> {
if let Some(u) = self.uses.get_mut(idx) {
*u += 1;
}
self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() });
self.pages.push(Page { width: area.width, height: area.height, items: Vec::new(), area: area.name.clone() });
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);
// What scripts set on this page's master content (a button hidden on this page only) goes
// on a copy of it for this page; other pages keep the template's.
let prefix = format!("{}.", ctx.som);
let copy =
self.overrides.filter(|ov| !ctx.som.is_empty() && ov.presence.keys().chain(ov.access.keys()).any(|k| k.starts_with(&prefix))).map(|ov| {
let mut copy = area.items.clone();
crate::script::apply_overrides_to(&mut copy, &ctx.som, ov, 1);
let copy = Rc::new(copy);
self.master_copies.push(Rc::clone(&copy));
copy
});
let items: &[Node] = copy.as_deref().map_or(&area.items, Vec::as_slice);
let sibs = SomIndices::of(items);
// The master page's own objects: shown where they say, whatever the body was drawing
// when it needed this page.
let body = std::mem::replace(&mut self.views, Views::ALL);
let drawn = area.items.iter().enumerate().try_for_each(|(i, item)| self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i)));
let drawn = items.iter().enumerate().try_for_each(|(i, item)| self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i)));
self.views = body;
drawn
}
+6 -5
View File
@@ -101,12 +101,12 @@ pub use data::{
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout};
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, numbers_pages, parse};
pub use pdf::{CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
pub use pdf::{AREA_KEY, CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
pub use picture::typed_date;
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields,
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, has_focus_scripts, has_scripts, overrides, path_is_embedded, refresh_value,
rerender, reset_default, set_overrides, template_of,
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, form_tree_paged, has_focus_scripts, has_scripts, overrides, page_area_uses,
path_is_embedded, refresh_value, rerender, reset_default, set_overrides, template_of,
};
#[derive(Debug, thiserror::Error)]
@@ -161,8 +161,9 @@ pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
}
let (tpl, parse_warnings) = parse(&packets.xdp)?;
let data = parse_datasets(&packets.xdp);
let laid = apply_overrides(&tpl, &overrides(doc));
let mut form = layout(&laid, data.as_ref())?;
let ov = overrides(doc);
let laid = apply_overrides(&tpl, &ov);
let mut form = layout::layout_with_overrides(&laid, data.as_ref(), &ov)?;
form.warnings.splice(0..0, parse_warnings);
if form.pages.is_empty() {
return Err(XfaError::Malformed("the template laid out to no pages".into()));
+4
View File
@@ -636,6 +636,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,
/// `<keep intact="contentArea">` or `"pageArea"`: a page break doesn't split the field (XFA 3.3
/// p. 292). A field's default is `none`.
pub keep_intact: bool,
+2
View File
@@ -832,6 +832,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")),
+8
View File
@@ -42,6 +42,11 @@ pub const DATE_KEY: &[u8] = b"PCXfaDate";
/// fields they hold.
pub const NODE_KEY: &[u8] = b"PCXfaNode";
/// Private key on each page laying the form out writes: the name of the page area the page was
/// made from. The n-th page with a name is that area's `[n-1]` instance, which scripts address
/// even when no field on the page is left to show it (see `page_area_uses`).
pub const AREA_KEY: &[u8] = b"PCXfaArea";
pub struct Written {
pub pages: usize,
pub fields: usize,
@@ -871,6 +876,9 @@ impl Emitter<'_> {
pd.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, page.width, page.height].iter().map(|v| Object::Real(*v)).collect()));
pd.set(b"Resources".to_vec(), Object::Dict(res));
pd.set(b"Contents".to_vec(), Object::Ref(contents));
if let Some(area) = &page.area {
pd.set(AREA_KEY.to_vec(), Object::String(PdfString::text(area)));
}
if !annots.is_empty() {
pd.set(b"Annots".to_vec(), Object::Array(annots));
}
+135 -82
View File
@@ -418,32 +418,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 }
@@ -495,81 +511,84 @@ pub fn has_focus_scripts(tpl: &Template) -> bool {
walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
}
/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
/// instance of a repeating subform hide them all).
pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
/// Set the presence `ov` gives each of `nodes` (children of `parent_som`) and their descendants,
/// down to [`MAX_DEPTH`], and the access it gives a field outside an exclusion group. An unnamed
/// draw, area or subform shares its parent's SOM path, so it takes no override of its own.
fn walk(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
if depth > MAX_DEPTH {
return;
}
let sibs = SomIndices::of(nodes);
for (i, n) in nodes.iter_mut().enumerate() {
let sib = sibs.at(i);
let name_default = match n {
Node::Field(_) => Some("field"),
Node::ExclGroup(_) => Some("group"),
_ => None,
};
let name = n.common().name.as_deref().or(name_default);
let som = child_som(parent_som, name, sib);
// An unnamed draw, area or subform has no SOM name: `som` is its parent's, and so are
// the overrides under it.
let presence = name.and_then(|_| ov.presence.get(&som)).map(|p| Presence::parse(Some(p)));
let access = name.and_then(|_| ov.access.get(&som)).cloned();
match n {
Node::Subform(s) => {
if let Some(p) = presence {
s.common.presence = p;
}
walk(&mut s.children, &som, ov, depth + 1);
/// Set the presence `ov` gives each of `nodes` (children of `parent_som`) and their descendants,
/// down to [`MAX_DEPTH`], and the access it gives a field outside an exclusion group. An unnamed
/// draw, area or subform shares its parent's SOM path, so it takes no override of its own.
pub(crate) fn apply_overrides_to(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
if depth > MAX_DEPTH {
return;
}
let sibs = SomIndices::of(nodes);
for (i, n) in nodes.iter_mut().enumerate() {
let sib = sibs.at(i);
let name_default = match n {
Node::Field(_) => Some("field"),
Node::ExclGroup(_) => Some("group"),
_ => None,
};
let name = n.common().name.as_deref().or(name_default);
let som = child_som(parent_som, name, sib);
// An unnamed draw, area or subform has no SOM name: `som` is its parent's, and so are
// the overrides under it.
let presence = name.and_then(|_| ov.presence.get(&som)).map(|p| Presence::parse(Some(p)));
let access = name.and_then(|_| ov.access.get(&som)).cloned();
match n {
Node::Subform(s) => {
if let Some(p) = presence {
s.common.presence = p;
}
Node::Area(a) => {
if let Some(p) = presence {
a.common.presence = p;
}
walk(&mut a.children, &som, ov, depth + 1);
apply_overrides_to(&mut s.children, &som, ov, depth + 1);
}
Node::Area(a) => {
if let Some(p) = presence {
a.common.presence = p;
}
Node::Field(f) => {
if let Some(p) = presence {
f.common.presence = p;
}
if let Some(a) = access {
f.access = match a.as_str() {
"readOnly" => Access::ReadOnly,
"protected" => Access::Protected,
"nonInteractive" => Access::NonInteractive,
_ => Access::Open,
};
}
apply_overrides_to(&mut a.children, &som, ov, depth + 1);
}
Node::Field(f) => {
if let Some(p) = presence {
f.common.presence = p;
}
Node::Draw(d) => {
if let Some(p) = presence {
d.common.presence = p;
}
if let Some(a) = access {
f.access = match a.as_str() {
"readOnly" => Access::ReadOnly,
"protected" => Access::Protected,
"nonInteractive" => Access::NonInteractive,
_ => Access::Open,
};
}
Node::ExclGroup(g) => {
if let Some(p) = presence {
g.common.presence = p;
}
let fields: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
let fsibs = SomIndices::of(&fields);
for (fi, f) in g.fields.iter_mut().enumerate() {
let fsom = child_som(&som, f.common.name.as_deref().or(Some("field")), fsibs.at(fi));
if let Some(p) = ov.presence.get(&fsom) {
f.common.presence = Presence::parse(Some(p));
}
}
Node::Draw(d) => {
if let Some(p) = presence {
d.common.presence = p;
}
}
Node::ExclGroup(g) => {
if let Some(p) = presence {
g.common.presence = p;
}
let fields: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
let fsibs = SomIndices::of(&fields);
for (fi, f) in g.fields.iter_mut().enumerate() {
let fsom = child_som(&som, f.common.name.as_deref().or(Some("field")), fsibs.at(fi));
if let Some(p) = ov.presence.get(&fsom) {
f.common.presence = Presence::parse(Some(p));
}
}
}
}
}
}
/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
/// instance of a repeating subform hide them all). Page-master content is left as it is: a page
/// area makes many pages, each with its own SOM path, so `ov` is applied to it page by page as
/// the pages are laid out ([`crate::layout::layout_with_overrides`]).
pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
let mut t = tpl.clone();
if !ov.is_empty() {
let mut root = vec![Node::Subform(Box::new(t.root.clone()))];
walk(&mut root, "", ov, 0);
apply_overrides_to(&mut root, "", ov, 0);
if let Some(Node::Subform(r)) = root.pop() {
t.root = *r;
}
@@ -656,6 +675,40 @@ pub fn reset_default(tpl: &Template, som: &str) -> Option<String> {
}
}
/// How many pages the layout made from each named page area of `tpl`: the master content of the
/// n-th page from area `master` is `master[n-1]`. Each page the layout wrote names its area
/// ([`crate::AREA_KEY`]), so a page whose master fields are all hidden still counts; a file laid
/// out before pages were named shows it by its generated fields. Areas neither shows 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 };
let is_area = |name: &str| tpl.root.common.name.as_deref() != Some(name) && ps.areas.iter().any(|a| a.name.as_deref() == Some(name));
// The flat page list laying the form out writes.
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"Pages")).map(|p| doc.resolve(p)));
let kids =
pages.and_then(|p| p.as_dict().and_then(|d| d.get(b"Kids")).map(|k| doc.resolve(k))).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for page in kids.iter().filter_map(Object::as_ref).take(crate::layout::MAX_PAGES) {
let page = doc.get(page);
let Some(name) = page.as_dict().and_then(|d| d.get(crate::AREA_KEY)).and_then(Object::as_string).map(PdfString::to_text) else { continue };
if is_area(&name) {
let pages = out.entry(name).or_insert(0);
*pages = pages.saturating_add(1).min(crate::layout::MAX_PAGES);
}
}
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 !is_area(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> {
@@ -663,7 +716,7 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError>
let data = parse_datasets(&packets.xdp);
let ov = overrides(doc);
let laid = apply_overrides(tpl, &ov);
let form = crate::layout(&laid, data.as_ref())?;
let form = crate::layout::layout_with_overrides(&laid, data.as_ref(), &ov)?;
if form.pages.is_empty() {
return Err(XfaError::Malformed("the template laid out to no pages".into()));
}
+110
View File
@@ -2783,6 +2783,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]
@@ -5295,6 +5336,75 @@ fn a_row_outside_a_table_keeps_its_minimum_height() {
}
}
/// What a script sets on page-master content (a field hidden on the second page made from a
/// page area) is applied to that page's copy only, when the form is laid out again: the other
/// pages keep the field.
#[test]
fn a_script_hiding_master_content_on_one_page_hides_it_there_only() {
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>
</pageArea>
</pageSet>
{}{}{}
</subform>
</template>
</xdp:xdp>
"##,
section(1),
section(2),
section(3)
);
let (tpl, _) = crate::parse(&xdp).unwrap();
let mut ov = Overrides::default();
ov.presence.insert("Body[1].pageNo[0]".into(), "hidden".into());
let laid = apply_overrides(&tpl, &ov);
let form = crate::layout::layout_with_overrides(&laid, None, &ov).unwrap();
let has: Vec<bool> = form.pages.iter().map(|p| widgets(p).iter().any(|w| w.som.contains("pageNo"))).collect();
assert_eq!(has, [true, false, true], "hidden on the second page only");
}
/// Hidden master widgets still count their page area, and removed pages leave no
/// stale count when a page set has several page areas (wireless89, #5368).
#[test]
fn page_area_uses_follow_hidden_masters_and_shrink_with_multiple_areas() {
let xml = fixtures::repeated_print_button_template()
.replace(
"<pageSet><pageArea name=\"master\"><occur max=\"-1\"/>",
r#"<pageSet><pageArea name="Cover"><occur max="1"/><contentArea w="540pt" h="648pt"/><medium short="612pt" long="792pt"/>
<field name="hidden" presence="hidden"><ui><textEdit/></ui></field></pageArea><pageArea name="master"><occur max="-1"/>"#,
)
.replace("<occur max=\"-1\"/>", "<occur min=\"0\" max=\"-1\"/>")
.replace("name=\"PrintButton1\"", "name=\"PrintButton1\" presence=\"hidden\"");
let (tpl, _) = parse(&xml).unwrap();
let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
render_into(&mut doc).unwrap();
let uses = page_area_uses(&doc, &tpl);
assert_eq!(uses.get("Cover"), Some(&1));
assert_eq!(uses.get("master"), Some(&2));
let mut ov = Overrides::default();
ov.presence.insert("form[0].page3[0]".into(), "hidden".into());
set_overrides(&mut doc, &ov).unwrap();
rerender(&mut doc, &tpl).unwrap();
assert_eq!(page_area_uses(&doc, &tpl).get("master"), Some(&1));
ov.presence.insert("form[0].page2[0]".into(), "hidden".into());
set_overrides(&mut doc, &ov).unwrap();
rerender(&mut doc, &tpl).unwrap();
let uses = page_area_uses(&doc, &tpl);
assert_eq!(uses.get("Cover"), Some(&1));
assert!(!uses.contains_key("master"), "removed pages leave no stale occurrence metadata: {uses:?}");
}
/// The pages of `form[0].last[0].f[0]` and of the field `after` that follows `last`, when `last`
/// holds `before` ahead of `f` and `after` behind it. A breakAfter acts after its subform, so the
/// page of `after` shows whether it broke.