From 35e04743d0f87500fc6027a33c6e3e8c7c5c5a68 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:26:55 -0400 Subject: [PATCH 1/3] M12: showing an XFA subform leaves its unnamed hidden objects hidden --- crates/xfa/src/fixtures.rs | 24 ++++++++++++++++++++++++ crates/xfa/src/script.rs | 25 +++++++++++++++---------- crates/xfa/src/tests.rs | 24 +++++++++++++++++++++++- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 54bb9bc21..a46bee5a2 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -118,6 +118,30 @@ fn assemble(objs: Vec>) -> Vec { /// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform; /// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons; /// `grand`, the calculated sum of the amounts. +/// A hidden subform `details` a script may show, holding a named field and three unnamed +/// objects: a caption, an old caption the form keeps hidden, and a hidden subform. Unnamed +/// objects have no SOM name of their own, so showing `details` must leave the hidden ones hidden. +pub fn unnamed_hidden_template() -> String { + r##" + + + +"## + .to_string() +} + pub fn scripted_template() -> String { r##" diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 17a037664..df7ba2fdf 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -227,8 +227,10 @@ impl Walker<'_> { } } - fn presence(&self, som: &str, p: Presence) -> String { - self.ov.presence.get(som).cloned().unwrap_or_else(|| presence_name(p).to_string()) + /// The presence of the object named `name` at `som`: a script's, or else the template's. An + /// unnamed object shares its parent's SOM path, so a presence set there is not its own. + fn presence(&self, name: Option<&str>, som: &str, p: Presence) -> String { + name.and_then(|_| self.ov.presence.get(som)).cloned().unwrap_or_else(|| presence_name(p).to_string()) } fn access(&self, som: &str, a: Access) -> String { @@ -254,7 +256,7 @@ impl Walker<'_> { kind: Some(NodeKind::Field), value, numeric: f.numeric, - presence: self.presence(&som, f.common.presence), + presence: self.presence(Some("field"), &som, f.common.presence), access: self.access(&som, f.access), repeatable: false, occur_min: 1, @@ -307,7 +309,7 @@ impl Walker<'_> { kind: Some(NodeKind::Subform), value: String::new(), numeric: false, - presence: self.presence(&som, sf.common.presence), + presence: self.presence(sf.common.name.as_deref(), &som, sf.common.presence), access: "open".into(), repeatable: sf.occur.max != Some(1), occur_min: sf.occur.min, @@ -324,7 +326,7 @@ impl Walker<'_> { name: a.common.name.clone().unwrap_or_else(|| "#area".into()), som: som.clone(), kind: Some(NodeKind::Area), - presence: self.presence(&som, a.common.presence), + presence: self.presence(a.common.name.as_deref(), &som, a.common.presence), access: "open".into(), occur_min: 1, occur_max: Some(1), @@ -341,7 +343,7 @@ impl Walker<'_> { som: som.clone(), kind: Some(NodeKind::Draw), value: d.value.plain().unwrap_or_default(), - presence: self.presence(&som, d.common.presence), + presence: self.presence(d.common.name.as_deref(), &som, d.common.presence), access: "open".into(), occur_min: 1, occur_max: Some(1), @@ -368,7 +370,7 @@ impl Walker<'_> { som: som.clone(), kind: Some(NodeKind::ExclGroup), value: selected.unwrap_or_default(), - presence: self.presence(&som, g.common.presence), + presence: self.presence(Some("group"), &som, g.common.presence), access: "open".into(), occur_min: 1, occur_max: Some(1), @@ -455,9 +457,12 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template { Node::ExclGroup(_) => Some("group"), _ => None, }; - let som = child_som(parent_som, n.common().name.as_deref().or(name_default), sib); - let presence = ov.presence.get(&som).map(|p| Presence::parse(Some(p))); - let access = ov.access.get(&som).cloned(); + 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 { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67a..d1503527c 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -7,7 +7,9 @@ use crate::data::{ DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, write_datasets, }; use crate::data::{add_data_instances, remove_data_instance, write_data_value}; -use crate::fixtures::{TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png}; +use crate::fixtures::{ + TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png, unnamed_hidden_template, +}; use crate::layout::Item; use crate::layout::WidgetKind; use crate::model::*; @@ -714,6 +716,26 @@ fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() { assert!(overrides(&doc).is_empty()); } +#[test] +fn showing_a_subform_leaves_its_unnamed_hidden_objects_hidden() { + let (tpl, _) = parse(&unnamed_hidden_template()).unwrap(); + let mut ov = Overrides::default(); + ov.presence.insert("form[0].page1[0].details[0]".into(), "visible".into()); + let form = layout(&apply_overrides(&tpl, &ov), None).unwrap(); + let shown = texts(&form.pages[0]); + assert!(shown.iter().any(|t| t == "Current caption"), "{shown:?}"); + assert!(!shown.iter().any(|t| t == "Old caption" || t == "Old section"), "{shown:?}"); + assert_eq!(form.fields, 1); + // Scripts see the same: the hidden draw stays hidden in the live form. + let root = form_tree(&tpl, None, &ov).root; + let details = &root.children[0].children[0]; + assert_eq!(details.presence, "visible"); + let presence = |text: &str| details.children.iter().find(|c| c.value == text).map(|c| c.presence.clone()); + assert_eq!(presence("Current caption").as_deref(), Some("visible")); + assert_eq!(presence("Old caption").as_deref(), Some("hidden")); + assert_eq!(details.children.iter().find(|c| c.kind == Some(NodeKind::Subform)).map(|c| c.presence.as_str()), Some("hidden")); +} + #[test] fn data_instances_are_added_and_removed_in_the_packet() { let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap(); From 5cd7e816b942cb75082b0d47ddc7c8a61059dbf2 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:43:14 -0400 Subject: [PATCH 2/3] M12: doc comments for the functions this change touches --- crates/xfa/src/fixtures.rs | 8 ++++---- crates/xfa/src/script.rs | 9 +++++++++ crates/xfa/src/tests.rs | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index a46bee5a2..b075cc811 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -114,10 +114,6 @@ fn assemble(objs: Vec>) -> Vec { out } -/// A scripted form (JavaScript events): `qty` (initialised to 2, validated ≤ 100) × `price` -/// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform; -/// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons; -/// `grand`, the calculated sum of the amounts. /// A hidden subform `details` a script may show, holding a named field and three unnamed /// objects: a caption, an old caption the form keeps hidden, and a hidden subform. Unnamed /// objects have no SOM name of their own, so showing `details` must leave the hidden ones hidden. @@ -142,6 +138,10 @@ pub fn unnamed_hidden_template() -> String { .to_string() } +/// A scripted form (JavaScript events): `qty` (initialised to 2, validated ≤ 100) × `price` +/// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform; +/// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons; +/// `grand`, the calculated sum of the amounts. pub fn scripted_template() -> String { r##" diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index df7ba2fdf..1d0dc8a86 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -237,6 +237,9 @@ impl Walker<'_> { self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string()) } + /// The live node of field `f`, child `index` of `parent_som`, with its scripts recorded. In an + /// exclusion group (`radio` is the group's selected value) its value is its on value when picked, + /// else empty; elsewhere it is its data value (as saved) or its template default. fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode { let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index); let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); @@ -276,6 +279,9 @@ impl Walker<'_> { } } + /// The live nodes of `nodes`, the children of `parent_som`, with their scripts recorded: one per + /// instance of a repeating subform (what the data holds, else its initial count). Nothing past + /// [`MAX_DEPTH`] is built, and building stops once the object budget is spent. fn children(&mut self, nodes: &[Node], parent_som: &str, depth: usize) -> Vec { let mut out = Vec::new(); if depth > MAX_DEPTH { @@ -445,6 +451,9 @@ pub fn has_scripts(tpl: &Template) -> bool { /// 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; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index d1503527c..ab49e26bf 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -716,6 +716,9 @@ fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() { assert!(overrides(&doc).is_empty()); } +/// Showing a hidden subform through an override leaves its unnamed hidden draw and subform hidden, +/// in the layout and in the live form scripts see: they share the subform's SOM path, so its +/// override is not theirs. #[test] fn showing_a_subform_leaves_its_unnamed_hidden_objects_hidden() { let (tpl, _) = parse(&unnamed_hidden_template()).unwrap(); From f9daeeb1fa613748da34fa959536be0071e451b5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:25:19 -0400 Subject: [PATCH 3/3] M12: doc comments for the functions around this change --- crates/xfa/src/fixtures.rs | 2 ++ crates/xfa/src/script.rs | 4 ++++ crates/xfa/src/tests.rs | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index b075cc811..fb957b3cf 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -96,6 +96,8 @@ pub fn static_shell(data: &str) -> Vec { assemble(objs) } +/// A PDF file holding `objs` as objects 1, 2, … in order, with a cross-reference table and a +/// trailer whose root is object 1. fn assemble(objs: Vec>) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 1d0dc8a86..f552de778 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -186,6 +186,9 @@ impl Walker<'_> { true } + /// Record the scripts of the object at `som` as events: each non-empty event script, then its + /// calculate and validate scripts (the validate one with `message`). Once [`MAX_EVENTS`] events + /// are recorded, nothing more is and the form is marked truncated. fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) { if self.events.len() >= MAX_EVENTS { self.truncated = true; @@ -233,6 +236,7 @@ impl Walker<'_> { name.and_then(|_| self.ov.presence.get(som)).cloned().unwrap_or_else(|| presence_name(p).to_string()) } + /// The access of the object at `som`: a script's, or else the template's `a`. fn access(&self, som: &str, a: Access) -> String { self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string()) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index ab49e26bf..5a7a685a7 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -539,6 +539,9 @@ fn xfa_bytes(doc: &Document) -> Vec { s.decoded_within(1 << 20).unwrap() } +/// Writing field values into the datasets keeps every data node no field binds to as written, +/// changes the bound values in place and adds a missing one beside them. Writing the same values +/// again changes nothing, and a field bound to structured content is reported, not flattened. #[test] fn writing_values_keeps_the_data_no_field_binds_to() { // Regression: the data element was rebuilt from the fields alone, dropping everything else. @@ -579,6 +582,9 @@ fn writing_values_keeps_the_data_no_field_binds_to() { assert!(String::from_utf8(xfa_bytes(&doc)).unwrap().contains("

rich

")); } +/// A UTF-16 packet in either byte order, with a byte order mark, has its values read exactly and +/// is written back as UTF-16 with its mark. A packet in neither UTF-8 nor UTF-16 is left alone, +/// and that is reported. #[test] fn utf16_packets_are_read_and_written_back_as_utf16() { let mut doc = Document::open(Arc::new(static_shell("Zoë 日本ünbound"))).unwrap(); @@ -608,6 +614,9 @@ fn utf16_packets_are_read_and_written_back_as_utf16() { assert_eq!(xfa_bytes(&doc), latin1); } +/// Hundreds of fields with absurd SOM indices finish within seconds, both writing the datasets and +/// building fresh data, create at most the node budget and report what they could not write. A +/// moderate index still works, creating the empty instances before it. #[test] fn huge_som_indices_are_capped_not_built() { // Regression: each index created its missing siblings one count at a time (quadratic), with @@ -647,6 +656,9 @@ fn huge_som_indices_are_capped_not_built() { assert!(d.get(&som_to_path("form1[0].row[2]")).is_some(), "the instances in between exist, empty"); } +/// The live form tree carries each object's SOM path, value, presence and access, one instance of +/// a repeating row without data, and the template's event, calculate and validate scripts (FormCalc +/// when no content type is given). Data adds rows and values, and an override changes presence. #[test] fn the_form_tree_carries_values_presence_instances_and_the_events() { let xml = scripted_template(); @@ -681,6 +693,9 @@ fn the_form_tree_carries_values_presence_instances_and_the_events() { assert_eq!(page.children.iter().find(|c| c.name == "details").unwrap().kind, Some(NodeKind::Subform)); } +/// Presence and access overrides are kept on the AcroForm and applied to the template: `note` has +/// no widget while `details` is hidden and gets one once an override shows it and the form is laid +/// out again. Saving and reopening keeps the overrides and one set of fields; storing none removes the key. #[test] fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() { let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap(); @@ -739,6 +754,9 @@ fn showing_a_subform_leaves_its_unnamed_hidden_objects_hidden() { assert_eq!(details.children.iter().find(|c| c.kind == Some(NodeKind::Subform)).map(|c| c.presence.as_str()), Some("hidden")); } +/// Adding and removing data instances edits the datasets packet, creating it when missing: asking +/// for no more rows than exist, or removing one that is not there, changes nothing, and removing a +/// row moves the later ones up. The layout follows the data, and an absurd count is capped, not built. #[test] fn data_instances_are_added_and_removed_in_the_packet() { let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();