Skip to main content

pdf_xfa/
merger.rs

1//! XFA Form DOM — the merged result of template + data.
2//!
3//! Implements the Form DOM from XFA 3.3 §3 and §5.
4//! The Form DOM is a hierarchical tree of merged nodes, where repeating
5//! subforms have been expanded based on data instances.
6//!
7//! Data binding follows XFA Spec 3.3 §4.4 p176-214 ("Merging Data with a
8//! Template"). Implements both `consumeData` and `matchTemplate` merge modes.
9//!
10//! ## Spec gaps (see individual TODOs):
11//! - §4.4.3 p176: `bind match="global"` not implemented (approximated)
12//! - §4.4 p197: attribute matching step skipped
13//! - §4.4 p198: re-normalization not implemented
14//! - §4.2 p143: localization/canonicalization not implemented
15//! - §4.4 p195: exclusion group short/long format not implemented
16//! - §4.4 p199: setProperty/bindItems not implemented
17
18// Copyright (c) 2026 Innovation Trigger B.V.
19//
20// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
21// the PDFluent Commercial Licence. See the LICENSE file in this repository --
22// that file travels with the copy you received, which a URL does not.
23
24/// XFA Spec 3.3 §4.4 p176 — Merge mode controlling how data is bound to template.
25///
26/// - `ConsumeData` (default): walk the template tree top-down, binding each
27///   node against the current data context positionally (by name within context).
28/// - `MatchTemplate`: data drives the merge — for each data node, find the
29///   template node by name match regardless of hierarchy depth.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum MergeMode {
32    /// XFA §4.4 p176 — template-driven merge (default).
33    ConsumeData,
34    /// XFA §4.4 p176 — data-driven merge: match data nodes to template by name.
35    MatchTemplate,
36}
37
38/// Detect the merge mode from the XFA config packet or default to `ConsumeData`.
39///
40/// XFA Spec 3.3 §4.4 p176: the merge mode can be specified via the
41/// `<config><present><xfa:generator>` or `<config><acrobat><xdp><packet>` elements.
42/// In practice most forms do not specify a mode and `consumeData` is implied.
43/// We detect `matchTemplate` by scanning for explicit `matchTemplate` text in
44/// a `<config>` packet when one is present in the template XML.
45pub fn detect_merge_mode(template_xml: &str) -> MergeMode {
46    // Quick scan: if the template explicitly requests matchTemplate mode.
47    if template_xml.contains("matchTemplate") {
48        return MergeMode::MatchTemplate;
49    }
50    MergeMode::ConsumeData
51}
52
53use crate::error::{Result, XfaError};
54use roxmltree::Node;
55use xfa_dom_resolver::data_dom::{DataDom, DataNodeId};
56use xfa_dom_resolver::som::resolve_data_path;
57use xfa_layout_engine::form::{
58    Access, AnchorType, ContentArea, DrawContent, EventScript, FieldKind, FormNode, FormNodeId,
59    FormNodeMeta, FormNodeStyle, FormNodeType, FormTree, GroupKind, Occur, Presence, RichTextSpan,
60    ScriptLanguage,
61};
62use xfa_layout_engine::text::{FontFamily, FontMetrics};
63use xfa_layout_engine::types::{
64    BoxModel, Caption, CaptionPlacement, Insets, LayoutStrategy, Measurement, TextAlign,
65    VerticalAlign,
66};
67
68/// Merges an XFA template (XML) with data from a DataDom to produce a FormTree.
69pub struct FormMerger<'a> {
70    data_dom: &'a DataDom,
71    form_tree: FormTree,
72    /// Embedded image files from the PDF's Names/EmbeddedFiles tree,
73    /// keyed by filename (e.g. `.\lintje.jpg`).  Used to resolve
74    /// `<image href="…">` references in the XFA template (XFA §2.3).
75    image_files: std::collections::HashMap<String, Vec<u8>>,
76}
77
78fn area_layout(elem: Node<'_, '_>) -> LayoutStrategy {
79    if elem.tag_name().name() == "area" && attr(elem, "layout").is_none() {
80        let has_positioned_child = elem.children().filter(|n| n.is_element()).any(|child| {
81            attr(child, "x")
82                .and_then(parse_dim)
83                .is_some_and(|v| v > 0.0)
84                || attr(child, "y")
85                    .and_then(parse_dim)
86                    .is_some_and(|v| v > 0.0)
87        });
88        if has_positioned_child {
89            LayoutStrategy::Positioned
90        } else {
91            LayoutStrategy::TopToBottom
92        }
93    } else {
94        parse_layout_attr(elem)
95    }
96}
97
98impl<'a> FormMerger<'a> {
99    /// new.
100    pub fn new(data_dom: &'a DataDom) -> Self {
101        Self {
102            data_dom,
103            form_tree: FormTree::new(),
104            image_files: std::collections::HashMap::new(),
105        }
106    }
107
108    /// Set embedded image files for resolving `<image href="…">` references.
109    pub fn with_image_files(mut self, files: std::collections::HashMap<String, Vec<u8>>) -> Self {
110        self.image_files = files;
111        self
112    }
113
114    /// Merge the template XML into a FormTree.
115    ///
116    /// XFA Spec 3.3 §4.4 p176 — supports both merge modes:
117    /// - `consumeData` (default): walk template top-down, bind each node
118    ///   against matching data context positionally.
119    /// - `matchTemplate`: walk data nodes, find template counterparts by name,
120    ///   bind regardless of hierarchy depth.
121    pub fn merge(mut self, template_xml: &str) -> Result<(FormTree, FormNodeId)> {
122        let mode = detect_merge_mode(template_xml);
123        log::debug!(
124            "XFA merge: mode={:?}, {} data nodes",
125            mode,
126            self.data_dom.len()
127        );
128        let doc = roxmltree::Document::parse(template_xml)
129            .map_err(|e| XfaError::ParseFailed(format!("template XML parse error: {e}")))?;
130        let root_elem = doc.root_element();
131
132        // The packet may start with <template> directly, or may have been
133        // wrapped inside <xdp:xdp>. Accept both.
134        let template_elem = if root_elem.tag_name().name() == "template" {
135            root_elem
136        } else {
137            // Find the first <template> descendant.
138            find_first_child_by_name(root_elem, "template").ok_or_else(|| {
139                XfaError::PacketNotFound("no <template> element found".to_string())
140            })?
141        };
142
143        if mode == MergeMode::MatchTemplate {
144            // XFA §4.4 p176 matchTemplate: pre-index all template nodes by name,
145            // then for each data node find and bind to its template counterpart.
146            self.apply_match_template_bindings(template_elem);
147        }
148
149        // Phase D-ι: collect <variables> <script name="X"> blocks at the root
150        // subform level. XFA 3.3 §5.5 — these named scripts define
151        // form-level objects whose top-level `var`/`function` declarations
152        // are accessible from event/calculate scripts via `<scriptName>.X`.
153        // Sandboxed JS runtime evaluates these once per document.
154        self.collect_variables_scripts(template_elem);
155        let (root_id, _trailing) = self.parse_node(template_elem, None, true)?;
156
157        Ok((self.form_tree, root_id))
158    }
159
160    /// Phase D-ι / D-ι.2: walk `<variables>` blocks at every subform level
161    /// below the template root (XFA 3.3 §5.5). Root-subform scripts get
162    /// `subform_scope = None` (globally accessible); named descendant-subform
163    /// scripts get `subform_scope = Some(subform_name)` so they are accessible
164    /// via `subformHandle.variables.scriptName`.
165    fn collect_variables_scripts(&mut self, template_elem: Node<'_, '_>) {
166        let Some(root_subform) = find_first_child_by_name(template_elem, "subform") else {
167            return;
168        };
169        self.collect_variables_scripts_recursive(root_subform, None);
170    }
171
172    fn collect_variables_scripts_recursive(
173        &mut self,
174        subform: Node<'_, '_>,
175        subform_scope: Option<String>,
176    ) {
177        for child in subform.children().filter(|n| n.is_element()) {
178            match child.tag_name().name() {
179                "variables" => {
180                    for var_child in child.children().filter(|n| n.is_element()) {
181                        match var_child.tag_name().name() {
182                            "script" => {
183                                let Some(name) = attr(var_child, "name") else {
184                                    continue;
185                                };
186                                let body: String = var_child
187                                    .children()
188                                    .filter(|n| n.is_text())
189                                    .filter_map(|n| n.text())
190                                    .collect::<String>();
191                                if body.trim().is_empty() {
192                                    continue;
193                                }
194                                self.form_tree.variables_scripts.push((
195                                    subform_scope.clone(),
196                                    name.to_string(),
197                                    body,
198                                ));
199                            }
200                            // W3-D RETRY: XFA 3.3 §5.5.2 — `<variables>` may
201                            // contain `<text name="X">value</text>` data items
202                            // alongside `<script>` blocks. Adobe Reader
203                            // exposes these to event scripts as form-level
204                            // mutable string containers (`X.value` reads /
205                            // writes the data item). Canonical residual:
206                            // IMM5709/IMM5257/IMM5710 declare
207                            // `<text name="globValidatePressed"/>` and event
208                            // scripts call `globValidatePressed.value = "true";`.
209                            // Pre-fix this surfaced as the post-W2-B
210                            // `implicit_function` cluster residual.
211                            //
212                            // We treat an absent / whitespace-only body as
213                            // the empty string (per spec, an empty `<text/>`
214                            // is a valid empty data item).
215                            "text" => {
216                                let Some(name) = attr(var_child, "name") else {
217                                    continue;
218                                };
219                                if name.is_empty() {
220                                    continue;
221                                }
222                                let initial: String = var_child
223                                    .children()
224                                    .filter(|n| n.is_text())
225                                    .filter_map(|n| n.text())
226                                    .collect::<String>();
227                                self.form_tree.variables_data_items.push((
228                                    subform_scope.clone(),
229                                    name.to_string(),
230                                    initial,
231                                ));
232                            }
233                            _ => {}
234                        }
235                    }
236                }
237                "subform" | "area" | "exclGroup" => {
238                    let child_scope = attr(child, "name")
239                        .filter(|n| !n.is_empty())
240                        .map(|n| n.to_string());
241                    self.collect_variables_scripts_recursive(child, child_scope);
242                }
243                _ => {}
244            }
245        }
246    }
247
248    /// XFA Spec 3.3 §4.4 p176 — matchTemplate pre-pass.
249    ///
250    /// In matchTemplate mode the data DOM drives the merge: for each data node
251    /// that has a matching (same-named) template node, the binding is resolved
252    /// purely by name without regard to hierarchy depth.  We implement this by
253    /// walking the data DOM once and storing the resolved data value for every
254    /// field whose name appears as a data child anywhere in the data tree.
255    ///
256    /// Because `parse_field` already performs a global fallback via
257    /// `find_value_in_descendants`, matchTemplate mode for most real-world forms
258    /// reduces to ensuring the global fallback is preferred over the positional
259    /// context match.  The actual switch happens in `lookup_value_by_name`:
260    /// when matchTemplate is active we skip the context-child check and go
261    /// straight to the global search.
262    fn apply_match_template_bindings(&mut self, _template_elem: roxmltree::Node<'_, '_>) {
263        // Intentionally empty: the matchTemplate semantics are realised by
264        // passing `None` as the data_context for all field lookups when the
265        // mode is MatchTemplate, which forces `lookup_value_by_name` to use
266        // the global descendant search (find_value_in_descendants) exclusively.
267        // The parse_node / parse_field paths already handle None context.
268        // This hook exists for future extension (e.g. explicit bind-ref tables).
269    }
270
271    fn parse_node(
272        &mut self,
273        elem: Node<'_, '_>,
274        data_context: Option<DataNodeId>,
275        is_root: bool,
276    ) -> Result<(FormNodeId, (bool, Option<String>))> {
277        let tag = elem.tag_name().name();
278
279        let (node, trailing_info) = match tag {
280            "template" => {
281                let mut n = self.blank_node("root", FormNodeType::Root);
282                n.layout = LayoutStrategy::TopToBottom;
283                let ti = self.add_children(&mut n, elem, data_context)?;
284                (n, ti)
285            }
286            "subform" | "exclGroup" | "area" => {
287                let name = attr(elem, "name").unwrap_or("").to_string();
288                let layout = area_layout(elem);
289                let bm = parse_box_model(elem);
290                let occur = parse_occur(elem);
291                self.build_subform_instance(elem, data_context, is_root, occur, name, layout, bm)?
292            }
293            "field" => (self.parse_field(elem, data_context)?, (false, None)),
294            "draw" => (self.parse_draw(elem, data_context)?, (false, None)),
295            "pageSet" => (self.parse_page_set(elem, data_context)?, (false, None)),
296            "pageArea" => (self.parse_page_area(elem, data_context)?, (false, None)),
297            _ => {
298                let mut n = self.blank_node(tag, FormNodeType::Subform);
299                let ti = self.add_children(&mut n, elem, data_context)?;
300                (n, ti)
301            }
302        };
303
304        if tag == "exclGroup" {
305            // fixes #798: flatten.rs uses FormMerger, not template_parser, so
306            // exclusion-group selection must be resolved here. XFA 3.3 §4.4.5
307            // / §11.2 says assigning a value to an exclGroup selects the child
308            // whose asserted value matches the group's bound value.
309            self.apply_exclusive_choice_value(elem, data_context, &node.children);
310        }
311
312        let mut meta = parse_node_meta(elem);
313
314        // XFA Spec 3.3 §7.4.2 — presence binding: a data node named "presence"
315        // as a child of this element's data context can override the template's
316        // static `presence` attribute.  Check for a bound data value and apply it.
317        if let Some(bound_presence) = self.lookup_value_by_name("presence", data_context) {
318            meta.presence = parse_presence_str(&bound_presence);
319        }
320
321        let is_draw_or_field = tag == "draw" || tag == "field";
322        if is_draw_or_field {
323            // Fields get their <margin topInset/...> bridged to style.inset_*_pt
324            // so the renderer offsets the value and shrinks the border/bg to
325            // the inner rect (render_bridge.rs:238-280). Draws skip this bridge:
326            // draws in complex background-image forms (e.g. VA Form 10-2478,
327            // 49f8705c) carry decorative insets that visually misalign with the
328            // pre-rendered form backdrop when shifted inward. Keeping the
329            // transfer only for fields preserves the #841 field-padding fix
330            // without re-introducing the −0.18 regression on 49f8705c that
331            // c24798f7a originally set out to fix.
332            if tag == "field" {
333                meta.style.inset_top_pt = Some(node.box_model.margins.top);
334                meta.style.inset_bottom_pt = Some(node.box_model.margins.bottom);
335                meta.style.inset_left_pt = Some(node.box_model.margins.left);
336                meta.style.inset_right_pt = Some(node.box_model.margins.right);
337            }
338            if meta.style.font_weight.is_none() {
339                if let Some(weight) = extract_exdata_font_weight(elem) {
340                    meta.style.font_weight = Some(weight);
341                }
342            }
343            if meta.style.font_style.is_none() {
344                if let Some(style) = extract_exdata_font_style(elem) {
345                    meta.style.font_style = Some(style);
346                }
347            }
348            if meta.style.font_family.is_none() {
349                if let Some(family) = extract_exdata_font_family(elem) {
350                    meta.style.font_family = Some(family);
351                }
352            }
353            if meta.style.text_color.is_none() {
354                if let Some(color) = extract_exdata_color(elem) {
355                    meta.style.text_color = Some(color);
356                }
357            }
358            if meta.style.space_above_pt.is_none() || meta.style.space_below_pt.is_none() {
359                if let Some((above, below)) = extract_exdata_margins(elem) {
360                    if meta.style.space_above_pt.is_none() {
361                        meta.style.space_above_pt = Some(above);
362                    }
363                    if meta.style.space_below_pt.is_none() {
364                        meta.style.space_below_pt = Some(below);
365                    }
366                }
367            }
368            if meta.style.rich_text_spans.is_none() {
369                meta.style.rich_text_spans = parse_exdata_rich_text_spans(elem);
370            }
371        }
372        // Phase D-γ: store the data binding so the JS runtime can resolve
373        // `$record` (the data record for the current subform's context).
374        meta.bound_data_node = data_context.map(|c| c.as_raw());
375        let id = self.form_tree.add_node_with_meta(node, meta);
376        Ok((id, trailing_info))
377    }
378
379    #[allow(clippy::too_many_arguments)]
380    fn build_subform_instance(
381        &mut self,
382        elem: Node<'_, '_>,
383        data_context: Option<DataNodeId>,
384        is_root: bool,
385        occur: Occur,
386        name: String,
387        layout: LayoutStrategy,
388        mut bm: BoxModel,
389    ) -> Result<(FormNode, (bool, Option<String>))> {
390        if layout == LayoutStrategy::TopToBottom && bm.width.is_none() && is_root {
391            bm.width = Some(612.0);
392        }
393
394        // XFA Spec 3.3 §4.4 p193 — transparent nodes: a subform with no `name`
395        // attribute is "transparent" to data binding.  Its children bind against
396        // the parent's data context directly — we do NOT descend into a child
397        // data group.
398        if name.is_empty() {
399            // Transparent subform: pass parent context straight through.
400            let mut n = FormNode {
401                name,
402                node_type: FormNodeType::Subform,
403                box_model: bm,
404                layout,
405                children: Vec::new(),
406                occur,
407                font: FontMetrics::default(),
408                calculate: None,
409                validate: None,
410                column_widths: Vec::new(),
411                col_span: 1,
412            };
413            let ti = self.add_children(&mut n, elem, data_context)?;
414            return Ok((n, ti));
415        }
416
417        // XFA Spec 3.3 §4.4.3 p180-185 — Data binding for subforms:
418        // Step 1: "direct match" — find a data node with matching name
419        // among the current context's children.
420        // Step 2: scope matching — walk ancestor chain if direct match fails
421        //   (XFA §4.4.3 p185).
422        let mut child_context = data_context;
423        if let Some(ctx) = data_context {
424            // Direct child match
425            let matches = self.data_dom.children_by_name(ctx, &name);
426            if let Some(&first) = matches.first() {
427                child_context = Some(first);
428            } else {
429                // Scope match: walk up ancestor chain
430                child_context = self.resolve_with_scope_group(ctx, &name).or(data_context);
431            }
432        } else if let Some(root) = self.data_dom.root() {
433            if self.data_dom.get(root).is_some_and(|n| n.name() == name) {
434                // Exact name match: the data root IS the subform's record.
435                child_context = Some(root);
436            } else {
437                let matches = self.data_dom.children_by_name(root, &name);
438                if let Some(&first) = matches.first() {
439                    // Named child match (e.g. template "form1" → data <form1>).
440                    child_context = Some(first);
441                } else {
442                    // No name match: bind the root subform to the data root so
443                    // that `$record.FIELD` paths in scripts resolve correctly.
444                    // XFA Spec 3.3 §4.4.3 p180: when the root subform has no
445                    // explicit bind element and its name doesn't match any data
446                    // node, the form context is the data DOM root — making
447                    // `$record` refer to the top-level data element (e.g.
448                    // `$record.COMPANY_ADDRESS.nodes` works when data root has
449                    // `<COMPANY_ADDRESS>` as a direct child).
450                    child_context = Some(root);
451                }
452            }
453        }
454
455        let mut n = FormNode {
456            name,
457            node_type: FormNodeType::Subform,
458            box_model: bm,
459            layout,
460            children: Vec::new(),
461            occur,
462            font: FontMetrics::default(),
463            calculate: None,
464            validate: None,
465            column_widths: Vec::new(),
466            col_span: 1,
467        };
468        let ti = self.add_children(&mut n, elem, child_context)?;
469        Ok((n, ti))
470    }
471
472    // XFA Spec 3.3 §9.2 "Variable Number of Subforms" (p336):
473    // The data binding process creates min copies, then adds more copies
474    // for each additional data match up to max. When max=-1 the template
475    // is unbounded, but merger-time expansion must still stop at the
476    // number of matching data records so we do not synthesize extra
477    // siblings and over-paginate repeating content.
478    /// XFA Spec 3.3 §4.4 p186-192 — Repeating subforms: when `<occur>` allows
479    /// multiple instances (max > 1 or max = -1), the number of form subform
480    /// instances is driven by matching data records. Each data record creates
481    /// one subform instance, clamped to [occur.min, occur.max].
482    #[allow(clippy::type_complexity)]
483    fn expand_repeating_subform_instances(
484        &mut self,
485        element: Node<'_, '_>,
486        data_context: Option<DataNodeId>,
487        is_root: bool,
488    ) -> Result<Vec<(FormNodeId, (bool, Option<String>))>> {
489        let name = attr(element, "name").unwrap_or("").to_string();
490        let occur = parse_occur(element);
491
492        // XFA §4.4.3: match="none" means no data binding, no automatic expansion.
493        // The subform exists as a single instance; only scriptable instanceManager
494        // may add more.
495        let (_bind_ref_unused, bind_none) = parse_bind(element);
496        if bind_none {
497            // Diagnostic (read-only, env-gated; no behavior change). See milestone
498            // XFA_LAYOUT_INSTANCE_EXPANSION_PARITY.
499            if std::env::var_os("XFA_IE_TRACE").is_some() {
500                eprintln!("XFA_IE_TRACE expand name={name:?} bind=none data_count=0 -> count=1 (script-instanceManager-only)");
501            }
502            return Ok(vec![self.parse_node(element, data_context, is_root)?]);
503        }
504
505        // Get raw bind ref for multi-segment SOM path resolution.
506        // parse_bind_data_name() only takes the last segment, which fails for
507        // multi-segment refs like "$.group.field[*]" (XFA §4.4 p199).
508        let bind_ref = find_first_child_by_name(element, "bind")
509            .and_then(|b| attr(b, "ref"))
510            .map(|s| s.to_string());
511
512        let data_instances = if let Some(ref raw_ref) = bind_ref {
513            // Normalize $record → $ for SOM parser compatibility
514            let normalized = if let Some(rest) = raw_ref.strip_prefix("$record") {
515                format!("${}", rest)
516            } else {
517                raw_ref.clone()
518            };
519            // Ensure [*] on last segment: repeating subforms need ALL matches
520            let with_wildcard = if normalized.ends_with("[*]") {
521                normalized
522            } else {
523                format!("{}[*]", normalized)
524            };
525            // Full SOM resolution; fall back to root when no data_context
526            resolve_data_path(self.data_dom, &with_wildcard, data_context)
527                .or_else(|_| resolve_data_path(self.data_dom, &with_wildcard, self.data_dom.root()))
528                .unwrap_or_default()
529        } else {
530            // No bind ref: look up by element name
531            if let Some(ctx) = data_context {
532                self.data_dom.children_by_name(ctx, &name)
533            } else if let Some(root) = self.data_dom.root() {
534                self.data_dom.children_by_name(root, &name)
535            } else {
536                Vec::new()
537            }
538        };
539
540        let data_count = data_instances.len() as u32;
541        let min = occur.min;
542        let max = occur.max.unwrap_or(data_count).max(min);
543        // XFA Spec 3.3 §7.2.18 (occur) + §9.2 (Variable Number of Subforms):
544        //   `min`     — minimum instance count after data binding.
545        //   `max`     — maximum allowed (data records exceeding this are dropped).
546        //   `initial` — instance count when the subform is rendered WITHOUT a
547        //               backing data record (defaults to `min`).
548        //
549        // When data records exist, data binding wins: `count = clamp(data_count, min, max)`.
550        // When no data records exist, render `initial` instances (which is
551        // already raised to `min` by `Occur::repeating`), clamped to `max`.
552        //
553        // This affects only subforms that explicitly set `initial > min` and
554        // have no datasets-bound records; documents that rely on `initial==min`
555        // (the default) keep their current behaviour.
556        let count = if data_count == 0 {
557            occur.initial.min(max)
558        } else {
559            data_count.clamp(min, max)
560        };
561
562        // Diagnostic (read-only, env-gated; no behavior change). See milestone
563        // XFA_LAYOUT_INSTANCE_EXPANSION_PARITY.
564        if std::env::var_os("XFA_IE_TRACE").is_some() {
565            let bind_kind = if bind_ref.is_some() { "ref" } else { "byname" };
566            eprintln!(
567                "XFA_IE_TRACE expand name={name:?} bind={bind_kind} data_count={data_count} occur(min={},max={:?},initial={}) -> count={count}",
568                min, occur.max, occur.initial
569            );
570        }
571
572        let layout = area_layout(element);
573        let bm = parse_box_model(element);
574
575        if count == 0 && occur.max.unwrap_or(u32::MAX) > 0 {
576            // Build a single Hidden skeleton instance so `parent._<name>` resolves
577            // to a usable InstanceManager. The skeleton is layout-skipped via
578            // presence=Hidden; the flag tells the host that count() should return 0.
579            let (inst_node, trailing) = self.build_subform_instance(
580                element,
581                None,
582                is_root,
583                Occur::once(),
584                name.clone(),
585                layout,
586                bm.clone(),
587            )?;
588            let mut meta = parse_node_meta(element);
589            meta.is_zero_instance_prototype = true;
590            meta.presence = Presence::Hidden;
591            let inst_id = self.form_tree.add_node_with_meta(inst_node, meta);
592            return Ok(vec![(inst_id, trailing)]);
593        }
594
595        let mut instances = Vec::with_capacity(count as usize);
596
597        for i in 0..count {
598            let instance_data_ctx = data_instances.get(i as usize).copied();
599            let (inst_node, trailing) = self.build_subform_instance(
600                element,
601                instance_data_ctx,
602                is_root,
603                Occur::once(),
604                name.clone(),
605                layout,
606                bm.clone(),
607            )?;
608            if element.tag_name().name() == "exclGroup" {
609                self.apply_exclusive_choice_value(element, instance_data_ctx, &inst_node.children);
610            }
611            let mut meta = parse_node_meta(element);
612            // Phase D-γ: bind each repeating instance to its own data record.
613            meta.bound_data_node = instance_data_ctx.map(|c| c.as_raw());
614            let inst_id = self.form_tree.add_node_with_meta(inst_node, meta);
615            instances.push((inst_id, trailing));
616        }
617
618        Ok(instances)
619    }
620
621    /// Search descendants of a data node for a DataValue with the given name.
622    /// Returns the first matching value (breadth-first).
623    ///
624    /// XFA Spec 3.3 §4.4.3 p185 — global search used as final fallback after
625    /// scope matching (direct child → sibling → ancestor chain) all fail.
626    fn find_value_in_descendants(&self, node: DataNodeId, name: &str) -> Option<String> {
627        for &child in self.data_dom.children(node) {
628            if let Some(cn) = self.data_dom.get(child) {
629                if cn.name() == name && cn.is_value() {
630                    return self.data_dom.value(child).ok().map(|s| s.to_string());
631                }
632            }
633        }
634        // Recurse into child groups
635        for &child in self.data_dom.children(node) {
636            if self.data_dom.get(child).is_some_and(|n| n.is_group()) {
637                if let Some(val) = self.find_value_in_descendants(child, name) {
638                    return Some(val);
639                }
640            }
641        }
642        None
643    }
644
645    /// XFA Spec 3.3 §4.4.3 p185 — Scope matching for DataValue nodes.
646    ///
647    /// Resolution order per spec:
648    /// 1. Direct child of current data context.
649    /// 2. Sibling (direct child of parent data context).
650    /// 3. Ancestor chain — walk up to root, try children at each level.
651    /// 4. Global scope (root-level and all descendants).
652    fn resolve_with_scope_value(&self, data_context: DataNodeId, name: &str) -> Option<String> {
653        // 1. Direct child
654        let direct = self.data_dom.children_by_name(data_context, name);
655        if let Some(&val_id) = direct.first() {
656            if self.data_dom.get(val_id).is_some_and(|n| n.is_value()) {
657                return self.data_dom.value(val_id).ok().map(|s| s.to_string());
658            }
659        }
660
661        // 2. Sibling (parent's children)
662        if let Some(parent) = self.data_dom.get(data_context).and_then(|n| n.parent()) {
663            let sibling_matches = self.data_dom.children_by_name(parent, name);
664            if let Some(&val_id) = sibling_matches.first() {
665                if self.data_dom.get(val_id).is_some_and(|n| n.is_value()) {
666                    return self.data_dom.value(val_id).ok().map(|s| s.to_string());
667                }
668            }
669
670            // 3. Walk up ancestor chain
671            let mut ancestor = self.data_dom.get(parent).and_then(|n| n.parent());
672            while let Some(anc_id) = ancestor {
673                let anc_matches = self.data_dom.children_by_name(anc_id, name);
674                if let Some(&val_id) = anc_matches.first() {
675                    if self.data_dom.get(val_id).is_some_and(|n| n.is_value()) {
676                        return self.data_dom.value(val_id).ok().map(|s| s.to_string());
677                    }
678                }
679                ancestor = self.data_dom.get(anc_id).and_then(|n| n.parent());
680            }
681        }
682
683        // 4. Global fallback from root
684        self.data_dom
685            .root()
686            .and_then(|root| self.find_value_in_descendants(root, name))
687    }
688
689    /// XFA Spec 3.3 §4.4.3 p185 — Scope matching for DataGroup nodes.
690    ///
691    /// Used when looking for a named child data group for subform binding.
692    /// Walks the ancestor chain to find a matching group node by name.
693    fn resolve_with_scope_group(&self, data_context: DataNodeId, name: &str) -> Option<DataNodeId> {
694        // Walk up the ancestor chain looking for a same-named group
695        let mut cursor = self.data_dom.get(data_context).and_then(|n| n.parent());
696        while let Some(anc_id) = cursor {
697            let matches = self.data_dom.children_by_name(anc_id, name);
698            if let Some(&grp_id) = matches.first() {
699                if self.data_dom.get(grp_id).is_some_and(|n| n.is_group()) {
700                    return Some(grp_id);
701                }
702            }
703            cursor = self.data_dom.get(anc_id).and_then(|n| n.parent());
704        }
705        None
706    }
707
708    fn lookup_value_by_name(&self, name: &str, data_context: Option<DataNodeId>) -> Option<String> {
709        if name.is_empty() {
710            return None;
711        }
712
713        if let Some(ctx) = data_context {
714            // Use full scope resolution: direct → sibling → ancestor → global
715            return self.resolve_with_scope_value(ctx, name);
716        }
717
718        self.data_dom
719            .root()
720            .and_then(|root| self.find_value_in_descendants(root, name))
721    }
722
723    fn apply_exclusive_choice_value(
724        &mut self,
725        elem: Node<'_, '_>,
726        data_context: Option<DataNodeId>,
727        child_ids: &[FormNodeId],
728    ) {
729        let Some(group_name) = attr(elem, "name") else {
730            return;
731        };
732        let Some(group_value) = self.lookup_value_by_name(group_name, data_context) else {
733            return;
734        };
735
736        for &child_id in child_ids {
737            let item_value = self.form_tree.meta(child_id).item_value.clone();
738            if let FormNodeType::Field { value } = &mut self.form_tree.get_mut(child_id).node_type {
739                // fixes #798: only the matching radio/check child should stay
740                // asserted. The others render as off/null so Acrobat-style
741                // radio groups do not show multiple active choices.
742                *value = if item_value.as_deref() == Some(group_value.as_str()) {
743                    group_value.clone()
744                } else {
745                    String::new()
746                };
747            }
748        }
749    }
750
751    /// XFA Spec 3.3 §4.4.3 p180 — Field data binding: fields are leaf nodes
752    /// that bind to DataValue nodes in the data DOM.
753    fn parse_field(
754        &mut self,
755        elem: Node<'_, '_>,
756        data_context: Option<DataNodeId>,
757    ) -> Result<FormNode> {
758        let name = attr(elem, "name").unwrap_or("").to_string();
759        let bm = parse_box_model(elem);
760
761        let mut value = extract_value_text(elem).unwrap_or_default();
762
763        // XFA Spec 3.3 §4.4.3 p180-185 — Field binding with scope resolution:
764        // 1. Direct match: search current context children by name (§4.4.3 p180)
765        // 2. Sibling match: children of parent context (§4.4.3 p185)
766        // 3. Ancestor chain walk (§4.4.3 p185)
767        // 4. Global fallback: descendant search from data root
768        //
769        // NOTE: §4.4 p197 — attribute matching (data attributes → fields) is
770        // not implemented; we only match element nodes.
771        if let Some(bound_value) = self.lookup_value_by_name(&name, data_context) {
772            value = bound_value;
773        }
774
775        let mut bm_with_caption = bm.clone();
776        if !is_hidden(elem) {
777            if let Some(cap) = parse_caption(elem) {
778                bm_with_caption.caption = Some(cap);
779            }
780        }
781
782        let mut font = parse_font_metrics(elem);
783        if let Some(html_size) = extract_exdata_font_size(elem) {
784            font.size = html_size;
785        }
786        if let Some(css_align) = extract_exdata_text_align(elem) {
787            font.text_align = css_align;
788        }
789
790        Ok(FormNode {
791            name,
792            node_type: FormNodeType::Field { value },
793            box_model: bm_with_caption,
794            layout: LayoutStrategy::Positioned,
795            children: Vec::new(),
796            occur: Occur::once(),
797            font,
798            calculate: None,
799            validate: None,
800            column_widths: Vec::new(),
801            col_span: parse_col_span(elem),
802        })
803    }
804
805    /// XFA Spec 3.3 §4.4 p180 — Draw elements are static content that do
806    /// not participate in data binding (they have no `<bind>` element).
807    /// The `_data_context` parameter is unused.
808    fn parse_draw(
809        &mut self,
810        elem: Node<'_, '_>,
811        _data_context: Option<DataNodeId>,
812    ) -> Result<FormNode> {
813        let name = attr(elem, "name").unwrap_or("").to_string();
814        let bm = parse_box_model(elem);
815
816        if let Some(draw_content) = extract_draw_content(elem) {
817            return Ok(FormNode {
818                name,
819                node_type: FormNodeType::Draw(draw_content),
820                box_model: bm,
821                layout: LayoutStrategy::Positioned,
822                children: Vec::new(),
823                occur: Occur::once(),
824                font: FontMetrics::default(),
825                calculate: None,
826                validate: None,
827                column_widths: Vec::new(),
828                col_span: 1,
829            });
830        }
831
832        if let Some((image_data, mime_type)) = extract_value_image(elem, &self.image_files) {
833            return Ok(FormNode {
834                name,
835                node_type: FormNodeType::Image {
836                    data: image_data,
837                    mime_type,
838                },
839                box_model: bm,
840                layout: LayoutStrategy::Positioned,
841                children: Vec::new(),
842                occur: Occur::once(),
843                font: FontMetrics::default(),
844                calculate: None,
845                validate: None,
846                column_widths: Vec::new(),
847                col_span: 1,
848            });
849        }
850
851        let mut font = parse_font_metrics(elem);
852        if let Some(html_size) = extract_exdata_font_size(elem) {
853            font.size = html_size;
854        }
855        if let Some(css_align) = extract_exdata_text_align(elem) {
856            font.text_align = css_align;
857        }
858
859        let content = extract_value_text(elem).unwrap_or_default();
860
861        Ok(FormNode {
862            name,
863            node_type: FormNodeType::Draw(DrawContent::Text(content)),
864            box_model: bm,
865            layout: LayoutStrategy::Positioned,
866            children: Vec::new(),
867            occur: Occur::once(),
868            font,
869            calculate: None,
870            validate: None,
871            column_widths: Vec::new(),
872            col_span: 1,
873        })
874    }
875
876    fn parse_page_set(
877        &mut self,
878        elem: Node<'_, '_>,
879        data_context: Option<DataNodeId>,
880    ) -> Result<FormNode> {
881        let name = attr(elem, "name").unwrap_or("pageSet").to_string();
882        let mut node = FormNode {
883            name,
884            node_type: FormNodeType::PageSet,
885            box_model: BoxModel {
886                max_width: f64::MAX,
887                max_height: f64::MAX,
888                ..Default::default()
889            },
890            layout: LayoutStrategy::TopToBottom,
891            children: Vec::new(),
892            occur: Occur::once(),
893            font: FontMetrics::default(),
894            calculate: None,
895            validate: None,
896            column_widths: Vec::new(),
897            col_span: 1,
898        };
899        for child in elem.children().filter(|n| n.is_element()) {
900            if child.tag_name().name() == "pageArea" {
901                let (child_id, _) = self.parse_node(child, data_context, false)?;
902                node.children.push(child_id);
903            }
904        }
905        Ok(node)
906    }
907
908    fn parse_page_area(
909        &mut self,
910        elem: Node<'_, '_>,
911        data_context: Option<DataNodeId>,
912    ) -> Result<FormNode> {
913        let name = attr(elem, "name").unwrap_or("").to_string();
914        let (page_w, page_h) = read_medium(elem);
915        let content_areas = read_content_areas(elem, page_w, page_h);
916
917        let bm = BoxModel {
918            width: Some(page_w),
919            height: Some(page_h),
920            ..Default::default()
921        };
922
923        let mut node = FormNode {
924            name,
925            node_type: FormNodeType::PageArea { content_areas },
926            box_model: bm,
927            layout: LayoutStrategy::Positioned,
928            children: Vec::new(),
929            occur: Occur::once(),
930            font: FontMetrics::default(),
931            calculate: None,
932            validate: None,
933            column_widths: Vec::new(),
934            col_span: 1,
935        };
936        self.add_children(&mut node, elem, data_context)?;
937        Ok(node)
938    }
939
940    /// Recursively process child elements of a template node.
941    ///
942    /// XFA Spec 3.3 §4.4 p180 — the merge walks template children in document
943    /// order, binding each to the current data context.
944    ///
945    /// TODO: XFA Spec 3.3 §4.4 p198 — re-normalization step: after all template
946    /// children are bound, excess/unmatched data nodes should create new form
947    /// nodes. Not implemented.
948    ///
949    /// TODO: XFA Spec 3.3 §4.4 p199 — setProperty and bindItems: after binding,
950    /// `<setProperty>` and `<bindItems>` on `<bind>` should be evaluated to
951    /// dynamically set properties or populate choice lists from data. Not implemented.
952    ///
953    /// TODO: XFA Spec 3.3 §4.2 p143-170 — localization: data values should be
954    /// canonicalized using picture clauses before comparison/binding. Not implemented.
955    fn add_children(
956        &mut self,
957        node: &mut FormNode,
958        elem: Node<'_, '_>,
959        data_context: Option<DataNodeId>,
960    ) -> Result<(bool, Option<String>)> {
961        let mut pending_break = false;
962        let mut pending_break_target = None;
963        let mut pending_ca_break = false;
964
965        for child in elem.children().filter(|n| n.is_element()) {
966            let tag = child.tag_name().name();
967            match tag {
968                "subform" | "field" | "draw" | "pageSet" | "pageArea" | "exclGroup" | "area" => {
969                    let child_entries = if matches!(tag, "subform" | "exclGroup" | "area") {
970                        let name = attr(child, "name").unwrap_or("");
971                        let occur = parse_occur(child);
972                        let (_, bind_none) = parse_bind(child);
973                        if occur.is_repeating() && !name.is_empty() && !bind_none {
974                            self.expand_repeating_subform_instances(child, data_context, false)?
975                        } else {
976                            vec![self.parse_node(child, data_context, false)?]
977                        }
978                    } else {
979                        vec![self.parse_node(child, data_context, false)?]
980                    };
981
982                    for (child_id, (trailing_break, trailing_target)) in child_entries {
983                        if pending_break {
984                            let meta = self.form_tree.meta_mut(child_id);
985                            meta.page_break_before = true;
986                            if meta.break_target.is_none() {
987                                meta.break_target = pending_break_target.take();
988                            }
989                            pending_break = false;
990                        }
991                        if pending_ca_break {
992                            self.form_tree.meta_mut(child_id).content_area_break = true;
993                            pending_ca_break = false;
994                        }
995                        node.children.push(child_id);
996                        if trailing_break {
997                            pending_break = true;
998                            pending_break_target = trailing_target;
999                        }
1000                    }
1001                }
1002                "breakBefore" => {
1003                    let target_type = attr(child, "targetType");
1004                    if target_type == Some("pageArea") {
1005                        pending_break = true;
1006                        pending_break_target = attr(child, "target").map(|s| s.to_string());
1007                    } else if target_type == Some("contentArea") {
1008                        pending_ca_break = true;
1009                    }
1010                }
1011                "break"
1012                    if attr(child, "before") == Some("pageArea")
1013                        && attr(child, "targetType") == Some("pageArea") =>
1014                {
1015                    pending_break = true;
1016                    pending_break_target = attr(child, "target").map(|s| s.to_string());
1017                }
1018                _ => {}
1019            }
1020        }
1021        Ok((pending_break, pending_break_target))
1022    }
1023
1024    fn blank_node(&self, name: &str, node_type: FormNodeType) -> FormNode {
1025        FormNode {
1026            name: name.to_string(),
1027            node_type,
1028            box_model: BoxModel {
1029                max_width: f64::MAX,
1030                max_height: f64::MAX,
1031                ..Default::default()
1032            },
1033            layout: LayoutStrategy::TopToBottom,
1034            children: Vec::new(),
1035            occur: Occur::once(),
1036            font: FontMetrics::default(),
1037            calculate: None,
1038            validate: None,
1039            column_widths: Vec::new(),
1040            col_span: 1,
1041        }
1042    }
1043}
1044
1045// ---------------------------------------------------------------------------
1046// Helper functions (mirrored from template_parser.rs)
1047// ---------------------------------------------------------------------------
1048
1049fn attr<'a>(elem: Node<'a, '_>, name: &str) -> Option<&'a str> {
1050    elem.attributes()
1051        .find(|a| a.name() == name)
1052        .map(|a| a.value())
1053}
1054
1055fn find_first_child_by_name<'a, 'input>(
1056    elem: Node<'a, 'input>,
1057    name: &str,
1058) -> Option<Node<'a, 'input>> {
1059    elem.children()
1060        .filter(|n| n.is_element())
1061        .find(|n| n.tag_name().name() == name)
1062}
1063
1064fn parse_layout_attr(elem: Node<'_, '_>) -> LayoutStrategy {
1065    match attr(elem, "layout").unwrap_or("") {
1066        "tb" => LayoutStrategy::TopToBottom,
1067        "lr-tb" => LayoutStrategy::LeftToRightTB,
1068        "rl-tb" => LayoutStrategy::RightToLeftTB,
1069        "table" => LayoutStrategy::Table,
1070        "row" => LayoutStrategy::Row,
1071        "paginate" => LayoutStrategy::TopToBottom,
1072        "position" => LayoutStrategy::Positioned,
1073        _ => LayoutStrategy::Positioned,
1074    }
1075}
1076
1077fn parse_anchor_type(elem: Node<'_, '_>) -> AnchorType {
1078    match attr(elem, "anchorType").unwrap_or("") {
1079        "topCenter" => AnchorType::TopCenter,
1080        "topRight" => AnchorType::TopRight,
1081        "middleLeft" => AnchorType::MiddleLeft,
1082        "middleCenter" => AnchorType::MiddleCenter,
1083        "middleRight" => AnchorType::MiddleRight,
1084        "bottomLeft" => AnchorType::BottomLeft,
1085        "bottomCenter" => AnchorType::BottomCenter,
1086        "bottomRight" => AnchorType::BottomRight,
1087        _ => AnchorType::TopLeft,
1088    }
1089}
1090
1091fn parse_box_model(elem: Node<'_, '_>) -> BoxModel {
1092    let w = attr(elem, "w").and_then(parse_dim);
1093    let h = attr(elem, "h").and_then(parse_dim);
1094    let x = attr(elem, "x").and_then(parse_dim).unwrap_or(0.0);
1095    let y = attr(elem, "y").and_then(parse_dim).unwrap_or(0.0);
1096    let min_h = attr(elem, "minH").and_then(parse_dim).unwrap_or(0.0);
1097    let min_w = attr(elem, "minW").and_then(parse_dim).unwrap_or(0.0);
1098    let max_h = attr(elem, "maxH").and_then(parse_dim).unwrap_or(f64::MAX);
1099    let max_w = attr(elem, "maxW").and_then(parse_dim).unwrap_or(f64::MAX);
1100    let margins = parse_margin(elem);
1101
1102    BoxModel {
1103        width: w,
1104        height: h,
1105        x,
1106        y,
1107        margins,
1108        min_width: min_w,
1109        max_width: max_w,
1110        min_height: min_h,
1111        max_height: max_h,
1112        ..Default::default()
1113    }
1114}
1115
1116fn parse_margin(elem: Node<'_, '_>) -> Insets {
1117    if let Some(margin) = find_first_child_by_name(elem, "margin") {
1118        Insets {
1119            top: attr(margin, "topInset").and_then(parse_dim).unwrap_or(0.0),
1120            bottom: attr(margin, "bottomInset")
1121                .and_then(parse_dim)
1122                .unwrap_or(0.0),
1123            left: attr(margin, "leftInset").and_then(parse_dim).unwrap_or(0.0),
1124            right: attr(margin, "rightInset")
1125                .and_then(parse_dim)
1126                .unwrap_or(0.0),
1127        }
1128    } else {
1129        Insets::default()
1130    }
1131}
1132
1133fn parse_dim(s: &str) -> Option<f64> {
1134    if s.trim().parse::<f64>().is_ok() {
1135        return Measurement::parse(&format!("{s}in")).map(|m: Measurement| m.to_points());
1136    }
1137    Measurement::parse(s).map(|m: Measurement| m.to_points())
1138}
1139
1140/// Parse a percentage string like `"96%"` → `0.96`, `"110%"` → `1.1`.
1141fn parse_percentage(s: &str) -> Option<f64> {
1142    let s = s.trim();
1143    let num_str = s.strip_suffix('%')?;
1144    let v: f64 = num_str.trim().parse().ok()?;
1145    Some(v / 100.0)
1146}
1147
1148/// Parse a letter-spacing string (`"-0.018em"`, `"0.5pt"`, etc.) to points.
1149fn parse_letter_spacing(s: &str, font_size_pt: f64) -> Option<f64> {
1150    let s = s.trim();
1151    if s == "0" {
1152        return Some(0.0);
1153    }
1154    if let Some(num_str) = s.strip_suffix("em") {
1155        let v: f64 = num_str.trim().parse().ok()?;
1156        return Some(v * font_size_pt);
1157    }
1158    Measurement::parse(s).map(|m| m.to_points())
1159}
1160
1161// XFA Spec 3.3 §9.2 "The Occur Element" (p339):
1162// - min: defaults to 1 — minimum copies during non-empty merge
1163// - max: defaults to min (NOT to 1); -1 means unlimited
1164// - initial: defaults to min — copies during empty merge
1165//
1166// §9.2 p357: if max is not supplied, max defaults to min.
1167// §9.2 p357: if initial is not supplied, initial defaults to min.
1168//
1169// Our implementation correctly handles these defaults and the -1 sentinel.
1170fn parse_occur(elem: Node<'_, '_>) -> Occur {
1171    if let Some(occur) = find_first_child_by_name(elem, "occur") {
1172        let min: u32 = attr(occur, "min").and_then(|s| s.parse().ok()).unwrap_or(1);
1173        // XFA Spec 3.3 §9.2 p357: "if the max attribute is not supplied then
1174        // the max property defaults to the value of min."
1175        //
1176        // NOTE: the previous `.map(…).unwrap_or()` chain let parse failures
1177        // (e.g. max="") collapse to `None` (unlimited), causing massive
1178        // over-pagination.  Use an explicit match instead.
1179        let max: Option<u32> = match attr(occur, "max") {
1180            Some("-1") => None, // -1 means unlimited
1181            Some(s) => Some(s.parse::<u32>().unwrap_or(min)),
1182            None => Some(min),
1183        };
1184        // XFA Spec 3.3 §9.2 p357: "if the initial attribute is not supplied
1185        // then the initial property defaults to the value of min."
1186        let initial: u32 = attr(occur, "initial")
1187            .and_then(|s| s.parse().ok())
1188            .unwrap_or(min);
1189        Occur::repeating(min, max, initial)
1190    } else {
1191        Occur::once()
1192    }
1193}
1194
1195fn parse_font_size(s: &str) -> Option<f64> {
1196    if let Ok(v) = s.trim().parse::<f64>() {
1197        return if v > 0.0 { Some(v) } else { None };
1198    }
1199    Measurement::parse(s).map(|m: Measurement| m.to_points())
1200}
1201
1202fn parse_font_metrics(elem: Node<'_, '_>) -> FontMetrics {
1203    let font_elem = find_first_child_by_name(elem, "font");
1204    let size = font_elem
1205        .and_then(|f| attr(f, "size"))
1206        .and_then(parse_font_size)
1207        .unwrap_or(FontMetrics::default().size);
1208    let typeface = font_elem
1209        .and_then(|f| attr(f, "typeface"))
1210        .map(FontFamily::from_typeface)
1211        .unwrap_or_default();
1212    let text_align = find_first_child_by_name(elem, "para")
1213        .and_then(|p| attr(p, "hAlign"))
1214        .map(|a| match a {
1215            "center" => TextAlign::Center,
1216            "right" => TextAlign::Right,
1217            "justify" => TextAlign::Justify,
1218            _ => TextAlign::Left,
1219        })
1220        .unwrap_or_default();
1221    FontMetrics {
1222        size,
1223        text_align,
1224        typeface,
1225        ..FontMetrics::default()
1226    }
1227}
1228
1229fn read_medium(page_area: Node<'_, '_>) -> (f64, f64) {
1230    if let Some(m) = find_first_child_by_name(page_area, "medium") {
1231        let short = attr(m, "short").and_then(parse_dim).unwrap_or(612.0);
1232        let long_ = attr(m, "long").and_then(parse_dim).unwrap_or(792.0);
1233        (short, long_)
1234    } else {
1235        (612.0, 792.0)
1236    }
1237}
1238
1239fn read_content_areas(
1240    page_area: Node<'_, '_>,
1241    page_width: f64,
1242    page_height: f64,
1243) -> Vec<ContentArea> {
1244    let mut areas = Vec::new();
1245    for child in page_area.children().filter(|n| n.is_element()) {
1246        if child.tag_name().name() == "contentArea" {
1247            // XFA 3.3 §8.3.1 — contentArea x/y default to "0in" (0pt).
1248            // Previously defaulted to 36pt (0.5in margin), which shifted
1249            // all content down/right for templates omitting x/y. Fixes #797.
1250            let x = attr(child, "x").and_then(parse_dim).unwrap_or(0.0);
1251            let y = attr(child, "y").and_then(parse_dim).unwrap_or(0.0);
1252            // w/h default to full page dimensions when omitted, matching the
1253            // behavior of the empty (no contentArea) fallback. Previously used
1254            // hardcoded 540×720 (US Letter body), which was inconsistent with
1255            // the no-contentArea path and had no spec basis.
1256            let w = attr(child, "w").and_then(parse_dim).unwrap_or(page_width);
1257            let h = attr(child, "h").and_then(parse_dim).unwrap_or(page_height);
1258            areas.push(ContentArea {
1259                name: attr(child, "name").unwrap_or("").to_string(),
1260                x,
1261                y,
1262                width: w,
1263                height: h,
1264                leader: None,
1265                trailer: None,
1266            });
1267        }
1268    }
1269    if areas.is_empty() {
1270        areas.push(ContentArea {
1271            name: String::new(),
1272            x: 0.0,
1273            y: 0.0,
1274            width: page_width,
1275            height: page_height,
1276            leader: None,
1277            trailer: None,
1278        });
1279    }
1280    areas
1281}
1282
1283fn extract_value_text(elem: Node<'_, '_>) -> Option<String> {
1284    let value = find_first_child_by_name(elem, "value")?;
1285    for tag in &["text", "float", "integer", "date", "dateTime", "decimal"] {
1286        if let Some(child) = find_first_child_by_name(value, tag) {
1287            let text = child.text().unwrap_or("");
1288            let trimmed = text.trim_start_matches(|c: char| c.is_whitespace() && c != '\n');
1289            let trimmed = trimmed.trim_end_matches(|c: char| c.is_whitespace() && c != '\n');
1290            if !trimmed.is_empty() {
1291                return Some(trimmed.to_string());
1292            }
1293        }
1294    }
1295    if let Some(ex) = find_first_child_by_name(value, "exData") {
1296        let text = extract_text_from_descendants(ex);
1297        if !text.is_empty() {
1298            return Some(text);
1299        }
1300    }
1301    None
1302}
1303
1304fn extract_text_from_descendants(node: Node<'_, '_>) -> String {
1305    let block_tags = [
1306        "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr", "br",
1307    ];
1308    let mut result = String::new();
1309    let mut last_was_block = false;
1310
1311    for desc in node.descendants() {
1312        let is_block = desc.is_element() && block_tags.contains(&desc.tag_name().name());
1313
1314        if is_block && !result.is_empty() {
1315            result.push('\n');
1316            last_was_block = true;
1317        }
1318
1319        if desc.is_text() {
1320            if let Some(t) = desc.text() {
1321                let t = t.trim();
1322                if !t.is_empty() {
1323                    if last_was_block || result.is_empty() {
1324                        result.push_str(t);
1325                    } else {
1326                        result.push(' ');
1327                        result.push_str(t);
1328                    }
1329                    last_was_block = false;
1330                }
1331            }
1332        }
1333    }
1334
1335    result.trim().to_string()
1336}
1337
1338fn extract_exdata_font_size(elem: Node<'_, '_>) -> Option<f64> {
1339    let value = find_first_child_by_name(elem, "value")?;
1340    let ex = find_first_child_by_name(value, "exData")?;
1341    for desc in ex.descendants() {
1342        if !desc.is_element() {
1343            continue;
1344        }
1345        let style = desc
1346            .attribute("style")
1347            .or_else(|| desc.attribute("Style"))?;
1348        for part in style.split(';') {
1349            let part = part.trim();
1350            if let Some(val) = part
1351                .strip_prefix("font-size:")
1352                .or_else(|| part.strip_prefix("font-size :"))
1353            {
1354                let val = val.trim();
1355                if let Some(pt) = val.strip_suffix("pt") {
1356                    if let Ok(size) = pt.trim().parse::<f64>() {
1357                        if size > 0.0 {
1358                            return Some(size);
1359                        }
1360                    }
1361                }
1362            }
1363        }
1364    }
1365    None
1366}
1367
1368/// Extract the dominant `font-weight` from `<exData contentType="text/html">` styles.
1369/// Returns `Some("bold")` when the first styled `<p>` or `<span>` has `font-weight:bold`.
1370/// XFA Spec 3.3 §27.4: font-weight supports normal, bold, and numeric values 100-900.
1371fn extract_exdata_font_weight(elem: Node<'_, '_>) -> Option<String> {
1372    let value = find_first_child_by_name(elem, "value")?;
1373    let ex = find_first_child_by_name(value, "exData")?;
1374    for desc in ex.descendants() {
1375        if !desc.is_element() {
1376            continue;
1377        }
1378        let style = desc.attribute("style")?;
1379        for part in style.split(';') {
1380            let part = part.trim();
1381            if let Some(val) = part
1382                .strip_prefix("font-weight:")
1383                .or_else(|| part.strip_prefix("font-weight :"))
1384            {
1385                let val = val.trim();
1386                if val == "bold" || val == "700" || val == "800" || val == "900" {
1387                    return Some("bold".to_string());
1388                }
1389                if val == "normal" || val == "400" || val == "500" || val == "600" {
1390                    return Some("normal".to_string());
1391                }
1392            }
1393        }
1394    }
1395    None
1396}
1397
1398/// Extract `font-style` from `<exData contentType="text/html">` CSS styles.
1399/// XFA Spec 3.3 §27.4: font-style supports normal, italic, oblique.
1400fn extract_exdata_font_style(elem: Node<'_, '_>) -> Option<String> {
1401    let value = find_first_child_by_name(elem, "value")?;
1402    let ex = find_first_child_by_name(value, "exData")?;
1403    for desc in ex.descendants() {
1404        if !desc.is_element() {
1405            continue;
1406        }
1407        let style = desc.attribute("style")?;
1408        for part in style.split(';') {
1409            let part = part.trim();
1410            if let Some(val) = part
1411                .strip_prefix("font-style:")
1412                .or_else(|| part.strip_prefix("font-style :"))
1413            {
1414                let val = val.trim();
1415                if val == "italic" || val == "oblique" {
1416                    return Some("italic".to_string());
1417                }
1418                if val == "normal" {
1419                    return Some("normal".to_string());
1420                }
1421            }
1422        }
1423    }
1424    None
1425}
1426
1427/// Extract `color` from `<exData contentType="text/html">` CSS styles.
1428/// XFA Spec 3.3 §27.4: color supports #RGB, #RRGGBB, and rgb(r,g,b) formats.
1429/// Returns RGB tuple (0-255).
1430fn extract_exdata_color(elem: Node<'_, '_>) -> Option<(u8, u8, u8)> {
1431    let value = find_first_child_by_name(elem, "value")?;
1432    let ex = find_first_child_by_name(value, "exData")?;
1433    for desc in ex.descendants() {
1434        if !desc.is_element() {
1435            continue;
1436        }
1437        let style = desc.attribute("style")?;
1438        for part in style.split(';') {
1439            let part = part.trim();
1440            if let Some(val) = part
1441                .strip_prefix("color:")
1442                .or_else(|| part.strip_prefix("color :"))
1443            {
1444                let val = val.trim();
1445                if let Some(rgb) = parse_css_color(val) {
1446                    return Some(rgb);
1447                }
1448            }
1449        }
1450    }
1451    None
1452}
1453
1454/// Parse CSS color value: #RGB, #RRGGBB, rgb(r,g,b), or r,g,b.
1455fn parse_ascii_hex_color(hex: &str) -> Option<(u8, u8, u8)> {
1456    fn hex_nibble(byte: u8) -> Option<u8> {
1457        match byte {
1458            b'0'..=b'9' => Some(byte - b'0'),
1459            b'a'..=b'f' => Some(byte - b'a' + 10),
1460            b'A'..=b'F' => Some(byte - b'A' + 10),
1461            _ => None,
1462        }
1463    }
1464
1465    match hex.as_bytes() {
1466        [r, g, b] => Some((
1467            hex_nibble(*r)? * 17,
1468            hex_nibble(*g)? * 17,
1469            hex_nibble(*b)? * 17,
1470        )),
1471        [r1, r2, g1, g2, b1, b2] => Some((
1472            (hex_nibble(*r1)? << 4) | hex_nibble(*r2)?,
1473            (hex_nibble(*g1)? << 4) | hex_nibble(*g2)?,
1474            (hex_nibble(*b1)? << 4) | hex_nibble(*b2)?,
1475        )),
1476        _ => None,
1477    }
1478}
1479
1480fn parse_css_color(s: &str) -> Option<(u8, u8, u8)> {
1481    let s = s.trim();
1482    if let Some(hex) = s.strip_prefix('#') {
1483        parse_ascii_hex_color(hex)
1484    } else if let Some(rgb_part) = s.strip_prefix("rgb(") {
1485        if let Some(inner) = rgb_part.strip_suffix(')') {
1486            let parts: Vec<&str> = inner.split(',').collect();
1487            if parts.len() >= 3 {
1488                let r: f64 = parts[0].trim().parse().ok()?;
1489                let g: f64 = parts[1].trim().parse().ok()?;
1490                let b: f64 = parts[2].trim().parse().ok()?;
1491                return Some((r as u8, g as u8, b as u8));
1492            }
1493        }
1494        None
1495    } else {
1496        let parts: Vec<&str> = s.split(',').collect();
1497        if parts.len() >= 3 {
1498            let r = parts[0].trim().parse::<u8>().ok()?;
1499            let g = parts[1].trim().parse::<u8>().ok()?;
1500            let b = parts[2].trim().parse::<u8>().ok()?;
1501            Some((r, g, b))
1502        } else {
1503            None
1504        }
1505    }
1506}
1507
1508/// Extract the first `font-family` name from `<exData contentType="text/html">` CSS.
1509/// XFA Spec 3.3 §27.5 (p1198) — Character Formatting: font-family specifies
1510/// the typeface. §28.1 (p1228): Adobe uses only the first family name.
1511fn extract_exdata_font_family(elem: Node<'_, '_>) -> Option<String> {
1512    let value = find_first_child_by_name(elem, "value")?;
1513    let ex = find_first_child_by_name(value, "exData")?;
1514    for desc in ex.descendants() {
1515        if !desc.is_element() {
1516            continue;
1517        }
1518        let style = desc.attribute("style")?;
1519        for part in style.split(';') {
1520            let part = part.trim();
1521            if let Some(val) = part
1522                .strip_prefix("font-family:")
1523                .or_else(|| part.strip_prefix("font-family :"))
1524            {
1525                let val = val.trim();
1526                let first_family = val
1527                    .split(',')
1528                    .next()
1529                    .map(|s| s.trim().trim_matches(['"', '\'']))
1530                    .filter(|s| !s.is_empty())?;
1531                return Some(first_family.to_string());
1532            }
1533        }
1534    }
1535    None
1536}
1537
1538/// Extract `text-align` from `<exData contentType="text/html">` CSS styles.
1539/// XFA Spec 3.3 §27.4 (p1190-1197): text-align supports left, center, right,
1540/// justify. Maps to XFA `<para hAlign>`.
1541fn extract_exdata_text_align(elem: Node<'_, '_>) -> Option<TextAlign> {
1542    let value = find_first_child_by_name(elem, "value")?;
1543    let ex = find_first_child_by_name(value, "exData")?;
1544    for desc in ex.descendants() {
1545        if !desc.is_element() {
1546            continue;
1547        }
1548        let style = desc.attribute("style")?;
1549        for part in style.split(';') {
1550            let part = part.trim();
1551            if let Some(val) = part
1552                .strip_prefix("text-align:")
1553                .or_else(|| part.strip_prefix("text-align :"))
1554            {
1555                let val = val.trim();
1556                return Some(match val {
1557                    "center" => TextAlign::Center,
1558                    "right" => TextAlign::Right,
1559                    "justify" => TextAlign::Justify,
1560                    _ => TextAlign::Left,
1561                });
1562            }
1563        }
1564    }
1565    None
1566}
1567
1568/// Extract `margin-top` and `margin-bottom` from `<exData contentType="text/html">` CSS.
1569/// XFA Spec 3.3 §27.4 (p1190-1197): margin-top maps to XFA `<para spaceAbove>`,
1570/// margin-bottom maps to `<para spaceBelow>`. Returns (space_above_pt, space_below_pt).
1571fn extract_exdata_margins(elem: Node<'_, '_>) -> Option<(f64, f64)> {
1572    let value = find_first_child_by_name(elem, "value")?;
1573    let ex = find_first_child_by_name(value, "exData")?;
1574    let mut space_above: Option<f64> = None;
1575    let mut space_below: Option<f64> = None;
1576    for desc in ex.descendants() {
1577        if !desc.is_element() {
1578            continue;
1579        }
1580        let style = match desc.attribute("style") {
1581            Some(s) => s,
1582            None => continue,
1583        };
1584        for part in style.split(';') {
1585            let part = part.trim();
1586            if space_above.is_none() {
1587                if let Some(val) = part
1588                    .strip_prefix("margin-top:")
1589                    .or_else(|| part.strip_prefix("margin-top :"))
1590                {
1591                    let val = val.trim();
1592                    if let Some(m) = Measurement::parse(val) {
1593                        let pt = m.to_points();
1594                        if pt >= 0.0 {
1595                            space_above = Some(pt);
1596                        }
1597                    }
1598                }
1599            }
1600            if space_below.is_none() {
1601                if let Some(val) = part
1602                    .strip_prefix("margin-bottom:")
1603                    .or_else(|| part.strip_prefix("margin-bottom :"))
1604                {
1605                    let val = val.trim();
1606                    if let Some(m) = Measurement::parse(val) {
1607                        let pt = m.to_points();
1608                        if pt >= 0.0 {
1609                            space_below = Some(pt);
1610                        }
1611                    }
1612                }
1613            }
1614        }
1615        if space_above.is_some() && space_below.is_some() {
1616            break;
1617        }
1618    }
1619    match (space_above, space_below) {
1620        (Some(above), Some(below)) => Some((above, below)),
1621        (Some(above), None) => Some((above, 0.0)),
1622        (None, Some(below)) => Some((0.0, below)),
1623        (None, None) => None,
1624    }
1625}
1626
1627/// Inline CSS style properties accumulated from parent elements.
1628#[derive(Debug, Clone, Default)]
1629struct InheritedStyle {
1630    font_size: Option<f64>,
1631    font_family: Option<String>,
1632    font_weight: Option<String>,
1633    font_style: Option<String>,
1634    text_color: Option<(u8, u8, u8)>,
1635    underline: bool,
1636    line_through: bool,
1637    /// See [`RichTextSpan::baseline_shift`]. A fraction of the font size.
1638    baseline_shift: f64,
1639}
1640
1641impl InheritedStyle {
1642    fn merge_with_css(&self, css: &str) -> Self {
1643        let mut child = self.clone();
1644        for part in css.split(';') {
1645            let part = part.trim();
1646            if let Some(val) = strip_css_prop(part, "font-size") {
1647                if let Some(pt) = val.strip_suffix("pt") {
1648                    if let Ok(size) = pt.trim().parse::<f64>() {
1649                        if size > 0.0 {
1650                            child.font_size = Some(size);
1651                        }
1652                    }
1653                }
1654            } else if let Some(val) = strip_css_prop(part, "font-family") {
1655                let first = val
1656                    .split(',')
1657                    .next()
1658                    .map(|s| s.trim().trim_matches(['"', '\'']))
1659                    .filter(|s| !s.is_empty());
1660                if let Some(fam) = first {
1661                    child.font_family = Some(fam.to_string());
1662                }
1663            } else if let Some(val) = strip_css_prop(part, "font-weight") {
1664                if val == "bold" || val == "700" || val == "800" || val == "900" {
1665                    child.font_weight = Some("bold".to_string());
1666                } else if val == "normal" || val == "400" || val == "500" {
1667                    child.font_weight = Some("normal".to_string());
1668                }
1669            } else if let Some(val) = strip_css_prop(part, "font-style") {
1670                if val == "italic" || val == "oblique" {
1671                    child.font_style = Some("italic".to_string());
1672                } else if val == "normal" {
1673                    child.font_style = Some("normal".to_string());
1674                }
1675            } else if let Some(val) = strip_css_prop(part, "text-decoration") {
1676                if val.contains("underline") {
1677                    child.underline = true;
1678                } else if val.contains("line-through") {
1679                    child.line_through = true;
1680                } else if val == "none" {
1681                    child.underline = false;
1682                    child.line_through = false;
1683                }
1684            } else if let Some(val) = strip_css_prop(part, "vertical-align") {
1685                // Keywords only; a length in pt would be an absolute measure
1686                // and then the shift would not scale with the font size.
1687                match val {
1688                    "super" => child.baseline_shift = 0.33,
1689                    "sub" => child.baseline_shift = -0.20,
1690                    "baseline" => child.baseline_shift = 0.0,
1691                    _ => {}
1692                }
1693            } else if let Some(val) = strip_css_prop(part, "color") {
1694                if let Some(rgb) = parse_css_color(val) {
1695                    child.text_color = Some(rgb);
1696                }
1697            }
1698        }
1699        child
1700    }
1701
1702    fn to_span(&self, text: String) -> RichTextSpan {
1703        RichTextSpan {
1704            text,
1705            font_size: self.font_size,
1706            font_family: self.font_family.clone(),
1707            font_weight: self.font_weight.clone(),
1708            font_style: self.font_style.clone(),
1709            text_color: self.text_color,
1710            underline: self.underline,
1711            line_through: self.line_through,
1712            baseline_shift: self.baseline_shift,
1713        }
1714    }
1715}
1716
1717fn strip_css_prop<'a>(decl: &'a str, prop: &str) -> Option<&'a str> {
1718    decl.strip_prefix(prop)
1719        .and_then(|rest| rest.trim_start().strip_prefix(':'))
1720        .map(|v| v.trim())
1721}
1722
1723/// Parse `<exData contentType="text/html">` XHTML into rich text spans.
1724fn parse_exdata_rich_text_spans(elem: Node<'_, '_>) -> Option<Vec<RichTextSpan>> {
1725    let value = find_first_child_by_name(elem, "value")?;
1726    let ex = find_first_child_by_name(value, "exData")?;
1727    let ct = ex.attribute("contentType").unwrap_or("");
1728    if ct != "text/html" && ct != "text/xml" {
1729        return None;
1730    }
1731    let body = ex
1732        .descendants()
1733        .find(|d| d.is_element() && d.tag_name().name() == "body")?;
1734    let mut spans: Vec<RichTextSpan> = Vec::new();
1735    let base_style = InheritedStyle::default();
1736    let mut first_para = true;
1737    for child in body.children() {
1738        if child.is_element() && child.tag_name().name() == "p" {
1739            if !first_para && !spans.is_empty() {
1740                spans.push(RichTextSpan {
1741                    text: "\n".to_string(),
1742                    font_size: None,
1743                    font_family: None,
1744                    font_weight: None,
1745                    font_style: None,
1746                    text_color: None,
1747                    underline: false,
1748                    line_through: false,
1749                    baseline_shift: 0.0,
1750                });
1751            }
1752            first_para = false;
1753            let p_style = match child.attribute("style") {
1754                Some(css) => base_style.merge_with_css(css),
1755                None => base_style.clone(),
1756            };
1757            collect_inline_spans(child, &p_style, &mut spans);
1758        } else if child.is_text() {
1759            if let Some(t) = child.text() {
1760                let t = t.trim();
1761                if !t.is_empty() {
1762                    spans.push(base_style.to_span(t.to_string()));
1763                }
1764            }
1765        }
1766    }
1767    if spans.is_empty() {
1768        return None;
1769    }
1770    let all_default = spans.iter().all(|s| {
1771        s.font_size.is_none()
1772            && s.font_family.is_none()
1773            && s.font_weight.is_none()
1774            && s.font_style.is_none()
1775            && s.text_color.is_none()
1776            && !s.underline
1777    });
1778    if all_default {
1779        return None;
1780    }
1781    Some(spans)
1782}
1783
1784fn collect_inline_spans(
1785    node: Node<'_, '_>,
1786    inherited: &InheritedStyle,
1787    spans: &mut Vec<RichTextSpan>,
1788) {
1789    for child in node.children() {
1790        if child.is_text() {
1791            if let Some(t) = child.text() {
1792                let t = t.trim();
1793                if !t.is_empty() {
1794                    spans.push(inherited.to_span(t.to_string()));
1795                }
1796            }
1797        } else if child.is_element() {
1798            let tag = child.tag_name().name();
1799            match tag {
1800                "br" => {
1801                    spans.push(RichTextSpan {
1802                        text: "\n".to_string(),
1803                        font_size: None,
1804                        font_family: None,
1805                        font_weight: None,
1806                        font_style: None,
1807                        text_color: None,
1808                        underline: false,
1809                        line_through: false,
1810                        baseline_shift: 0.0,
1811                    });
1812                }
1813                "span" => {
1814                    let child_style = match child.attribute("style") {
1815                        Some(css) => {
1816                            if css.contains("xfa-spacerun:yes") {
1817                                if let Some(t) = child.text() {
1818                                    if !t.is_empty() {
1819                                        spans.push(inherited.to_span(t.to_string()));
1820                                    }
1821                                }
1822                                collect_inline_spans(child, inherited, spans);
1823                                continue;
1824                            }
1825                            inherited.merge_with_css(css)
1826                        }
1827                        None => inherited.clone(),
1828                    };
1829                    collect_inline_spans(child, &child_style, spans);
1830                }
1831                "b" | "strong" => {
1832                    let mut s = inherited.clone();
1833                    s.font_weight = Some("bold".to_string());
1834                    if let Some(css) = child.attribute("style") {
1835                        s = s.merge_with_css(css);
1836                    }
1837                    collect_inline_spans(child, &s, spans);
1838                }
1839                "i" | "em" => {
1840                    let mut s = inherited.clone();
1841                    s.font_style = Some("italic".to_string());
1842                    if let Some(css) = child.attribute("style") {
1843                        s = s.merge_with_css(css);
1844                    }
1845                    collect_inline_spans(child, &s, spans);
1846                }
1847                // In XHTML rich text `<sup>` carries footnote markers and
1848                // units, `<sub>` carries indices. CSS spells this
1849                // `vertical-align`, and the values below are a browser's:
1850                // 0.33 em up, 0.20 em down, characters at about 0.83 of the
1851                // surrounding size.
1852                "sup" => {
1853                    let mut s = inherited.clone();
1854                    s.baseline_shift = 0.33;
1855                    s.font_size = s.font_size.map(|fs| fs * 0.83);
1856                    if let Some(css) = child.attribute("style") {
1857                        s = s.merge_with_css(css);
1858                    }
1859                    collect_inline_spans(child, &s, spans);
1860                }
1861                "sub" => {
1862                    let mut s = inherited.clone();
1863                    s.baseline_shift = -0.20;
1864                    s.font_size = s.font_size.map(|fs| fs * 0.83);
1865                    if let Some(css) = child.attribute("style") {
1866                        s = s.merge_with_css(css);
1867                    }
1868                    collect_inline_spans(child, &s, spans);
1869                }
1870                "u" => {
1871                    let mut s = inherited.clone();
1872                    s.underline = true;
1873                    if let Some(css) = child.attribute("style") {
1874                        s = s.merge_with_css(css);
1875                    }
1876                    collect_inline_spans(child, &s, spans);
1877                }
1878                _ => {
1879                    let child_style = match child.attribute("style") {
1880                        Some(css) => inherited.merge_with_css(css),
1881                        None => inherited.clone(),
1882                    };
1883                    collect_inline_spans(child, &child_style, spans);
1884                }
1885            }
1886        }
1887    }
1888}
1889
1890fn parse_caption(elem: Node<'_, '_>) -> Option<Caption> {
1891    let cap_elem = find_first_child_by_name(elem, "caption")?;
1892    if is_hidden(cap_elem) {
1893        return None;
1894    }
1895    let text = extract_value_text(cap_elem)?;
1896    if text.is_empty() {
1897        return None;
1898    }
1899    let placement = match attr(cap_elem, "placement") {
1900        Some("right") => CaptionPlacement::Right,
1901        Some("top") => CaptionPlacement::Top,
1902        Some("bottom") => CaptionPlacement::Bottom,
1903        Some("inline") => CaptionPlacement::Inline,
1904        _ => CaptionPlacement::Left,
1905    };
1906    let reserve = attr(cap_elem, "reserve")
1907        .and_then(Measurement::parse)
1908        .map(|m| m.to_points());
1909    // The caption styles its own label text via its `<font>` child, independent
1910    // of the field's `<font>`. Capture typeface/size so the caption is not drawn
1911    // in the field's face (XFA 3.3 §7.4 / §6.5).
1912    let cap_font = find_first_child_by_name(cap_elem, "font");
1913    let font_family = cap_font
1914        .and_then(|f| attr(f, "typeface"))
1915        .map(|s| s.to_string());
1916    let font_size = cap_font
1917        .and_then(|f| attr(f, "size"))
1918        .and_then(parse_font_size);
1919    Some(Caption {
1920        placement,
1921        reserve,
1922        text,
1923        font_family,
1924        font_size,
1925    })
1926}
1927
1928fn is_hidden(elem: Node<'_, '_>) -> bool {
1929    matches!(
1930        attr(elem, "presence"),
1931        Some("hidden") | Some("invisible") | Some("inactive")
1932    )
1933}
1934
1935/// XFA Spec 3.3 §7.4.2 — Parse a presence string to the `Presence` enum.
1936///
1937/// Used when applying data-bound presence overrides.  Returns `Visible` for
1938/// any unrecognised value (spec default).
1939fn parse_presence_str(s: &str) -> Presence {
1940    match s.trim() {
1941        "hidden" => Presence::Hidden,
1942        "invisible" => Presence::Invisible,
1943        "inactive" => Presence::Inactive,
1944        _ => Presence::Visible,
1945    }
1946}
1947
1948fn extract_value_image(
1949    elem: Node<'_, '_>,
1950    image_files: &std::collections::HashMap<String, Vec<u8>>,
1951) -> Option<(Vec<u8>, String)> {
1952    let value = find_first_child_by_name(elem, "value")?;
1953    let image = find_first_child_by_name(value, "image")?;
1954    let content_type = attr(image, "contentType")
1955        .unwrap_or("image/png")
1956        .to_string();
1957
1958    // XFA §2.3: `href` references an image embedded in the PDF's Names tree.
1959    // When present, the inline text content is empty — resolve from the PDF.
1960    if let Some(href) = attr(image, "href") {
1961        if let Some(data) = image_files.get(href) {
1962            let decoded = data.clone();
1963            if decoded.starts_with(b"BM") || content_type == "image/bmp" {
1964                if let Some(png_data) = bmp_to_png(&decoded) {
1965                    return Some((png_data, "image/png".to_string()));
1966                }
1967                return None;
1968            }
1969            return Some((decoded, content_type));
1970        }
1971        // Try normalized key: strip leading ".\" or "./"
1972        let normalized = href.trim_start_matches(".\\").trim_start_matches("./");
1973        for (k, data) in image_files {
1974            let k_norm = k.trim_start_matches(".\\").trim_start_matches("./");
1975            if k_norm == normalized {
1976                let decoded = data.clone();
1977                if decoded.starts_with(b"BM") || content_type == "image/bmp" {
1978                    if let Some(png_data) = bmp_to_png(&decoded) {
1979                        return Some((png_data, "image/png".to_string()));
1980                    }
1981                    return None;
1982                }
1983                return Some((decoded, content_type));
1984            }
1985        }
1986    }
1987
1988    let data = image.text().unwrap_or_default();
1989    let decoded = base64_decode(data);
1990
1991    // Skip empty images from unresolved href references.
1992    if decoded.is_empty() {
1993        return None;
1994    }
1995
1996    if decoded.starts_with(b"BM") || content_type == "image/bmp" {
1997        if let Some(png_data) = bmp_to_png(&decoded) {
1998            return Some((png_data, "image/png".to_string()));
1999        }
2000        log::warn!("BMP to PNG conversion failed; skipping image");
2001        return None;
2002    }
2003
2004    Some((decoded, content_type))
2005}
2006
2007fn extract_draw_content(elem: Node<'_, '_>) -> Option<DrawContent> {
2008    let value = find_first_child_by_name(elem, "value")?;
2009
2010    if let Some(line) = find_first_child_by_name(value, "line") {
2011        let x1 = attr_as_f64(line, "x1").unwrap_or(0.0);
2012        let y1 = attr_as_f64(line, "y1").unwrap_or(0.0);
2013        let x2 = attr_as_f64(line, "x2").unwrap_or(0.0);
2014        let y2 = attr_as_f64(line, "y2").unwrap_or(0.0);
2015        return Some(DrawContent::Line { x1, y1, x2, y2 });
2016    }
2017
2018    if let Some(rect) = find_first_child_by_name(value, "rectangle") {
2019        let x = attr_as_f64(rect, "x").unwrap_or(0.0);
2020        let y = attr_as_f64(rect, "y").unwrap_or(0.0);
2021        let w = attr_as_f64(rect, "w").unwrap_or(attr_as_f64(rect, "width").unwrap_or(0.0));
2022        let h = attr_as_f64(rect, "h").unwrap_or(attr_as_f64(rect, "height").unwrap_or(0.0));
2023        let radius =
2024            attr_as_f64(rect, "r").unwrap_or(attr_as_f64(rect, "cornerRadius").unwrap_or(0.0));
2025        return Some(DrawContent::Rectangle { x, y, w, h, radius });
2026    }
2027
2028    if let Some(arc) = find_first_child_by_name(value, "arc") {
2029        let x = attr_as_f64(arc, "x").unwrap_or(0.0);
2030        let y = attr_as_f64(arc, "y").unwrap_or(0.0);
2031        let w = attr_as_f64(arc, "w").unwrap_or(attr_as_f64(arc, "width").unwrap_or(0.0));
2032        let h = attr_as_f64(arc, "h").unwrap_or(attr_as_f64(arc, "height").unwrap_or(0.0));
2033        let start_angle = attr_as_f64(arc, "startAngle").unwrap_or(0.0);
2034        let sweep_angle = attr_as_f64(arc, "sweepAngle").unwrap_or(0.0);
2035        return Some(DrawContent::Arc {
2036            x,
2037            y,
2038            w,
2039            h,
2040            start_angle,
2041            sweep_angle,
2042        });
2043    }
2044
2045    None
2046}
2047
2048fn base64_decode(input: &str) -> Vec<u8> {
2049    use base64::Engine;
2050    base64::engine::general_purpose::STANDARD
2051        .decode(input.trim())
2052        .unwrap_or_default()
2053}
2054
2055fn bmp_to_png(bmp_data: &[u8]) -> Option<Vec<u8>> {
2056    let img = image::load_from_memory_with_format(bmp_data, image::ImageFormat::Bmp).ok()?;
2057    let mut buf = Vec::new();
2058    img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
2059        .ok()?;
2060    Some(buf)
2061}
2062
2063fn attr_as_f64(elem: Node<'_, '_>, name: &str) -> Option<f64> {
2064    attr(elem, name)?.parse().ok()
2065}
2066
2067fn parse_col_span(elem: Node<'_, '_>) -> i32 {
2068    attr(elem, "colSpan")
2069        .and_then(|s| s.parse().ok())
2070        .unwrap_or(1)
2071}
2072
2073fn parse_node_meta(elem: Node<'_, '_>) -> FormNodeMeta {
2074    let tag = elem.tag_name().name();
2075    let presence = match attr(elem, "presence") {
2076        Some("hidden") => Presence::Hidden,
2077        Some("invisible") => Presence::Invisible,
2078        Some("inactive") => Presence::Inactive,
2079        _ => Presence::Visible,
2080    };
2081
2082    let (page_break_before, break_before_target) = detect_page_break_before(elem);
2083    let (page_break_after, break_after_target) = detect_page_break_after(elem);
2084    let content_area_break = detect_content_area_break(elem);
2085    let break_target = break_before_target.or(break_after_target);
2086
2087    let event_scripts = collect_event_scripts(elem);
2088    let (keep_next_content_area, keep_previous_content_area, keep_intact_content_area) =
2089        parse_keep(elem);
2090    let (overflow_leader, overflow_trailer) = parse_overflow(elem);
2091    let (bookend_leader, bookend_trailer) = parse_bookend(elem);
2092
2093    let group_kind = if tag == "exclGroup" {
2094        GroupKind::ExclusiveChoice
2095    } else {
2096        GroupKind::None
2097    };
2098
2099    let item_value = if tag == "field" {
2100        parse_item_value(elem)
2101    } else {
2102        None
2103    };
2104
2105    let (display_items, save_items) = if tag == "field" {
2106        parse_items_lists(elem)
2107    } else {
2108        (Vec::new(), Vec::new())
2109    };
2110
2111    let xfa_id = attr(elem, "id").map(|s| s.to_string());
2112    let field_kind = detect_field_kind(elem);
2113    let style = parse_node_style(elem);
2114    let (data_bind_ref, data_bind_none) = parse_bind(elem);
2115    let anchor_type = parse_anchor_type(elem);
2116    let access = attr(elem, "access").map(Access::parse);
2117    let multiline = parse_multiline(elem);
2118    let required = parse_required(elem);
2119
2120    FormNodeMeta {
2121        xfa_id,
2122        presence,
2123        access,
2124        multiline,
2125        required,
2126        page_break_before,
2127        page_break_after,
2128        break_target,
2129        content_area_break,
2130        overflow_leader,
2131        overflow_trailer,
2132        bookend_leader,
2133        bookend_trailer,
2134        keep_next_content_area,
2135        keep_previous_content_area,
2136        keep_intact_content_area,
2137        event_scripts,
2138        data_bind_ref,
2139        data_bind_none,
2140        group_kind,
2141        item_value,
2142        field_kind,
2143        style,
2144        display_items,
2145        save_items,
2146        anchor_type,
2147        ..Default::default()
2148    }
2149}
2150
2151fn detect_page_break_before(elem: Node<'_, '_>) -> (bool, Option<String>) {
2152    for child in elem.children().filter(|n| n.is_element()) {
2153        let tag = child.tag_name().name();
2154        if matches!(tag, "subform" | "field" | "draw" | "exclGroup") {
2155            break;
2156        }
2157        if tag == "breakBefore" && attr(child, "targetType") == Some("pageArea") {
2158            return (true, attr(child, "target").map(|s| s.to_string()));
2159        }
2160        if tag == "break" && attr(child, "before") == Some("pageArea") {
2161            return (true, attr(child, "target").map(|s| s.to_string()));
2162        }
2163    }
2164    (false, None)
2165}
2166
2167fn detect_page_break_after(elem: Node<'_, '_>) -> (bool, Option<String>) {
2168    let mut last_content_idx = 0;
2169    let children: Vec<_> = elem.children().filter(|n| n.is_element()).collect();
2170    for (i, child) in children.iter().enumerate() {
2171        let tag = child.tag_name().name();
2172        if matches!(tag, "subform" | "field" | "draw" | "exclGroup") {
2173            last_content_idx = i;
2174        }
2175    }
2176
2177    for child in children.iter().skip(last_content_idx) {
2178        let tag = child.tag_name().name();
2179        if tag == "breakAfter" && attr(*child, "targetType") == Some("pageArea") {
2180            return (true, attr(*child, "target").map(|s| s.to_string()));
2181        }
2182        if tag == "break" && attr(*child, "after") == Some("pageArea") {
2183            return (true, attr(*child, "target").map(|s| s.to_string()));
2184        }
2185    }
2186    (false, None)
2187}
2188
2189fn detect_content_area_break(elem: Node<'_, '_>) -> bool {
2190    for child in elem.children().filter(|n| n.is_element()) {
2191        let tag = child.tag_name().name();
2192        if tag == "breakBefore" && attr(child, "targetType") == Some("contentArea") {
2193            return true;
2194        }
2195    }
2196    false
2197}
2198
2199fn collect_event_scripts(elem: Node<'_, '_>) -> Vec<EventScript> {
2200    let mut scripts = Vec::new();
2201    for child in elem.children().filter(|n| n.is_element()) {
2202        let child_tag = child.tag_name().name();
2203        if child_tag == "event" {
2204            let activity = attr(child, "activity");
2205            let event_ref = attr(child, "ref");
2206            if activity == Some("ready") && event_ref == Some("$layout") {
2207                continue;
2208            }
2209            if let Some(script_elem) = find_first_child_by_name(child, "script") {
2210                if let Some(script) =
2211                    build_event_script(script_elem, activity, event_ref, attr(script_elem, "runAt"))
2212                {
2213                    scripts.push(script);
2214                }
2215            }
2216        } else if child_tag == "calculate" {
2217            if let Some(script_elem) = find_first_child_by_name(child, "script") {
2218                if let Some(script) = build_event_script(
2219                    script_elem,
2220                    Some("calculate"),
2221                    None,
2222                    attr(script_elem, "runAt"),
2223                ) {
2224                    scripts.push(script);
2225                }
2226            }
2227        }
2228    }
2229    scripts
2230}
2231
2232fn build_event_script(
2233    script_elem: Node<'_, '_>,
2234    activity: Option<&str>,
2235    event_ref: Option<&str>,
2236    run_at: Option<&str>,
2237) -> Option<EventScript> {
2238    let text = script_elem.text()?.trim();
2239    if text.is_empty() {
2240        return None;
2241    }
2242
2243    Some(EventScript::new(
2244        text.to_string(),
2245        detect_script_language(attr(script_elem, "contentType")),
2246        activity.map(str::to_string),
2247        event_ref.map(str::to_string),
2248        run_at.map(str::to_string),
2249    ))
2250}
2251
2252fn detect_script_language(content_type: Option<&str>) -> ScriptLanguage {
2253    match content_type.map(|value| value.trim().to_ascii_lowercase()) {
2254        None => ScriptLanguage::FormCalc,
2255        Some(value) if value == "application/x-formcalc" || value.ends_with("/x-formcalc") => {
2256            ScriptLanguage::FormCalc
2257        }
2258        Some(value)
2259            if value == "application/x-javascript"
2260                || value == "application/javascript"
2261                || value == "text/javascript"
2262                || value.ends_with("/x-javascript") =>
2263        {
2264            ScriptLanguage::JavaScript
2265        }
2266        Some(_) => ScriptLanguage::Other,
2267    }
2268}
2269
2270fn parse_keep(elem: Node<'_, '_>) -> (bool, bool, bool) {
2271    if let Some(keep) = find_first_child_by_name(elem, "keep") {
2272        let next = attr(keep, "next") == Some("contentArea");
2273        let prev = attr(keep, "previous") == Some("contentArea");
2274        let intact = attr(keep, "intact") == Some("contentArea");
2275        (next, prev, intact)
2276    } else {
2277        (false, false, false)
2278    }
2279}
2280
2281/// `<bookend leader trailer>` (XFA 3.3 §17).
2282///
2283/// Word for word the same shape as `parse_overflow` below, and like that one a
2284/// second copy of what also stands in `template_parser.rs`. That duplication is
2285/// older than this function; it is carried here so the two routes cannot drift
2286/// apart, and #208 tracks merging them.
2287fn parse_bookend(elem: Node<'_, '_>) -> (Option<String>, Option<String>) {
2288    match find_first_child_by_name(elem, "bookend") {
2289        Some(bookend) => (
2290            attr(bookend, "leader").map(|s| s.to_string()),
2291            attr(bookend, "trailer").map(|s| s.to_string()),
2292        ),
2293        None => (None, None),
2294    }
2295}
2296
2297fn parse_overflow(elem: Node<'_, '_>) -> (Option<String>, Option<String>) {
2298    if let Some(overflow) = find_first_child_by_name(elem, "overflow") {
2299        let leader = attr(overflow, "leader").map(|s| s.to_string());
2300        let trailer = attr(overflow, "trailer").map(|s| s.to_string());
2301        (leader, trailer)
2302    } else {
2303        (None, None)
2304    }
2305}
2306
2307fn parse_item_value(elem: Node<'_, '_>) -> Option<String> {
2308    let items = find_first_child_by_name(elem, "items")?;
2309    let text_elem = find_first_child_by_name(items, "text")?;
2310    let text = text_elem.text()?.trim();
2311    if text.is_empty() {
2312        None
2313    } else {
2314        Some(text.to_string())
2315    }
2316}
2317
2318/// Extract all text values from an `<items>` element.
2319fn collect_items_texts(items_elem: Node<'_, '_>) -> Vec<String> {
2320    items_elem
2321        .children()
2322        .filter(|n| n.is_element())
2323        .filter_map(|child| {
2324            let txt = child.text().unwrap_or("").trim().to_string();
2325            if txt.is_empty() {
2326                None
2327            } else {
2328                Some(txt)
2329            }
2330        })
2331        .collect()
2332}
2333
2334/// Parse choice list `<items>` elements from a `<field>` node (XFA 3.3 §7.7).
2335fn parse_items_lists(elem: Node<'_, '_>) -> (Vec<String>, Vec<String>) {
2336    let items_elems: Vec<_> = elem
2337        .children()
2338        .filter(|n| n.is_element() && n.tag_name().name() == "items")
2339        .collect();
2340    match items_elems.len() {
2341        0 => (Vec::new(), Vec::new()),
2342        1 => {
2343            let vals = collect_items_texts(items_elems[0]);
2344            (vals, Vec::new())
2345        }
2346        _ => {
2347            let first = items_elems[0];
2348            let second = items_elems[1];
2349            let first_is_save = attr(first, "save") == Some("1");
2350            if first_is_save {
2351                (collect_items_texts(second), collect_items_texts(first))
2352            } else {
2353                (collect_items_texts(first), collect_items_texts(second))
2354            }
2355        }
2356    }
2357}
2358
2359/// XFA 3.3 §6.1 — a field is multiline when its `<ui><textEdit>` declares
2360/// `multiLine="1"` (or the non-canonical `"true"` some producers emit).
2361///
2362/// `pub(crate)` because `template_parser.rs` reads the same element and must
2363/// reach the same answer; a second copy there is what #208 is about.
2364pub(crate) fn parse_multiline(elem: Node<'_, '_>) -> bool {
2365    let Some(ui) = find_first_child_by_name(elem, "ui") else {
2366        return false;
2367    };
2368    let Some(te) = find_first_child_by_name(ui, "textEdit") else {
2369        return false;
2370    };
2371    matches!(attr(te, "multiLine"), Some("1") | Some("true"))
2372}
2373
2374/// XFA 3.3 §6.3 — `<validate nullTest="error">` marks a mandatory field.
2375///
2376/// `pub(crate)` for the same reason as `parse_multiline`.
2377pub(crate) fn parse_required(elem: Node<'_, '_>) -> bool {
2378    find_first_child_by_name(elem, "validate")
2379        .and_then(|v| attr(v, "nullTest"))
2380        .map(|s| s == "error")
2381        .unwrap_or(false)
2382}
2383
2384fn detect_field_kind(elem: Node<'_, '_>) -> FieldKind {
2385    let Some(ui) = find_first_child_by_name(elem, "ui") else {
2386        return FieldKind::Text;
2387    };
2388    for child in ui.children().filter(|n| n.is_element()) {
2389        match child.tag_name().name() {
2390            "button" => return FieldKind::Button,
2391            "checkButton" => {
2392                // fixes #798: XFA 3.3 §11.2 / §17.8 uses round checkButtons
2393                // for exclusion-group radio widgets. Treating every
2394                // checkButton as a checkbox loses the correct renderer.
2395                let shape = attr(child, "shape").unwrap_or("square");
2396                return if shape == "round" {
2397                    FieldKind::Radio
2398                } else {
2399                    FieldKind::Checkbox
2400                };
2401            }
2402            "choiceList" => return FieldKind::Dropdown,
2403            "dateTimeEdit" => return FieldKind::DateTimePicker,
2404            "numericEdit" => return FieldKind::NumericEdit,
2405            "passwordEdit" => return FieldKind::PasswordEdit,
2406            "imageEdit" => return FieldKind::ImageEdit,
2407            "signature" => return FieldKind::Signature,
2408            "barcode" => return FieldKind::Barcode,
2409            _ => {}
2410        }
2411    }
2412    FieldKind::Text
2413}
2414
2415fn parse_node_style(elem: Node<'_, '_>) -> FormNodeStyle {
2416    let (check_on_value, check_off_value, check_neutral_value) = parse_check_button_values(elem);
2417    let mut style = FormNodeStyle {
2418        check_button_mark: parse_check_button_mark(elem),
2419        check_button_on_value: check_on_value,
2420        check_button_off_value: check_off_value,
2421        check_button_neutral_value: check_neutral_value,
2422        ..Default::default()
2423    };
2424    if let Some(fill) = find_first_child_by_name(elem, "fill") {
2425        if !is_hidden(fill) {
2426            style.bg_color = parse_fill_color(fill);
2427        }
2428    }
2429    // Borders can live directly on the element OR inside <ui><textEdit|…><border>.
2430    let border = find_first_child_by_name(elem, "border").or_else(|| {
2431        let ui = find_first_child_by_name(elem, "ui")?;
2432        ui.children()
2433            .filter(|c| c.is_element() && c.tag_name().name() != "border")
2434            .find_map(|widget| find_first_child_by_name(widget, "border"))
2435    });
2436    if let Some(border) = border {
2437        // Collect all <edge> elements (XFA §9.3.3).
2438        let edge_elems: Vec<_> = border
2439            .children()
2440            .filter(|n| n.is_element() && n.tag_name().name() == "edge")
2441            .collect();
2442        if !edge_elems.is_empty() {
2443            let first_visible = edge_elems
2444                .iter()
2445                .find(|e| !is_hidden(**e) && attr(**e, "stroke").unwrap_or("solid") != "none")
2446                .or_else(|| edge_elems.first())
2447                .copied();
2448            let first = first_visible.unwrap_or(edge_elems[0]);
2449            if let Some(color) = find_first_child_by_name(first, "color") {
2450                if let Some(rgb) = parse_xfa_color(color) {
2451                    style.border_color = Some(rgb);
2452                }
2453            }
2454            let stroke = attr(first, "stroke").unwrap_or("solid");
2455            if stroke != "none" {
2456                let thickness = attr(first, "thickness")
2457                    .and_then(Measurement::parse)
2458                    .map(|m: Measurement| m.to_points())
2459                    .unwrap_or(0.5);
2460                if thickness > 0.0 {
2461                    style.border_width_pt = Some(thickness);
2462                }
2463            }
2464
2465            // Keep edge ordering consistent with render_bridge expectations:
2466            // [top, right, bottom, left]. The previous mapping treated 4-edge
2467            // borders as [top, bottom, left, right], which swapped bottom-only
2468            // underlines into left vertical lines.
2469            let edge_visible = |e: &roxmltree::Node<'_, '_>| -> bool {
2470                !is_hidden(*e) && attr(*e, "stroke").unwrap_or("solid") != "none"
2471            };
2472            style.border_edges = match edge_elems.len() {
2473                0 => [true, true, true, true],
2474                1 => {
2475                    let v = edge_visible(&edge_elems[0]);
2476                    [v, v, v, v]
2477                }
2478                2 => {
2479                    let even = edge_visible(&edge_elems[0]);
2480                    let odd = edge_visible(&edge_elems[1]);
2481                    [even, odd, even, odd]
2482                }
2483                3 => {
2484                    let top = edge_visible(&edge_elems[0]);
2485                    let rl = edge_visible(&edge_elems[1]);
2486                    let bot = edge_visible(&edge_elems[2]);
2487                    [top, rl, bot, rl]
2488                }
2489                _ => [
2490                    edge_visible(&edge_elems[0]),
2491                    edge_visible(&edge_elems[1]),
2492                    edge_visible(&edge_elems[2]),
2493                    edge_visible(&edge_elems[3]),
2494                ],
2495            };
2496
2497            if edge_elems.len() > 1 {
2498                let default_rgb = style.border_color.unwrap_or((0, 0, 0));
2499                let edge_color = |edge: roxmltree::Node<'_, '_>| -> (u8, u8, u8) {
2500                    find_first_child_by_name(edge, "color")
2501                        .and_then(parse_xfa_color)
2502                        .unwrap_or(default_rgb)
2503                };
2504                let per_edge_colors = match edge_elems.len() {
2505                    2 => Some([
2506                        edge_color(edge_elems[0]),
2507                        edge_color(edge_elems[1]),
2508                        edge_color(edge_elems[0]),
2509                        edge_color(edge_elems[1]),
2510                    ]),
2511                    3 => Some([
2512                        edge_color(edge_elems[0]),
2513                        edge_color(edge_elems[1]),
2514                        edge_color(edge_elems[2]),
2515                        edge_color(edge_elems[1]),
2516                    ]),
2517                    _ => Some([
2518                        edge_color(edge_elems[0]),
2519                        edge_color(edge_elems[1]),
2520                        edge_color(edge_elems[2]),
2521                        edge_color(edge_elems[3]),
2522                    ]),
2523                };
2524                if let Some([top, right, bottom, left]) = per_edge_colors {
2525                    if !(top == bottom && bottom == left && left == right) {
2526                        style.border_colors = Some([top, right, bottom, left]);
2527                    }
2528                }
2529
2530                let default_thickness = style.border_width_pt.unwrap_or(0.5);
2531                let edge_thickness = |edge: roxmltree::Node<'_, '_>| -> f64 {
2532                    attr(edge, "thickness")
2533                        .and_then(Measurement::parse)
2534                        .map(|m: Measurement| m.to_points())
2535                        .unwrap_or(default_thickness)
2536                };
2537                let per_edge_widths = match edge_elems.len() {
2538                    2 => Some([
2539                        edge_thickness(edge_elems[0]),
2540                        edge_thickness(edge_elems[1]),
2541                        edge_thickness(edge_elems[0]),
2542                        edge_thickness(edge_elems[1]),
2543                    ]),
2544                    3 => Some([
2545                        edge_thickness(edge_elems[0]),
2546                        edge_thickness(edge_elems[1]),
2547                        edge_thickness(edge_elems[2]),
2548                        edge_thickness(edge_elems[1]),
2549                    ]),
2550                    _ => Some([
2551                        edge_thickness(edge_elems[0]),
2552                        edge_thickness(edge_elems[1]),
2553                        edge_thickness(edge_elems[2]),
2554                        edge_thickness(edge_elems[3]),
2555                    ]),
2556                };
2557                if let Some([top_t, right_t, bottom_t, left_t]) = per_edge_widths {
2558                    if !(top_t == bottom_t && bottom_t == left_t && left_t == right_t) {
2559                        style.border_widths = Some([top_t, right_t, bottom_t, left_t]);
2560                    }
2561                }
2562            }
2563        }
2564        if style.bg_color.is_none() {
2565            if let Some(fill) = find_first_child_by_name(border, "fill") {
2566                if !is_hidden(fill) {
2567                    // fixes #809: XFA 3.3 §7.6.3 allows widget fills to be
2568                    // expressed as either <fill><color> or <fill><solid><color>.
2569                    // Supporting both keeps explicit field backgrounds aligned
2570                    // with Adobe/pdfRest output instead of leaving them white.
2571                    style.bg_color = parse_fill_color(fill);
2572                }
2573            }
2574        }
2575    }
2576    // XFA 3.3 §2.6 — a `<line>` draw shape carries its stroke on
2577    // `<value><line><edge>`, not `<border>`, so the border pass above never
2578    // sees it. Capture the line's edge thickness/colour into the node style so
2579    // `render_draw` can stroke the spanned grid rule at the authored weight
2580    // (e.g. the 2.12 mm header rule vs the 0.21 mm cell rules). Scoped to
2581    // `<line>` only — the rectangle path is unaffected, and the zero-area line
2582    // box keeps the non-field border pass from drawing a spurious box.
2583    if style.border_width_pt.is_none() {
2584        if let Some(line) = find_first_child_by_name(elem, "value")
2585            .and_then(|value| find_first_child_by_name(value, "line"))
2586        {
2587            if let Some(edge) = find_first_child_by_name(line, "edge") {
2588                if attr(edge, "stroke").unwrap_or("solid") != "none" {
2589                    if let Some(thickness) = attr(edge, "thickness")
2590                        .and_then(Measurement::parse)
2591                        .map(|m: Measurement| m.to_points())
2592                    {
2593                        if thickness > 0.0 {
2594                            style.border_width_pt = Some(thickness);
2595                        }
2596                    }
2597                    if style.border_color.is_none() {
2598                        if let Some(rgb) =
2599                            find_first_child_by_name(edge, "color").and_then(parse_xfa_color)
2600                        {
2601                            style.border_color = Some(rgb);
2602                        }
2603                    }
2604                }
2605            }
2606        }
2607    }
2608    if let Some(font) = find_first_child_by_name(elem, "font") {
2609        if let Some(typeface) = attr(font, "typeface") {
2610            style.font_family = Some(typeface.to_string());
2611        }
2612        if let Some(gf) = attr(font, "genericFamily") {
2613            style.generic_family = Some(gf.to_string());
2614        }
2615        if let Some(size_str) = attr(font, "size") {
2616            if let Some(m) = Measurement::parse(size_str) {
2617                let m: Measurement = m;
2618                style.font_size = Some(m.to_points());
2619            }
2620        }
2621        if let Some(weight) = attr(font, "weight") {
2622            style.font_weight = Some(weight.to_string());
2623        }
2624        if let Some(posture) = attr(font, "posture") {
2625            style.font_style = Some(posture.to_string());
2626        }
2627        if let Some(fill) = find_first_child_by_name(font, "fill") {
2628            if let Some(color) = find_first_child_by_name(fill, "color") {
2629                if let Some(rgb) = parse_xfa_color(color) {
2630                    style.text_color = Some(rgb);
2631                }
2632            }
2633        }
2634        // <font color="#RRGGBB"> attribute (fallback when <fill><color> not present)
2635        if style.text_color.is_none() {
2636            if let Some(color_str) = attr(font, "color") {
2637                if let Some(rgb) = parse_font_color_attr(color_str) {
2638                    style.text_color = Some(rgb);
2639                }
2640            }
2641        }
2642        // fontHorizontalScale="96%" → 0.96
2643        if let Some(scale_str) = attr(font, "fontHorizontalScale") {
2644            if let Some(v) = parse_percentage(scale_str) {
2645                style.font_horizontal_scale = Some(v);
2646            }
2647        }
2648        // letterSpacing="-0.018em" or "0.5pt"
2649        if let Some(ls_str) = attr(font, "letterSpacing") {
2650            if let Some(v) = parse_letter_spacing(ls_str, style.font_size.unwrap_or(10.0)) {
2651                style.letter_spacing_pt = Some(v);
2652            }
2653        }
2654        // XFA Spec 3.3 §2.6 — underline="1" (single) or "2" (double)
2655        if let Some(underline_str) = attr(font, "underline") {
2656            style.underline = underline_str == "1" || underline_str == "2";
2657        }
2658        // XFA Spec 3.3 §2.6 — lineThrough="1"
2659        if let Some(line_through_str) = attr(font, "lineThrough") {
2660            style.line_through = line_through_str == "1";
2661        }
2662    }
2663
2664    // Parse <para> for paragraph attributes (XFA 3.3 §D.7).
2665    if let Some(para) = find_first_child_by_name(elem, "para") {
2666        if let Some(v) = attr(para, "spaceAbove").and_then(Measurement::parse) {
2667            style.space_above_pt = Some(v.to_points());
2668        }
2669        if let Some(v) = attr(para, "spaceBelow").and_then(Measurement::parse) {
2670            style.space_below_pt = Some(v.to_points());
2671        }
2672        if let Some(v) = attr(para, "marginLeft").and_then(Measurement::parse) {
2673            style.margin_left_pt = Some(v.to_points());
2674        }
2675        if let Some(v) = attr(para, "marginRight").and_then(Measurement::parse) {
2676            style.margin_right_pt = Some(v.to_points());
2677        }
2678        if let Some(va) = attr(para, "vAlign") {
2679            style.v_align = Some(match va {
2680                "middle" => VerticalAlign::Middle,
2681                "bottom" => VerticalAlign::Bottom,
2682                _ => VerticalAlign::Top,
2683            });
2684        }
2685    }
2686
2687    // Parse <border><corner> for border radius and <border><edge> for style/visibility.
2688    if let Some(border) = border {
2689        if let Some(corner) = find_first_child_by_name(border, "corner") {
2690            if let Some(v) = attr(corner, "radius").and_then(Measurement::parse) {
2691                style.border_radius_pt = Some(v.to_points());
2692            }
2693        }
2694        let edge_elems2: Vec<_> = border
2695            .children()
2696            .filter(|n| n.is_element() && n.tag_name().name() == "edge")
2697            .collect();
2698        if !edge_elems2.is_empty() {
2699            if let Some(stroke) = attr(edge_elems2[0], "stroke") {
2700                if stroke != "none" {
2701                    style.border_style = Some(stroke.to_string());
2702                }
2703            }
2704        }
2705    }
2706
2707    // Parse <caption> for caption text, placement, and reserve (XFA 3.3 §7.4).
2708    if let Some(cap) = parse_caption(elem) {
2709        style.caption_text = Some(cap.text);
2710        style.caption_placement = Some(
2711            match cap.placement {
2712                CaptionPlacement::Left => "left",
2713                CaptionPlacement::Right => "right",
2714                CaptionPlacement::Top => "top",
2715                CaptionPlacement::Bottom => "bottom",
2716                CaptionPlacement::Inline => "inline",
2717            }
2718            .to_string(),
2719        );
2720        style.caption_reserve = cap.reserve;
2721        style.caption_font_family = cap.font_family;
2722        style.caption_font_size = cap.font_size;
2723    }
2724
2725    style
2726}
2727
2728fn parse_fill_color(fill_node: Node<'_, '_>) -> Option<(u8, u8, u8)> {
2729    find_first_child_by_name(fill_node, "color")
2730        .and_then(parse_xfa_color)
2731        .or_else(|| {
2732            find_first_child_by_name(fill_node, "solid")
2733                .and_then(|solid| find_first_child_by_name(solid, "color"))
2734                .and_then(parse_xfa_color)
2735        })
2736}
2737
2738fn parse_check_button_mark(elem: Node<'_, '_>) -> Option<String> {
2739    let ui = find_first_child_by_name(elem, "ui")?;
2740    let check_button = ui
2741        .children()
2742        .find(|n| n.is_element() && n.tag_name().name() == "checkButton")?;
2743    let mark = attr(check_button, "mark")?.to_ascii_lowercase();
2744    match mark.as_str() {
2745        "check" | "circle" | "cross" | "diamond" | "square" | "star" => Some(mark),
2746        _ => None,
2747    }
2748}
2749
2750fn parse_check_button_values(
2751    elem: Node<'_, '_>,
2752) -> (Option<String>, Option<String>, Option<String>) {
2753    let ui = match find_first_child_by_name(elem, "ui") {
2754        Some(ui) => ui,
2755        None => return (None, None, None),
2756    };
2757    let check_button = match find_first_child_by_name(ui, "checkButton") {
2758        Some(check_button) => check_button,
2759        None => return (None, None, None),
2760    };
2761
2762    // XFA Spec 3.3 §7.2.21 allows `<items>` to be authored either directly on
2763    // the field or nested inside `<ui><checkButton>`. Adobe honors both forms;
2764    // if we only look on the field, asserted values like "Yes"/"No" collapse
2765    // back to the hardcoded 1/0 fallback and the checkbox renders unchecked.
2766    let items = find_first_child_by_name(elem, "items")
2767        .or_else(|| find_first_child_by_name(check_button, "items"));
2768    let Some(items) = items else {
2769        return (None, None, None);
2770    };
2771
2772    let values: Vec<String> = items
2773        .children()
2774        .filter(|n| n.is_element())
2775        .map(|child| child.text().unwrap_or("").trim().to_string())
2776        .collect();
2777
2778    (
2779        values.first().cloned(),
2780        values.get(1).cloned(),
2781        values.get(2).cloned(),
2782    )
2783}
2784
2785fn parse_xfa_color(color_node: Node<'_, '_>) -> Option<(u8, u8, u8)> {
2786    let value = attr(color_node, "value")?;
2787    let parts: Vec<&str> = value.split(',').collect();
2788    if parts.len() >= 3 {
2789        let r = parts[0].trim().parse::<u8>().ok()?;
2790        let g = parts[1].trim().parse::<u8>().ok()?;
2791        let b = parts[2].trim().parse::<u8>().ok()?;
2792        Some((r, g, b))
2793    } else {
2794        None
2795    }
2796}
2797
2798/// Parse a color string from a `color` attribute on `<font>`.
2799///
2800/// Supported formats:
2801/// - `#RRGGBB` (e.g. `#000080`)
2802/// - `#RGB` shorthand (e.g. `#00F` → `#0000FF`)
2803/// - `r,g,b` with decimal values 0-255 (e.g. `0,0,128`)
2804fn parse_font_color_attr(s: &str) -> Option<(u8, u8, u8)> {
2805    let s = s.trim();
2806    if let Some(hex) = s.strip_prefix('#') {
2807        parse_ascii_hex_color(hex)
2808    } else {
2809        // Try "r,g,b" decimal format
2810        let parts: Vec<&str> = s.split(',').collect();
2811        if parts.len() >= 3 {
2812            let r = parts[0].trim().parse::<u8>().ok()?;
2813            let g = parts[1].trim().parse::<u8>().ok()?;
2814            let b = parts[2].trim().parse::<u8>().ok()?;
2815            Some((r, g, b))
2816        } else {
2817            None
2818        }
2819    }
2820}
2821
2822/// Extract the data field name from a bind ref like `$.listInitiales[*]`.
2823/// Returns `Some("listInitiales")` for that example, or None if no bind/ref.
2824///
2825/// XFA Spec 3.3 §4.4 p199-201 — explicit data references via
2826/// `<bind match="dataRef" ref="SOM.expression">`. The ref is a SOM
2827/// expression that resolves to a data node. We extract the last segment
2828/// name for use as the data matching key.
2829///
2830/// TODO: XFA Spec 3.3 §4.4 p199 — full SOM expression evaluation not
2831/// implemented. Only simple `$.name` and `$.name[*]` patterns are supported.
2832/// Multi-segment paths like `$record.group.field` are partially supported
2833/// (we take the last segment).
2834#[allow(dead_code)]
2835fn parse_bind_data_name(elem: Node<'_, '_>) -> Option<String> {
2836    let bind = find_first_child_by_name(elem, "bind")?;
2837    let ref_val = attr(bind, "ref")?;
2838    // Typical refs: "$.fieldName", "$.fieldName[*]", "$record.fieldName"
2839    // Extract the last dot-separated segment, strip any trailing [*] etc.
2840    let segment = ref_val.rsplit('.').next().unwrap_or(ref_val);
2841    let name = segment.split('[').next().unwrap_or(segment).trim();
2842    if name.is_empty() || name == "$" {
2843        None
2844    } else {
2845        Some(name.to_string())
2846    }
2847}
2848
2849/// XFA Spec 3.3 §4.4.3 p176 — `<bind>` element controls data binding:
2850/// - `match="once"` (default): bind to first matching data node
2851/// - `match="none"`: skip data binding entirely
2852/// - `match="dataRef"`: use explicit SOM ref to locate data
2853/// - `match="global"`: search entire data DOM
2854///
2855/// TODO: XFA Spec 3.3 §4.4.3 p176 — `match="global"` not implemented.
2856/// Currently treated the same as `match="once"`.
2857fn parse_bind(elem: Node<'_, '_>) -> (Option<String>, bool) {
2858    let Some(bind) = find_first_child_by_name(elem, "bind") else {
2859        return (None, false);
2860    };
2861    let bind_none = attr(bind, "match") == Some("none");
2862    let bind_ref = if bind_none {
2863        None
2864    } else {
2865        attr(bind, "ref").map(|s| s.trim().to_string())
2866    };
2867    (bind_ref, bind_none)
2868}
2869
2870#[cfg(test)]
2871mod tests {
2872    use super::*;
2873    use xfa_dom_resolver::data_dom::DataDom;
2874
2875    #[test]
2876    fn parse_css_color_rejects_non_ascii_hex_without_panicking() {
2877        assert_eq!(parse_css_color("#€"), None);
2878        assert_eq!(parse_css_color("#€abc"), None);
2879        assert_eq!(parse_css_color("#0f8"), Some((0x00, 0xff, 0x88)));
2880        assert_eq!(parse_css_color("#00ff88"), Some((0x00, 0xff, 0x88)));
2881    }
2882
2883    #[test]
2884    fn parse_font_color_attr_rejects_non_ascii_hex_without_panicking() {
2885        assert_eq!(parse_font_color_attr("#€"), None);
2886        assert_eq!(parse_font_color_attr("#€abc"), None);
2887        assert_eq!(parse_font_color_attr("#0f8"), Some((0x00, 0xff, 0x88)));
2888        assert_eq!(parse_font_color_attr("#00ff88"), Some((0x00, 0xff, 0x88)));
2889    }
2890
2891    #[test]
2892    fn repeating_subform_expands_from_data() {
2893        let template = r#"<?xml version="1.0"?>
2894<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2895  <subform name="form1" layout="tb">
2896    <pageSet>
2897      <pageArea name="Page1">
2898        <contentArea w="595pt" h="842pt"/>
2899        <medium short="595pt" long="842pt"/>
2900      </pageArea>
2901    </pageSet>
2902    <subform name="Orders" layout="tb" w="500pt">
2903      <subform name="Order" layout="position" w="500pt" h="60pt">
2904        <occur min="0" max="10" initial="1"/>
2905        <field name="Item" w="200pt" h="20pt" x="0pt" y="0pt"/>
2906        <field name="Qty" w="100pt" h="20pt" x="200pt" y="0pt"/>
2907      </subform>
2908    </subform>
2909  </subform>
2910</template>"#;
2911
2912        let data_xml = r#"<?xml version="1.0"?>
2913<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
2914  <xfa:data>
2915    <form1>
2916      <Order><Item>Widget A</Item><Qty>5</Qty></Order>
2917      <Order><Item>Widget B</Item><Qty>3</Qty></Order>
2918      <Order><Item>Widget C</Item><Qty>7</Qty></Order>
2919    </form1>
2920  </xfa:data>
2921</xfa:datasets>"#;
2922
2923        let data_dom = DataDom::from_xml(data_xml).unwrap();
2924        let merger = FormMerger::new(&data_dom);
2925        let (tree, _root_id) = merger.merge(template).unwrap();
2926
2927        let orders_id = tree
2928            .nodes
2929            .iter()
2930            .enumerate()
2931            .find(|(_, n)| n.name == "Orders")
2932            .map(|(i, _)| FormNodeId(i))
2933            .unwrap();
2934        let orders = tree.get(orders_id);
2935        assert_eq!(orders.children.len(), 3);
2936        assert!(orders
2937            .children
2938            .iter()
2939            .all(|&id| tree.get(id).name == "Order" && tree.get(id).occur.count() == 1));
2940    }
2941
2942    /// Double-wrapped <xfa:data> must be unwrapped for data binding to work.
2943    #[test]
2944    fn repeating_subform_double_wrapped_data() {
2945        let template = r#"<?xml version="1.0"?>
2946<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2947  <subform name="form1" layout="tb">
2948    <pageSet>
2949      <pageArea name="Page">
2950        <contentArea w="595pt" h="842pt"/>
2951        <medium short="595pt" long="842pt"/>
2952      </pageArea>
2953    </pageSet>
2954    <subform name="Orders" layout="tb" w="559pt">
2955      <subform name="CoreOrders" layout="tb" w="361pt">
2956        <subform name="Order" layout="position" w="360pt" h="162pt">
2957          <occur min="0" max="3" initial="1"/>
2958          <field name="Item" w="200pt" h="25pt" x="9pt" y="9pt"/>
2959        </subform>
2960      </subform>
2961    </subform>
2962  </subform>
2963</template>"#;
2964
2965        let data_xml = concat!(
2966            r#"<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">"#,
2967            r#"<xfa:data><xfa:data xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">"#,
2968            r#"<form1>"#,
2969            r#"<Order><Item>A</Item></Order>"#,
2970            r#"<Order><Item>B</Item></Order>"#,
2971            r#"<Order><Item>C</Item></Order>"#,
2972            r#"</form1>"#,
2973            r#"</xfa:data></xfa:data></xfa:datasets>"#,
2974        );
2975
2976        let data_dom = DataDom::from_xml(data_xml).unwrap();
2977        let merger = FormMerger::new(&data_dom);
2978        let (tree, _root_id) = merger.merge(template).unwrap();
2979
2980        let core_orders_id = tree
2981            .nodes
2982            .iter()
2983            .enumerate()
2984            .find(|(_, n)| n.name == "CoreOrders")
2985            .map(|(i, _)| FormNodeId(i))
2986            .unwrap();
2987        let core_orders = tree.get(core_orders_id);
2988        assert_eq!(
2989            core_orders.children.len(),
2990            3,
2991            "Expected 3 Order instances from double-wrapped data"
2992        );
2993    }
2994
2995    /// Bind ref resolution: when subform name differs from data name,
2996    /// <bind match="dataRef" ref="$.dataName[*]"> should use the data name.
2997    #[test]
2998    fn repeating_subform_bind_ref_resolves_data_name() {
2999        let template = r#"<?xml version="1.0"?>
3000<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3001  <subform name="form" layout="tb">
3002    <pageSet>
3003      <pageArea name="Page1">
3004        <contentArea w="595pt" h="842pt"/>
3005        <medium short="595pt" long="842pt"/>
3006      </pageArea>
3007    </pageSet>
3008    <subform name="ListItems" layout="tb" w="500pt">
3009      <subform name="ItemGroup" layout="tb" w="500pt" h="50pt">
3010        <occur min="0" max="-1"/>
3011        <bind match="dataRef" ref="$.itemGroup[*]"/>
3012        <field name="title" w="200pt" h="20pt" x="0pt" y="0pt"/>
3013      </subform>
3014    </subform>
3015  </subform>
3016</template>"#;
3017
3018        let data_xml = r#"<?xml version="1.0"?>
3019<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3020  <xfa:data>
3021    <form>
3022      <itemGroup><title>Group A</title></itemGroup>
3023      <itemGroup><title>Group B</title></itemGroup>
3024    </form>
3025  </xfa:data>
3026</xfa:datasets>"#;
3027
3028        let data_dom = DataDom::from_xml(data_xml).unwrap();
3029        let merger = FormMerger::new(&data_dom);
3030        let (tree, _root_id) = merger.merge(template).unwrap();
3031
3032        let list_items = tree
3033            .nodes
3034            .iter()
3035            .find(|n| n.name == "ListItems")
3036            .expect("ListItems must exist");
3037        assert_eq!(
3038            list_items.children.len(),
3039            2,
3040            "Expected 2 instances from bind ref $.itemGroup[*]"
3041        );
3042    }
3043
3044    #[test]
3045    fn repeating_table_rows_expand_as_sibling_rows() {
3046        let template = r#"<?xml version="1.0"?>
3047<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3048  <subform name="form1" layout="tb">
3049    <pageSet>
3050      <pageArea name="Page1">
3051        <contentArea w="595pt" h="842pt"/>
3052        <medium short="595pt" long="842pt"/>
3053      </pageArea>
3054    </pageSet>
3055    <subform name="Table1" layout="table" columnWidths="100pt 100pt 100pt">
3056      <subform name="HeaderRow" layout="row">
3057        <draw name="H1" w="100pt" h="20pt"/>
3058        <draw name="H2" w="100pt" h="20pt"/>
3059        <draw name="H3" w="100pt" h="20pt"/>
3060      </subform>
3061      <subform name="Row1" layout="row">
3062        <occur min="0" max="-1"/>
3063        <field name="Cell1" w="100pt" h="20pt"/>
3064        <field name="Cell2" w="100pt" h="20pt"/>
3065        <field name="Cell3" w="100pt" h="20pt"/>
3066      </subform>
3067    </subform>
3068  </subform>
3069</template>"#;
3070
3071        let data_xml = r#"<?xml version="1.0"?>
3072<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3073  <xfa:data>
3074    <form1>
3075      <Table1>
3076        <Row1><Cell1>A1</Cell1><Cell2>B1</Cell2><Cell3>C1</Cell3></Row1>
3077        <Row1><Cell1>A2</Cell1><Cell2>B2</Cell2><Cell3>C2</Cell3></Row1>
3078        <Row1><Cell1>A3</Cell1><Cell2>B3</Cell2><Cell3>C3</Cell3></Row1>
3079        <Row1><Cell1>A4</Cell1><Cell2>B4</Cell2><Cell3>C4</Cell3></Row1>
3080      </Table1>
3081    </form1>
3082  </xfa:data>
3083</xfa:datasets>"#;
3084
3085        let data_dom = DataDom::from_xml(data_xml).unwrap();
3086        let merger = FormMerger::new(&data_dom);
3087        let (tree, _root_id) = merger.merge(template).unwrap();
3088
3089        let table_id = tree
3090            .nodes
3091            .iter()
3092            .enumerate()
3093            .find(|(_, n)| n.name == "Table1")
3094            .map(|(i, _)| FormNodeId(i))
3095            .unwrap();
3096        let table = tree.get(table_id);
3097        assert_eq!(table.children.len(), 5, "header + 4 repeated data rows");
3098        assert_eq!(tree.get(table.children[0]).name, "HeaderRow");
3099        assert!(table.children[1..]
3100            .iter()
3101            .all(|&id| tree.get(id).name == "Row1" && tree.get(id).children.len() == 3));
3102    }
3103
3104    #[test]
3105    fn repeating_subform_clamps_to_occur_max() {
3106        let template = r#"<?xml version="1.0"?>
3107<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3108  <subform name="form1" layout="tb">
3109    <subform name="Orders" layout="tb">
3110      <subform name="Order" layout="position" w="200pt" h="20pt">
3111        <occur min="0" max="2"/>
3112        <field name="Item" w="100pt" h="20pt"/>
3113      </subform>
3114    </subform>
3115  </subform>
3116</template>"#;
3117
3118        let data_xml = r#"<?xml version="1.0"?>
3119<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3120  <xfa:data>
3121    <form1>
3122      <Order><Item>A</Item></Order>
3123      <Order><Item>B</Item></Order>
3124      <Order><Item>C</Item></Order>
3125      <Order><Item>D</Item></Order>
3126      <Order><Item>E</Item></Order>
3127    </form1>
3128  </xfa:data>
3129</xfa:datasets>"#;
3130
3131        let data_dom = DataDom::from_xml(data_xml).unwrap();
3132        let merger = FormMerger::new(&data_dom);
3133        let (tree, _root_id) = merger.merge(template).unwrap();
3134
3135        let orders_id = tree
3136            .nodes
3137            .iter()
3138            .enumerate()
3139            .find(|(_, n)| n.name == "Orders")
3140            .map(|(i, _)| FormNodeId(i))
3141            .unwrap();
3142        assert_eq!(tree.get(orders_id).children.len(), 2);
3143    }
3144
3145    #[test]
3146    fn repeating_subform_unbounded_uses_data_count() {
3147        let template = r#"<?xml version="1.0"?>
3148<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3149  <subform name="form1" layout="tb">
3150    <subform name="Orders" layout="tb">
3151      <subform name="Order" layout="position" w="200pt" h="20pt">
3152        <occur min="0" max="-1"/>
3153        <field name="Item" w="100pt" h="20pt"/>
3154      </subform>
3155    </subform>
3156  </subform>
3157</template>"#;
3158
3159        let data_xml = r#"<?xml version="1.0"?>
3160<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3161  <xfa:data>
3162    <form1>
3163      <Order><Item>A</Item></Order>
3164      <Order><Item>B</Item></Order>
3165      <Order><Item>C</Item></Order>
3166    </form1>
3167  </xfa:data>
3168</xfa:datasets>"#;
3169
3170        let data_dom = DataDom::from_xml(data_xml).unwrap();
3171        let merger = FormMerger::new(&data_dom);
3172        let (tree, _root_id) = merger.merge(template).unwrap();
3173
3174        let orders_id = tree
3175            .nodes
3176            .iter()
3177            .enumerate()
3178            .find(|(_, n)| n.name == "Orders")
3179            .map(|(i, _)| FormNodeId(i))
3180            .unwrap();
3181        assert_eq!(tree.get(orders_id).children.len(), 3);
3182    }
3183
3184    #[test]
3185    fn repeating_subform_respects_occur_min() {
3186        let template = r#"<?xml version="1.0"?>
3187<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3188  <subform name="form1" layout="tb">
3189    <subform name="Orders" layout="tb">
3190      <subform name="Order" layout="position" w="200pt" h="20pt">
3191        <occur min="2" max="-1"/>
3192        <field name="Item" w="100pt" h="20pt"/>
3193      </subform>
3194    </subform>
3195  </subform>
3196</template>"#;
3197
3198        let data_xml = r#"<?xml version="1.0"?>
3199<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3200  <xfa:data>
3201    <form1/>
3202  </xfa:data>
3203</xfa:datasets>"#;
3204
3205        let data_dom = DataDom::from_xml(data_xml).unwrap();
3206        let merger = FormMerger::new(&data_dom);
3207        let (tree, _root_id) = merger.merge(template).unwrap();
3208
3209        let orders_id = tree
3210            .nodes
3211            .iter()
3212            .enumerate()
3213            .find(|(_, n)| n.name == "Orders")
3214            .map(|(i, _)| FormNodeId(i))
3215            .unwrap();
3216        let orders = tree.get(orders_id);
3217        assert_eq!(orders.children.len(), 2);
3218        assert!(orders
3219            .children
3220            .iter()
3221            .all(|&id| tree.get(id).name == "Order"));
3222    }
3223
3224    /// Root subform binds to data root when names match (XFA §4.7.2).
3225    #[test]
3226    fn root_subform_binds_to_matching_data_root() {
3227        let template = r#"<?xml version="1.0"?>
3228<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3229  <subform name="form" layout="tb">
3230    <pageSet>
3231      <pageArea name="Page1">
3232        <contentArea w="595pt" h="842pt"/>
3233        <medium short="595pt" long="842pt"/>
3234      </pageArea>
3235    </pageSet>
3236    <field name="title" w="200pt" h="20pt" x="0pt" y="0pt"/>
3237    <field name="code" w="200pt" h="20pt" x="0pt" y="20pt"/>
3238  </subform>
3239</template>"#;
3240
3241        let data_xml = r#"<?xml version="1.0"?>
3242<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3243  <xfa:data>
3244    <form>
3245      <title>Hello World</title>
3246      <code>42</code>
3247    </form>
3248  </xfa:data>
3249</xfa:datasets>"#;
3250
3251        let data_dom = DataDom::from_xml(data_xml).unwrap();
3252        let merger = FormMerger::new(&data_dom);
3253        let (tree, _root_id) = merger.merge(template).unwrap();
3254
3255        let title_node = tree
3256            .nodes
3257            .iter()
3258            .find(|n| n.name == "title")
3259            .expect("title field must exist");
3260        match &title_node.node_type {
3261            FormNodeType::Field { value } => {
3262                assert_eq!(value, "Hello World", "title should bind to data root");
3263            }
3264            _ => panic!("title should be a field"),
3265        }
3266    }
3267
3268    #[test]
3269    fn check_button_mark_parsed_into_style() {
3270        let template = r#"<?xml version="1.0"?>
3271<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3272  <subform name="form" layout="tb">
3273    <pageSet>
3274      <pageArea name="Page1">
3275        <contentArea w="595pt" h="842pt"/>
3276        <medium short="595pt" long="842pt"/>
3277      </pageArea>
3278    </pageSet>
3279    <field name="agree" w="20pt" h="20pt">
3280      <ui><checkButton mark="circle"/></ui>
3281      <value><text>1</text></value>
3282      <items><text>1</text><text>0</text><text>2</text></items>
3283    </field>
3284  </subform>
3285</template>"#;
3286
3287        let data_xml = r#"<?xml version="1.0"?>
3288<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3289  <xfa:data>
3290    <form>
3291      <agree>1</agree>
3292    </form>
3293  </xfa:data>
3294</xfa:datasets>"#;
3295
3296        let data_dom = DataDom::from_xml(data_xml).unwrap();
3297        let merger = FormMerger::new(&data_dom);
3298        let (tree, _root_id) = merger.merge(template).unwrap();
3299
3300        let agree_id = tree
3301            .nodes
3302            .iter()
3303            .enumerate()
3304            .find(|(_, n)| n.name == "agree")
3305            .map(|(i, _)| FormNodeId(i))
3306            .expect("agree field must exist");
3307        let meta = tree.meta(agree_id);
3308        assert_eq!(meta.field_kind, FieldKind::Checkbox);
3309        assert_eq!(meta.style.check_button_mark.as_deref(), Some("circle"));
3310        assert_eq!(meta.style.check_button_on_value.as_deref(), Some("1"));
3311        assert_eq!(meta.style.check_button_off_value.as_deref(), Some("0"));
3312        assert_eq!(meta.style.check_button_neutral_value.as_deref(), Some("2"));
3313    }
3314
3315    #[test]
3316    fn nested_check_button_items_are_used_for_checkbox_values() {
3317        let template = r#"<?xml version="1.0"?>
3318<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3319  <subform name="form" layout="tb">
3320    <pageSet>
3321      <pageArea name="Page1">
3322        <contentArea w="595pt" h="842pt"/>
3323        <medium short="595pt" long="842pt"/>
3324      </pageArea>
3325    </pageSet>
3326    <field name="agree" w="20pt" h="20pt">
3327      <ui>
3328        <checkButton>
3329          <items><text>Yes</text><text>No</text></items>
3330        </checkButton>
3331      </ui>
3332      <value><text>Yes</text></value>
3333    </field>
3334  </subform>
3335</template>"#;
3336
3337        let data_xml = r#"<?xml version="1.0"?>
3338<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3339  <xfa:data>
3340    <form>
3341      <agree>Yes</agree>
3342    </form>
3343  </xfa:data>
3344</xfa:datasets>"#;
3345
3346        let data_dom = DataDom::from_xml(data_xml).unwrap();
3347        let merger = FormMerger::new(&data_dom);
3348        let (tree, _root_id) = merger.merge(template).unwrap();
3349
3350        let agree_id = tree
3351            .nodes
3352            .iter()
3353            .enumerate()
3354            .find(|(_, n)| n.name == "agree")
3355            .map(|(i, _)| FormNodeId(i))
3356            .expect("agree field must exist");
3357        let meta = tree.meta(agree_id);
3358        assert_eq!(meta.field_kind, FieldKind::Checkbox);
3359        assert_eq!(meta.style.check_button_on_value.as_deref(), Some("Yes"));
3360        assert_eq!(meta.style.check_button_off_value.as_deref(), Some("No"));
3361        match &tree.get(agree_id).node_type {
3362            FormNodeType::Field { value } => assert_eq!(value, "Yes"),
3363            _ => panic!("agree should be a field"),
3364        }
3365    }
3366
3367    #[test]
3368    fn border_fill_solid_color_populates_background_color() {
3369        let template = r#"<?xml version="1.0"?>
3370<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3371  <subform name="form" layout="tb">
3372    <pageSet>
3373      <pageArea name="Page1">
3374        <contentArea w="595pt" h="842pt"/>
3375        <medium short="595pt" long="842pt"/>
3376      </pageArea>
3377    </pageSet>
3378    <field name="name" w="200pt" h="20pt">
3379      <ui><textEdit/></ui>
3380      <border>
3381        <fill>
3382          <solid>
3383            <color value="240,240,240"/>
3384          </solid>
3385        </fill>
3386      </border>
3387    </field>
3388  </subform>
3389</template>"#;
3390
3391        let data_xml = r#"<?xml version="1.0"?>
3392<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3393  <xfa:data>
3394    <form>
3395      <name>Jane Doe</name>
3396    </form>
3397  </xfa:data>
3398</xfa:datasets>"#;
3399
3400        let data_dom = DataDom::from_xml(data_xml).unwrap();
3401        let merger = FormMerger::new(&data_dom);
3402        let (tree, _root_id) = merger.merge(template).unwrap();
3403
3404        let name_id = tree
3405            .nodes
3406            .iter()
3407            .enumerate()
3408            .find(|(_, n)| n.name == "name")
3409            .map(|(i, _)| FormNodeId(i))
3410            .expect("name field must exist");
3411        assert_eq!(tree.meta(name_id).style.bg_color, Some((240, 240, 240)));
3412    }
3413
3414    #[test]
3415    fn border_fill_direct_color_populates_button_bg_color() {
3416        // Mirrors 053ecab3's E-Mail submit button: <border><fill><color value=...>
3417        // without the <solid> wrapper. Parser must pick the color up so
3418        // render_button() draws the yellow fill instead of the gray fallback.
3419        let template = r#"<?xml version="1.0"?>
3420<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3421  <subform name="form" layout="tb">
3422    <pageSet>
3423      <pageArea name="Page1">
3424        <contentArea w="595pt" h="842pt"/>
3425        <medium short="595pt" long="842pt"/>
3426      </pageArea>
3427    </pageSet>
3428    <field name="Btn" w="25.4mm" h="9.525mm">
3429      <ui><button highlight="inverted"/></ui>
3430      <caption><value><text>E-Mail</text></value></caption>
3431      <border hand="right">
3432        <edge stroke="raised"/>
3433        <fill><color value="255,255,153"/></fill>
3434      </border>
3435    </field>
3436  </subform>
3437</template>"#;
3438        let data_xml = r#"<?xml version="1.0"?>
3439<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3440  <xfa:data><form/></xfa:data>
3441</xfa:datasets>"#;
3442        let data_dom = DataDom::from_xml(data_xml).unwrap();
3443        let merger = FormMerger::new(&data_dom);
3444        let (tree, _root_id) = merger.merge(template).unwrap();
3445        let btn_id = tree
3446            .nodes
3447            .iter()
3448            .enumerate()
3449            .find(|(_, n)| n.name == "Btn")
3450            .map(|(i, _)| FormNodeId(i))
3451            .expect("button field must exist");
3452        assert_eq!(tree.meta(btn_id).style.bg_color, Some((255, 255, 153)));
3453    }
3454
3455    #[test]
3456    fn rich_text_exdata_captions_are_collapsed_to_plain_text() {
3457        let template = r#"<?xml version="1.0"?>
3458<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3459  <subform name="form" layout="tb">
3460    <pageSet>
3461      <pageArea name="Page1">
3462        <contentArea w="595pt" h="842pt"/>
3463        <medium short="595pt" long="842pt"/>
3464      </pageArea>
3465    </pageSet>
3466    <field name="patient_name" w="200pt" h="20pt">
3467      <ui><textEdit/></ui>
3468      <caption placement="top" reserve="0.230972in">
3469        <value>
3470          <exData contentType="text/html" maxLength="0">
3471            <body xmlns="http://www.w3.org/1999/xhtml"
3472                  xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"
3473                  xfa:APIVersion="1.4.4136.0">
3474              <p>NAME<span style="xfa-spacerun:yes"> </span><span style="font-style:italic">(Last, First, Middle Initial)</span></p>
3475            </body>
3476          </exData>
3477        </value>
3478      </caption>
3479    </field>
3480    <field name="telephone" w="200pt" h="20pt">
3481      <ui><textEdit/></ui>
3482      <caption placement="top" reserve="0.230972in">
3483        <value>
3484          <exData contentType="text/html" maxLength="0">
3485            <body xmlns="http://www.w3.org/1999/xhtml"
3486                  xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"
3487                  xfa:APIVersion="1.4.4136.0">
3488              <p>TELEPHONE<span style="font-style:italic"> (Include area code)</span></p>
3489            </body>
3490          </exData>
3491        </value>
3492      </caption>
3493    </field>
3494  </subform>
3495</template>"#;
3496
3497        let data_dom = DataDom::new();
3498        let merger = FormMerger::new(&data_dom);
3499        let (tree, _root_id) = merger.merge(template).unwrap();
3500
3501        let patient_name_id = tree
3502            .nodes
3503            .iter()
3504            .enumerate()
3505            .find(|(_, n)| n.name == "patient_name")
3506            .map(|(i, _)| FormNodeId(i))
3507            .expect("patient_name field must exist");
3508        let patient_name = tree.meta(patient_name_id);
3509        assert_eq!(
3510            patient_name.style.caption_text.as_deref(),
3511            Some("NAME (Last, First, Middle Initial)")
3512        );
3513        assert_eq!(patient_name.style.caption_placement.as_deref(), Some("top"));
3514        assert_eq!(
3515            tree.get(patient_name_id)
3516                .box_model
3517                .caption
3518                .as_ref()
3519                .map(|caption| caption.text.as_str()),
3520            Some("NAME (Last, First, Middle Initial)")
3521        );
3522
3523        let telephone_id = tree
3524            .nodes
3525            .iter()
3526            .enumerate()
3527            .find(|(_, n)| n.name == "telephone")
3528            .map(|(i, _)| FormNodeId(i))
3529            .expect("telephone field must exist");
3530        let telephone = tree.meta(telephone_id);
3531        assert_eq!(
3532            telephone.style.caption_text.as_deref(),
3533            Some("TELEPHONE (Include area code)")
3534        );
3535        assert_eq!(telephone.style.caption_placement.as_deref(), Some("top"));
3536        assert_eq!(
3537            tree.get(telephone_id)
3538                .box_model
3539                .caption
3540                .as_ref()
3541                .map(|caption| caption.text.as_str()),
3542            Some("TELEPHONE (Include area code)")
3543        );
3544    }
3545
3546    #[test]
3547    fn border_edge_order_keeps_bottom_only_underline() {
3548        let template = r#"<?xml version="1.0"?>
3549<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3550  <subform name="form" layout="tb">
3551    <pageSet>
3552      <pageArea name="Page1">
3553        <contentArea w="595pt" h="842pt"/>
3554        <medium short="595pt" long="842pt"/>
3555      </pageArea>
3556    </pageSet>
3557    <field name="underlined" w="200pt" h="20pt">
3558      <ui><textEdit/></ui>
3559      <border>
3560        <edge stroke="none" thickness="1pt"/>
3561        <edge stroke="none" thickness="2pt"/>
3562        <edge stroke="solid" thickness="3pt"/>
3563        <edge stroke="none" thickness="4pt"/>
3564      </border>
3565    </field>
3566  </subform>
3567</template>"#;
3568
3569        let data_dom = DataDom::new();
3570        let merger = FormMerger::new(&data_dom);
3571        let (tree, _root_id) = merger.merge(template).unwrap();
3572
3573        let underlined_id = tree
3574            .nodes
3575            .iter()
3576            .enumerate()
3577            .find(|(_, n)| n.name == "underlined")
3578            .map(|(i, _)| FormNodeId(i))
3579            .expect("underlined field must exist");
3580        let style = &tree.meta(underlined_id).style;
3581
3582        // Edges are interpreted in XFA order top/right/bottom/left.
3583        assert_eq!(style.border_edges, [false, false, true, false]);
3584        assert_eq!(style.border_widths, Some([1.0, 2.0, 3.0, 4.0]));
3585        assert_eq!(style.border_width_pt, Some(3.0));
3586    }
3587
3588    #[test]
3589    fn round_check_button_parsed_as_radio() {
3590        let template = r#"<?xml version="1.0"?>
3591<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3592  <subform name="form" layout="tb">
3593    <pageSet>
3594      <pageArea name="Page1">
3595        <contentArea w="595pt" h="842pt"/>
3596        <medium short="595pt" long="842pt"/>
3597      </pageArea>
3598    </pageSet>
3599    <field name="choice" w="20pt" h="20pt">
3600      <ui><checkButton shape="round"/></ui>
3601      <items><text>Y</text><text>N</text></items>
3602    </field>
3603  </subform>
3604</template>"#;
3605
3606        let data_dom = DataDom::new();
3607        let merger = FormMerger::new(&data_dom);
3608        let (tree, _root_id) = merger.merge(template).unwrap();
3609
3610        let choice_id = tree
3611            .nodes
3612            .iter()
3613            .enumerate()
3614            .find(|(_, n)| n.name == "choice")
3615            .map(|(i, _)| FormNodeId(i))
3616            .expect("choice field must exist");
3617        assert_eq!(tree.meta(choice_id).field_kind, FieldKind::Radio);
3618    }
3619
3620    #[test]
3621    fn excl_group_value_selects_matching_child() {
3622        let template = r#"<?xml version="1.0"?>
3623<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3624  <subform name="form" layout="tb">
3625    <pageSet>
3626      <pageArea name="Page1">
3627        <contentArea w="595pt" h="842pt"/>
3628        <medium short="595pt" long="842pt"/>
3629      </pageArea>
3630    </pageSet>
3631    <exclGroup name="choice">
3632      <field name="yes" w="20pt" h="20pt">
3633        <ui><checkButton shape="round"/></ui>
3634        <items><text>Y</text><text>N</text></items>
3635      </field>
3636      <field name="no" w="20pt" h="20pt">
3637        <ui><checkButton shape="round"/></ui>
3638        <items><text>N</text><text>Y</text></items>
3639      </field>
3640    </exclGroup>
3641  </subform>
3642</template>"#;
3643
3644        let data_xml = r#"<?xml version="1.0"?>
3645<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3646  <xfa:data>
3647    <form>
3648      <choice>Y</choice>
3649    </form>
3650  </xfa:data>
3651</xfa:datasets>"#;
3652
3653        let data_dom = DataDom::from_xml(data_xml).unwrap();
3654        let merger = FormMerger::new(&data_dom);
3655        let (tree, _root_id) = merger.merge(template).unwrap();
3656
3657        let yes_node = tree
3658            .nodes
3659            .iter()
3660            .find(|n| n.name == "yes")
3661            .expect("yes field must exist");
3662        let no_node = tree
3663            .nodes
3664            .iter()
3665            .find(|n| n.name == "no")
3666            .expect("no field must exist");
3667
3668        match (&yes_node.node_type, &no_node.node_type) {
3669            (FormNodeType::Field { value: yes }, FormNodeType::Field { value: no }) => {
3670                assert_eq!(yes, "Y");
3671                assert!(
3672                    no.is_empty(),
3673                    "non-selected exclGroup child should be cleared"
3674                );
3675            }
3676            _ => panic!("exclGroup children must be fields"),
3677        }
3678    }
3679
3680    #[test]
3681    fn draw_with_image_creates_image_node() {
3682        // Base64-encoded 1x1 red PNG
3683        let image_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
3684        let template = format!(
3685            r#"<?xml version="1.0"?>
3686<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3687  <subform name="form1" layout="tb">
3688    <pageSet>
3689      <pageArea name="Page1">
3690        <contentArea w="595pt" h="842pt"/>
3691        <medium short="595pt" long="842pt"/>
3692      </pageArea>
3693    </pageSet>
3694    <draw name="logo" x="0pt" y="0pt" w="100pt" h="100pt">
3695      <value>
3696        <image contentType="image/png">{}</image>
3697      </value>
3698    </draw>
3699  </subform>
3700</template>"#,
3701            image_b64
3702        );
3703
3704        let data_dom = DataDom::new();
3705        let merger = FormMerger::new(&data_dom);
3706        let (tree, _root_id) = merger.merge(&template).unwrap();
3707
3708        let logo_node = tree
3709            .nodes
3710            .iter()
3711            .find(|n| n.name == "logo")
3712            .expect("logo draw must exist");
3713
3714        match &logo_node.node_type {
3715            FormNodeType::Image { data, mime_type } => {
3716                assert_eq!(mime_type, "image/png");
3717                assert!(!data.is_empty(), "image data should not be empty");
3718                // Verify it's valid PNG by checking magic bytes
3719                assert!(
3720                    data.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
3721                    "decoded data should be PNG"
3722                );
3723            }
3724            _ => panic!(
3725                "draw with image should create Image node, got {:?}",
3726                logo_node.node_type
3727            ),
3728        }
3729    }
3730
3731    #[test]
3732    fn draw_with_rectangle_creates_draw_node() {
3733        let template = r#"<?xml version="1.0"?>
3734<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3735  <subform name="form1" layout="tb">
3736    <pageSet>
3737      <pageArea name="Page1">
3738        <contentArea w="595pt" h="842pt"/>
3739        <medium short="595pt" long="842pt"/>
3740      </pageArea>
3741    </pageSet>
3742    <draw name="box" x="0pt" y="0pt" w="100pt" h="100pt">
3743      <value>
3744        <rectangle/>
3745      </value>
3746    </draw>
3747  </subform>
3748</template>"#;
3749
3750        let data_dom = DataDom::new();
3751        let merger = FormMerger::new(&data_dom);
3752        let (tree, _root_id) = merger.merge(template).unwrap();
3753
3754        let box_node = tree
3755            .nodes
3756            .iter()
3757            .find(|n| n.name == "box")
3758            .expect("box draw must exist");
3759
3760        match &box_node.node_type {
3761            FormNodeType::Draw(DrawContent::Rectangle { .. }) => {}
3762            other => panic!(
3763                "draw with rectangle should create Rectangle draw, got {:?}",
3764                other
3765            ),
3766        }
3767    }
3768
3769    #[test]
3770    fn field_anchor_type_is_preserved_in_merged_meta() {
3771        let template = r#"<?xml version="1.0"?>
3772<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3773  <subform name="form1" layout="tb">
3774    <pageSet>
3775      <pageArea name="Page1">
3776        <contentArea w="595pt" h="842pt"/>
3777        <medium short="595pt" long="842pt"/>
3778      </pageArea>
3779    </pageSet>
3780    <field name="centeredBox" layout="position" anchorType="middleCenter" w="20pt" h="10pt" x="100pt" y="50pt"/>
3781  </subform>
3782</template>"#;
3783
3784        let data_dom = DataDom::new();
3785        let merger = FormMerger::new(&data_dom);
3786        let (tree, _root_id) = merger.merge(template).unwrap();
3787
3788        let centered_id = tree
3789            .nodes
3790            .iter()
3791            .enumerate()
3792            .find(|(_, n)| n.name == "centeredBox")
3793            .map(|(i, _)| FormNodeId(i))
3794            .expect("centeredBox field must exist");
3795
3796        assert_eq!(tree.meta(centered_id).anchor_type, AnchorType::MiddleCenter);
3797    }
3798
3799    #[test]
3800    fn area_without_layout_uses_positioned_when_children_have_coordinates() {
3801        let template = r#"<?xml version="1.0"?>
3802<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3803  <subform name="form1" layout="tb">
3804    <pageSet>
3805      <pageArea name="Page1">
3806        <contentArea w="595pt" h="842pt"/>
3807        <medium short="595pt" long="842pt"/>
3808      </pageArea>
3809    </pageSet>
3810    <area name="Nagl">
3811      <draw name="headerLine" x="10pt" y="12pt" w="100pt" h="1pt">
3812        <value><rectangle/></value>
3813      </draw>
3814    </area>
3815  </subform>
3816</template>"#;
3817
3818        let data_dom = DataDom::new();
3819        let merger = FormMerger::new(&data_dom);
3820        let (tree, _root_id) = merger.merge(template).unwrap();
3821
3822        let nagl = tree
3823            .nodes
3824            .iter()
3825            .find(|n| n.name == "Nagl")
3826            .expect("Nagl area must exist");
3827
3828        assert_eq!(nagl.layout, LayoutStrategy::Positioned);
3829    }
3830
3831    // ─── #1092: matchTemplate merge mode ────────────────────────────────────
3832
3833    /// Default mode (no `matchTemplate` keyword in XML) must be ConsumeData.
3834    #[test]
3835    fn merge_mode_defaults_to_consume_data() {
3836        let template = r#"<?xml version="1.0"?>
3837<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3838  <subform name="form1" layout="tb"/>
3839</template>"#;
3840        assert_eq!(detect_merge_mode(template), MergeMode::ConsumeData);
3841    }
3842
3843    /// When the XML contains `matchTemplate`, the mode is detected.
3844    #[test]
3845    fn merge_mode_detects_match_template() {
3846        let template = r#"<?xml version="1.0"?>
3847<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3848  <!-- config: matchTemplate -->
3849  <subform name="form1" layout="tb"/>
3850</template>"#;
3851        assert_eq!(detect_merge_mode(template), MergeMode::MatchTemplate);
3852    }
3853
3854    /// In matchTemplate mode a flat data structure binds to named fields
3855    /// regardless of template hierarchy.
3856    #[test]
3857    fn match_template_binds_flat_data_to_named_fields() {
3858        // Template has a nested subform but data is flat
3859        let template = r#"<?xml version="1.0"?>
3860<!-- matchTemplate -->
3861<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3862  <subform name="form1" layout="tb">
3863    <pageSet>
3864      <pageArea name="Page1">
3865        <contentArea w="595pt" h="842pt"/>
3866        <medium short="595pt" long="842pt"/>
3867      </pageArea>
3868    </pageSet>
3869    <subform name="section" layout="tb">
3870      <field name="firstName" w="200pt" h="20pt" x="0pt" y="0pt"/>
3871      <field name="lastName" w="200pt" h="20pt" x="0pt" y="20pt"/>
3872    </subform>
3873  </subform>
3874</template>"#;
3875
3876        // Flat data — no "section" group, fields at root level
3877        let data_xml = r#"<?xml version="1.0"?>
3878<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3879  <xfa:data>
3880    <form1>
3881      <firstName>John</firstName>
3882      <lastName>Doe</lastName>
3883    </form1>
3884  </xfa:data>
3885</xfa:datasets>"#;
3886
3887        let data_dom = DataDom::from_xml(data_xml).unwrap();
3888        let merger = FormMerger::new(&data_dom);
3889        let (tree, _root_id) = merger.merge(template).unwrap();
3890
3891        let first = tree
3892            .nodes
3893            .iter()
3894            .find(|n| n.name == "firstName")
3895            .expect("firstName must exist");
3896        let last = tree
3897            .nodes
3898            .iter()
3899            .find(|n| n.name == "lastName")
3900            .expect("lastName must exist");
3901
3902        match &first.node_type {
3903            FormNodeType::Field { value } => assert_eq!(value, "John"),
3904            _ => panic!("firstName must be a field"),
3905        }
3906        match &last.node_type {
3907            FormNodeType::Field { value } => assert_eq!(value, "Doe"),
3908            _ => panic!("lastName must be a field"),
3909        }
3910    }
3911
3912    // ─── #1093: scope matching ────────────────────────────────────────────────
3913
3914    /// Sibling resolution: template `a.b.city` finds data `a.city` when `b`
3915    /// has no `city` child.
3916    #[test]
3917    fn field_binds_via_sibling_scope() {
3918        let template = r#"<?xml version="1.0"?>
3919<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3920  <subform name="form1" layout="tb">
3921    <pageSet>
3922      <pageArea name="Page1">
3923        <contentArea w="595pt" h="842pt"/>
3924        <medium short="595pt" long="842pt"/>
3925      </pageArea>
3926    </pageSet>
3927    <subform name="address" layout="tb">
3928      <subform name="details" layout="tb">
3929        <field name="city" w="200pt" h="20pt" x="0pt" y="0pt"/>
3930      </subform>
3931    </subform>
3932  </subform>
3933</template>"#;
3934
3935        // city is a sibling of details (at the address level), not inside details
3936        let data_xml = r#"<?xml version="1.0"?>
3937<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3938  <xfa:data>
3939    <form1>
3940      <address>
3941        <city>Rotterdam</city>
3942        <details/>
3943      </address>
3944    </form1>
3945  </xfa:data>
3946</xfa:datasets>"#;
3947
3948        let data_dom = DataDom::from_xml(data_xml).unwrap();
3949        let merger = FormMerger::new(&data_dom);
3950        let (tree, _root_id) = merger.merge(template).unwrap();
3951
3952        let city = tree
3953            .nodes
3954            .iter()
3955            .find(|n| n.name == "city")
3956            .expect("city field must exist");
3957        match &city.node_type {
3958            FormNodeType::Field { value } => assert_eq!(
3959                value, "Rotterdam",
3960                "city should be found via sibling scope resolution"
3961            ),
3962            _ => panic!("city should be a field"),
3963        }
3964    }
3965
3966    // ─── #1094: transparent subforms ─────────────────────────────────────────
3967
3968    /// A nameless subform is transparent: its children bind against the parent
3969    /// data context, not a child data group.
3970    #[test]
3971    fn transparent_subform_passes_data_context_through() {
3972        let template = r#"<?xml version="1.0"?>
3973<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
3974  <subform name="form1" layout="tb">
3975    <pageSet>
3976      <pageArea name="Page1">
3977        <contentArea w="595pt" h="842pt"/>
3978        <medium short="595pt" long="842pt"/>
3979      </pageArea>
3980    </pageSet>
3981    <subform layout="tb">
3982      <field name="country" w="200pt" h="20pt" x="0pt" y="0pt"/>
3983    </subform>
3984  </subform>
3985</template>"#;
3986
3987        // Data has `country` at form1 level; there is no anonymous data group
3988        let data_xml = r#"<?xml version="1.0"?>
3989<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
3990  <xfa:data>
3991    <form1>
3992      <country>Netherlands</country>
3993    </form1>
3994  </xfa:data>
3995</xfa:datasets>"#;
3996
3997        let data_dom = DataDom::from_xml(data_xml).unwrap();
3998        let merger = FormMerger::new(&data_dom);
3999        let (tree, _root_id) = merger.merge(template).unwrap();
4000
4001        let country = tree
4002            .nodes
4003            .iter()
4004            .find(|n| n.name == "country")
4005            .expect("country field must exist");
4006        match &country.node_type {
4007            FormNodeType::Field { value } => assert_eq!(
4008                value, "Netherlands",
4009                "transparent subform should pass context through"
4010            ),
4011            _ => panic!("country should be a field"),
4012        }
4013    }
4014
4015    /// Nested transparent subforms still bind correctly.
4016    #[test]
4017    fn nested_transparent_subforms_bind_correctly() {
4018        let template = r#"<?xml version="1.0"?>
4019<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4020  <subform name="form1" layout="tb">
4021    <pageSet>
4022      <pageArea name="Page1">
4023        <contentArea w="595pt" h="842pt"/>
4024        <medium short="595pt" long="842pt"/>
4025      </pageArea>
4026    </pageSet>
4027    <subform layout="tb">
4028      <subform layout="tb">
4029        <field name="postalCode" w="100pt" h="20pt" x="0pt" y="0pt"/>
4030      </subform>
4031    </subform>
4032  </subform>
4033</template>"#;
4034
4035        let data_xml = r#"<?xml version="1.0"?>
4036<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4037  <xfa:data>
4038    <form1>
4039      <postalCode>1234AB</postalCode>
4040    </form1>
4041  </xfa:data>
4042</xfa:datasets>"#;
4043
4044        let data_dom = DataDom::from_xml(data_xml).unwrap();
4045        let merger = FormMerger::new(&data_dom);
4046        let (tree, _root_id) = merger.merge(template).unwrap();
4047
4048        let postal = tree
4049            .nodes
4050            .iter()
4051            .find(|n| n.name == "postalCode")
4052            .expect("postalCode field must exist");
4053        match &postal.node_type {
4054            FormNodeType::Field { value } => assert_eq!(value, "1234AB"),
4055            _ => panic!("postalCode should be a field"),
4056        }
4057    }
4058
4059    // ─── #1095: occur expansion ───────────────────────────────────────────────
4060
4061    /// Repeating subform with 3 data items creates 3 form instances (already
4062    /// tested in repeating_subform_expands_from_data — this variant verifies
4063    /// the instances correctly contain their respective data).
4064    #[test]
4065    fn repeating_subform_three_items_creates_three_instances() {
4066        let template = r#"<?xml version="1.0"?>
4067<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4068  <subform name="form1" layout="tb">
4069    <pageSet>
4070      <pageArea name="Page1">
4071        <contentArea w="595pt" h="842pt"/>
4072        <medium short="595pt" long="842pt"/>
4073      </pageArea>
4074    </pageSet>
4075    <subform name="row" layout="position" w="500pt" h="30pt">
4076      <occur min="0" max="-1" initial="0"/>
4077      <field name="label" w="200pt" h="20pt" x="0pt" y="0pt"/>
4078    </subform>
4079  </subform>
4080</template>"#;
4081
4082        let data_xml = r#"<?xml version="1.0"?>
4083<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4084  <xfa:data>
4085    <form1>
4086      <row><label>Alpha</label></row>
4087      <row><label>Beta</label></row>
4088      <row><label>Gamma</label></row>
4089    </form1>
4090  </xfa:data>
4091</xfa:datasets>"#;
4092
4093        let data_dom = DataDom::from_xml(data_xml).unwrap();
4094        let merger = FormMerger::new(&data_dom);
4095        let (tree, _root_id) = merger.merge(template).unwrap();
4096
4097        let rows: Vec<_> = tree.nodes.iter().filter(|n| n.name == "row").collect();
4098        assert_eq!(rows.len(), 3, "must have exactly 3 row instances");
4099    }
4100
4101    /// occur min=1 max=1 limits to one instance even when there are multiple
4102    /// matching data items.
4103    #[test]
4104    fn occur_min1_max1_limits_to_single_instance() {
4105        let template = r#"<?xml version="1.0"?>
4106<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4107  <subform name="form1" layout="tb">
4108    <pageSet>
4109      <pageArea name="Page1">
4110        <contentArea w="595pt" h="842pt"/>
4111        <medium short="595pt" long="842pt"/>
4112      </pageArea>
4113    </pageSet>
4114    <subform name="item" layout="position" w="500pt" h="30pt">
4115      <occur min="1" max="1"/>
4116      <field name="val" w="100pt" h="20pt" x="0pt" y="0pt"/>
4117    </subform>
4118  </subform>
4119</template>"#;
4120
4121        let data_xml = r#"<?xml version="1.0"?>
4122<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4123  <xfa:data>
4124    <form1>
4125      <item><val>X</val></item>
4126      <item><val>Y</val></item>
4127      <item><val>Z</val></item>
4128    </form1>
4129  </xfa:data>
4130</xfa:datasets>"#;
4131
4132        let data_dom = DataDom::from_xml(data_xml).unwrap();
4133        let merger = FormMerger::new(&data_dom);
4134        let (tree, _root_id) = merger.merge(template).unwrap();
4135
4136        let items: Vec<_> = tree.nodes.iter().filter(|n| n.name == "item").collect();
4137        assert_eq!(items.len(), 1, "occur max=1 must limit to one instance");
4138    }
4139
4140    // ─── #1096: presence binding ──────────────────────────────────────────────
4141
4142    /// A field with `presence="hidden"` in the template is excluded from
4143    /// layout (presence remains Hidden after merge with no data override).
4144    #[test]
4145    fn field_with_presence_hidden_stays_hidden() {
4146        let template = r#"<?xml version="1.0"?>
4147<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4148  <subform name="form1" layout="tb">
4149    <pageSet>
4150      <pageArea name="Page1">
4151        <contentArea w="595pt" h="842pt"/>
4152        <medium short="595pt" long="842pt"/>
4153      </pageArea>
4154    </pageSet>
4155    <field name="secretField" presence="hidden" w="100pt" h="20pt" x="0pt" y="0pt"/>
4156  </subform>
4157</template>"#;
4158
4159        let data_dom = DataDom::new();
4160        let merger = FormMerger::new(&data_dom);
4161        let (tree, _root_id) = merger.merge(template).unwrap();
4162
4163        let secret_id = tree
4164            .nodes
4165            .iter()
4166            .enumerate()
4167            .find(|(_, n)| n.name == "secretField")
4168            .map(|(i, _)| FormNodeId(i))
4169            .expect("secretField must exist");
4170
4171        assert_eq!(
4172            tree.meta(secret_id).presence,
4173            Presence::Hidden,
4174            "presence=hidden in template must be preserved"
4175        );
4176    }
4177
4178    /// A field with no data value for presence defaults to Visible.
4179    #[test]
4180    fn field_without_presence_data_defaults_to_visible() {
4181        let template = r#"<?xml version="1.0"?>
4182<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4183  <subform name="form1" layout="tb">
4184    <pageSet>
4185      <pageArea name="Page1">
4186        <contentArea w="595pt" h="842pt"/>
4187        <medium short="595pt" long="842pt"/>
4188      </pageArea>
4189    </pageSet>
4190    <field name="normalField" w="100pt" h="20pt" x="0pt" y="0pt"/>
4191  </subform>
4192</template>"#;
4193
4194        let data_xml = r#"<?xml version="1.0"?>
4195<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4196  <xfa:data>
4197    <form1>
4198      <normalField>hello</normalField>
4199    </form1>
4200  </xfa:data>
4201</xfa:datasets>"#;
4202
4203        let data_dom = DataDom::from_xml(data_xml).unwrap();
4204        let merger = FormMerger::new(&data_dom);
4205        let (tree, _root_id) = merger.merge(template).unwrap();
4206
4207        let field_id = tree
4208            .nodes
4209            .iter()
4210            .enumerate()
4211            .find(|(_, n)| n.name == "normalField")
4212            .map(|(i, _)| FormNodeId(i))
4213            .expect("normalField must exist");
4214
4215        assert_eq!(
4216            tree.meta(field_id).presence,
4217            Presence::Visible,
4218            "field with no presence data must default to Visible"
4219        );
4220    }
4221
4222    // ─── #1092 (existing test renamed for clarity) ────────────────────────────
4223
4224    /// Ancestor scope: a field nested inside a subform should find data
4225    /// at an ancestor level when not present at the direct context.
4226    #[test]
4227    fn field_binds_via_ancestor_scope_walk() {
4228        let template = r#"<?xml version="1.0"?>
4229<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4230  <subform name="form1" layout="tb">
4231    <pageSet>
4232      <pageArea name="Page1">
4233        <contentArea w="595pt" h="842pt"/>
4234        <medium short="595pt" long="842pt"/>
4235      </pageArea>
4236    </pageSet>
4237    <subform name="details" layout="tb">
4238      <field name="city" w="200pt" h="20pt" x="0pt" y="0pt"/>
4239    </subform>
4240  </subform>
4241</template>"#;
4242
4243        // "city" is at the form1 level, NOT inside "details".
4244        // The ancestor scope walk should find it.
4245        let data_xml = r#"<?xml version="1.0"?>
4246<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4247  <xfa:data>
4248    <form1>
4249      <city>Amsterdam</city>
4250      <details/>
4251    </form1>
4252  </xfa:data>
4253</xfa:datasets>"#;
4254
4255        let data_dom = DataDom::from_xml(data_xml).unwrap();
4256        let merger = FormMerger::new(&data_dom);
4257        let (tree, _root_id) = merger.merge(template).unwrap();
4258
4259        let city = tree
4260            .nodes
4261            .iter()
4262            .find(|n| n.name == "city")
4263            .expect("city field must exist");
4264        match &city.node_type {
4265            FormNodeType::Field { value } => {
4266                assert_eq!(
4267                    value, "Amsterdam",
4268                    "city should be found via ancestor scope walk"
4269                );
4270            }
4271            _ => panic!("city should be a field"),
4272        }
4273    }
4274
4275    // ─── XFA §7.2.18 / §9.2: occur `initial`/`min`/`max` semantics ────────────
4276    //
4277    // These regression tests pin the spec-correct interaction between `occur`
4278    // and data binding:
4279    //   - data records present → bind drives count, clamped to [min, max];
4280    //   - no data records      → render `initial` instances, clamped to max.
4281    //
4282    // The third test guards against regression on the dominant authoring
4283    // pattern (`initial==min`, default), where the empty-data path must keep
4284    // producing exactly `min` instances.
4285
4286    /// XFA §7.2.18 + §9.2: data binding wins over `initial` when records exist.
4287    /// `occur initial=2 max=10` with 3 datasets records → 3 instances.
4288    #[test]
4289    fn occur_initial_yields_to_data_binding_when_records_exist() {
4290        let template = r#"<?xml version="1.0"?>
4291<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4292  <subform name="form1" layout="tb">
4293    <pageSet>
4294      <pageArea name="Page1">
4295        <contentArea w="595pt" h="842pt"/>
4296        <medium short="595pt" long="842pt"/>
4297      </pageArea>
4298    </pageSet>
4299    <subform name="Row" layout="position" w="200pt" h="20pt">
4300      <occur min="0" max="10" initial="2"/>
4301      <field name="Item" w="100pt" h="20pt" x="0pt" y="0pt"/>
4302    </subform>
4303  </subform>
4304</template>"#;
4305
4306        let data_xml = r#"<?xml version="1.0"?>
4307<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4308  <xfa:data>
4309    <form1>
4310      <Row><Item>A</Item></Row>
4311      <Row><Item>B</Item></Row>
4312      <Row><Item>C</Item></Row>
4313    </form1>
4314  </xfa:data>
4315</xfa:datasets>"#;
4316
4317        let data_dom = DataDom::from_xml(data_xml).unwrap();
4318        let merger = FormMerger::new(&data_dom);
4319        let (tree, _root_id) = merger.merge(template).unwrap();
4320
4321        let rows: Vec<_> = tree.nodes.iter().filter(|n| n.name == "Row").collect();
4322        assert_eq!(
4323            rows.len(),
4324            3,
4325            "data binding must win over occur.initial when records exist"
4326        );
4327    }
4328
4329    /// XFA §7.2.18 + §9.2: `initial` drives instance count when no data
4330    /// records exist. `occur initial=5 max=10` with 0 records → 5 instances.
4331    #[test]
4332    fn occur_initial_drives_count_when_no_data_records() {
4333        let template = r#"<?xml version="1.0"?>
4334<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4335  <subform name="form1" layout="tb">
4336    <pageSet>
4337      <pageArea name="Page1">
4338        <contentArea w="595pt" h="842pt"/>
4339        <medium short="595pt" long="842pt"/>
4340      </pageArea>
4341    </pageSet>
4342    <subform name="Row" layout="position" w="200pt" h="20pt">
4343      <occur min="0" max="10" initial="5"/>
4344      <field name="Item" w="100pt" h="20pt" x="0pt" y="0pt"/>
4345    </subform>
4346  </subform>
4347</template>"#;
4348
4349        // No datasets payload — initial must be honoured.
4350        let data_xml = r#"<?xml version="1.0"?>
4351<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4352  <xfa:data>
4353    <form1/>
4354  </xfa:data>
4355</xfa:datasets>"#;
4356
4357        let data_dom = DataDom::from_xml(data_xml).unwrap();
4358        let merger = FormMerger::new(&data_dom);
4359        let (tree, _root_id) = merger.merge(template).unwrap();
4360
4361        let rows: Vec<_> = tree.nodes.iter().filter(|n| n.name == "Row").collect();
4362        assert_eq!(
4363            rows.len(),
4364            5,
4365            "occur.initial must drive instance count when no data records bind"
4366        );
4367    }
4368
4369    /// XFA §7.2.18 + §9.2: default `initial==min` path is unchanged — a
4370    /// subform with `min=0` and no records still produces a single
4371    /// zero-instance skeleton (presence=Hidden) so `parent._<name>` resolves.
4372    #[test]
4373    fn occur_default_initial_keeps_zero_instance_skeleton_when_no_data() {
4374        let template = r#"<?xml version="1.0"?>
4375<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
4376  <subform name="form1" layout="tb">
4377    <pageSet>
4378      <pageArea name="Page1">
4379        <contentArea w="595pt" h="842pt"/>
4380        <medium short="595pt" long="842pt"/>
4381      </pageArea>
4382    </pageSet>
4383    <subform name="Row" layout="position" w="200pt" h="20pt">
4384      <occur min="0" max="10"/>
4385      <field name="Item" w="100pt" h="20pt" x="0pt" y="0pt"/>
4386    </subform>
4387  </subform>
4388</template>"#;
4389
4390        let data_xml = r#"<?xml version="1.0"?>
4391<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
4392  <xfa:data>
4393    <form1/>
4394  </xfa:data>
4395</xfa:datasets>"#;
4396
4397        let data_dom = DataDom::from_xml(data_xml).unwrap();
4398        let merger = FormMerger::new(&data_dom);
4399        let (tree, _root_id) = merger.merge(template).unwrap();
4400
4401        let rows: Vec<_> = tree.nodes.iter().filter(|n| n.name == "Row").collect();
4402        assert_eq!(
4403            rows.len(),
4404            1,
4405            "default initial==min path must keep producing a single zero-instance skeleton"
4406        );
4407        // The skeleton must remain hidden (regression guard for the
4408        // `_<name>` InstanceManager handle behaviour).
4409        let row_id = tree
4410            .nodes
4411            .iter()
4412            .enumerate()
4413            .find(|(_, n)| n.name == "Row")
4414            .map(|(i, _)| FormNodeId(i))
4415            .unwrap();
4416        assert_eq!(tree.meta(row_id).presence, Presence::Hidden);
4417        assert!(tree.meta(row_id).is_zero_instance_prototype);
4418    }
4419
4420    // ---- <sub> and <sup> (#151) ----
4421
4422    fn spans_of(html: &str) -> Vec<xfa_layout_engine::form::RichTextSpan> {
4423        let xml = format!(r#"<body xmlns="http://www.w3.org/1999/xhtml">{html}</body>"#);
4424        let doc = roxmltree::Document::parse(&xml).expect("xhtml does not parse");
4425        let mut spans = Vec::new();
4426        collect_inline_spans(doc.root_element(), &InheritedStyle::default(), &mut spans);
4427        spans
4428    }
4429
4430    #[test]
4431    fn sup_shifts_up_and_shrinks() {
4432        // `<sub>` and `<sup>` were silently ignored: the text stayed, the
4433        // meaning went. In a legal form that is the difference between a
4434        // footnote marker and a number.
4435        let spans = spans_of("plain<sup>1</sup>");
4436        let sup = spans
4437            .iter()
4438            .find(|s| s.text.contains('1'))
4439            .expect("no span for the superscript");
4440        assert!(
4441            sup.baseline_shift > 0.0,
4442            "superscript does not shift up: {}",
4443            sup.baseline_shift
4444        );
4445        let plain = spans.iter().find(|s| s.text.contains("plain")).unwrap();
4446        assert_eq!(plain.baseline_shift, 0.0, "the plain text shifted along");
4447    }
4448
4449    #[test]
4450    fn sub_shifts_down() {
4451        let spans = spans_of("H<sub>2</sub>O");
4452        let sub = spans
4453            .iter()
4454            .find(|s| s.text.contains('2'))
4455            .expect("no span");
4456        assert!(sub.baseline_shift < 0.0, "subscript does not shift down");
4457        // And the text around it stays on the baseline -- otherwise the whole
4458        // formula sinks.
4459        for s in spans.iter().filter(|s| !s.text.contains('2')) {
4460            assert_eq!(s.baseline_shift, 0.0, "{:?} shifted along", s.text);
4461        }
4462    }
4463
4464    #[test]
4465    fn vertical_align_in_css_works_too() {
4466        // XFA templates written by an editor use CSS more often than semantic
4467        // tags.
4468        let spans = spans_of(r#"<span style="vertical-align: super">x</span>"#);
4469        let s = spans
4470            .iter()
4471            .find(|s| s.text.contains('x'))
4472            .expect("no span");
4473        assert!(s.baseline_shift > 0.0, "vertical-align: super did nothing");
4474    }
4475
4476    #[test]
4477    fn a_shift_does_not_inherit_to_siblings() {
4478        // The shift belongs to the span, not to the rest of the paragraph.
4479        let spans = spans_of("a<sup>b</sup>c");
4480        let c = spans.iter().find(|s| s.text.contains('c'));
4481        if let Some(c) = c {
4482            assert_eq!(
4483                c.baseline_shift, 0.0,
4484                "the text after the superscript shifted along"
4485            );
4486        }
4487    }
4488}