Merge PR #3582: M12: showing an XFA subform leaves its unnamed hidden objects hidden

crates/xfa/src/script.rs: both sides added doc comments to the same functions; kept #3582's (they describe its presence(name, ...) signature).
This commit is contained in:
aiongg
2026-10-22 01:45:16 +00:00
3 changed files with 98 additions and 19 deletions
+26
View File
@@ -96,6 +96,8 @@ pub fn static_shell(data: &str) -> Vec<u8> {
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<u8>>) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
@@ -114,6 +116,30 @@ fn assemble(objs: Vec<Vec<u8>>) -> Vec<u8> {
out
}
/// 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##"<?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="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="page1" layout="tb">
<subform name="details" layout="tb" presence="hidden">
<draw w="4in" h="0.3in"><value><text>Current caption</text></value></draw>
<draw w="4in" h="0.3in" presence="hidden"><value><text>Old caption</text></value></draw>
<subform layout="tb" presence="hidden"><draw w="4in" h="0.3in"><value><text>Old section</text></value></draw></subform>
<field name="note" w="4in" h="0.3in"><ui><textEdit/></ui></field>
</subform>
</subform>
</subform>
</template>
</xdp:xdp>
"##
.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;
+28 -18
View File
@@ -188,8 +188,9 @@ impl Walker<'_> {
true
}
/// Note the scripted events of the object at `som`: its event scripts, its calculation and
/// its validation (with the message a failing one shows).
/// 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;
@@ -231,18 +232,20 @@ impl Walker<'_> {
}
}
/// The presence of the object at `som`: a script's override, else the template's `p`.
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())
}
/// The access of the object at `som`: a script's override, else the template's `a`.
/// 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())
}
/// The live node of field `f`, instance `index` under `parent_som`, valued from the data or
/// its template default (`radio`: the value its exclusion group chose), with its events noted.
/// 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());
@@ -262,7 +265,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,
@@ -283,8 +286,9 @@ impl Walker<'_> {
}
}
/// The live nodes of template `nodes` under `parent_som`: as many instances of a subform as
/// the data or its occurrence asks for, areas, fields, exclusion groups and draws.
/// 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<FormNode> {
let mut out = Vec::new();
if depth > MAX_DEPTH {
@@ -318,7 +322,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,
@@ -336,7 +340,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),
@@ -353,7 +357,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),
@@ -380,7 +384,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,6 +459,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;
@@ -467,9 +474,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 {
+44 -1
View File
@@ -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::*;
@@ -575,6 +577,9 @@ fn xfa_bytes(doc: &Document) -> Vec<u8> {
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.
@@ -615,6 +620,9 @@ fn writing_values_keeps_the_data_no_field_binds_to() {
assert!(String::from_utf8(xfa_bytes(&doc)).unwrap().contains("<name><p>rich</p></name>"));
}
/// 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("<form1><page1><name>Zoë 日本</name><other>ünbound</other></page1></form1>"))).unwrap();
@@ -644,6 +652,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
@@ -683,6 +694,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();
@@ -717,6 +731,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();
@@ -752,6 +769,32 @@ 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();
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"));
}
/// 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();