Skip to main content

pdf_xfa/
template_parser.rs

1//! XDP template XML → FormTree parser.
2//!
3//! Reads the `<template>` packet from an XFA XDP document and builds a
4//! `FormTree` suitable for `LayoutEngine::layout()`.
5//!
6//! XFA Spec 3.3 §2.1 — Form Structural Building Blocks:
7//!   Container elements: subform, field, draw, exclGroup, area
8//!   Page-level:         pageSet, pageArea, contentArea, medium
9//!   Metadata:           caption, value, ui, font, border, margin, para
10//!
11//! XFA Spec 3.3 §2.2 — Basic Composition:
12//!   Measurements use absolute units (in, cm, mm, pt) with inches as default.
13//!   Dimension strings ("0.5in", "72pt", "10mm") are converted to PDF
14//!   points via `Measurement::parse`.
15
16// Copyright (c) 2026 Innovation Trigger B.V.
17//
18// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
19// the PDFluent Commercial Licence. See the LICENSE file in this repository --
20// that file travels with the copy you received, which a URL does not.
21
22use roxmltree::Node;
23
24use xfa_layout_engine::form::{
25    Access, AnchorType, ContentArea, DrawContent, EventScript, FieldKind, FormNode, FormNodeId,
26    FormNodeMeta, FormNodeStyle, FormNodeType, FormTree, GroupKind, Occur, Presence,
27    ScriptLanguage,
28};
29use xfa_layout_engine::text::{FontFamily, FontMetrics};
30use xfa_layout_engine::types::{
31    BoxModel, Caption, CaptionPlacement, Insets, LayoutStrategy, Measurement, TextAlign,
32    VerticalAlign,
33};
34
35use crate::error::{Result, XfaError};
36
37/// Parse a `<template>` XML packet into a `FormTree`.
38///
39/// `xml` should be the raw content of the template packet, starting with
40/// the `<template …>` element (with or without an XML declaration).
41///
42/// If `datasets_xml` is provided, field values are merged from the
43/// `<xfa:data>` section of the datasets packet.
44pub fn parse_template(xml: &str, datasets_xml: Option<&str>) -> Result<(FormTree, FormNodeId)> {
45    let doc = roxmltree::Document::parse(xml)
46        .map_err(|e| XfaError::ParseFailed(format!("template XML parse error: {e}")))?;
47
48    let root_elem = doc.root_element();
49    // The packet may start with <template> directly, or may have been
50    // wrapped inside <xdp:xdp>. Accept both.
51    let template_elem = if root_elem.tag_name().name() == "template" {
52        root_elem
53    } else {
54        // Find the first <template> descendant.
55        find_first_child_by_name(root_elem, "template")
56            .ok_or_else(|| XfaError::PacketNotFound("no <template> element found".to_string()))?
57    };
58
59    let mut tree = FormTree::new();
60    let (root_id, _trailing) = parse_node(&mut tree, template_elem, true)?;
61
62    // Data binding: merge field values from datasets XML.
63    if let Some(ds_xml) = datasets_xml {
64        if let Ok(ds_doc) = roxmltree::Document::parse(ds_xml) {
65            if let Some(data_root) = find_data_root(ds_doc.root_element()) {
66                bind_data(&mut tree, root_id, &data_root, &data_root);
67            }
68        }
69    }
70
71    Ok((tree, root_id))
72}
73
74// ---------------------------------------------------------------------------
75// Recursive node parser
76// ---------------------------------------------------------------------------
77
78/// Parse an XML element into a form node and add it to the tree.
79///
80/// Returns `(FormNodeId, trailing_break)` where `trailing_break` is true when
81/// the node's `add_children` ended with a pending breakBefore that could not
82/// be consumed within the node (i.e. the breakBefore appeared after the last
83/// content child and should propagate to the next sibling at the parent level).
84fn parse_node(
85    tree: &mut FormTree,
86    elem: Node<'_, '_>,
87    is_root: bool,
88) -> Result<(FormNodeId, (bool, Option<String>))> {
89    let tag = elem.tag_name().name();
90
91    let (node, trailing_info) = match tag {
92        "template" => {
93            let mut n = parse_root_node(tree, elem)?;
94            let ti = add_children(tree, &mut n, elem)?;
95            (n, ti)
96        }
97        // XFA Spec 3.3 §2.1 — Five building blocks: subform, field, draw, exclGroup, area.
98        // `area` is a fixed-size container identical to subform but does not grow;
99        // if capacity is exceeded a new area is created. We parse it like a subform.
100        "subform" | "exclGroup" | "area" => {
101            let mut n = parse_subform_node(tree, elem, is_root)?;
102            let ti = add_children(tree, &mut n, elem)?;
103            // Per XFA 3.3 §2.1: checkButton inside exclGroup renders as radio button (circle).
104            if tag == "exclGroup" {
105                for &child_id in &n.children {
106                    let child_meta = tree.meta_mut(child_id);
107                    if child_meta.field_kind == FieldKind::Checkbox {
108                        child_meta.field_kind = FieldKind::Radio;
109                    }
110                }
111            }
112            (n, ti)
113        }
114        "field" => (parse_field(tree, elem)?, (false, None)),
115        "draw" => (parse_draw(tree, elem)?, (false, None)),
116        "pageSet" => (parse_page_set(tree, elem)?, (false, None)),
117        "pageArea" => (parse_page_area(tree, elem)?, (false, None)),
118        _ => {
119            let mut n = blank_node(tag);
120            let ti = add_children(tree, &mut n, elem)?;
121            (n, ti)
122        }
123    };
124
125    let mut meta = parse_node_meta(elem);
126    // Only fields need their <margin ...Inset> values forwarded to
127    // style.inset_*_pt — the renderer uses that to offset the value and
128    // shrink the border/bg to the inner rect. Draws are skipped because
129    // their decorative insets in background-image forms (see 49f8705c)
130    // cause visible misalignment with the pre-rendered backdrop; subforms
131    // are skipped to avoid the double-application the layout engine
132    // already handles via content_width()/content_height() subtraction.
133    if tag == "field" {
134        meta.style.inset_top_pt = Some(node.box_model.margins.top);
135        meta.style.inset_bottom_pt = Some(node.box_model.margins.bottom);
136        meta.style.inset_left_pt = Some(node.box_model.margins.left);
137        meta.style.inset_right_pt = Some(node.box_model.margins.right);
138    }
139    Ok((tree.add_node_with_meta(node, meta), trailing_info))
140}
141
142/// Build the root FormNode (without children — `parse_node` calls `add_children`).
143fn parse_root_node(_tree: &mut FormTree, _elem: Node<'_, '_>) -> Result<FormNode> {
144    Ok(FormNode {
145        name: "root".to_string(),
146        node_type: FormNodeType::Root,
147        box_model: BoxModel {
148            max_width: f64::MAX,
149            max_height: f64::MAX,
150            ..Default::default()
151        },
152        layout: LayoutStrategy::TopToBottom,
153        children: Vec::new(),
154        occur: Occur::once(),
155        font: FontMetrics::default(),
156        calculate: None,
157        validate: None,
158        column_widths: Vec::new(),
159        col_span: 1,
160    })
161}
162
163/// Build a subform FormNode (without children — `parse_node` calls `add_children`).
164fn parse_subform_node(
165    _tree: &mut FormTree,
166    elem: Node<'_, '_>,
167    _is_root: bool,
168) -> Result<FormNode> {
169    let name = attr(elem, "name").unwrap_or("").to_string();
170    let layout = parse_layout_attr(elem);
171    let mut bm = parse_box_model(elem);
172    // For paginate-layout root subforms, set a default US Letter size if
173    // no explicit size is given.
174    if layout == LayoutStrategy::TopToBottom && bm.width.is_none() {
175        bm.width = Some(612.0);
176    }
177
178    Ok(FormNode {
179        name,
180        node_type: FormNodeType::Subform,
181        box_model: bm,
182        layout,
183        children: Vec::new(),
184        occur: parse_occur(elem),
185        font: FontMetrics::default(),
186        calculate: None,
187        validate: None,
188        column_widths: Vec::new(),
189        col_span: 1,
190    })
191}
192
193fn parse_field(tree: &mut FormTree, elem: Node<'_, '_>) -> Result<FormNode> {
194    let name = attr(elem, "name").unwrap_or("").to_string();
195    let bm = parse_box_model(elem);
196
197    // Always extract the field value and preserve the Field node type.
198    // Visibility is controlled by FormNodeMeta.presence -- the layout
199    // engine skips invisible/inactive nodes; hidden ones produce empty space.
200    // Dynamic scripts can later toggle presence to "visible", so we must
201    // preserve the content for all fields.
202    let value = extract_value_text(elem).unwrap_or_default();
203
204    // Extract caption from <caption> child element (placement, reserve, text).
205    let mut bm_with_caption = bm.clone();
206    if !is_hidden(elem) {
207        if let Some(cap) = parse_caption(elem) {
208            bm_with_caption.caption = Some(cap);
209        }
210    }
211
212    let mut font = parse_font_metrics(elem);
213    // Override font size from exData HTML if present.
214    if let Some(html_size) = extract_exdata_font_size(elem) {
215        font.size = html_size;
216    }
217
218    let node = FormNode {
219        name,
220        node_type: FormNodeType::Field { value },
221        box_model: bm_with_caption,
222        layout: LayoutStrategy::Positioned,
223        children: Vec::new(),
224        occur: Occur::once(),
225        font,
226        calculate: None,
227        validate: None,
228        column_widths: Vec::new(),
229        col_span: parse_col_span(elem),
230    };
231    // Fields are leaf nodes — no child traversal needed.
232    let _ = tree; // suppress unused warning
233    Ok(node)
234}
235
236fn parse_draw(tree: &mut FormTree, elem: Node<'_, '_>) -> Result<FormNode> {
237    let name = attr(elem, "name").unwrap_or("").to_string();
238    let bm = parse_box_model(elem);
239
240    // Try geometric draw content (line, rectangle, arc) first
241    if let Some(draw_content) = extract_draw_content(elem) {
242        let node = FormNode {
243            name,
244            node_type: FormNodeType::Draw(draw_content),
245            box_model: bm,
246            layout: LayoutStrategy::Positioned,
247            children: Vec::new(),
248            occur: Occur::once(),
249            font: FontMetrics::default(),
250            calculate: None,
251            validate: None,
252            column_widths: Vec::new(),
253            col_span: 1,
254        };
255        let _ = tree;
256        return Ok(node);
257    }
258
259    if let Some((image_data, mime_type)) = extract_value_image(elem) {
260        let node = FormNode {
261            name,
262            node_type: FormNodeType::Image {
263                data: image_data,
264                mime_type,
265            },
266            box_model: bm,
267            layout: LayoutStrategy::Positioned,
268            children: Vec::new(),
269            occur: Occur::once(),
270            font: FontMetrics::default(),
271            calculate: None,
272            validate: None,
273            column_widths: Vec::new(),
274            col_span: 1,
275        };
276        let _ = tree;
277        return Ok(node);
278    }
279
280    let mut font = parse_font_metrics(elem);
281    // If the content came from <exData contentType="text/html">, extract
282    // the dominant font size from the HTML <span style="font-size:Xpt">.
283    // This overrides the default 10pt when the HTML specifies differently.
284    if let Some(html_size) = extract_exdata_font_size(elem) {
285        font.size = html_size;
286    }
287
288    // Always extract content — visibility is controlled by metadata.
289    let content = extract_value_text(elem).unwrap_or_default();
290
291    let node = FormNode {
292        name,
293        node_type: FormNodeType::Draw(DrawContent::Text(content)),
294        box_model: bm,
295        layout: LayoutStrategy::Positioned,
296        children: Vec::new(),
297        occur: Occur::once(),
298        font,
299        calculate: None,
300        validate: None,
301        column_widths: Vec::new(),
302        col_span: 1,
303    };
304    let _ = tree;
305    Ok(node)
306}
307
308/// Return `true` when the element has a `presence` attribute value that means
309/// the element should not be rendered (`"hidden"`, `"invisible"`, `"inactive"`).
310/// Elements with no `presence` attribute or `presence="visible"` are rendered.
311fn is_hidden(elem: Node<'_, '_>) -> bool {
312    matches!(
313        attr(elem, "presence"),
314        Some("hidden") | Some("invisible") | Some("inactive")
315    )
316}
317
318/// Parse a font size string where bare numbers are in **points** (not inches).
319///
320/// XFA `<font size="…">` uses points as the default unit — a bare number like `"10"`
321/// means 10pt, not 10 inches.  General dimension attributes (w, h, x, y) use inches
322/// as the default, so they go through `parse_dim` instead.
323fn parse_font_size(s: &str) -> Option<f64> {
324    // Bare number → points (XFA default for font sizes).
325    if let Ok(v) = s.trim().parse::<f64>() {
326        return if v > 0.0 { Some(v) } else { None };
327    }
328    // Explicit unit ("10pt", "3mm", …) → convert to points.
329    Measurement::parse(s).map(|m| m.to_points())
330}
331
332/// Parse a percentage string like `"96%"` → `0.96`, `"110%"` → `1.1`.
333/// Returns `None` if the string is not a valid percentage.
334fn parse_percentage(s: &str) -> Option<f64> {
335    let s = s.trim();
336    let num_str = s.strip_suffix('%')?;
337    let v: f64 = num_str.trim().parse().ok()?;
338    Some(v / 100.0)
339}
340
341/// Parse a letter-spacing string. Supported formats:
342/// - `"-0.018em"` → converted to points using the given font size
343/// - `"0.5pt"` → points directly
344/// - `"1mm"` → converted to points via Measurement
345///   Returns `None` if the string cannot be parsed.
346fn parse_letter_spacing(s: &str, font_size_pt: f64) -> Option<f64> {
347    let s = s.trim();
348    if s == "0" {
349        return Some(0.0);
350    }
351    // Try em-based value: "-0.018em"
352    if let Some(num_str) = s.strip_suffix("em") {
353        let v: f64 = num_str.trim().parse().ok()?;
354        return Some(v * font_size_pt);
355    }
356    // Try standard measurement ("0.5pt", "1mm", etc.)
357    Measurement::parse(s).map(|m| m.to_points())
358}
359
360/// Parse font size and text alignment from `<font size="…">` and `<para hAlign="…">` child
361/// elements (XFA 3.3 §7.1). Returns `FontMetrics::default()` when no matching elements found.
362///
363/// XFA Spec 3.3 §2.6 (p57-58) — Font properties: typeface (default Courier for
364/// data-entry), size (default 10pt), posture (normal/italic), weight (bold/normal),
365/// baselineShift, fontHorizontalScale, fontVerticalScale, kerningMode, letterSpacing,
366/// lineThrough/lineThroughPeriod, overline/overlinePeriod, underline/underlinePeriod.
367///
368/// TODO(§2.6): baselineShift, kerningMode, lineThrough, underline not parsed.
369/// TODO(§2.4 p59): hAlign="radix" and hAlign="justifyAll" not handled.
370///
371/// XFA Spec 3.3 §28.1 — Adobe Non-conformance: Adobe ignores the "overline"
372/// attribute on the font element (p1227) and the "lineThroughPeriod" attribute
373/// (p1228). We intentionally skip these to match Adobe's behavior for SSIM.
374fn parse_font_metrics(elem: Node<'_, '_>) -> FontMetrics {
375    let font_elem = find_first_child_by_name(elem, "font");
376    let size = font_elem
377        .and_then(|f| attr(f, "size"))
378        .and_then(parse_font_size)
379        .unwrap_or(FontMetrics::default().size);
380    // XFA Spec 3.3 §17 (p716) — genericFamily: fallback classification hint.
381    // If present, use it to classify the font family instead of guessing from
382    // the typeface name. This matches Adobe's §28.2 step 4 behavior.
383    let generic_family_str = font_elem.and_then(|f| attr(f, "genericFamily"));
384    let typeface = if let Some(gf) = generic_family_str {
385        FontFamily::from_generic_family(gf)
386    } else {
387        font_elem
388            .and_then(|f| attr(f, "typeface"))
389            .map(FontFamily::from_typeface)
390            .unwrap_or_default()
391    };
392    // XFA Spec 3.3 §2.4 (p44, p59-60) — hAlign values:
393    //   left, center, right, justify, justifyAll, radix
394    let text_align = find_first_child_by_name(elem, "para")
395        .and_then(|p| attr(p, "hAlign"))
396        .map(|a| match a {
397            "center" => TextAlign::Center,
398            "right" => TextAlign::Right,
399            "justify" | "justifyAll" => TextAlign::Justify,
400            // TODO(§2.4): "radix" alignment needs radixOffset support
401            _ => TextAlign::Left,
402        })
403        .unwrap_or_default();
404    FontMetrics {
405        size,
406        text_align,
407        typeface,
408        ..FontMetrics::default()
409    }
410}
411
412fn parse_page_set(tree: &mut FormTree, elem: Node<'_, '_>) -> Result<FormNode> {
413    let name = attr(elem, "name").unwrap_or("pageSet").to_string();
414    let mut node = FormNode {
415        name,
416        node_type: FormNodeType::PageSet,
417        box_model: BoxModel {
418            max_width: f64::MAX,
419            max_height: f64::MAX,
420            ..Default::default()
421        },
422        layout: LayoutStrategy::TopToBottom,
423        children: Vec::new(),
424        occur: Occur::once(),
425        font: FontMetrics::default(),
426        calculate: None,
427        validate: None,
428        column_widths: Vec::new(),
429        col_span: 1,
430    };
431    // Children of pageSet are pageArea elements.
432    for child in elem.children().filter(|n| n.is_element()) {
433        if child.tag_name().name() == "pageArea" {
434            let (child_id, _) = parse_node(tree, child, false)?;
435            node.children.push(child_id);
436        }
437    }
438    Ok(node)
439}
440
441fn parse_page_area(tree: &mut FormTree, elem: Node<'_, '_>) -> Result<FormNode> {
442    let name = attr(elem, "name").unwrap_or("").to_string();
443
444    // Read <medium> for page dimensions.
445    let (page_w, page_h) = read_medium(elem);
446
447    // Read <contentArea> elements.
448    let content_areas = read_content_areas(elem, page_w, page_h);
449
450    let bm = BoxModel {
451        width: Some(page_w),
452        height: Some(page_h),
453        ..Default::default()
454    };
455
456    let mut node = FormNode {
457        name,
458        node_type: FormNodeType::PageArea { content_areas },
459        box_model: bm,
460        layout: LayoutStrategy::Positioned,
461        children: Vec::new(),
462        occur: Occur::once(),
463        font: FontMetrics::default(),
464        calculate: None,
465        validate: None,
466        column_widths: Vec::new(),
467        col_span: 1,
468    };
469    // Parse child draw/subform elements (page-level headers, footers, lines).
470    add_children(tree, &mut node, elem)?;
471    Ok(node)
472}
473
474// ---------------------------------------------------------------------------
475// Metadata parsing
476// ---------------------------------------------------------------------------
477
478/// Build `FormNodeMeta` from XFA element attributes and child elements.
479fn parse_node_meta(elem: Node<'_, '_>) -> FormNodeMeta {
480    let tag = elem.tag_name().name();
481
482    // (a) Presence attribute (XFA 3.3 §2.6 p67-68):
483    //     visible  — normal rendering (default)
484    //     invisible — takes space but not visible
485    //     hidden   — no space, no visible rendering
486    //     inactive — completely ignored (no binding, no space)
487    let presence = match attr(elem, "presence") {
488        Some("hidden") => Presence::Hidden,
489        Some("invisible") => Presence::Invisible,
490        Some("inactive") => Presence::Inactive,
491        _ => Presence::Visible,
492    };
493
494    // (b) Page break detection: look for <breakBefore>, <breakAfter>, or <break> child.
495    let (page_break_before, break_before_target) = detect_page_break_before(elem);
496    let (page_break_after, break_after_target) = detect_page_break_after(elem);
497    let content_area_break = detect_content_area_break(elem);
498
499    // Prefer break_before_target, then break_after_target.
500    let break_target = break_before_target.or(break_after_target);
501
502    // (c) Event scripts.
503    let event_scripts = collect_event_scripts(elem);
504
505    // (d) Keep child attributes.
506    let (keep_next_content_area, keep_previous_content_area, keep_intact_content_area) =
507        parse_keep(elem);
508
509    // (e) Overflow leader/trailer.
510    let (overflow_leader, overflow_trailer) = parse_overflow(elem);
511    let (bookend_leader, bookend_trailer) = parse_bookend(elem);
512
513    // (f) Group kind: exclGroup → ExclusiveChoice.
514    let group_kind = if tag == "exclGroup" {
515        GroupKind::ExclusiveChoice
516    } else {
517        GroupKind::None
518    };
519
520    // (g) Item value for field elements: <items><text>VALUE</text></items>.
521    let item_value = if tag == "field" {
522        parse_item_value(elem)
523    } else {
524        None
525    };
526
527    // (g2) Choice list items for dropdown fields (XFA 3.3 §7.7).
528    let (display_items, save_items) = if tag == "field" {
529        parse_items_lists(elem)
530    } else {
531        (Vec::new(), Vec::new())
532    };
533
534    // (h) XFA id attribute.
535    let xfa_id = attr(elem, "id").map(|s| s.to_string());
536
537    // (i) Field UI kind: detect <checkButton>, <choiceList>, etc. inside <ui>.
538    let field_kind = detect_field_kind(elem);
539
540    // (j) Visual style: colors, borders, font from XFA template elements.
541    let style = parse_node_style(elem);
542    let (data_bind_ref, data_bind_none) = parse_bind(elem);
543    let anchor_type = parse_anchor_type(elem);
544
545    // (k) Access, multiline and required. The merger's reader has always read
546    // these three off the same element; this one left them at their defaults,
547    // and `session.rs` resolves an absent `access` to `Access::Open`. So a
548    // field the template declares `access="readOnly"` was protected on one
549    // route and editable on the other, from the same file (#208). The two
550    // helpers come from `merger.rs` rather than being written again here --
551    // a second copy is the defect, not the fix.
552    let access = attr(elem, "access").map(Access::parse);
553    let multiline = crate::merger::parse_multiline(elem);
554    let required = crate::merger::parse_required(elem);
555
556    FormNodeMeta {
557        xfa_id,
558        presence,
559        access,
560        multiline,
561        required,
562        page_break_before,
563        page_break_after,
564        break_target,
565        content_area_break,
566        overflow_leader,
567        overflow_trailer,
568        bookend_leader,
569        bookend_trailer,
570        keep_next_content_area,
571        keep_previous_content_area,
572        keep_intact_content_area,
573        event_scripts,
574        data_bind_ref,
575        data_bind_none,
576        group_kind,
577        item_value,
578        field_kind,
579        style,
580        display_items,
581        save_items,
582        anchor_type,
583        ..Default::default()
584    }
585}
586
587fn parse_bind(elem: Node<'_, '_>) -> (Option<String>, bool) {
588    let Some(bind) = find_first_child_by_name(elem, "bind") else {
589        return (None, false);
590    };
591
592    let bind_none = attr(bind, "match") == Some("none");
593    let bind_ref = if bind_none {
594        None
595    } else {
596        attr(bind, "ref").map(|s| s.trim().to_string())
597    };
598    (bind_ref, bind_none)
599}
600
601/// Parse visual style from XFA template elements.
602///
603/// Extracts colors from `<fill><color value="r,g,b"/>`, border from
604/// `<border><edge><color value="r,g,b"/>`, and font from `<font>`.
605fn parse_node_style(elem: Node<'_, '_>) -> FormNodeStyle {
606    let mut style = FormNodeStyle {
607        check_button_mark: parse_check_button_mark(elem),
608        ..Default::default()
609    };
610
611    // Parse <fill><color value="r,g,b"/> for background color.
612    // Skip when presence="hidden"/"invisible"/"inactive".
613    if let Some(fill) = find_first_child_by_name(elem, "fill") {
614        if !is_hidden(fill) {
615            if let Some(color) = find_first_child_by_name(fill, "color") {
616                if let Some(rgb) = parse_xfa_color(color) {
617                    style.bg_color = Some(rgb);
618                }
619            }
620            // Also check <fill><solid><color .../> pattern.
621            if style.bg_color.is_none() {
622                if let Some(solid) = find_first_child_by_name(fill, "solid") {
623                    if let Some(color) = find_first_child_by_name(solid, "color") {
624                        if let Some(rgb) = parse_xfa_color(color) {
625                            style.bg_color = Some(rgb);
626                        }
627                    }
628                }
629            }
630        }
631    }
632
633    // Parse <border><edge> for border color and thickness.
634    // Borders can live directly on the element OR inside <ui><textEdit|…><border>.
635    let border = find_first_child_by_name(elem, "border").or_else(|| {
636        let ui = find_first_child_by_name(elem, "ui")?;
637        ui.children()
638            .filter(|c| c.is_element() && c.tag_name().name() != "border")
639            .find_map(|widget| find_first_child_by_name(widget, "border"))
640    });
641    if let Some(border) = border {
642        // Collect all <edge> children for per-edge visibility (XFA §D.7).
643        let edges: Vec<_> = border
644            .children()
645            .filter(|c| c.is_element() && c.tag_name().name() == "edge")
646            .collect();
647        // Use first visible edge for color/thickness (backward compat).
648        let first_visible = edges
649            .iter()
650            .find(|e| !is_hidden(**e))
651            .or_else(|| edges.first());
652        if let Some(edge) = first_visible {
653            if let Some(color) = find_first_child_by_name(*edge, "color") {
654                if let Some(rgb) = parse_xfa_color(color) {
655                    style.border_color = Some(rgb);
656                }
657            }
658            let stroke = attr(*edge, "stroke").unwrap_or("solid");
659            if stroke != "none" {
660                // fix(#808): XFA default edge thickness is 1pt (matches BoxModel::border_width
661                // default and Adobe's behavior). Previously defaulted to 0.5pt, causing borders
662                // to appear ~1px thinner at 150 DPI rendering.
663                let thickness = attr(*edge, "thickness")
664                    .and_then(Measurement::parse)
665                    .map(|m| m.to_points())
666                    .unwrap_or(1.0);
667                if thickness > 0.0 {
668                    style.border_width_pt = Some(thickness);
669                }
670            }
671        }
672        // Per-edge visibility: 1=all, 2=even/odd, 3=T/RL/B, 4=T/R/B/L.
673        let edge_visible = |e: &roxmltree::Node| -> bool {
674            !is_hidden(*e) && attr(*e, "stroke").unwrap_or("solid") != "none"
675        };
676        style.border_edges = match edges.len() {
677            0 => [true, true, true, true],
678            1 => {
679                let v = edge_visible(&edges[0]);
680                [v, v, v, v]
681            }
682            2 => {
683                let even = edge_visible(&edges[0]);
684                let odd = edge_visible(&edges[1]);
685                [even, odd, even, odd]
686            }
687            3 => {
688                let top = edge_visible(&edges[0]);
689                let rl = edge_visible(&edges[1]);
690                let bot = edge_visible(&edges[2]);
691                [top, rl, bot, rl]
692            }
693            _ => [
694                edge_visible(&edges[0]),
695                edge_visible(&edges[1]),
696                edge_visible(&edges[2]),
697                edge_visible(&edges[3]),
698            ],
699        };
700        let default_thickness = style.border_width_pt.unwrap_or(0.5);
701        let edge_thickness = |edge: roxmltree::Node<'_, '_>| -> f64 {
702            attr(edge, "thickness")
703                .and_then(Measurement::parse)
704                .map(|m| m.to_points())
705                .unwrap_or(default_thickness)
706        };
707        let per_edge_widths = match edges.len() {
708            0 | 1 => None,
709            2 => Some([
710                edge_thickness(edges[0]),
711                edge_thickness(edges[1]),
712                edge_thickness(edges[0]),
713                edge_thickness(edges[1]),
714            ]),
715            3 => Some([
716                edge_thickness(edges[0]),
717                edge_thickness(edges[1]),
718                edge_thickness(edges[2]),
719                edge_thickness(edges[1]),
720            ]),
721            _ => Some([
722                edge_thickness(edges[0]),
723                edge_thickness(edges[1]),
724                edge_thickness(edges[2]),
725                edge_thickness(edges[3]),
726            ]),
727        };
728        if let Some(widths) = per_edge_widths {
729            if !(widths[0] == widths[1] && widths[1] == widths[2] && widths[2] == widths[3]) {
730                style.border_widths = Some(widths);
731            }
732        }
733        // Also parse <border><fill><color .../> for border background (field bg).
734        // Skip when fill has presence="hidden"/"invisible"/"inactive".
735        if style.bg_color.is_none() {
736            if let Some(fill) = find_first_child_by_name(border, "fill") {
737                if !is_hidden(fill) {
738                    if let Some(color) = find_first_child_by_name(fill, "color") {
739                        if let Some(rgb) = parse_xfa_color(color) {
740                            style.bg_color = Some(rgb);
741                        }
742                    }
743                }
744            }
745        }
746    }
747
748    // Parse <value><rectangle><edge><color> for draw rectangle border color.
749    // XFA rectangle elements define their stroke color on <edge> children
750    // inside the <value> container, NOT inside <border>.
751    if style.border_color.is_none() {
752        if let Some(value) = find_first_child_by_name(elem, "value") {
753            if let Some(rect) = find_first_child_by_name(value, "rectangle") {
754                let edges: Vec<_> = rect
755                    .children()
756                    .filter(|c| c.is_element() && c.tag_name().name() == "edge")
757                    .collect();
758                if let Some(edge) = edges.first() {
759                    if let Some(color) = find_first_child_by_name(*edge, "color") {
760                        if let Some(rgb) = parse_xfa_color(color) {
761                            style.border_color = Some(rgb);
762                        }
763                    }
764                    if style.border_width_pt.is_none() {
765                        let thickness = attr(*edge, "thickness")
766                            .and_then(Measurement::parse)
767                            .map(|m| m.to_points())
768                            .unwrap_or(1.0);
769                        if thickness > 0.0 {
770                            style.border_width_pt = Some(thickness);
771                        }
772                    }
773                }
774            }
775        }
776    }
777
778    // XFA `<line>` draw shapes (the `FFLineN` cell-grid rules) likewise carry
779    // their stroke on `<value><line><edge>`, not `<border>`. Mirror the
780    // rectangle branch so `render_draw` can stroke the spanned line at the
781    // authored weight (the merge path `merger::parse_node_style` agrees).
782    if style.border_width_pt.is_none() {
783        if let Some(line) = find_first_child_by_name(elem, "value")
784            .and_then(|value| find_first_child_by_name(value, "line"))
785        {
786            if let Some(edge) = find_first_child_by_name(line, "edge") {
787                if attr(edge, "stroke").unwrap_or("solid") != "none" {
788                    if let Some(thickness) = attr(edge, "thickness")
789                        .and_then(Measurement::parse)
790                        .map(|m| m.to_points())
791                    {
792                        if thickness > 0.0 {
793                            style.border_width_pt = Some(thickness);
794                        }
795                    }
796                    if style.border_color.is_none() {
797                        if let Some(rgb) =
798                            find_first_child_by_name(edge, "color").and_then(parse_xfa_color)
799                        {
800                            style.border_color = Some(rgb);
801                        }
802                    }
803                }
804            }
805        }
806    }
807
808    // Parse <font typeface="..." size="..." weight="..."> for font properties.
809    // XFA Spec 3.3 §28.1 — Adobe Non-conformance:
810    //   - font-weight: numeric values (100-900) ignored, only "bold"/"normal" (p1229)
811    //   - font-stretch: not implemented in rich text (p1228)
812    //   - font-family: only first name used in rich text (p1228)
813    // We follow Adobe's behavior for all three.
814    if let Some(font) = find_first_child_by_name(elem, "font") {
815        if let Some(typeface) = attr(font, "typeface") {
816            style.font_family = Some(typeface.to_string());
817        }
818        // XFA Spec 3.3 §17 (p716) — genericFamily attribute.
819        if let Some(gf) = attr(font, "genericFamily") {
820            style.generic_family = Some(gf.to_string());
821        }
822        if let Some(size_str) = attr(font, "size") {
823            if let Some(m) = Measurement::parse(size_str) {
824                style.font_size = Some(m.to_points());
825            }
826        }
827        if let Some(weight) = attr(font, "weight") {
828            style.font_weight = Some(weight.to_string());
829        }
830        if let Some(posture) = attr(font, "posture") {
831            style.font_style = Some(posture.to_string());
832        }
833        // <font><fill><color .../> for text color
834        if let Some(fill) = find_first_child_by_name(font, "fill") {
835            if let Some(color) = find_first_child_by_name(fill, "color") {
836                if let Some(rgb) = parse_xfa_color(color) {
837                    style.text_color = Some(rgb);
838                }
839            }
840        }
841        // <font color="#RRGGBB"> attribute (fallback when <fill><color> not present)
842        if style.text_color.is_none() {
843            if let Some(color_str) = attr(font, "color") {
844                if let Some(rgb) = parse_font_color_attr(color_str) {
845                    style.text_color = Some(rgb);
846                }
847            }
848        }
849        // fontHorizontalScale="96%" → 0.96
850        if let Some(scale_str) = attr(font, "fontHorizontalScale") {
851            if let Some(v) = parse_percentage(scale_str) {
852                style.font_horizontal_scale = Some(v);
853            }
854        }
855        // letterSpacing="-0.018em" or "0.5pt"
856        if let Some(ls_str) = attr(font, "letterSpacing") {
857            if let Some(v) = parse_letter_spacing(ls_str, style.font_size.unwrap_or(10.0)) {
858                style.letter_spacing_pt = Some(v);
859            }
860        }
861        // XFA Spec 3.3 §2.6 — underline="1" (single) or "2" (double)
862        if let Some(underline_str) = attr(font, "underline") {
863            style.underline = underline_str == "1" || underline_str == "2";
864        }
865        // XFA Spec 3.3 §2.6 — lineThrough="1"
866        if let Some(line_through_str) = attr(font, "lineThrough") {
867            style.line_through = line_through_str == "1";
868        }
869    }
870
871    // XFA Spec 3.3 §17 "para" (p803) — Paragraph-level formatting attributes:
872    // hAlign: left/center/right/justify (handled in parse_font_metrics)
873    // vAlign: top/middle/bottom for vertical alignment within container
874    // spaceAbove, spaceBelow: paragraph spacing in points
875    // marginLeft, marginRight: paragraph indentation
876    // Note: hAlign/vAlign on container elements are deprecated since XFA 2.4
877    // and ignored by Adobe; we correctly read these only from <para>.
878    if let Some(para) = find_first_child_by_name(elem, "para") {
879        if let Some(v) = attr(para, "spaceAbove").and_then(Measurement::parse) {
880            style.space_above_pt = Some(v.to_points());
881        }
882        if let Some(v) = attr(para, "spaceBelow").and_then(Measurement::parse) {
883            style.space_below_pt = Some(v.to_points());
884        }
885        if let Some(v) = attr(para, "marginLeft").and_then(Measurement::parse) {
886            style.margin_left_pt = Some(v.to_points());
887        }
888        if let Some(v) = attr(para, "marginRight").and_then(Measurement::parse) {
889            style.margin_right_pt = Some(v.to_points());
890        }
891        // XFA Spec 3.3 §17 "para" (p803) — lineHeight / textIndent.
892        if let Some(v) = attr(para, "lineHeight").and_then(Measurement::parse) {
893            style.line_height_pt = Some(v.to_points());
894        }
895        if let Some(v) = attr(para, "textIndent").and_then(Measurement::parse) {
896            style.text_indent_pt = Some(v.to_points());
897        }
898        if let Some(va) = attr(para, "vAlign") {
899            style.v_align = Some(match va {
900                "middle" => VerticalAlign::Middle,
901                "bottom" => VerticalAlign::Bottom,
902                _ => VerticalAlign::Top,
903            });
904        }
905        // XFA Spec 3.3 §8.3 (p282-284) — hAlign positions child within parent
906        // layout container. Stored separately from FontMetrics.text_align which
907        // controls text rendering alignment within the element itself.
908        if let Some(ha) = attr(para, "hAlign") {
909            style.h_align = Some(match ha {
910                "center" => TextAlign::Center,
911                "right" => TextAlign::Right,
912                _ => TextAlign::Left,
913            });
914        }
915    }
916
917    // Parse <border><corner> for border radius and <border><edge> for border style.
918    if let Some(border) = border {
919        if let Some(corner) = find_first_child_by_name(border, "corner") {
920            if let Some(v) = attr(corner, "radius").and_then(Measurement::parse) {
921                style.border_radius_pt = Some(v.to_points());
922            }
923        }
924        if let Some(edge) = find_first_child_by_name(border, "edge") {
925            if let Some(stroke) = attr(edge, "stroke") {
926                if stroke != "none" {
927                    style.border_style = Some(stroke.to_string());
928                }
929            }
930        }
931    }
932
933    // Parse <format><picture> for numeric/date/time formatting patterns.
934    if let Some(format) = find_first_child_by_name(elem, "format") {
935        if let Some(picture) = find_first_child_by_name(format, "picture") {
936            if let Some(text) = picture.text() {
937                let trimmed = text.trim();
938                if !trimmed.is_empty() {
939                    style.format_pattern = Some(trimmed.to_string());
940                }
941            }
942        }
943    }
944
945    style
946}
947
948fn parse_check_button_mark(elem: Node<'_, '_>) -> Option<String> {
949    let ui = find_first_child_by_name(elem, "ui")?;
950    let check_button = ui
951        .children()
952        .find(|n| n.is_element() && n.tag_name().name() == "checkButton")?;
953    let mark = attr(check_button, "mark")?.to_ascii_lowercase();
954    match mark.as_str() {
955        "check" | "circle" | "cross" | "diamond" | "square" | "star" => Some(mark),
956        _ => None,
957    }
958}
959
960/// Parse XFA `<color value="r,g,b"/>` into (u8, u8, u8).
961fn parse_xfa_color(color_node: Node<'_, '_>) -> Option<(u8, u8, u8)> {
962    let value = attr(color_node, "value")?;
963    let parts: Vec<&str> = value.split(',').collect();
964    if parts.len() >= 3 {
965        let r = parts[0].trim().parse::<u8>().ok()?;
966        let g = parts[1].trim().parse::<u8>().ok()?;
967        let b = parts[2].trim().parse::<u8>().ok()?;
968        Some((r, g, b))
969    } else {
970        None
971    }
972}
973
974/// Parse a color string from a `color` attribute on `<font>`.
975///
976/// Supported formats:
977/// - `#RRGGBB` (e.g. `#000080`)
978/// - `#RGB` shorthand (e.g. `#00F` → `#0000FF`)
979/// - `r,g,b` with decimal values 0-255 (e.g. `0,0,128`)
980fn parse_font_color_attr(s: &str) -> Option<(u8, u8, u8)> {
981    let s = s.trim();
982    if let Some(hex) = s.strip_prefix('#') {
983        match hex.len() {
984            6 => {
985                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
986                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
987                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
988                Some((r, g, b))
989            }
990            3 => {
991                let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
992                let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
993                let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
994                Some((r * 17, g * 17, b * 17))
995            }
996            _ => None,
997        }
998    } else {
999        // Try "r,g,b" decimal format
1000        let parts: Vec<&str> = s.split(',').collect();
1001        if parts.len() >= 3 {
1002            let r = parts[0].trim().parse::<u8>().ok()?;
1003            let g = parts[1].trim().parse::<u8>().ok()?;
1004            let b = parts[2].trim().parse::<u8>().ok()?;
1005            Some((r, g, b))
1006        } else {
1007            None
1008        }
1009    }
1010}
1011
1012/// Detect field UI type from `<ui>` child element.
1013///
1014/// XFA Spec 3.3 §2.1 (p35) — User Interface: each container may have a `<ui>`
1015/// subelement specifying the widget type. If absent, defaults based on content type.
1016/// Supported: textEdit, checkButton, button, choiceList, dateTimeEdit,
1017///            numericEdit, passwordEdit, imageEdit, signature, barcode.
1018fn detect_field_kind(elem: Node<'_, '_>) -> FieldKind {
1019    let Some(ui) = find_first_child_by_name(elem, "ui") else {
1020        return FieldKind::Text;
1021    };
1022    for child in ui.children().filter(|n| n.is_element()) {
1023        let tag = child.tag_name().name();
1024        match tag {
1025            "checkButton" => {
1026                // XFA 3.3 §7.2.7: shape="round" → radio button (circle).
1027                let shape = attr(child, "shape").unwrap_or("square");
1028                return if shape == "round" {
1029                    FieldKind::Radio
1030                } else {
1031                    FieldKind::Checkbox
1032                };
1033            }
1034            "choiceList" => return FieldKind::Dropdown,
1035            "button" => return FieldKind::Button,
1036            "dateTimeEdit" => return FieldKind::DateTimePicker,
1037            "numericEdit" => return FieldKind::NumericEdit,
1038            "passwordEdit" => return FieldKind::PasswordEdit,
1039            "imageEdit" => return FieldKind::ImageEdit,
1040            "signature" => return FieldKind::Signature,
1041            "barcode" => return FieldKind::Barcode,
1042            _ => {}
1043        }
1044    }
1045    FieldKind::Text
1046}
1047
1048/// Detect page breaks: look for a child element named `breakBefore` or `break`.
1049///
1050/// Only considers breakBefore/break elements that appear BEFORE the first
1051/// content child (subform/field/draw/exclGroup). Inline breakBefore elements
1052/// between content children are handled by `add_children` which propagates
1053/// them to the next sibling's metadata.
1054///
1055/// Returns `(break_found, target_name)`.
1056fn detect_page_break_before(elem: Node<'_, '_>) -> (bool, Option<String>) {
1057    for child in elem.children().filter(|n| n.is_element()) {
1058        let tag = child.tag_name().name();
1059        if matches!(tag, "subform" | "field" | "draw" | "exclGroup") {
1060            break;
1061        }
1062        if tag == "breakBefore" && attr(child, "targetType") == Some("pageArea") {
1063            return (true, attr(child, "target").map(|s| s.to_string()));
1064        }
1065        if tag == "break" && attr(child, "before") == Some("pageArea") {
1066            // XFA §9.2.1: `before="pageArea"` triggers a page break
1067            // regardless of targetType. targetType only constrains which
1068            // specific page area to target.
1069            return (true, attr(child, "target").map(|s| s.to_string()));
1070        }
1071    }
1072    (false, None)
1073}
1074
1075/// Detect page-break-after: look for a child element named `breakAfter` or `break`.
1076///
1077/// Scans all children after the last content child.
1078fn detect_page_break_after(elem: Node<'_, '_>) -> (bool, Option<String>) {
1079    let mut last_content_idx = 0;
1080    let children: Vec<_> = elem.children().filter(|n| n.is_element()).collect();
1081    for (i, child) in children.iter().enumerate() {
1082        let tag = child.tag_name().name();
1083        if matches!(tag, "subform" | "field" | "draw" | "exclGroup") {
1084            last_content_idx = i;
1085        }
1086    }
1087
1088    // Check elements after the last content node.
1089    for child in children.iter().skip(last_content_idx) {
1090        let tag = child.tag_name().name();
1091        if tag == "breakAfter" && attr(*child, "targetType") == Some("pageArea") {
1092            return (true, attr(*child, "target").map(|s| s.to_string()));
1093        }
1094        if tag == "break" && attr(*child, "after") == Some("pageArea") {
1095            return (true, attr(*child, "target").map(|s| s.to_string()));
1096        }
1097    }
1098    (false, None)
1099}
1100
1101/// Detect `breakBefore targetType="contentArea"` — the node targets a
1102/// specific named content area (e.g. "flatten", "eSign") and should be
1103/// excluded from the primary content flow.
1104///
1105/// Scans ALL children (not just before the first content child) because
1106/// contentArea breaks often appear as trailing elements inside subforms
1107/// (DOT form pattern: eSign/lock break appears after their content fields).
1108fn detect_content_area_break(elem: Node<'_, '_>) -> bool {
1109    for child in elem.children().filter(|n| n.is_element()) {
1110        let tag = child.tag_name().name();
1111        if tag == "breakBefore" && attr(child, "targetType") == Some("contentArea") {
1112            return true;
1113        }
1114    }
1115    false
1116}
1117
1118/// Collect event scripts from `<event>` and `<calculate>` children.
1119fn collect_event_scripts(elem: Node<'_, '_>) -> Vec<EventScript> {
1120    let mut scripts = Vec::new();
1121    for child in elem.children().filter(|n| n.is_element()) {
1122        let child_tag = child.tag_name().name();
1123        if child_tag == "event" {
1124            // Skip layout-ready events (activity="ready" ref="$layout").
1125            let activity = attr(child, "activity");
1126            let event_ref = attr(child, "ref");
1127            if activity == Some("ready") && event_ref == Some("$layout") {
1128                continue;
1129            }
1130            // Look for a <script> child.
1131            if let Some(script_elem) = find_first_child_by_name(child, "script") {
1132                if let Some(script) =
1133                    build_event_script(script_elem, activity, event_ref, attr(script_elem, "runAt"))
1134                {
1135                    scripts.push(script);
1136                }
1137            }
1138        } else if child_tag == "calculate" {
1139            // Direct <calculate><script>...</script></calculate>
1140            if let Some(script_elem) = find_first_child_by_name(child, "script") {
1141                if let Some(script) = build_event_script(
1142                    script_elem,
1143                    Some("calculate"),
1144                    None,
1145                    attr(script_elem, "runAt"),
1146                ) {
1147                    scripts.push(script);
1148                }
1149            }
1150        }
1151    }
1152    scripts
1153}
1154
1155fn build_event_script(
1156    script_elem: Node<'_, '_>,
1157    activity: Option<&str>,
1158    event_ref: Option<&str>,
1159    run_at: Option<&str>,
1160) -> Option<EventScript> {
1161    let text = script_elem.text()?.trim();
1162    if text.is_empty() {
1163        return None;
1164    }
1165
1166    Some(EventScript::new(
1167        text.to_string(),
1168        detect_script_language(attr(script_elem, "contentType")),
1169        activity.map(str::to_string),
1170        event_ref.map(str::to_string),
1171        run_at.map(str::to_string),
1172    ))
1173}
1174
1175fn detect_script_language(content_type: Option<&str>) -> ScriptLanguage {
1176    match content_type.map(|value| value.trim().to_ascii_lowercase()) {
1177        None => ScriptLanguage::FormCalc,
1178        Some(value) if value == "application/x-formcalc" || value.ends_with("/x-formcalc") => {
1179            ScriptLanguage::FormCalc
1180        }
1181        Some(value)
1182            if value == "application/x-javascript"
1183                || value == "application/javascript"
1184                || value == "text/javascript"
1185                || value.ends_with("/x-javascript") =>
1186        {
1187            ScriptLanguage::JavaScript
1188        }
1189        Some(_) => ScriptLanguage::Other,
1190    }
1191}
1192
1193// XFA Spec 3.3 §17 "keep" (p776-777) — Controls whether content Area breaks are allowed:
1194// next: keep next content area together
1195// previous: keep previous content area together
1196// intact: keep content area intact (no breaks within)
1197fn parse_keep(elem: Node<'_, '_>) -> (bool, bool, bool) {
1198    if let Some(keep) = find_first_child_by_name(elem, "keep") {
1199        let next = attr(keep, "next") == Some("contentArea");
1200        let prev = attr(keep, "previous") == Some("contentArea");
1201        let intact = attr(keep, "intact") == Some("contentArea");
1202        (next, prev, intact)
1203    } else {
1204        (false, false, false)
1205    }
1206}
1207
1208// XFA Spec 3.3 §17 "overflow" (p804-805) — Overflow leader/trailer for pagination:
1209// leader: reference to element to render before overflow content
1210// trailer: reference to element to render after overflow content
1211/// `<bookend leader="..." trailer="...">` (XFA 3.3 §17).
1212///
1213/// Same shape as `<overflow>`, different pages: the leader belongs on the
1214/// first page of this subform, the trailer on the last. Until #150 this
1215/// element was read nowhere -- a `<bookend>` in a template disappeared
1216/// without a word.
1217fn parse_bookend(elem: Node<'_, '_>) -> (Option<String>, Option<String>) {
1218    match find_first_child_by_name(elem, "bookend") {
1219        Some(bookend) => (
1220            attr(bookend, "leader").map(|s| s.to_string()),
1221            attr(bookend, "trailer").map(|s| s.to_string()),
1222        ),
1223        None => (None, None),
1224    }
1225}
1226
1227fn parse_overflow(elem: Node<'_, '_>) -> (Option<String>, Option<String>) {
1228    if let Some(overflow) = find_first_child_by_name(elem, "overflow") {
1229        let leader = attr(overflow, "leader").map(|s| s.to_string());
1230        let trailer = attr(overflow, "trailer").map(|s| s.to_string());
1231        (leader, trailer)
1232    } else {
1233        (None, None)
1234    }
1235}
1236
1237/// Parse item value from `<items><text>VALUE</text></items>`.
1238fn parse_item_value(elem: Node<'_, '_>) -> Option<String> {
1239    let items = find_first_child_by_name(elem, "items")?;
1240    let text_elem = find_first_child_by_name(items, "text")?;
1241    let text = text_elem.text()?.trim();
1242    if text.is_empty() {
1243        None
1244    } else {
1245        Some(text.to_string())
1246    }
1247}
1248
1249/// Extract all text values from an `<items>` element.
1250fn collect_items_texts(items_elem: Node<'_, '_>) -> Vec<String> {
1251    items_elem
1252        .children()
1253        .filter(|n| n.is_element())
1254        .filter_map(|child| {
1255            let txt = child.text().unwrap_or("").trim().to_string();
1256            if txt.is_empty() {
1257                None
1258            } else {
1259                Some(txt)
1260            }
1261        })
1262        .collect()
1263}
1264
1265/// Parse choice list `<items>` elements from a `<field>` node (XFA 3.3 §7.7).
1266fn parse_items_lists(elem: Node<'_, '_>) -> (Vec<String>, Vec<String>) {
1267    let items_elems: Vec<_> = elem
1268        .children()
1269        .filter(|n| n.is_element() && n.tag_name().name() == "items")
1270        .collect();
1271    match items_elems.len() {
1272        0 => (Vec::new(), Vec::new()),
1273        1 => {
1274            let vals = collect_items_texts(items_elems[0]);
1275            (vals, Vec::new())
1276        }
1277        _ => {
1278            let first = items_elems[0];
1279            let second = items_elems[1];
1280            let first_is_save = attr(first, "save") == Some("1");
1281            if first_is_save {
1282                (collect_items_texts(second), collect_items_texts(first))
1283            } else {
1284                (collect_items_texts(first), collect_items_texts(second))
1285            }
1286        }
1287    }
1288}
1289
1290// ---------------------------------------------------------------------------
1291// Data binding
1292// ---------------------------------------------------------------------------
1293
1294/// Find the data root element from a datasets document.
1295/// Datasets packet: `<xfa:datasets><xfa:data>...</xfa:data></xfa:datasets>`.
1296fn find_data_root<'a, 'input>(root: Node<'a, 'input>) -> Option<Node<'a, 'input>> {
1297    // Look for a child named "data".
1298    for child in root.children().filter(|n| n.is_element()) {
1299        if child.tag_name().name() == "data" {
1300            // Return first element child of <data>, or <data> itself.
1301            return child.children().find(|n| n.is_element()).or(Some(child));
1302        }
1303    }
1304    // If root is the data element itself.
1305    if root.tag_name().name() == "data" {
1306        return root.children().find(|n| n.is_element()).or(Some(root));
1307    }
1308    // Fall back to first element child.
1309    root.children().find(|n| n.is_element())
1310}
1311
1312/// Recursively walk the form tree and bind data values from the datasets.
1313fn bind_data(
1314    tree: &mut FormTree,
1315    node_id: FormNodeId,
1316    data_root: &Node<'_, '_>,
1317    data_node: &Node<'_, '_>,
1318) {
1319    let name = tree.get(node_id).name.clone();
1320    let children: Vec<FormNodeId> = tree.get(node_id).children.clone();
1321    let meta = tree.meta(node_id).clone();
1322    let group_kind = meta.group_kind;
1323
1324    // For exclGroups: look up group value, set matching child, clear others.
1325    if group_kind == GroupKind::ExclusiveChoice && !name.is_empty() {
1326        let data_value = lookup_bound_text(data_root, data_node, &meta, &name);
1327        // Pre-collect item values to avoid borrow conflicts.
1328        let child_item_vals: Vec<(FormNodeId, Option<String>)> = children
1329            .iter()
1330            .map(|&cid| (cid, tree.meta(cid).item_value.clone()))
1331            .collect();
1332        for (child_id, item_val) in child_item_vals {
1333            if let FormNodeType::Field { ref mut value } = tree.get_mut(child_id).node_type {
1334                if let Some(ref dv) = data_value {
1335                    if item_val.as_deref() == Some(dv.as_str()) {
1336                        *value = dv.clone();
1337                    } else {
1338                        *value = String::new();
1339                    }
1340                } else {
1341                    // No data found: clear all children to prevent template defaults
1342                    // from firing wrong scripts.
1343                    *value = String::new();
1344                }
1345            }
1346        }
1347        return;
1348    }
1349
1350    // For fields: look up data value directly.
1351    if let FormNodeType::Field { ref mut value } = tree.get_mut(node_id).node_type {
1352        if let Some(dv) = lookup_bound_text(data_root, data_node, &meta, &name) {
1353            *value = dv;
1354        }
1355        return; // Fields are leaf nodes.
1356    }
1357
1358    // For subforms: find matching data child and recurse.
1359    // When occur max > 1 and data has multiple matching children,
1360    // clone the subform for each additional data instance.
1361    let bound_nodes = resolve_bound_nodes(data_root, data_node, &meta, &name);
1362    let effective_data = bound_nodes.first().copied().unwrap_or(*data_node);
1363
1364    // Check for repeating subform instances in the data.
1365    let occur = tree.get(node_id).occur.clone();
1366    let max_instances = occur.max.map(|max| max as usize).unwrap_or(usize::MAX);
1367    if max_instances > 1 && !meta.data_bind_none {
1368        let data_instances: Vec<_> = if let Some(bind_ref) = meta.data_bind_ref.as_deref() {
1369            resolve_bind_nodes(data_root, data_node, bind_ref)
1370        } else if !name.is_empty() {
1371            data_node
1372                .children()
1373                .filter(|child| child.is_element() && child.tag_name().name() == name)
1374                .collect()
1375        } else {
1376            Vec::new()
1377        };
1378
1379        if !data_instances.is_empty() {
1380            tree.get_mut(node_id).occur.initial = 1;
1381        }
1382
1383        if data_instances.len() > 1 {
1384            // Bind the first instance to the existing subform node.
1385            bind_data_children(tree, node_id, &children, data_root, &data_instances[0]);
1386
1387            // Clone the subform for each additional data instance.
1388            let parent_id = tree
1389                .nodes
1390                .iter()
1391                .enumerate()
1392                .find(|(_, n)| n.children.contains(&node_id))
1393                .map(|(i, _)| FormNodeId(i));
1394            let mut insert_pos = parent_id.and_then(|pid| {
1395                tree.get(pid)
1396                    .children
1397                    .iter()
1398                    .position(|&c| c == node_id)
1399                    .map(|pos| (pid, pos + 1))
1400            });
1401
1402            for data_inst in &data_instances[1..data_instances.len().min(max_instances)] {
1403                let cloned_id = clone_subtree(tree, node_id);
1404                tree.get_mut(cloned_id).occur.initial = 1;
1405                bind_data_children(
1406                    tree,
1407                    cloned_id,
1408                    &tree.get(cloned_id).children.clone(),
1409                    data_root,
1410                    data_inst,
1411                );
1412                // Insert clone after the original in the parent's children list.
1413                if let Some((pid, pos)) = insert_pos.as_mut() {
1414                    let parent = tree.get_mut(*pid);
1415                    parent.children.insert(*pos, cloned_id);
1416                    *pos += 1;
1417                }
1418            }
1419            return;
1420        }
1421    }
1422
1423    bind_data_children(tree, node_id, &children, data_root, &effective_data);
1424}
1425
1426/// Bind data to a subform's children.
1427fn bind_data_children(
1428    tree: &mut FormTree,
1429    _parent_id: FormNodeId,
1430    children: &[FormNodeId],
1431    data_root: &Node<'_, '_>,
1432    data_node: &Node<'_, '_>,
1433) {
1434    for &child_id in children {
1435        bind_data(tree, child_id, data_root, data_node);
1436    }
1437}
1438
1439/// Deep-clone a subtree in the FormTree, returning the new root's ID.
1440fn clone_subtree(tree: &mut FormTree, source_id: FormNodeId) -> FormNodeId {
1441    let source = tree.get(source_id).clone();
1442    let source_meta = tree.meta(source_id).clone();
1443
1444    // Clone children recursively first.
1445    let new_children: Vec<FormNodeId> = source
1446        .children
1447        .iter()
1448        .map(|&child_id| clone_subtree(tree, child_id))
1449        .collect();
1450
1451    let mut new_node = source;
1452    new_node.children = new_children;
1453    tree.add_node_with_meta(new_node, source_meta)
1454}
1455
1456/// Look up a text value for a named element in the data node.
1457fn lookup_data_text(data_node: &Node<'_, '_>, name: &str) -> Option<String> {
1458    let child = find_child_element_by_name(data_node, name)?;
1459    child.text().map(|s| s.to_string())
1460}
1461
1462fn lookup_bound_text(
1463    data_root: &Node<'_, '_>,
1464    data_node: &Node<'_, '_>,
1465    meta: &FormNodeMeta,
1466    fallback_name: &str,
1467) -> Option<String> {
1468    if meta.data_bind_none {
1469        return None;
1470    }
1471
1472    if let Some(bind_ref) = meta.data_bind_ref.as_deref() {
1473        return resolve_bind_nodes(data_root, data_node, bind_ref)
1474            .into_iter()
1475            .next()
1476            .and_then(|node| node.text().map(|s| s.to_string()));
1477    }
1478
1479    if fallback_name.is_empty() {
1480        None
1481    } else {
1482        lookup_data_text(data_node, fallback_name)
1483    }
1484}
1485
1486fn resolve_bound_nodes<'a, 'input>(
1487    data_root: &Node<'a, 'input>,
1488    data_node: &Node<'a, 'input>,
1489    meta: &FormNodeMeta,
1490    fallback_name: &str,
1491) -> Vec<Node<'a, 'input>> {
1492    if meta.data_bind_none {
1493        return Vec::new();
1494    }
1495
1496    if let Some(bind_ref) = meta.data_bind_ref.as_deref() {
1497        return resolve_bind_nodes(data_root, data_node, bind_ref);
1498    }
1499
1500    if fallback_name.is_empty() {
1501        Vec::new()
1502    } else {
1503        find_child_element_by_name(data_node, fallback_name)
1504            .into_iter()
1505            .collect()
1506    }
1507}
1508
1509fn resolve_bind_nodes<'a, 'input>(
1510    data_root: &Node<'a, 'input>,
1511    data_node: &Node<'a, 'input>,
1512    bind_ref: &str,
1513) -> Vec<Node<'a, 'input>> {
1514    let mut path = bind_ref.trim();
1515    if path.is_empty() {
1516        return Vec::new();
1517    }
1518
1519    let mut current = if let Some(rest) = path.strip_prefix("$record.") {
1520        path = rest;
1521        vec![*data_root]
1522    } else if path == "$record" {
1523        return vec![*data_root];
1524    } else if let Some(rest) = path.strip_prefix("$.") {
1525        path = rest;
1526        vec![*data_node]
1527    } else {
1528        vec![*data_node]
1529    };
1530
1531    let segments: Vec<&str> = path
1532        .split('.')
1533        .map(str::trim)
1534        .filter(|segment| !segment.is_empty())
1535        .collect();
1536    if segments.is_empty() {
1537        return current;
1538    }
1539
1540    for segment in segments {
1541        let (name, selector) = parse_bind_segment(segment);
1542        if name.is_empty() {
1543            continue;
1544        }
1545
1546        let mut next = Vec::new();
1547        for node in current {
1548            let matches: Vec<Node<'a, 'input>> = node
1549                .children()
1550                .filter(|child| child.is_element() && child.tag_name().name() == name)
1551                .collect();
1552            match selector {
1553                BindSelector::First => {
1554                    if let Some(first) = matches.into_iter().next() {
1555                        next.push(first);
1556                    }
1557                }
1558                BindSelector::All => next.extend(matches),
1559                BindSelector::Index(idx) => {
1560                    if let Some(found) = matches.into_iter().nth(idx) {
1561                        next.push(found);
1562                    }
1563                }
1564            }
1565        }
1566        current = next;
1567        if current.is_empty() {
1568            break;
1569        }
1570    }
1571
1572    current
1573}
1574
1575#[derive(Clone, Copy)]
1576enum BindSelector {
1577    First,
1578    All,
1579    Index(usize),
1580}
1581
1582fn parse_bind_segment(segment: &str) -> (&str, BindSelector) {
1583    let Some(start) = segment.find('[') else {
1584        return (segment, BindSelector::First);
1585    };
1586    let name = &segment[..start];
1587    let index = segment[start + 1..]
1588        .strip_suffix(']')
1589        .unwrap_or_default()
1590        .trim();
1591    match index {
1592        "*" => (name, BindSelector::All),
1593        "" => (name, BindSelector::First),
1594        _ => index
1595            .parse::<usize>()
1596            .map(|idx| (name, BindSelector::Index(idx)))
1597            .unwrap_or((name, BindSelector::First)),
1598    }
1599}
1600
1601/// Find a direct child element by name.
1602fn find_child_element_by_name<'a, 'input>(
1603    node: &Node<'a, 'input>,
1604    name: &str,
1605) -> Option<Node<'a, 'input>> {
1606    node.children()
1607        .filter(|n| n.is_element())
1608        .find(|n| n.tag_name().name() == name)
1609}
1610
1611// ---------------------------------------------------------------------------
1612// Helpers
1613// ---------------------------------------------------------------------------
1614
1615/// Recursively add child form nodes (subform, field, draw, pageSet, pageArea).
1616///
1617/// When a `<breakBefore>` element appears between content children (inline
1618/// break), it is propagated as `page_break_before` on the next content
1619/// sibling's metadata.
1620///
1621/// Returns `(break_found, target_name)` if a pending break remains (i.e. a
1622/// `breakBefore` was found after the last content child), meaning the NEXT
1623/// sibling at the parent level should receive the break.
1624fn add_children(
1625    tree: &mut FormTree,
1626    node: &mut FormNode,
1627    elem: Node<'_, '_>,
1628) -> std::result::Result<(bool, Option<String>), crate::error::XfaError> {
1629    let mut pending_break = false;
1630    let mut pending_break_target = None;
1631    let mut pending_ca_break = false;
1632    for child in elem.children().filter(|n| n.is_element()) {
1633        let tag = child.tag_name().name();
1634        match tag {
1635            // XFA Spec 3.3 §2.1 — Container elements that produce form nodes.
1636            "subform" | "field" | "draw" | "pageSet" | "pageArea" | "exclGroup" | "area" => {
1637                let (child_id, (trailing_break, trailing_target)) = parse_node(tree, child, false)?;
1638                if pending_break {
1639                    let meta = tree.meta_mut(child_id);
1640                    meta.page_break_before = true;
1641                    if meta.break_target.is_none() {
1642                        meta.break_target = pending_break_target.take();
1643                    }
1644                    pending_break = false;
1645                }
1646                if pending_ca_break {
1647                    tree.meta_mut(child_id).content_area_break = true;
1648                    pending_ca_break = false;
1649                }
1650                node.children.push(child_id);
1651                if trailing_break {
1652                    pending_break = true;
1653                    pending_break_target = trailing_target;
1654                }
1655            }
1656            "breakBefore" => {
1657                let target_type = attr(child, "targetType");
1658                if target_type == Some("pageArea") {
1659                    pending_break = true;
1660                    pending_break_target = attr(child, "target").map(|s| s.to_string());
1661                } else if target_type == Some("contentArea") {
1662                    pending_ca_break = true;
1663                }
1664            }
1665            // Legacy <break> element between content children.
1666            "break"
1667                if attr(child, "before") == Some("pageArea")
1668                    && attr(child, "targetType") == Some("pageArea") =>
1669            {
1670                pending_break = true;
1671                pending_break_target = attr(child, "target").map(|s| s.to_string());
1672            }
1673            // Ignore XML elements that are layout metadata, not form nodes.
1674            // XFA Spec 3.3 §28.1 (p1229) — Adobe Non-conformance: stipple rate only
1675            // supports 25, 50, 75; others→100. Blends with WHITE, not bg color.
1676            // Currently not rendered; matches Adobe's limited implementation.
1677            "caption" | "value" | "ui" | "font" | "border" | "margin" | "para" | "format"
1678            | "items" | "medium" | "contentArea" | "desc" | "occur" | "event" | "bind"
1679            | "calculate" | "validate" | "assist" | "toolTip" | "fill" | "edge" | "corner"
1680            | "linear" | "radial" | "pattern" | "stipple" | "color" | "extras" | "traversal"
1681            | "proto" | "overflow" => {
1682                // Handled elsewhere or not needed for layout.
1683            }
1684            _ => {
1685                // Unknown element — skip silently.
1686            }
1687        }
1688    }
1689    Ok((pending_break, pending_break_target))
1690}
1691
1692fn blank_node(tag: &str) -> FormNode {
1693    FormNode {
1694        name: tag.to_string(),
1695        node_type: FormNodeType::Subform,
1696        box_model: BoxModel {
1697            max_width: f64::MAX,
1698            max_height: f64::MAX,
1699            ..Default::default()
1700        },
1701        layout: LayoutStrategy::TopToBottom,
1702        children: Vec::new(),
1703        occur: Occur::once(),
1704        font: FontMetrics::default(),
1705        calculate: None,
1706        validate: None,
1707        column_widths: Vec::new(),
1708        col_span: 1,
1709    }
1710}
1711
1712/// Parse the `layout` attribute into a `LayoutStrategy`.
1713///
1714/// XFA Spec 3.3 §2.6 — Layout Strategies: positioned (fixed x,y) and
1715/// flowing (tb, lr-tb, rl-tb, table, row). `pageArea` uses positioned only.
1716/// Default for subforms without an explicit layout attribute is "position".
1717fn parse_layout_attr(elem: Node<'_, '_>) -> LayoutStrategy {
1718    match attr(elem, "layout").unwrap_or("") {
1719        "tb" => LayoutStrategy::TopToBottom,
1720        "lr-tb" => LayoutStrategy::LeftToRightTB,
1721        "rl-tb" => LayoutStrategy::RightToLeftTB,
1722        "table" => LayoutStrategy::Table,
1723        "row" => LayoutStrategy::Row,
1724        "paginate" => LayoutStrategy::TopToBottom, // root layout
1725        "position" => LayoutStrategy::Positioned,
1726        _ => LayoutStrategy::Positioned,
1727    }
1728}
1729
1730/// Parse the `anchorType` attribute (XFA 3.3 §2.6, App A p1510).
1731///
1732/// Determines which anchor point of the element is placed at (x,y) in
1733/// positioned layout.  Default is `topLeft`.
1734fn parse_anchor_type(elem: Node<'_, '_>) -> AnchorType {
1735    match attr(elem, "anchorType").unwrap_or("") {
1736        "topCenter" => AnchorType::TopCenter,
1737        "topRight" => AnchorType::TopRight,
1738        "middleLeft" => AnchorType::MiddleLeft,
1739        "middleCenter" => AnchorType::MiddleCenter,
1740        "middleRight" => AnchorType::MiddleRight,
1741        "bottomLeft" => AnchorType::BottomLeft,
1742        "bottomCenter" => AnchorType::BottomCenter,
1743        "bottomRight" => AnchorType::BottomRight,
1744        _ => AnchorType::TopLeft,
1745    }
1746}
1747
1748/// Parse dimensional attributes (w, h, x, y) into a `BoxModel`.
1749///
1750/// XFA Spec 3.3 §2.6 — Box Model (p49): nominal extent is w × h.
1751/// Margins lie inside the nominal extent. Borders lie inside margins.
1752/// Caption may occupy part of the nominal content region.
1753/// Constraints: minW/minH/maxW/maxH (§2.6 p53).
1754///
1755/// TODO(§2.6): rotate not parsed — counter-clockwise rotation in degrees (multiples of 90).
1756fn parse_box_model(elem: Node<'_, '_>) -> BoxModel {
1757    let w = attr(elem, "w").and_then(parse_dim);
1758    let h = attr(elem, "h").and_then(parse_dim);
1759    let x = attr(elem, "x").and_then(parse_dim).unwrap_or(0.0);
1760    let y = attr(elem, "y").and_then(parse_dim).unwrap_or(0.0);
1761    let min_h = attr(elem, "minH").and_then(parse_dim).unwrap_or(0.0);
1762    let min_w = attr(elem, "minW").and_then(parse_dim).unwrap_or(0.0);
1763    let max_h = attr(elem, "maxH").and_then(parse_dim).unwrap_or(f64::MAX);
1764    let max_w = attr(elem, "maxW").and_then(parse_dim).unwrap_or(f64::MAX);
1765    let margins = parse_margin(elem);
1766
1767    BoxModel {
1768        width: w,
1769        height: h,
1770        x,
1771        y,
1772        margins,
1773        min_width: min_w,
1774        max_width: max_w,
1775        min_height: min_h,
1776        max_height: max_h,
1777        ..Default::default()
1778    }
1779}
1780
1781fn parse_margin(elem: Node<'_, '_>) -> Insets {
1782    if let Some(margin) = find_first_child_by_name(elem, "margin") {
1783        Insets {
1784            top: attr(margin, "topInset").and_then(parse_dim).unwrap_or(0.0),
1785            bottom: attr(margin, "bottomInset")
1786                .and_then(parse_dim)
1787                .unwrap_or(0.0),
1788            left: attr(margin, "leftInset").and_then(parse_dim).unwrap_or(0.0),
1789            right: attr(margin, "rightInset")
1790                .and_then(parse_dim)
1791                .unwrap_or(0.0),
1792        }
1793    } else {
1794        Insets::default()
1795    }
1796}
1797
1798/// Parse an XFA dimension string ("0.5in", "72pt", "10mm") to PDF points.
1799fn parse_dim(s: &str) -> Option<f64> {
1800    // Handle bare numbers as inches (common in XFA)
1801    if s.trim().parse::<f64>().is_ok() {
1802        return Measurement::parse(&format!("{s}in")).map(|m| m.to_points());
1803    }
1804    Measurement::parse(s).map(|m| m.to_points())
1805}
1806
1807// XFA Spec 3.3 §17 "occur" (p800-802) — Specifies min/max/initial occurrences:
1808// min: minimum instances (default 1)
1809// max: maximum instances (-1 means unlimited)
1810// initial: number of instances at initialization
1811fn parse_occur(elem: Node<'_, '_>) -> Occur {
1812    if let Some(occur) = find_first_child_by_name(elem, "occur") {
1813        let min: u32 = attr(occur, "min").and_then(|s| s.parse().ok()).unwrap_or(1);
1814        let max: Option<u32> = attr(occur, "max")
1815            .map(|s| if s == "-1" { None } else { s.parse().ok() })
1816            .unwrap_or(Some(1));
1817        // XFA 3.3 §3.2.5: when initial is absent, default to at least 1 —
1818        // the subform exists in the template and should render once unless
1819        // explicitly suppressed by initial="0".
1820        let initial: u32 = attr(occur, "initial")
1821            .and_then(|s| s.parse().ok())
1822            .unwrap_or(min.max(1));
1823        Occur::repeating(min, max, initial)
1824    } else {
1825        Occur::once()
1826    }
1827}
1828
1829/// Parse `colSpan` attribute.
1830fn parse_col_span(elem: Node<'_, '_>) -> i32 {
1831    attr(elem, "colSpan")
1832        .and_then(|s| s.parse().ok())
1833        .unwrap_or(1)
1834}
1835
1836/// Read the `<medium>` child and return (page_width, page_height) in points.
1837fn read_medium(page_area: Node<'_, '_>) -> (f64, f64) {
1838    if let Some(m) = find_first_child_by_name(page_area, "medium") {
1839        // XFA: short = narrow dimension, long = tall dimension.
1840        let short = attr(m, "short").and_then(parse_dim).unwrap_or(612.0);
1841        let long_ = attr(m, "long").and_then(parse_dim).unwrap_or(792.0);
1842        (short, long_)
1843    } else {
1844        (612.0, 792.0)
1845    }
1846}
1847
1848/// Read all `<contentArea>` children and return their `ContentArea` structs.
1849fn read_content_areas(
1850    page_area: Node<'_, '_>,
1851    page_width: f64,
1852    page_height: f64,
1853) -> Vec<ContentArea> {
1854    let mut areas = Vec::new();
1855    for child in page_area.children().filter(|n| n.is_element()) {
1856        if child.tag_name().name() == "contentArea" {
1857            // XFA 3.3 §8.3.1 — contentArea x/y default to 0 when omitted.
1858            // Treating missing coordinates as a 0.5in inset shifts the entire
1859            // page content down/right for templates that define full-page
1860            // content areas with only w/h.
1861            let x = attr(child, "x").and_then(parse_dim).unwrap_or(0.0);
1862            let y = attr(child, "y").and_then(parse_dim).unwrap_or(0.0);
1863            // w/h default to full page dimensions when omitted, matching the
1864            // behavior of the empty (no contentArea) fallback. Previously used
1865            // hardcoded 540×720 (US Letter body), which was inconsistent with
1866            // the no-contentArea path and had no spec basis.
1867            let w = attr(child, "w").and_then(parse_dim).unwrap_or(page_width);
1868            let h = attr(child, "h").and_then(parse_dim).unwrap_or(page_height);
1869            areas.push(ContentArea {
1870                name: attr(child, "name").unwrap_or("").to_string(),
1871                x,
1872                y,
1873                width: w,
1874                height: h,
1875                leader: None,
1876                trailer: None,
1877            });
1878        }
1879    }
1880    if areas.is_empty() {
1881        areas.push(ContentArea {
1882            name: String::new(),
1883            x: 0.0,
1884            y: 0.0,
1885            width: page_width,
1886            height: page_height,
1887            leader: None,
1888            trailer: None,
1889        });
1890    }
1891    areas
1892}
1893
1894/// Extract text from `<value><text>…</text></value>` or `<value><float>…</float></value>`.
1895///
1896/// Also handles `<value><exData contentType="text/html">…</exData></value>` by
1897/// stripping the HTML/XHTML markup and returning the concatenated plain text.
1898/// This covers XFA draw elements whose content is rich-text (e.g. IRS form
1899/// instructions stored as inline XHTML). (#557)
1900/// Extract image data from `<value><image contentType="image/…">…</image></value>`.
1901///
1902/// Returns `(raw_image_data, mime_type)` for supported image types:
1903/// - `image/jpeg` → JPEG bytes
1904/// - `image/png` → PNG bytes
1905/// - `image/bmp` → converted to PNG (PDF doesn't support BMP natively)
1906///
1907/// BMP images (magic bytes `0x42 0x4D`) are automatically converted to PNG
1908/// regardless of the declared `contentType`.
1909fn extract_value_image(elem: Node<'_, '_>) -> Option<(Vec<u8>, String)> {
1910    let value = find_first_child_by_name(elem, "value")?;
1911    let image = find_first_child_by_name(value, "image")?;
1912    let content_type = attr(image, "contentType")
1913        .unwrap_or("image/png")
1914        .to_string();
1915    let data = image.text().unwrap_or_default();
1916    let decoded = base64_decode(data);
1917
1918    // BMP is not supported by PDF — convert to PNG.
1919    // Detect by magic bytes (0x42 0x4D = "BM") or declared content type.
1920    if decoded.starts_with(b"BM") || content_type == "image/bmp" {
1921        if let Some(png_data) = bmp_to_png(&decoded) {
1922            return Some((png_data, "image/png".to_string()));
1923        }
1924        // Conversion failed — log and skip this image.
1925        log::warn!("BMP to PNG conversion failed; skipping image");
1926        return None;
1927    }
1928
1929    Some((decoded, content_type))
1930}
1931
1932/// Convert BMP image data to PNG format.
1933fn bmp_to_png(bmp_data: &[u8]) -> Option<Vec<u8>> {
1934    let img = image::load_from_memory_with_format(bmp_data, image::ImageFormat::Bmp).ok()?;
1935    let mut buf = Vec::new();
1936    img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
1937        .ok()?;
1938    Some(buf)
1939}
1940
1941fn extract_value_text(elem: Node<'_, '_>) -> Option<String> {
1942    let value = find_first_child_by_name(elem, "value")?;
1943    // Try <text>, <float>, <integer>, <date>
1944    for tag in &["text", "float", "integer", "date", "dateTime", "decimal"] {
1945        if let Some(child) = find_first_child_by_name(value, tag) {
1946            let text = child.text().unwrap_or("");
1947            let trimmed = text.trim_start_matches(|c: char| c.is_whitespace() && c != '\n');
1948            let trimmed = trimmed.trim_end_matches(|c: char| c.is_whitespace() && c != '\n');
1949            if !trimmed.is_empty() {
1950                return Some(trimmed.to_string());
1951            }
1952        }
1953    }
1954    // Fall back to <exData contentType="text/html|text/xml|…"> — collect all
1955    // descendant text nodes and join them, stripping the XHTML markup.
1956    if let Some(ex) = find_first_child_by_name(value, "exData") {
1957        let text = extract_text_from_descendants(ex);
1958        if !text.is_empty() {
1959            return Some(text);
1960        }
1961    }
1962    None
1963}
1964
1965/// XFA Spec 3.3 §2.1 (p24) — Draw element: container for fixed content (boilerplate).
1966/// Contains text, lines, rectangles, arcs, or images that remain unchanged.
1967/// Also handles `<value><image>` for embedded image data (§2.3 p41-42).
1968fn extract_draw_content(elem: Node<'_, '_>) -> Option<DrawContent> {
1969    let value = find_first_child_by_name(elem, "value")?;
1970
1971    if let Some(line) = find_first_child_by_name(value, "line") {
1972        let x1 = attr_as_f64(line, "x1").unwrap_or(0.0);
1973        let y1 = attr_as_f64(line, "y1").unwrap_or(0.0);
1974        let x2 = attr_as_f64(line, "x2").unwrap_or(0.0);
1975        let y2 = attr_as_f64(line, "y2").unwrap_or(0.0);
1976        return Some(DrawContent::Line { x1, y1, x2, y2 });
1977    }
1978
1979    if let Some(rect) = find_first_child_by_name(value, "rectangle") {
1980        let x = attr_as_f64(rect, "x").unwrap_or(0.0);
1981        let y = attr_as_f64(rect, "y").unwrap_or(0.0);
1982        let w = attr_as_f64(rect, "w").unwrap_or(attr_as_f64(rect, "width").unwrap_or(0.0));
1983        let h = attr_as_f64(rect, "h").unwrap_or(attr_as_f64(rect, "height").unwrap_or(0.0));
1984        let radius =
1985            attr_as_f64(rect, "r").unwrap_or(attr_as_f64(rect, "cornerRadius").unwrap_or(0.0));
1986        return Some(DrawContent::Rectangle { x, y, w, h, radius });
1987    }
1988
1989    if let Some(arc) = find_first_child_by_name(value, "arc") {
1990        let x = attr_as_f64(arc, "x").unwrap_or(0.0);
1991        let y = attr_as_f64(arc, "y").unwrap_or(0.0);
1992        let w = attr_as_f64(arc, "w").unwrap_or(attr_as_f64(arc, "width").unwrap_or(0.0));
1993        let h = attr_as_f64(arc, "h").unwrap_or(attr_as_f64(arc, "height").unwrap_or(0.0));
1994        let start_angle = attr_as_f64(arc, "startAngle").unwrap_or(0.0);
1995        let sweep_angle = attr_as_f64(arc, "sweepAngle").unwrap_or(0.0);
1996        return Some(DrawContent::Arc {
1997            x,
1998            y,
1999            w,
2000            h,
2001            start_angle,
2002            sweep_angle,
2003        });
2004    }
2005
2006    None
2007}
2008
2009/// Walk all descendant nodes of `node` and return text content joined
2010/// by newlines between block-level elements (e.g. `<p>`, `<div>`), preserving
2011/// paragraph structure. Used to extract plain text from XHTML-encoded
2012/// `<exData>` nodes. (#686)
2013fn extract_text_from_descendants(node: Node<'_, '_>) -> String {
2014    let block_tags = [
2015        "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr", "br",
2016    ];
2017    let mut result = String::new();
2018    let mut last_was_block = false;
2019
2020    for desc in node.descendants() {
2021        let is_block = desc.is_element() && block_tags.contains(&desc.tag_name().name());
2022
2023        if is_block && !result.is_empty() {
2024            result.push('\n');
2025            last_was_block = true;
2026        }
2027
2028        if desc.is_text() {
2029            if let Some(t) = desc.text() {
2030                let t = t.trim();
2031                if !t.is_empty() {
2032                    if last_was_block || result.is_empty() {
2033                        result.push_str(t);
2034                    } else {
2035                        result.push(' ');
2036                        result.push_str(t);
2037                    }
2038                    last_was_block = false;
2039                }
2040            }
2041        }
2042    }
2043
2044    result.trim().to_string()
2045}
2046
2047fn base64_decode(input: &str) -> Vec<u8> {
2048    use base64::Engine;
2049    base64::engine::general_purpose::STANDARD
2050        .decode(input.trim())
2051        .unwrap_or_default()
2052}
2053
2054/// Extract the dominant font size from `<exData contentType="text/html">` HTML.
2055///
2056/// Scans `<span style="font-size:Xpt">` attributes and returns the first
2057/// (typically dominant) font size in points.  Returns `None` when the element
2058/// has no exData HTML or no font-size is specified.
2059fn extract_exdata_font_size(elem: Node<'_, '_>) -> Option<f64> {
2060    let value = find_first_child_by_name(elem, "value")?;
2061    let ex = find_first_child_by_name(value, "exData")?;
2062    // Walk all descendant elements looking for style="...font-size:Xpt..."
2063    for desc in ex.descendants() {
2064        if !desc.is_element() {
2065            continue;
2066        }
2067        let style = desc
2068            .attribute("style")
2069            .or_else(|| desc.attribute("Style"))?;
2070        // Parse font-size from CSS style string
2071        for part in style.split(';') {
2072            let part = part.trim();
2073            if let Some(val) = part
2074                .strip_prefix("font-size:")
2075                .or_else(|| part.strip_prefix("font-size :"))
2076            {
2077                let val = val.trim();
2078                if let Some(pt) = val.strip_suffix("pt") {
2079                    if let Ok(size) = pt.trim().parse::<f64>() {
2080                        if size > 0.0 {
2081                            return Some(size);
2082                        }
2083                    }
2084                }
2085            }
2086        }
2087    }
2088    None
2089}
2090
2091/// Parse caption from `<caption placement="..." reserve="...">` element.
2092///
2093/// XFA Spec 3.3 §2.6 (p51) — Captions: reserve is a height for top/bottom
2094/// placement and a width for left/right placement. When reserve is absent
2095/// or zero, the layout processor calculates the minimum size.
2096/// Note: Acrobat only renders captions on button and barcode fields (§2.1 p32 Note).
2097fn parse_caption(elem: Node<'_, '_>) -> Option<Caption> {
2098    let cap_elem = find_first_child_by_name(elem, "caption")?;
2099    // Skip captions with presence="hidden"/"invisible"/"inactive".
2100    if is_hidden(cap_elem) {
2101        return None;
2102    }
2103    let text = extract_value_text(cap_elem)?;
2104    if text.is_empty() {
2105        return None;
2106    }
2107    let placement = match attr(cap_elem, "placement") {
2108        Some("right") => CaptionPlacement::Right,
2109        Some("top") => CaptionPlacement::Top,
2110        Some("bottom") => CaptionPlacement::Bottom,
2111        Some("inline") => CaptionPlacement::Inline,
2112        _ => CaptionPlacement::Left,
2113    };
2114    let reserve = attr(cap_elem, "reserve")
2115        .and_then(Measurement::parse)
2116        .map(|m| m.to_points());
2117    Some(Caption {
2118        placement,
2119        reserve,
2120        text,
2121        font_family: None,
2122        font_size: None,
2123    })
2124}
2125
2126/// Get an attribute value by local name, ignoring namespace prefixes.
2127fn attr<'a>(elem: Node<'a, '_>, name: &str) -> Option<&'a str> {
2128    elem.attributes()
2129        .find(|a| a.name() == name)
2130        .map(|a| a.value())
2131}
2132
2133fn attr_as_f64(elem: Node<'_, '_>, name: &str) -> Option<f64> {
2134    attr(elem, name)?.parse().ok()
2135}
2136
2137/// Find the first direct child element with a given local tag name.
2138fn find_first_child_by_name<'a, 'input>(
2139    elem: Node<'a, 'input>,
2140    name: &str,
2141) -> Option<Node<'a, 'input>> {
2142    elem.children()
2143        .filter(|n| n.is_element())
2144        .find(|n| n.tag_name().name() == name)
2145}
2146
2147// ---------------------------------------------------------------------------
2148// Tests
2149// ---------------------------------------------------------------------------
2150
2151#[cfg(test)]
2152mod tests {
2153    use super::*;
2154
2155    const SIMPLE_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
2156<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
2157<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2158  <subform name="form1" layout="paginate">
2159    <pageSet>
2160      <pageArea name="Page1">
2161        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2162        <medium stock="default" short="8.5in" long="11in"/>
2163      </pageArea>
2164    </pageSet>
2165    <subform name="section" layout="tb" w="7.5in">
2166      <field name="firstName" w="3.5in" h="0.3in">
2167        <caption><value><text>First Name</text></value></caption>
2168        <ui><textEdit/></ui>
2169        <value><text/></value>
2170      </field>
2171      <field name="lastName" w="3.5in" h="0.3in">
2172        <caption><value><text>Last Name</text></value></caption>
2173        <ui><textEdit/></ui>
2174        <value><text>Default</text></value>
2175      </field>
2176    </subform>
2177  </subform>
2178</template>
2179</xdp:xdp>"#;
2180
2181    #[test]
2182    fn parse_simple_form() {
2183        let (tree, root_id) = parse_template(SIMPLE_TEMPLATE, None).unwrap();
2184        let root = tree.get(root_id);
2185        // Root should have children (the paginate subform)
2186        assert!(!root.children.is_empty(), "root has no children");
2187    }
2188
2189    #[test]
2190    fn field_with_default_value() {
2191        let (tree, root_id) = parse_template(SIMPLE_TEMPLATE, None).unwrap();
2192        // Walk to find lastName field
2193        let found = find_node_by_name(&tree, root_id, "lastName");
2194        assert!(found.is_some(), "lastName field not found");
2195        if let Some(n) = found {
2196            match &n.node_type {
2197                FormNodeType::Field { value } => assert_eq!(value, "Default"),
2198                other => panic!("expected Field, got {other:?}"),
2199            }
2200        }
2201    }
2202
2203    #[test]
2204    fn unlimited_occur_expands_all_dataset_instances_in_order() {
2205        let template = r#"<?xml version="1.0" encoding="UTF-8"?>
2206<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2207  <subform name="form1" layout="paginate">
2208    <pageSet>
2209      <pageArea name="Page1">
2210        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2211      </pageArea>
2212    </pageSet>
2213    <subform name="items" layout="tb" w="7in">
2214      <subform name="row" layout="tb" w="7in">
2215        <occur min="0" max="-1"/>
2216        <field name="value" w="2in" h="0.3in">
2217          <ui><textEdit/></ui>
2218          <value><text/></value>
2219        </field>
2220      </subform>
2221    </subform>
2222  </subform>
2223</template>"#;
2224        let datasets = r#"<?xml version="1.0" encoding="UTF-8"?>
2225<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
2226  <xfa:data>
2227    <form1>
2228      <items>
2229        <row><value>A</value></row>
2230        <row><value>B</value></row>
2231        <row><value>C</value></row>
2232      </items>
2233    </form1>
2234  </xfa:data>
2235</xfa:datasets>"#;
2236
2237        let (tree, root_id) = parse_template(template, Some(datasets)).unwrap();
2238        let items_id = find_node_id_by_name(&tree, root_id, "items").unwrap();
2239        let row_ids = tree.get(items_id).children.clone();
2240
2241        assert_eq!(row_ids.len(), 3);
2242        assert!(row_ids
2243            .iter()
2244            .all(|&row_id| tree.get(row_id).occur.count() == 1));
2245
2246        let values: Vec<String> = row_ids
2247            .iter()
2248            .map(|&row_id| {
2249                let field_id = tree.get(row_id).children[0];
2250                match &tree.get(field_id).node_type {
2251                    FormNodeType::Field { value } => value.clone(),
2252                    other => panic!("expected Field, got {other:?}"),
2253                }
2254            })
2255            .collect();
2256
2257        assert_eq!(values, vec!["A", "B", "C"]);
2258    }
2259
2260    #[test]
2261    fn explicit_dataref_bind_repeats_subform_instances() {
2262        let template = r#"<?xml version="1.0" encoding="UTF-8"?>
2263<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2264  <subform name="form1" layout="paginate">
2265    <pageSet>
2266      <pageArea name="Page1">
2267        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2268      </pageArea>
2269    </pageSet>
2270    <subform name="items" layout="tb" w="7in">
2271      <subform name="entryRow" layout="tb" w="7in">
2272        <occur min="0" max="-1"/>
2273        <bind match="dataRef" ref="$.item[*]"/>
2274        <field name="label" w="2in" h="0.3in">
2275          <bind match="dataRef" ref="$.value"/>
2276          <ui><textEdit/></ui>
2277          <value><text/></value>
2278        </field>
2279      </subform>
2280    </subform>
2281  </subform>
2282</template>"#;
2283        let datasets = r#"<?xml version="1.0" encoding="UTF-8"?>
2284<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
2285  <xfa:data>
2286    <form1>
2287      <items>
2288        <item><value>One</value></item>
2289        <item><value>Two</value></item>
2290        <item><value>Three</value></item>
2291      </items>
2292    </form1>
2293  </xfa:data>
2294</xfa:datasets>"#;
2295
2296        let (tree, root_id) = parse_template(template, Some(datasets)).unwrap();
2297        let items_id = find_node_id_by_name(&tree, root_id, "items").unwrap();
2298        let row_ids = tree.get(items_id).children.clone();
2299
2300        assert_eq!(row_ids.len(), 3);
2301        assert!(row_ids
2302            .iter()
2303            .all(|&row_id| tree.get(row_id).occur.count() == 1));
2304
2305        let values: Vec<String> = row_ids
2306            .iter()
2307            .map(|&row_id| {
2308                let field_id = tree.get(row_id).children[0];
2309                match &tree.get(field_id).node_type {
2310                    FormNodeType::Field { value } => value.clone(),
2311                    other => panic!("expected Field, got {other:?}"),
2312                }
2313            })
2314            .collect();
2315
2316        assert_eq!(values, vec!["One", "Two", "Three"]);
2317    }
2318
2319    #[test]
2320    fn dimension_parsing() {
2321        assert!((parse_dim("0.5in").unwrap() - 36.0).abs() < 0.01);
2322        assert!((parse_dim("72pt").unwrap() - 72.0).abs() < 0.01);
2323        assert!((parse_dim("1in").unwrap() - 72.0).abs() < 0.01);
2324        assert!((parse_dim("8.5in").unwrap() - 612.0).abs() < 0.1);
2325        assert!((parse_dim("11in").unwrap() - 792.0).abs() < 0.1);
2326    }
2327
2328    #[test]
2329    fn layout_attr_parsing() {
2330        assert_eq!(parse_layout_str("tb"), LayoutStrategy::TopToBottom);
2331        assert_eq!(parse_layout_str("lr-tb"), LayoutStrategy::LeftToRightTB);
2332        assert_eq!(parse_layout_str("paginate"), LayoutStrategy::TopToBottom);
2333        assert_eq!(parse_layout_str("position"), LayoutStrategy::Positioned);
2334    }
2335
2336    fn parse_layout_str(s: &str) -> LayoutStrategy {
2337        let xml = format!(
2338            r#"<?xml version="1.0"?><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform layout="{s}"/></template>"#
2339        );
2340        let doc = roxmltree::Document::parse(&xml).unwrap();
2341        let root = doc.root_element();
2342        let subform = root.children().find(|n| n.is_element()).unwrap();
2343        parse_layout_attr(subform)
2344    }
2345
2346    fn find_node_by_name<'a>(
2347        tree: &'a FormTree,
2348        id: FormNodeId,
2349        name: &str,
2350    ) -> Option<&'a FormNode> {
2351        let node = tree.get(id);
2352        if node.name == name {
2353            return Some(node);
2354        }
2355        for &child_id in &node.children {
2356            if let Some(found) = find_node_by_name(tree, child_id, name) {
2357                return Some(found);
2358            }
2359        }
2360        None
2361    }
2362
2363    fn find_node_id_by_name(tree: &FormTree, id: FormNodeId, name: &str) -> Option<FormNodeId> {
2364        if tree.get(id).name == name {
2365            return Some(id);
2366        }
2367        for &child_id in &tree.get(id).children.clone() {
2368            if let Some(found) = find_node_id_by_name(tree, child_id, name) {
2369                return Some(found);
2370            }
2371        }
2372        None
2373    }
2374
2375    /// <exData contentType="text/html"> rich-text draw nodes must have their
2376    /// HTML stripped and plain text extracted so LayoutEngine can render them.
2377    #[test]
2378    fn draw_exdata_html_text_extracted() {
2379        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2380<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2381  <subform name="form1" layout="paginate">
2382    <pageSet>
2383      <pageArea name="Page1">
2384        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2385        <medium stock="default" short="8.5in" long="11in"/>
2386      </pageArea>
2387    </pageSet>
2388    <subform name="body" layout="tb" w="7.5in">
2389      <draw name="instructions" w="7in" h="1in">
2390        <value>
2391          <exData contentType="text/html">
2392            <body xmlns="http://www.w3.org/1999/xhtml">
2393              <p>Do <span>not</span> file this form.</p>
2394            </body>
2395          </exData>
2396        </value>
2397      </draw>
2398    </subform>
2399  </subform>
2400</template>"#;
2401        let (tree, root_id) = parse_template(xml, None).unwrap();
2402        let node =
2403            find_node_by_name(&tree, root_id, "instructions").expect("instructions draw not found");
2404        match &node.node_type {
2405            FormNodeType::Draw(DrawContent::Text(content)) => {
2406                assert!(
2407                    content.contains("not") && content.contains("file"),
2408                    "expected HTML text extracted, got: {content:?}"
2409                );
2410            }
2411            other => panic!("expected Draw, got {other:?}"),
2412        }
2413    }
2414
2415    /// Draw and field elements with presence="hidden" must not expose content
2416    /// to the renderer (content should be empty) while still occupying layout
2417    /// space (node is still present in the tree). (#557)
2418    #[test]
2419    fn hidden_elements_have_empty_content() {
2420        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2421<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2422  <subform name="form1" layout="paginate">
2423    <pageSet>
2424      <pageArea name="Page1">
2425        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2426        <medium stock="default" short="8.5in" long="11in"/>
2427      </pageArea>
2428    </pageSet>
2429    <subform name="body" layout="tb" w="7.5in">
2430      <draw name="visible_draw" w="7in" h="0.5in">
2431        <value><text>Visible text</text></value>
2432      </draw>
2433      <draw name="hidden_draw" w="7in" h="0.5in" presence="hidden">
2434        <value><text>DRAFT</text></value>
2435      </draw>
2436      <field name="hidden_field" w="3in" h="0.3in" presence="hidden">
2437        <value><text>secret</text></value>
2438      </field>
2439    </subform>
2440  </subform>
2441</template>"#;
2442        let (tree, root_id) = parse_template(xml, None).unwrap();
2443
2444        // Visible draw retains its content.
2445        let visible = find_node_by_name(&tree, root_id, "visible_draw").unwrap();
2446        match &visible.node_type {
2447            FormNodeType::Draw(DrawContent::Text(content)) => assert_eq!(content, "Visible text"),
2448            other => panic!("expected Draw, got {other:?}"),
2449        }
2450
2451        // Hidden draw preserves content (scripts may make it visible).
2452        // Visibility is tracked in FormNodeMeta.
2453        let hidden_draw = find_node_by_name(&tree, root_id, "hidden_draw").unwrap();
2454        match &hidden_draw.node_type {
2455            FormNodeType::Draw(DrawContent::Text(content)) => assert_eq!(content, "DRAFT"),
2456            other => panic!("expected Draw, got {other:?}"),
2457        }
2458        let hidden_draw_id = find_node_id_by_name(&tree, root_id, "hidden_draw").unwrap();
2459        assert!(tree.meta(hidden_draw_id).presence.is_not_visible());
2460
2461        // Hidden fields preserve content and remain Field type — layout
2462        // engine skips them via metadata.
2463        let hidden_field = find_node_by_name(&tree, root_id, "hidden_field").unwrap();
2464        match &hidden_field.node_type {
2465            FormNodeType::Field { value } => assert_eq!(value, "secret"),
2466            other => panic!("expected Field, got {other:?}"),
2467        }
2468        let hidden_field_id = find_node_id_by_name(&tree, root_id, "hidden_field").unwrap();
2469        assert!(tree.meta(hidden_field_id).presence.is_not_visible());
2470    }
2471
2472    /// Font sizes given as bare numbers (`<font size="10">`) must be treated as
2473    /// **points**, not inches.  A bare "10" used to go through `parse_dim` which
2474    /// added the "in" suffix, turning 10pt → 720pt and making text enormous. (#557)
2475    #[test]
2476    fn font_size_bare_number_is_points() {
2477        // parse_font_size must not multiply by 72.
2478        assert_eq!(parse_font_size("10"), Some(10.0));
2479        assert_eq!(parse_font_size("8"), Some(8.0));
2480        assert_eq!(parse_font_size("12"), Some(12.0));
2481        // Explicit unit must still work.
2482        assert!((parse_font_size("10pt").unwrap() - 10.0).abs() < 0.01);
2483        // Zero/negative → None.
2484        assert_eq!(parse_font_size("0"), None);
2485    }
2486
2487    /// `<para hAlign="center/right/justify">` on draw/field elements must be
2488    /// reflected in `FontMetrics.text_align` so the renderer can align text
2489    /// within the element's bounding box. (#557)
2490    #[test]
2491    fn para_halign_parsed_into_font_metrics() {
2492        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2493<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2494  <subform name="form1" layout="paginate">
2495    <pageSet>
2496      <pageArea name="Page1">
2497        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2498        <medium stock="default" short="8.5in" long="11in"/>
2499      </pageArea>
2500    </pageSet>
2501    <subform name="body" layout="tb" w="7.5in">
2502      <draw name="left_draw" w="7in" h="0.5in">
2503        <value><text>Left</text></value>
2504        <para hAlign="left"/>
2505      </draw>
2506      <draw name="center_draw" w="7in" h="0.5in">
2507        <value><text>Centered</text></value>
2508        <para hAlign="center"/>
2509      </draw>
2510      <draw name="right_draw" w="7in" h="0.5in">
2511        <value><text>Right</text></value>
2512        <para hAlign="right"/>
2513      </draw>
2514    </subform>
2515  </subform>
2516</template>"#;
2517        let (tree, root_id) = parse_template(xml, None).unwrap();
2518
2519        let left = find_node_by_name(&tree, root_id, "left_draw").unwrap();
2520        assert_eq!(
2521            left.font.text_align,
2522            TextAlign::Left,
2523            "left_draw should be Left"
2524        );
2525
2526        let center = find_node_by_name(&tree, root_id, "center_draw").unwrap();
2527        assert_eq!(
2528            center.font.text_align,
2529            TextAlign::Center,
2530            "center_draw should be Center"
2531        );
2532
2533        let right = find_node_by_name(&tree, root_id, "right_draw").unwrap();
2534        assert_eq!(
2535            right.font.text_align,
2536            TextAlign::Right,
2537            "right_draw should be Right"
2538        );
2539    }
2540
2541    /// BMP images in `<image contentType="image/bmp">` must be converted to PNG.
2542    /// PDF does not support BMP natively, so the parser converts on extraction. (#670)
2543    #[test]
2544    fn bmp_image_converted_to_png() {
2545        // Minimal 1×1 BMP (24-bit, no compression): 58 bytes.
2546        let bmp_bytes: [u8; 58] = [
2547            0x42, 0x4D, // "BM" magic
2548            0x3A, 0x00, 0x00, 0x00, // file size = 58
2549            0x00, 0x00, 0x00, 0x00, // reserved
2550            0x36, 0x00, 0x00, 0x00, // pixel data offset = 54
2551            0x28, 0x00, 0x00, 0x00, // DIB header size = 40
2552            0x01, 0x00, 0x00, 0x00, // width = 1
2553            0x01, 0x00, 0x00, 0x00, // height = 1
2554            0x01, 0x00, // planes = 1
2555            0x18, 0x00, // bits per pixel = 24
2556            0x00, 0x00, 0x00, 0x00, // compression = 0
2557            0x04, 0x00, 0x00, 0x00, // image size = 4 (1 pixel + 1 byte padding)
2558            0x13, 0x0B, 0x00, 0x00, // h-res
2559            0x13, 0x0B, 0x00, 0x00, // v-res
2560            0x00, 0x00, 0x00, 0x00, // colors
2561            0x00, 0x00, 0x00, 0x00, // important colors
2562            0xFF, 0x00, 0x00,
2563            0x00, // pixel (BGR: blue=FF, green=0, red=0) + 1 byte row padding
2564        ];
2565        use base64::Engine;
2566        let b64 = base64::engine::general_purpose::STANDARD.encode(bmp_bytes);
2567
2568        let xml = format!(
2569            r#"<?xml version="1.0" encoding="UTF-8"?>
2570<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2571  <subform name="form1" layout="paginate">
2572    <pageSet>
2573      <pageArea name="Page1">
2574        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2575        <medium stock="default" short="8.5in" long="11in"/>
2576      </pageArea>
2577    </pageSet>
2578    <subform name="body" layout="tb" w="7.5in">
2579      <draw name="barcode_img" w="2in" h="0.5in">
2580        <value>
2581          <image contentType="image/bmp">{b64}</image>
2582        </value>
2583      </draw>
2584    </subform>
2585  </subform>
2586</template>"#
2587        );
2588        let (tree, root_id) = parse_template(&xml, None).unwrap();
2589        let node = find_node_by_name(&tree, root_id, "barcode_img").expect("barcode_img not found");
2590        match &node.node_type {
2591            FormNodeType::Image { data, mime_type } => {
2592                assert_eq!(mime_type, "image/png", "BMP should be converted to PNG");
2593                // PNG magic bytes: 0x89 P N G
2594                assert!(
2595                    data.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
2596                    "expected PNG magic bytes, got {:?}",
2597                    &data[..4.min(data.len())]
2598                );
2599            }
2600            other => panic!("expected Image, got {other:?}"),
2601        }
2602    }
2603
2604    #[test]
2605    fn parse_percentage_values() {
2606        assert!((parse_percentage("96%").unwrap() - 0.96).abs() < 1e-10);
2607        assert!((parse_percentage("110%").unwrap() - 1.10).abs() < 1e-10);
2608        assert!((parse_percentage("100%").unwrap() - 1.0).abs() < 1e-10);
2609        assert!((parse_percentage("50%").unwrap() - 0.50).abs() < 1e-10);
2610        assert!(parse_percentage("notanumber%").is_none());
2611        assert!(parse_percentage("96").is_none()); // no % suffix
2612    }
2613
2614    #[test]
2615    fn parse_letter_spacing_values() {
2616        let font_size = 10.0;
2617        // em-based
2618        let v = parse_letter_spacing("-0.018em", font_size).unwrap();
2619        assert!((v - (-0.018 * 10.0)).abs() < 1e-10);
2620        let v = parse_letter_spacing("0.1em", font_size).unwrap();
2621        assert!((v - 1.0).abs() < 1e-10);
2622        // bare zero
2623        assert_eq!(parse_letter_spacing("0", font_size), Some(0.0));
2624        // pt-based (via Measurement)
2625        let v = parse_letter_spacing("0.5pt", font_size).unwrap();
2626        assert!((v - 0.5).abs() < 0.01);
2627    }
2628
2629    #[test]
2630    fn parse_font_color_attr_hex6() {
2631        assert_eq!(parse_font_color_attr("#000080"), Some((0, 0, 128)));
2632        assert_eq!(parse_font_color_attr("#FF0000"), Some((255, 0, 0)));
2633        assert_eq!(parse_font_color_attr("#00ff00"), Some((0, 255, 0)));
2634        assert_eq!(parse_font_color_attr("#ABCDEF"), Some((0xAB, 0xCD, 0xEF)));
2635    }
2636
2637    #[test]
2638    fn parse_font_color_attr_hex3() {
2639        // #RGB shorthand: each digit is doubled (e.g. #F00 → #FF0000)
2640        assert_eq!(parse_font_color_attr("#F00"), Some((255, 0, 0)));
2641        assert_eq!(parse_font_color_attr("#0F0"), Some((0, 255, 0)));
2642        assert_eq!(parse_font_color_attr("#00F"), Some((0, 0, 255)));
2643        assert_eq!(parse_font_color_attr("#ABC"), Some((0xAA, 0xBB, 0xCC)));
2644    }
2645
2646    #[test]
2647    fn parse_font_color_attr_decimal_csv() {
2648        assert_eq!(parse_font_color_attr("0,0,128"), Some((0, 0, 128)));
2649        assert_eq!(parse_font_color_attr("255, 128, 0"), Some((255, 128, 0)));
2650    }
2651
2652    #[test]
2653    fn parse_font_color_attr_invalid() {
2654        assert_eq!(parse_font_color_attr(""), None);
2655        assert_eq!(parse_font_color_attr("#GG0000"), None);
2656        assert_eq!(parse_font_color_attr("#12345"), None);
2657        assert_eq!(parse_font_color_attr("not_a_color"), None);
2658    }
2659
2660    /// `<font color="#000080">` attribute must be parsed into
2661    /// `style.text_color` when no `<fill><color>` child is present. (#740)
2662    #[test]
2663    fn font_color_attribute_parsed() {
2664        let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
2665<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2666  <subform name="form1" layout="paginate">
2667    <pageSet>
2668      <pageArea name="Page1">
2669        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2670        <medium stock="default" short="8.5in" long="11in"/>
2671      </pageArea>
2672    </pageSet>
2673    <subform name="body" layout="tb" w="7.5in">
2674      <draw name="blue_text" w="7in" h="0.5in">
2675        <value><text>Navy blue</text></value>
2676        <font typeface="Arial" size="10pt" color="#000080"/>
2677      </draw>
2678    </subform>
2679  </subform>
2680</template>"##;
2681        let (tree, root_id) = parse_template(xml, None).unwrap();
2682        let id = find_node_id_by_name(&tree, root_id, "blue_text").unwrap();
2683        let style = &tree.meta(id).style;
2684        assert_eq!(
2685            style.text_color,
2686            Some((0, 0, 128)),
2687            "font color=#000080 should parse to (0, 0, 128)"
2688        );
2689    }
2690
2691    #[test]
2692    fn font_horizontal_scale_and_letter_spacing_parsed() {
2693        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2694<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2695  <subform name="form1" layout="paginate">
2696    <pageSet>
2697      <pageArea name="Page1">
2698        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
2699        <medium stock="default" short="8.5in" long="11in"/>
2700      </pageArea>
2701    </pageSet>
2702    <subform name="body" layout="tb" w="7.5in">
2703      <draw name="scaled_text" w="7in" h="0.5in">
2704        <value><text>Scaled</text></value>
2705        <font typeface="Arial" size="10pt" fontHorizontalScale="96%" letterSpacing="-0.018em"/>
2706      </draw>
2707    </subform>
2708  </subform>
2709</template>"#;
2710        let (tree, root_id) = parse_template(xml, None).unwrap();
2711        let id = find_node_id_by_name(&tree, root_id, "scaled_text").unwrap();
2712        let style = &tree.meta(id).style;
2713        assert!(
2714            (style.font_horizontal_scale.unwrap() - 0.96).abs() < 1e-10,
2715            "expected 0.96, got {:?}",
2716            style.font_horizontal_scale
2717        );
2718        assert!(
2719            (style.letter_spacing_pt.unwrap() - (-0.18)).abs() < 0.01,
2720            "expected -0.18pt, got {:?}",
2721            style.letter_spacing_pt
2722        );
2723    }
2724
2725    #[test]
2726    fn choice_list_items_parsed() {
2727        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2728<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2729  <subform name="root" layout="tb">
2730    <pageSet>
2731      <pageArea name="Page1">
2732        <contentArea w="8in" h="10in"/>
2733      </pageArea>
2734    </pageSet>
2735    <field name="country" w="3in" h="0.3in">
2736      <ui><choiceList/></ui>
2737      <value><text>US</text></value>
2738      <items>
2739        <text>United States</text>
2740        <text>United Kingdom</text>
2741        <text>Canada</text>
2742      </items>
2743      <items save="1">
2744        <text>US</text>
2745        <text>UK</text>
2746        <text>CA</text>
2747      </items>
2748    </field>
2749    <field name="single_items" w="3in" h="0.3in">
2750      <ui><choiceList/></ui>
2751      <value><text>Red</text></value>
2752      <items>
2753        <text>Red</text>
2754        <text>Green</text>
2755        <text>Blue</text>
2756      </items>
2757    </field>
2758  </subform>
2759</template>"#;
2760
2761        let (tree, _pages) = parse_template(xml, None).unwrap();
2762
2763        // Find the "country" field — should have display + save items
2764        let country = tree
2765            .nodes
2766            .iter()
2767            .enumerate()
2768            .find(|(_, n)| n.name == "country")
2769            .map(|(i, _)| FormNodeId(i))
2770            .expect("country field not found");
2771        let meta = tree.meta(country);
2772        assert_eq!(meta.field_kind, FieldKind::Dropdown);
2773        assert_eq!(
2774            meta.display_items,
2775            vec!["United States", "United Kingdom", "Canada"]
2776        );
2777        assert_eq!(meta.save_items, vec!["US", "UK", "CA"]);
2778
2779        // Find the "single_items" field — only display items, no save
2780        let single = tree
2781            .nodes
2782            .iter()
2783            .enumerate()
2784            .find(|(_, n)| n.name == "single_items")
2785            .map(|(i, _)| FormNodeId(i))
2786            .expect("single_items field not found");
2787        let meta_s = tree.meta(single);
2788        assert_eq!(meta_s.display_items, vec!["Red", "Green", "Blue"]);
2789        assert!(meta_s.save_items.is_empty());
2790    }
2791
2792    #[test]
2793    fn content_area_without_xy_defaults_to_origin() {
2794        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2795<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2796  <subform name="root" layout="tb">
2797    <pageSet>
2798      <pageArea name="Page1">
2799        <contentArea w="8in" h="10in"/>
2800      </pageArea>
2801    </pageSet>
2802  </subform>
2803</template>"#;
2804
2805        let (tree, root_id) = parse_template(xml, None).unwrap();
2806        let page_area_id = find_node_id_by_name(&tree, root_id, "Page1").unwrap();
2807        let page_area = tree.get(page_area_id);
2808
2809        match &page_area.node_type {
2810            FormNodeType::PageArea { content_areas } => {
2811                assert_eq!(content_areas.len(), 1);
2812                assert_eq!(content_areas[0].x, 0.0);
2813                assert_eq!(content_areas[0].y, 0.0);
2814                assert!((content_areas[0].width - 576.0).abs() < 0.01);
2815                assert!((content_areas[0].height - 720.0).abs() < 0.01);
2816            }
2817            other => panic!("expected PageArea, got {other:?}"),
2818        }
2819    }
2820
2821    #[test]
2822    fn check_button_mark_parsed_into_style() {
2823        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2824<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2825  <subform name="root" layout="tb">
2826    <pageSet>
2827      <pageArea name="Page1">
2828        <contentArea w="8in" h="10in"/>
2829      </pageArea>
2830    </pageSet>
2831    <field name="agree" w="0.3in" h="0.3in">
2832      <ui><checkButton mark="circle"/></ui>
2833      <value><text>1</text></value>
2834    </field>
2835  </subform>
2836</template>"#;
2837
2838        let (tree, root_id) = parse_template(xml, None).unwrap();
2839        let id = find_node_id_by_name(&tree, root_id, "agree").unwrap();
2840        let meta = tree.meta(id);
2841        assert_eq!(meta.field_kind, FieldKind::Checkbox);
2842        assert_eq!(meta.style.check_button_mark.as_deref(), Some("circle"));
2843    }
2844
2845    #[test]
2846    fn border_widths_parsed_from_per_edge_template_border() {
2847        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2848<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
2849  <subform name="root" layout="tb">
2850    <pageSet>
2851      <pageArea name="Page1">
2852        <contentArea w="8in" h="10in"/>
2853      </pageArea>
2854    </pageSet>
2855    <field name="amount" w="2in" h="0.3in">
2856      <ui><textEdit/></ui>
2857      <value><text>42</text></value>
2858      <border>
2859        <edge thickness="1pt"/>
2860        <edge thickness="2pt"/>
2861        <edge thickness="3pt"/>
2862        <edge thickness="4pt"/>
2863      </border>
2864    </field>
2865  </subform>
2866</template>"#;
2867
2868        let (tree, root_id) = parse_template(xml, None).unwrap();
2869        let id = find_node_id_by_name(&tree, root_id, "amount").unwrap();
2870        let style = &tree.meta(id).style;
2871
2872        assert_eq!(style.border_width_pt, Some(1.0));
2873        assert_eq!(style.border_widths, Some([1.0, 2.0, 3.0, 4.0]));
2874    }
2875}
2876
2877#[cfg(test)]
2878mod bookend_parser_tests {
2879    //! `<bookend>` has to come out of the template, not out of an assumption.
2880    //!
2881    //! Without these tests the rest of #150 rests on a parser that may read
2882    //! nothing -- and then everything works except the beginning.
2883
2884    use roxmltree::Document;
2885
2886    fn first_subform(xml: &str) -> String {
2887        let doc = Document::parse(xml).expect("valid XML");
2888        let subform = doc
2889            .descendants()
2890            .find(|n| n.has_tag_name("subform"))
2891            .expect("a subform");
2892        let (leader, trailer) = super::parse_bookend(subform);
2893        format!("{leader:?}/{trailer:?}")
2894    }
2895
2896    #[test]
2897    fn reads_leader_and_trailer() {
2898        let xml = r#"<template><subform name="a">
2899            <bookend leader="Head" trailer="Foot"/>
2900        </subform></template>"#;
2901        assert_eq!(first_subform(xml), r#"Some("Head")/Some("Foot")"#);
2902    }
2903
2904    #[test]
2905    fn either_of_the_two_may_be_absent() {
2906        let xml = r#"<template><subform name="a">
2907            <bookend leader="Head"/>
2908        </subform></template>"#;
2909        assert_eq!(first_subform(xml), r#"Some("Head")/None"#);
2910    }
2911
2912    #[test]
2913    fn without_a_bookend_nothing() {
2914        let xml = r#"<template><subform name="a"/></template>"#;
2915        assert_eq!(first_subform(xml), "None/None");
2916    }
2917
2918    /// The easiest mistake to make: reading `<overflow>` where `<bookend>`
2919    /// stands, or the other way round. They carry the same attributes and
2920    /// serve different pages.
2921    #[test]
2922    fn overflow_is_not_a_bookend() {
2923        let xml = r#"<template><subform name="a">
2924            <overflow leader="OverflowHead" trailer="OverflowFoot"/>
2925        </subform></template>"#;
2926        assert_eq!(
2927            first_subform(xml),
2928            "None/None",
2929            "parse_bookend reads an <overflow> as though it were a <bookend>"
2930        );
2931    }
2932
2933    #[test]
2934    fn the_two_side_by_side_stay_separate() {
2935        let xml = r#"<template><subform name="a">
2936            <overflow leader="OHead" trailer="OFoot"/>
2937            <bookend leader="BHead" trailer="BFoot"/>
2938        </subform></template>"#;
2939        let doc = Document::parse(xml).unwrap();
2940        let subform = doc
2941            .descendants()
2942            .find(|n| n.has_tag_name("subform"))
2943            .unwrap();
2944        assert_eq!(
2945            super::parse_bookend(subform),
2946            (Some("BHead".into()), Some("BFoot".into()))
2947        );
2948        assert_eq!(
2949            super::parse_overflow(subform),
2950            (Some("OHead".into()), Some("OFoot".into()))
2951        );
2952    }
2953}