From 34f3716f5e3105edd0abebf4b1ffc353631d52dc Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:05:18 +0800 Subject: [PATCH] M12: XFA scripts read a field's border, ui, validate and other properties (XFA 3.3 ch. 3, ch. 17) Scripts got an object that takes anything for `this.border`, `this.ui`, `this.validate` and the like (XFA 3.3 ch. 3, "Properties vs. Children", p. 78), so `ui.oneOfChild.className == "choiceList"` was never true, `this.validate.nullTest == "error"` never matched, and `border.getElement("edge", 0).color.value` threw a TypeError. The parser now keeps each object's own attributes and its property elements (border, font, para, margin, ui, validate, occur, break, breakBefore, breakAfter, assist, format, value, bind, desc, extras, keep, caption, items, calculate) as a generic tree, bounded per object: 256 elements, 16 KiB of text, 8 levels, 64 attributes an element; past that the rest is left out with one warning. An element keeps only its own text, not that of children left out (an extras, a script); a validation or a calculation keeps its first script with its text outside the budget, as forms read it, within the script engine's 256 KiB limit on a script. A field's first two `` (a field has two at most, p. 726) are kept as lists: each counts against the budget as one element, and its entries, outside it, are bounded by the list's own limits (1,000 entries), as the field's lists are; a further `` (one public form has a third, empty one) is an ordinary property, every entry charged. The tree is shared through an Arc by the template, every instance of the object and every script's snapshot, and the engine keeps one converted copy of each tree in the form's template, so it is made once per opened form rather than per event or per script, behind the form's own lock, and goes when the form is closed. On a 10,000-field form with 17 property elements each, a script's snapshot takes 3.24 ms against 1.46 ms on main (median of 15 interleaved runs); the property trees are converted once per form, and each later snapshot adds one walk over the form and a lookup per tree. In JavaScript a property object is a Proxy naming its node and path. It reads what the template writes, or the default XFA 3.3 gives for the properties the 367 inventoried public forms read: presence of border, edge, corner, fill (p. 629, 687, 663, 734); colours (a fill white, p. 734; a font fill, edges and corners black, p. 734, 687, 663); font typeface/size/weight (pp. 741-744); para hAlign/vAlign (p. 800); margin insets (p. 776); validate nullTest/scriptTest/formatTest (pp. 880-881); occur min/max/initial (p. 786); break before/after (p. 632); bind ref (p. 623); keep next/previous none and intact by container (pp. 762-763); a comb's numberOfCells (p. 657); a gradient's type (p. 770, 806, 820); a fill's type solid (p. 734) and a field's widget textEdit (p. 877); a calculation's override, "disabled" where the template has no calculate element and "error" where it has one that doesn't say (pp. 641-642). Where XFA 3.3 leaves the outcome open, or where every reader tested contradicts its text, reads follow the readers. A border's presence reads "hidden" when the object has no border element ("visible" with one that doesn't say, p. 629). For the edges and corners a border doesn't list, p. 38 says the last one listed is reused; readers draw such a side from the edge or corner on the same side instead (two listed alternate, three reuse the second for the fourth, one serves every side), and reads follow the readers: a side past those listed reads as the one drawn there (with none listed, the defaults: black, 0.5pt, solid, a square cap, a corner's radius 0in, square join, not inverted, p. 687, p. 663), and is made as a copy of it (with what a script already set on it) when a script reaches it, so a write then changes only that side. A border has four sides: a fifth or later edge or corner, even one the template lists, is out of reach, as the layout draws four. The copies have their own budget (8 MiB a run), apart from the run's text budget, so reaching sides never crowds out a script's writes; past it a side still reads as the one drawn there. A fill type the fill doesn't have (its pattern when it is solid, p. 734) can't be read: the read raises an error the script can catch (GeneralError, "operation failed"), the fill keeps its type, and `"pattern" in fill` is false. Anything else unspecified reads as an empty value that compares as "" and never throws; further defaults are added as forms need them. Property objects have className, oneOfChild, children by name (desc.vMandatory, message.scriptTest) and by class (ui["#textEdit"]), getElement(name, index), isPropertySpecified, getAttribute, setAttribute, rawValue (a desc's first text), a validate's or calculate's script text and message (p. 827), resolveNode(s), somExpression and nodes (length/item/namedItem); nodes get getAttribute, getElement, isPropertySpecified, and mandatory, validationMessage, formatMessage and mandatoryMessage read from their validate's nullTest and message texts (the text of that name, as the template or a script named it, else a single unnamed one, which serves every test: p. 782, p. 880). Setting a test's message sets its named text, else overwrites a single unnamed one (so every test then reads it), else adds a text of that name, as readers do; reading validate.message. where no such text exists raises a script error, as in readers. fillColor, fontColor and borderColor read from the border fill, font fill and first edge colours; borderColor set colours all four edges. A child of the same name comes before a property, an attribute or a member, on nodes and on property objects alike, for reads and writes (a write to a member a child shadows leaves the property alone), as XFA SOM resolves name clashes in favour of the explicitly named object, and `#name` reaches the property (p. 99). A SOM expression from a node, or from xfa.resolveNode, that runs into objects' properties (resolveNode("ui.#textEdit"), "border.edge[1]", "row[*].border.fill.color", "extras.arrayGroup[*].groupName", "ui.*") gives those property objects, each instance's, 4,096 at most (about 30 of the forms do this); `value.#text` on a draw stays null, as before, until a node's value is its value element. Writes (border.fill.color.value = ..., validate.nullTest = ..., rawValue, setAttribute, mandatory, validationMessage = ...) are kept for the rest of the script run and read back through the same properties, bounded by the run's text budget and 64 attributes a property, but have no effect on the form yet. The caption (XFA 3.3 p. 643) and a field's items (p. 758) are property objects too. A caption's value holds its label as text or rich text, read by name, by class (`['#text']`), through resolveNode and oneOfChild; rich text reads as its words; the content a value doesn't hold reads as null (one of them, p. 598). Items list their entries through `nodes`, each with the text the choice list or check button shows. A field's `items` is its first `` (the shown list), a second is reached by index (`items[1]`), and a field with none reads an items object with no entries. Items reads follow the template: `HNode::items` is the hook PdfCraft's choice-list methods (addItem, clearItems; not on main yet) will set, so that reads agree with them once they land. exData.loadXML and saveXML do nothing yet. Over the 163 dynamic forms (118,208 objects) no object reaches the budget: elements p99 30, max 41 (limit 256); bytes p99 773, max 8,500 (limit 16 KiB); item entries per list p99 51, max 342. The choice-list methods (selectedIndex reads still give an object that takes anything), execEvent and script objects are handled separately. The one known gap is value.text and value.exData (about 15 of the 367 forms): a node's value stays its raw value, and changing what value returns is left to a follow-up with a full-corpus regression check. FormCalc does not read these properties yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/engine/src/xfa.rs | 179 +++- crates/js/src/xfa.rs | 2 + crates/js/src/xfa/host.rs | 30 +- crates/js/src/xfa/instances.rs | 3 + crates/js/src/xfa/model.rs | 3 + crates/js/src/xfa/nodes.rs | 102 ++- crates/js/src/xfa/props.rs | 1449 ++++++++++++++++++++++++++++++ crates/js/src/xfa/props_tests.rs | 925 +++++++++++++++++++ crates/xfa/README.md | 13 +- crates/xfa/src/model.rs | 60 ++ crates/xfa/src/parse.rs | 264 +++++- crates/xfa/src/props_tests.rs | 223 +++++ crates/xfa/src/script.rs | 11 +- parity/acrobat-features.toml | 4 +- 14 files changed, 3229 insertions(+), 39 deletions(-) create mode 100644 crates/js/src/xfa/props.rs create mode 100644 crates/js/src/xfa/props_tests.rs create mode 100644 crates/xfa/src/props_tests.rs diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index f20c82f6c6..ab658dc4d4 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -8,9 +8,9 @@ use std::collections::HashMap; use std::sync::Arc; -use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode}; +use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaProp, XfaProps}; use pdfcraft_js::xfa_session::{Script, XfaSession}; -use pdfcraft_xfa::model::Template; +use pdfcraft_xfa::model::{Prop, Props, Template}; use pdfcraft_xfa::{FormNode, LiveForm, NodeKind, ScriptEvent}; use crate::js::JsOutput; @@ -134,8 +134,94 @@ pub(crate) struct XfaRun<'a> { pub time_scale: u32, } -/// The live form node `n`, with its descendants, as the script engine's snapshot of it. -fn to_js(n: &FormNode) -> XfaNode { +/// The script engine's copies of a form's property trees, by the address of the tree each was +/// made from: made the first time a script of the form needs one, then shared by every later +/// script and event of that form. They are kept in the form's template +/// ([`pdfcraft_xfa::model::TemplateCache`]), so they go with it when the form is closed, and +/// the lock is the form's own. Each entry holds a weak reference to its tree, which keeps the +/// address from being reused while the entry exists; entries whose tree is gone (a tree of a +/// copy of the template, such as one with a script's overrides applied) are dropped each time +/// the map is used. +type PropsCopies = HashMap, Arc)>; +type PropsCache = std::sync::Mutex; + +/// The form's map of copies, with the entries of trees that are gone dropped; `None` only when +/// the template's cache holds something else. A poisoned lock only means a panic elsewhere +/// while it was held: the map is still a valid memo. +fn props_copies(tpl: &Template) -> Option> { + let mut copies = tpl.cache.get_or_init(PropsCache::default)?.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + copies.retain(|_, (tree, _)| tree.strong_count() > 0); + Some(copies) +} + +/// The copy made of `p`, if there is one. +fn copy_of(copies: &PropsCopies, p: &Arc) -> Option> { + let (tree, copy) = copies.get(&(Arc::as_ptr(p) as usize))?; + (std::sync::Weak::as_ptr(tree) == Arc::as_ptr(p) && tree.strong_count() > 0).then(|| Arc::clone(copy)) +} + +/// The script engine's copy of the template's property tree `p`. +fn convert_props(p: &Props) -> XfaProps { + fn prop(p: &Prop, depth: usize) -> XfaProp { + XfaProp { + tag: p.tag.clone(), + attrs: p.attrs.clone(), + // Shared, not copied: a validation's script can be long. + text: p.text.clone(), + // The parser keeps 8 levels; this bounds the walk whatever builds the tree. + children: if depth > 64 { Vec::new() } else { p.children.iter().map(|c| prop(c, depth + 1)).collect() }, + } + } + XfaProps { attrs: p.attrs.clone(), elements: p.elements.iter().map(|e| prop(e, 0)).collect() } +} + +/// The live form node `n` of the form whose template is `tpl`, with its descendants, as the +/// script engine's snapshot of it; the property trees are the form's shared copies +/// ([`PropsCopies`]), not copied again. The map is locked only to look copies up and to add the +/// ones made (outside the lock) for trees met for the first time. +fn to_js(n: &FormNode, tpl: &Template) -> XfaNode { + fn trees<'a>(n: &'a FormNode, out: &mut HashMap>, depth: usize) { + out.entry(Arc::as_ptr(&n.props) as usize).or_insert(&n.props); + if depth <= 256 { + n.children.iter().for_each(|c| trees(c, out, depth + 1)); + } + } + let mut needed = HashMap::new(); + trees(n, &mut needed, 0); + let mut found: HashMap> = HashMap::with_capacity(needed.len()); + let missing: Vec<&Arc> = { + let copies = props_copies(tpl); + needed + .iter() + .filter_map(|(&key, p)| match copies.as_ref().and_then(|c| copy_of(c, p)) { + Some(copy) => { + found.insert(key, copy); + None + } + None => Some(*p), + }) + .collect() + }; + if !missing.is_empty() { + let made: Vec<(&Arc, Arc)> = missing.into_iter().map(|p| (p, Arc::new(convert_props(p)))).collect(); + let mut copies = props_copies(tpl); + for (p, copy) in made { + let key = Arc::as_ptr(p) as usize; + // Another thread may have made one meanwhile: keep the first. + let copy = match copies.as_mut() { + Some(c) => copy_of(c, p).unwrap_or_else(|| { + c.insert(key, (Arc::downgrade(p), Arc::clone(©))); + copy + }), + None => copy, + }; + found.insert(key, copy); + } + } + to_js_with(n, &found, 0) +} + +fn to_js_with(n: &FormNode, copies: &HashMap>, depth: usize) -> XfaNode { XfaNode { name: n.name.clone(), som: n.som.clone(), @@ -154,7 +240,8 @@ fn to_js(n: &FormNode) -> XfaNode { occur_min: n.occur_min, occur_max: n.occur_max, index: n.index, - children: n.children.iter().map(to_js).collect(), + children: if depth > 256 { Vec::new() } else { n.children.iter().map(|c| to_js_with(c, copies, depth + 1)).collect() }, + props: copies.get(&(Arc::as_ptr(&n.props) as usize)).cloned().unwrap_or_else(|| Arc::new(convert_props(&n.props))), formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)), } } @@ -662,7 +749,8 @@ impl<'a, 'b> Runner<'a, 'b> { #[cfg(test)] self.check_live(); let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() }; - let root = to_js(&self.live().root); + let tpl = self.tpl; + let root = to_js(&self.live().root, tpl); let script = Script { source: ev.script.clone(), formcalc: ev.formcalc, @@ -954,4 +1042,83 @@ mod tests { Some((2, "form[0].table[0]".into(), "row".into(), 2)) ); } + + /// The scripts' snapshot shares the template's property trees: the next script's, and the + /// next event's (a live form built again, from a copy of the template as overrides make + /// one), point at the trees the first converted rather than converting them again. + #[test] + fn snapshots_share_property_trees_across_runs_and_events() { + let fields: String = (0..2_000) + .map(|i| format!(r#""#)) + .collect(); + let xml = format!( + r#""# + ); + let (tpl, _) = pdfcraft_xfa::parse(&xml).unwrap(); + let live = pdfcraft_xfa::form_tree(&tpl, None, &Default::default()); + let a = to_js(&live.root, &tpl); + let b = to_js(&live.root, &tpl); + // A tree built again (with a script's overrides) is still the form's: same copies. + let copy = pdfcraft_xfa::apply_overrides(&tpl, &Default::default()); + let c = to_js(&pdfcraft_xfa::form_tree(©, None, &Default::default()).root, &tpl); + assert_eq!(a.children.len(), 2_000); + for ((x, y), z) in a.children.iter().zip(&b.children).zip(&c.children) { + assert!(Arc::ptr_eq(&x.props, &y.props) && Arc::ptr_eq(&x.props, &z.props)); + } + assert_eq!(a.children[0].props.elements[1].children[1].children[0].attrs, [("value".to_string(), "1,2,3".to_string())]); + } + + /// A form's copies of its property trees are its own: kept for its later snapshots, and + /// dropped with its template when the form is closed, whatever other forms do. + #[test] + fn a_closed_forms_property_copies_go_with_its_template() { + let xml = |name: &str| { + format!( + r#""# + ) + }; + let (closed, _) = pdfcraft_xfa::parse(&xml("a")).unwrap(); + let (open, _) = pdfcraft_xfa::parse(&xml("b")).unwrap(); + let live = pdfcraft_xfa::form_tree(&closed, None, &Default::default()); + let snapshot = to_js(&live.root, &closed); + let copy = Arc::downgrade(&snapshot.children[0].props); + drop(snapshot); + assert!(copy.upgrade().is_some(), "kept for the form's next snapshot"); + // Another form's snapshot neither keeps nor needs this form's copies. + let other = to_js(&pdfcraft_xfa::form_tree(&open, None, &Default::default()).root, &open); + assert!(!Arc::ptr_eq(&other.children[0].props, ©.upgrade().unwrap_or_default())); + drop(live); + drop(closed); + assert!(copy.upgrade().is_none(), "closing the form drops its copies at once"); + } + + /// A field's click script reads its own and other objects' properties through a laid-out + /// dynamic form: template values, the spec's defaults, and a write read back, which changes + /// nothing on the form. + #[test] + fn a_click_script_reads_field_properties() { + let template = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#"