Skip to main content

pdf_xfa/
render_bridge.rs

1//! XFA layout output to PDF content stream overlay generation.
2//!
3//! Converts LayoutDom (from xfa-layout-engine) into PDF content stream
4//! operators that can be overlaid on existing PDF pages.
5//!
6//! XFA Spec 3.3 §2.6 (p55-56) — Transformations: XFA uses top-left origin
7//! (y grows downward), PDF uses bottom-left origin (y grows upward).
8//! The `CoordinateMapper` handles this transformation.
9//!
10//! XFA Spec 3.3 §2.7 — Z-Order: objects are rendered in document order.
11//! Later objects appear on top of earlier objects (painter's algorithm).
12
13// Copyright (c) 2026 Innovation Trigger B.V.
14//
15// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
16// the PDFluent Commercial Licence. See the LICENSE file in this repository --
17// that file travels with the copy you received, which a URL does not.
18
19use crate::error::Result;
20use crate::font_bridge::font_variant_key;
21use std::collections::HashMap;
22use std::sync::Arc;
23use xfa_layout_engine::form::{DrawContent, FieldKind, FormNodeStyle, RichTextSpan};
24use xfa_layout_engine::layout::{LayoutContent, LayoutDom, LayoutNode, LayoutPage};
25use xfa_layout_engine::text::{FontFamily, FontMetrics};
26use xfa_layout_engine::types::{TextAlign, VerticalAlign};
27
28/// Configuration for PDF overlay rendering.
29#[derive(Debug, Clone)]
30pub struct XfaRenderConfig {
31    /// Default font name to use in content streams.
32    pub default_font: String,
33    /// Default font size in points.
34    pub default_font_size: f64,
35    /// Whether to draw field borders.
36    pub draw_borders: bool,
37    /// Border line width.
38    pub border_width: f64,
39    /// Border color (RGB 0-1).
40    pub border_color: [f64; 3],
41    /// Text color (RGB 0-1).
42    pub text_color: [f64; 3],
43    /// Background color for fields (None = transparent).
44    pub background_color: Option<[f64; 3]>,
45    /// Text padding from field edges.
46    pub text_padding: f64,
47    /// Map from typeface name to PDF font resource name (e.g. "/XFA_F0").
48    ///
49    /// Wrapped in [`Arc`] so the immutable font-resource map can be shared
50    /// across thousands of `XfaRenderConfig::clone()` calls (PERF2-02:
51    /// `apply_node_style` clones the config per layout node). The field
52    /// dereferences to `HashMap<String, String>` transparently, so existing
53    /// `&config.font_map.get(...)` call sites continue to work unchanged.
54    pub font_map: Arc<HashMap<String, String>>,
55    /// Resolved font metrics per typeface.
56    ///
57    /// Same `Arc`-sharing rationale as [`Self::font_map`]: avoids deep-cloning
58    /// the per-typeface widths array on every node-level config clone.
59    pub font_metrics_data: Arc<HashMap<String, FontMetricsData>>,
60    /// CheckButton mark style (check, circle, cross, diamond, square, star).
61    pub check_button_mark: Option<String>,
62    /// When true, only render field value text — skip backgrounds, borders,
63    /// captions, draws, and wrapped text.  Used by the preserve_static hybrid
64    /// path to add XFA field values on top of pre-rendered page content without
65    /// duplicating the visual structure.
66    pub field_values_only: bool,
67}
68
69/// Resolved font metrics for a typeface, used for accurate text measurement.
70#[derive(Debug, Clone)]
71pub struct FontMetricsData {
72    /// Unicode-indexed widths derived from PDF `/Widths` or font data.
73    pub widths: Vec<u16>,
74    /// Units per em of the font.
75    pub upem: u16,
76    /// Font ascender in font units.
77    pub ascender: i16,
78    /// Font descender in font units (typically negative).
79    pub descender: i16,
80    /// Raw font data for glyph ID lookup (Identity-H fonts).
81    pub font_data: Option<Vec<u8>>,
82    /// Font face index within a collection.
83    pub face_index: u32,
84    /// Optional Unicode->code map for simple PDF fonts with custom encodings.
85    ///
86    /// When Identity-H glyph encoding is unavailable (`font_data == None`),
87    /// this map lets us emit bytes in the source font's actual encoding space
88    /// instead of assuming WinAnsi for every simple font.
89    pub simple_unicode_to_code: Option<HashMap<u16, u8>>,
90}
91
92/// Image data collected during rendering for XObject embedding.
93#[derive(Debug, Clone)]
94pub struct ImageInfo {
95    /// name.
96    pub name: String,
97    /// data.
98    pub data: Vec<u8>,
99    /// mime_type.
100    pub mime_type: String,
101}
102
103/// Overlay result for a single page, including content stream and images.
104#[derive(Debug, Clone)]
105pub struct PageOverlay {
106    /// content_stream.
107    pub content_stream: Vec<u8>,
108    /// images.
109    pub images: Vec<ImageInfo>,
110}
111
112impl Default for XfaRenderConfig {
113    fn default() -> Self {
114        Self {
115            default_font: "Helvetica".to_string(),
116            default_font_size: 10.0,
117            draw_borders: true,
118            border_width: 1.0, // fix(#808): XFA default border thickness is 1pt, not 0.5pt.
119            // BoxModel::border_width also defaults to 1.0pt (types.rs:368), so rendering
120            // must match this to avoid 1px visual difference at 150 DPI (0.5pt vs 1pt ≈ 1px).
121            border_color: [0.0, 0.0, 0.0],
122            text_color: [0.0, 0.0, 0.0],
123            background_color: None,
124            text_padding: xfa_layout_engine::types::DEFAULT_TEXT_PADDING,
125            font_map: Arc::new(HashMap::new()),
126            font_metrics_data: Arc::new(HashMap::new()),
127            check_button_mark: None,
128            field_values_only: false,
129        }
130    }
131}
132
133/// Maps XFA coordinates (top-left origin) to PDF coordinates (bottom-left origin).
134pub struct CoordinateMapper {
135    page_height: f64,
136    page_width: f64,
137}
138
139impl CoordinateMapper {
140    /// new.
141    pub fn new(page_height: f64, page_width: f64) -> Self {
142        Self {
143            page_height,
144            page_width,
145        }
146    }
147
148    /// Convert XFA y-coordinate to PDF y-coordinate.
149    pub fn xfa_to_pdf_y(&self, xfa_y: f64, element_height: f64) -> f64 {
150        self.page_height - xfa_y - element_height
151    }
152
153    /// Returns the page width for bounding content.
154    pub fn page_width(&self) -> f64 {
155        self.page_width
156    }
157}
158
159/// Create a per-node config by applying XFA template style overrides to the
160/// global config.
161fn apply_node_style(config: &XfaRenderConfig, style: &FormNodeStyle) -> XfaRenderConfig {
162    let mut cfg = config.clone();
163
164    if let Some((r, g, b)) = style.bg_color {
165        cfg.background_color = Some([r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]);
166    }
167
168    cfg.draw_borders = false;
169    // Some XFA templates only expose a usable border width via per-edge data
170    // (for example when the first edge is hidden and later edges remain
171    // visible). Treat those widths as sufficient to enable border rendering;
172    // otherwise visible right/left/bottom borders disappear because
173    // border_width_pt stays unset.
174    if let Some(bw) = effective_border_width(style) {
175        cfg.border_width = bw;
176        cfg.draw_borders = true;
177        if let Some((r, g, b)) = style.border_color {
178            cfg.border_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
179        }
180    }
181
182    if let Some((r, g, b)) = style.text_color {
183        cfg.text_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
184    }
185
186    if let Some(mark) = &style.check_button_mark {
187        cfg.check_button_mark = Some(mark.clone());
188    }
189
190    cfg
191}
192
193fn effective_border_width(style: &FormNodeStyle) -> Option<f64> {
194    if let Some(bw) = style.border_width_pt.filter(|bw| *bw > 0.0) {
195        return Some(bw);
196    }
197
198    style
199        .border_widths
200        .as_ref()
201        .map(|widths| {
202            widths
203                .iter()
204                .zip(style.border_edges.iter())
205                .filter_map(|(width, visible)| (*visible && *width > 0.0).then_some(*width))
206                .fold(0.0, f64::max)
207        })
208        .filter(|bw| *bw > 0.0)
209}
210
211/// Generate a PDF content stream overlay for a single page.
212pub fn generate_page_overlay(page: &LayoutPage, config: &XfaRenderConfig) -> Result<PageOverlay> {
213    let mapper = CoordinateMapper::new(page.height, page.width);
214    let mut ops = Vec::new();
215    let mut images: Vec<ImageInfo> = Vec::new();
216    ops.extend_from_slice(b"q\n");
217    render_nodes(
218        &page.nodes,
219        0.0,
220        0.0,
221        &mapper,
222        config,
223        &mut ops,
224        &mut images,
225    );
226    ops.extend_from_slice(b"Q\n");
227    Ok(PageOverlay {
228        content_stream: ops,
229        images,
230    })
231}
232
233/// Generate PDF content stream overlays for all pages in a layout.
234pub fn generate_all_overlays(
235    layout: &LayoutDom,
236    config: &XfaRenderConfig,
237) -> Result<Vec<PageOverlay>> {
238    layout
239        .pages
240        .iter()
241        .map(|page| generate_page_overlay(page, config))
242        .collect()
243}
244
245// ──────────────────────────────────────────────────────────────────────────────
246// RenderTree — inspectable intermediate representation (XFA-F5-01 / #1104)
247// ──────────────────────────────────────────────────────────────────────────────
248
249/// A node in the intermediate render representation.
250///
251/// `RenderTree` is cheap to build and intended for debug/inspection only.
252/// It mirrors the structure of the layout DOM but strips out raw PDF operator
253/// details in favour of human-readable fields.
254#[derive(Debug, Clone)]
255pub enum RenderNode {
256    /// A page container with known dimensions.
257    Page {
258        /// item.
259        width: f64,
260        /// item.
261        height: f64,
262        /// item.
263        children: Vec<RenderNode>,
264    },
265    /// A single line of text positioned in PDF coordinate space.
266    Text {
267        /// item.
268        x: f64,
269        /// item.
270        y: f64,
271        /// item.
272        content: String,
273        /// item.
274        font: String,
275        /// item.
276        size: f64,
277    },
278    /// A filled/stroked rectangle.
279    Rect {
280        /// item.
281        x: f64,
282        /// item.
283        y: f64,
284        /// item.
285        width: f64,
286        /// item.
287        height: f64,
288        /// item.
289        fill: Option<[u8; 3]>,
290        /// item.
291        stroke: Option<[u8; 3]>,
292    },
293    /// An embedded image placeholder (actual bytes excluded for brevity).
294    Image {
295        /// item.
296        x: f64,
297        /// item.
298        y: f64,
299        /// item.
300        width: f64,
301        /// item.
302        height: f64,
303        /// item.
304        data_len: usize,
305    },
306    /// An interactive form widget.
307    Widget {
308        /// item.
309        x: f64,
310        /// item.
311        y: f64,
312        /// item.
313        width: f64,
314        /// item.
315        height: f64,
316        /// item.
317        field_name: String,
318        /// item.
319        value: String,
320    },
321    /// A group of child nodes (e.g. a subform container).
322    Group {
323        /// Child render nodes.
324        children: Vec<RenderNode>,
325    },
326}
327
328impl RenderNode {
329    /// Produce a human-readable, indented tree representation.
330    fn fmt_indented(&self, buf: &mut String, depth: usize) {
331        let indent = "  ".repeat(depth);
332        match self {
333            RenderNode::Page {
334                width,
335                height,
336                children,
337            } => {
338                buf.push_str(&format!("{indent}Page({width:.1}x{height:.1})\n"));
339                for c in children {
340                    c.fmt_indented(buf, depth + 1);
341                }
342            }
343            RenderNode::Text {
344                x,
345                y,
346                content,
347                font,
348                size,
349            } => {
350                let preview: String = content.chars().take(40).collect();
351                let ellipsis = if content.len() > 40 { "…" } else { "" };
352                buf.push_str(&format!(
353                    "{indent}Text({x:.1},{y:.1}) font={font} size={size:.1} \"{preview}{ellipsis}\"\n"
354                ));
355            }
356            RenderNode::Rect {
357                x,
358                y,
359                width,
360                height,
361                fill,
362                stroke,
363            } => {
364                let fill_str = fill
365                    .map(|[r, g, b]| format!("fill=#{r:02X}{g:02X}{b:02X}"))
366                    .unwrap_or_default();
367                let stroke_str = stroke
368                    .map(|[r, g, b]| format!("stroke=#{r:02X}{g:02X}{b:02X}"))
369                    .unwrap_or_default();
370                buf.push_str(&format!(
371                    "{indent}Rect({x:.1},{y:.1} {width:.1}x{height:.1}) {fill_str} {stroke_str}\n"
372                ));
373            }
374            RenderNode::Image {
375                x,
376                y,
377                width,
378                height,
379                data_len,
380            } => {
381                buf.push_str(&format!(
382                    "{indent}Image({x:.1},{y:.1} {width:.1}x{height:.1}) bytes={data_len}\n"
383                ));
384            }
385            RenderNode::Widget {
386                x,
387                y,
388                width,
389                height,
390                field_name,
391                value,
392            } => {
393                let preview: String = value.chars().take(30).collect();
394                buf.push_str(&format!(
395                    "{indent}Widget({x:.1},{y:.1} {width:.1}x{height:.1}) name={field_name} value=\"{preview}\"\n"
396                ));
397            }
398            RenderNode::Group { children } => {
399                buf.push_str(&format!("{indent}Group\n"));
400                for c in children {
401                    c.fmt_indented(buf, depth + 1);
402                }
403            }
404        }
405    }
406}
407
408/// The full intermediate render tree for a document.
409///
410/// Each element of `pages` is a `RenderNode::Page` containing the positioned
411/// content nodes for that page.
412#[derive(Debug, Clone)]
413pub struct RenderTree {
414    /// pages.
415    pub pages: Vec<RenderNode>,
416}
417
418impl RenderTree {
419    /// Produce a human-readable, indented tree string for debug output.
420    pub fn to_debug_string(&self) -> String {
421        let mut buf = String::new();
422        for page in &self.pages {
423            page.fmt_indented(&mut buf, 0);
424        }
425        buf
426    }
427}
428
429/// Build a `RenderTree` from a `LayoutDom`.
430///
431/// This is a lightweight, non-mutating walk: no PDF operators are emitted.
432/// Intended for inspection and debug tooling only; not called on the hot path.
433pub fn layout_dom_to_render_tree(layout: &LayoutDom, config: &XfaRenderConfig) -> RenderTree {
434    let pages = layout
435        .pages
436        .iter()
437        .map(|page| {
438            let mapper = CoordinateMapper::new(page.height, page.width);
439            let children = render_tree_nodes(&page.nodes, 0.0, 0.0, &mapper, config);
440            RenderNode::Page {
441                width: page.width,
442                height: page.height,
443                children,
444            }
445        })
446        .collect();
447    RenderTree { pages }
448}
449
450fn render_tree_nodes(
451    nodes: &[LayoutNode],
452    parent_x: f64,
453    parent_y: f64,
454    mapper: &CoordinateMapper,
455    config: &XfaRenderConfig,
456) -> Vec<RenderNode> {
457    let mut result = Vec::new();
458    for node in nodes {
459        let abs_x = node.rect.x + parent_x;
460        let abs_y = node.rect.y + parent_y;
461        let w = node.rect.width;
462        let h = node.rect.height;
463        let pdf_y = mapper.xfa_to_pdf_y(abs_y, h);
464        let node_cfg = apply_node_style(config, &node.style);
465
466        // Emit a Rect node when background or border is configured.
467        let fill = node_cfg.background_color.map(|c| {
468            [
469                (c[0] * 255.0) as u8,
470                (c[1] * 255.0) as u8,
471                (c[2] * 255.0) as u8,
472            ]
473        });
474        let stroke = if node_cfg.draw_borders && node_cfg.border_width > 0.0 {
475            let bc = node_cfg.border_color;
476            Some([
477                (bc[0] * 255.0) as u8,
478                (bc[1] * 255.0) as u8,
479                (bc[2] * 255.0) as u8,
480            ])
481        } else {
482            None
483        };
484        if fill.is_some() || stroke.is_some() {
485            result.push(RenderNode::Rect {
486                x: abs_x,
487                y: pdf_y,
488                width: w,
489                height: h,
490                fill,
491                stroke,
492            });
493        }
494
495        let leaf = match &node.content {
496            LayoutContent::Field {
497                value,
498                field_kind,
499                font_size,
500                font_family,
501            } => {
502                use xfa_layout_engine::form::FieldKind;
503                match field_kind {
504                    FieldKind::Checkbox | FieldKind::Radio => Some(RenderNode::Widget {
505                        x: abs_x,
506                        y: pdf_y,
507                        width: w,
508                        height: h,
509                        field_name: node.name.clone(),
510                        value: value.clone(),
511                    }),
512                    _ => {
513                        let font_ref = config
514                            .font_map
515                            .get(&font_bridge_key_for_tree(*font_family))
516                            .cloned()
517                            .unwrap_or_else(|| node_cfg.default_font.clone());
518                        if !value.is_empty() {
519                            Some(RenderNode::Text {
520                                x: abs_x,
521                                y: pdf_y,
522                                content: value.clone(),
523                                font: font_ref,
524                                size: if *font_size > 0.0 {
525                                    *font_size
526                                } else {
527                                    node_cfg.default_font_size
528                                },
529                            })
530                        } else {
531                            Some(RenderNode::Widget {
532                                x: abs_x,
533                                y: pdf_y,
534                                width: w,
535                                height: h,
536                                field_name: node.name.clone(),
537                                value: value.clone(),
538                            })
539                        }
540                    }
541                }
542            }
543            LayoutContent::Text(text) => {
544                if text.is_empty() {
545                    None
546                } else {
547                    Some(RenderNode::Text {
548                        x: abs_x,
549                        y: pdf_y,
550                        content: text.clone(),
551                        font: node_cfg.default_font.clone(),
552                        size: node_cfg.default_font_size,
553                    })
554                }
555            }
556            LayoutContent::WrappedText {
557                lines, font_size, ..
558            } => {
559                if lines.is_empty() {
560                    None
561                } else {
562                    Some(RenderNode::Text {
563                        x: abs_x,
564                        y: pdf_y,
565                        content: lines.join(" "),
566                        font: node_cfg.default_font.clone(),
567                        size: *font_size,
568                    })
569                }
570            }
571            LayoutContent::Image { data, .. } => Some(RenderNode::Image {
572                x: abs_x,
573                y: pdf_y,
574                width: w,
575                height: h,
576                data_len: data.len(),
577            }),
578            LayoutContent::Draw(_) | LayoutContent::None => None,
579        };
580        if let Some(leaf_node) = leaf {
581            result.push(leaf_node);
582        }
583
584        // Recurse into children.
585        if !node.children.is_empty() {
586            let child_x = abs_x + node.style.inset_left_pt.unwrap_or(0.0);
587            let child_y = abs_y + node.style.inset_top_pt.unwrap_or(0.0);
588            let child_nodes = render_tree_nodes(&node.children, child_x, child_y, mapper, config);
589            if !child_nodes.is_empty() {
590                result.push(RenderNode::Group {
591                    children: child_nodes,
592                });
593            }
594        }
595    }
596    result
597}
598
599/// Return a font-bridge lookup key from a FontFamily enum for render-tree use.
600fn font_bridge_key_for_tree(family: xfa_layout_engine::text::FontFamily) -> String {
601    use xfa_layout_engine::text::FontFamily;
602    match family {
603        FontFamily::SansSerif => "sans-serif".to_string(),
604        FontFamily::Monospace => "monospace".to_string(),
605        FontFamily::Serif => "serif".to_string(),
606    }
607}
608
609/// Generate overlays containing only field value text — no backgrounds,
610/// borders, captions, draws, or images.  Used by the preserve_static path
611/// when widgets lack AP streams.
612pub fn generate_field_values_overlays(
613    layout: &LayoutDom,
614    config: &XfaRenderConfig,
615) -> Result<Vec<PageOverlay>> {
616    let mut fv_config = config.clone();
617    fv_config.field_values_only = true;
618    layout
619        .pages
620        .iter()
621        .map(|page| generate_page_overlay(page, &fv_config))
622        .collect()
623}
624
625fn render_nodes(
626    nodes: &[LayoutNode],
627    parent_x: f64,
628    parent_y: f64,
629    mapper: &CoordinateMapper,
630    config: &XfaRenderConfig,
631    ops: &mut Vec<u8>,
632    images: &mut Vec<ImageInfo>,
633) {
634    for node in nodes {
635        let abs_x = node.rect.x + parent_x;
636        let abs_y = node.rect.y + parent_y;
637        let w = node.rect.width;
638        let h = node.rect.height;
639        let pdf_y = mapper.xfa_to_pdf_y(abs_y, h);
640
641        let node_config = apply_node_style(config, &node.style);
642
643        // XFA §2.5.6 — Margin insets define the space between the element's
644        // outer edges and its border/content.  Compute the inner rect (after
645        // insets) and use it for caption/value offset and border/bg drawing.
646        let inset_l = node.style.inset_left_pt.unwrap_or(0.0);
647        let inset_t = node.style.inset_top_pt.unwrap_or(0.0);
648        let inset_r = node.style.inset_right_pt.unwrap_or(0.0);
649        let inset_b = node.style.inset_bottom_pt.unwrap_or(0.0);
650        let inner_w = (w - inset_l - inset_r).max(0.0);
651        let inner_h = (h - inset_t - inset_b).max(0.0);
652        let (caption_font_size, caption_font_family) =
653            caption_font_for_content(&node.content, &node.style, &node_config);
654        let is_button = matches!(
655            &node.content,
656            LayoutContent::Field {
657                field_kind: FieldKind::Button,
658                ..
659            }
660        );
661        let caption_reserve = if is_button {
662            0.0
663        } else {
664            effective_caption_reserve(
665                &node.style,
666                caption_font_size,
667                caption_font_family,
668                &node_config,
669            )
670        };
671
672        // Caption/value offset computed from inner rect (after margin insets).
673        let (cap_dx, cap_dy, val_w, val_h) =
674            caption_value_offset(&node.style, caption_reserve, inner_w, inner_h);
675        let val_x = abs_x + inset_l + cap_dx;
676        let val_y_offset = inset_t + cap_dy;
677        let val_pdf_y = mapper.xfa_to_pdf_y(abs_y + val_y_offset, val_h);
678
679        if !matches!(node.content, LayoutContent::Field { .. }) && !config.field_values_only {
680            let border_radius = node.style.border_radius_pt.unwrap_or(0.0);
681            let border_style = node.style.border_style.as_deref();
682            // Border/bg at inner rect (after margin insets), or at value
683            // area when a caption is present.
684            let (bx, by, bw, bh) = if node.style.caption_text.is_some() {
685                (val_x, val_pdf_y, val_w, val_h)
686            } else {
687                let inner_pdf_y = mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h);
688                (abs_x + inset_l, inner_pdf_y, inner_w, inner_h)
689            };
690            if let Some(bg) = &node_config.background_color {
691                write_ops(
692                    ops,
693                    format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
694                );
695                emit_rect_path(ops, bx, by, bw, bh, border_radius);
696                ops.extend_from_slice(b"f\n");
697            }
698            if node_config.draw_borders && node_config.border_width > 0.0 && bw > 0.0 && bh > 0.0 {
699                let bwid = node_config.border_width;
700                let bc = node_config.border_color;
701                write_ops(
702                    ops,
703                    format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", bwid, bc[0], bc[1], bc[2]),
704                );
705                let per_edge = node.style.border_colors.map(|cs| {
706                    cs.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
707                });
708                let per_edge_widths = node.style.border_widths.as_ref();
709                apply_border_dash(ops, border_style);
710                let edges = node.style.border_edges;
711                if per_edge.is_some() || per_edge_widths.is_some() {
712                    emit_individual_edges(
713                        ops,
714                        bx,
715                        by,
716                        bw,
717                        bh,
718                        &edges,
719                        per_edge.as_ref(),
720                        per_edge_widths,
721                        bwid,
722                    );
723                } else if edges[0] && edges[1] && edges[2] && edges[3] {
724                    emit_rect_path(ops, bx, by, bw, bh, border_radius);
725                    ops.extend_from_slice(b"S\n");
726                } else {
727                    emit_individual_edges(ops, bx, by, bw, bh, &edges, None, None, bwid);
728                }
729                reset_border_dash(ops, border_style);
730            }
731        }
732
733        // XFA §8 — Clip content to the node's declared bounds so that
734        // text in fixed-height fields cannot overflow into adjacent nodes.
735        ops.extend_from_slice(b"q\n");
736        // A spanned `<line>` draw fills its box edge-to-edge, but the grid rules
737        // have a zero-width or zero-height box, so the usual per-node clip
738        // (re W n) is a zero-area region that would erase the stroke. Skip the
739        // clip for such degenerate spanned line draws (a line has nothing to
740        // clip). Gated on the span flag so the opt-out path stays byte-identical.
741        let skip_line_clip = draw_line_span_enabled()
742            && (w <= 0.01 || h <= 0.01)
743            && matches!(
744                &node.content,
745                LayoutContent::Draw(DrawContent::Line { x1, y1, x2, y2 })
746                    if *x1 == 0.0 && *y1 == 0.0 && *x2 == 0.0 && *y2 == 0.0
747            );
748        if !skip_line_clip {
749            write_ops(
750                ops,
751                format_args!("{:.2} {:.2} {:.2} {:.2} re W n\n", abs_x, pdf_y, w, h),
752            );
753        }
754
755        let is_bold = node.style.font_weight.as_deref() == Some("bold");
756
757        // Render caption for any node that has caption_text in its style.
758        // For Button fields, skip the external caption — the caption text is
759        // used as the button label rendered inside the button body instead.
760        let is_field = matches!(&node.content, LayoutContent::Field { .. });
761        let field_caption_needs_post_body_render =
762            is_field && matches!(node.style.caption_placement.as_deref(), Some("top"));
763        if node.style.caption_text.is_some()
764            && !is_button
765            && (!is_field || !field_caption_needs_post_body_render)
766            && !config.field_values_only
767        {
768            render_caption(
769                abs_x + inset_l,
770                mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h),
771                inner_w,
772                inner_h,
773                caption_reserve,
774                caption_font_size,
775                caption_font_family,
776                &node.style,
777                &node_config,
778                ops,
779            );
780        }
781
782        match &node.content {
783            LayoutContent::Field {
784                value,
785                field_kind,
786                font_size,
787                font_family,
788            } => {
789                match field_kind {
790                    FieldKind::Checkbox => render_checkbox(
791                        val_x,
792                        val_pdf_y,
793                        val_w,
794                        val_h,
795                        value,
796                        &node.style,
797                        &node_config,
798                        ops,
799                    ),
800                    FieldKind::Radio => render_radio(
801                        val_x,
802                        val_pdf_y,
803                        val_w,
804                        val_h,
805                        value,
806                        &node.style,
807                        &node_config,
808                        ops,
809                    ),
810                    FieldKind::Dropdown => render_dropdown(
811                        val_x,
812                        val_pdf_y,
813                        val_w,
814                        val_h,
815                        value,
816                        *font_size,
817                        *font_family,
818                        &node.style,
819                        &node_config,
820                        ops,
821                        &node.display_items,
822                        &node.save_items,
823                    ),
824                    FieldKind::Button => {
825                        let label = if value.is_empty() {
826                            node.style.caption_text.as_deref().unwrap_or("")
827                        } else {
828                            value
829                        };
830                        if !label.is_empty() {
831                            render_button(
832                                val_x,
833                                val_pdf_y,
834                                val_w,
835                                val_h,
836                                label,
837                                *font_size,
838                                *font_family,
839                                &node.style,
840                                &node_config,
841                                ops,
842                            )
843                        }
844                    }
845                    FieldKind::PasswordEdit => {
846                        let masked_value: String = value.chars().map(|_| '•').collect();
847                        render_field(
848                            val_x,
849                            val_pdf_y,
850                            val_w,
851                            val_h,
852                            &masked_value,
853                            *font_size,
854                            *font_family,
855                            &node.style,
856                            &node_config,
857                            ops,
858                        )
859                    }
860                    FieldKind::Barcode => render_barcode(
861                        val_x,
862                        val_pdf_y,
863                        val_w,
864                        val_h,
865                        value,
866                        &node.style,
867                        &node_config,
868                        ops,
869                    ),
870                    FieldKind::Signature => render_signature(
871                        val_x,
872                        val_pdf_y,
873                        val_w,
874                        val_h,
875                        value,
876                        &node.style,
877                        &node_config,
878                        ops,
879                    ),
880                    _ => {
881                        let display_val = if node.style.format_pattern.is_some() {
882                            crate::appearance_bridge::format_value(
883                                value,
884                                node.style.format_pattern.as_deref(),
885                            )
886                        } else if matches!(field_kind, FieldKind::NumericEdit) {
887                            crate::appearance_bridge::format_numeric_default(value)
888                        } else {
889                            value.to_string()
890                        };
891                        render_field(
892                            val_x,
893                            val_pdf_y,
894                            val_w,
895                            val_h,
896                            &display_val,
897                            *font_size,
898                            *font_family,
899                            &node.style,
900                            &node_config,
901                            ops,
902                        )
903                    }
904                }
905
906                if node.style.caption_text.is_some()
907                    && !is_button
908                    && field_caption_needs_post_body_render
909                    && !config.field_values_only
910                {
911                    // fixes #818: only top-placed field captions need the
912                    // post-body path. Left/right/bottom captions relied on the
913                    // legacy pre-body ordering, and moving all field captions
914                    // after the body made caption-only empty text fields show
915                    // labels that Adobe/pdfRest keep visually blank (#3f563698).
916                    render_caption(
917                        abs_x + inset_l,
918                        mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h),
919                        inner_w,
920                        inner_h,
921                        caption_reserve,
922                        caption_font_size,
923                        caption_font_family,
924                        &node.style,
925                        &node_config,
926                        ops,
927                    );
928                }
929            }
930            LayoutContent::Text(_) if config.field_values_only => {}
931            LayoutContent::Text(text) => {
932                let inner_pdf_y = mapper.xfa_to_pdf_y(abs_y + inset_t, inner_h);
933                render_text(
934                    abs_x + inset_l,
935                    inner_pdf_y,
936                    inner_w,
937                    inner_h,
938                    text,
939                    &node.style,
940                    &node_config,
941                    ops,
942                )
943            }
944            LayoutContent::WrappedText {
945                from_field: false, ..
946            } if config.field_values_only => {}
947            LayoutContent::WrappedText {
948                lines,
949                first_line_of_para,
950                font_size,
951                text_align,
952                font_family,
953                ..
954            } => {
955                // Only use the rich-text renderer when there are multiple
956                // spans with distinct formatting. Single-span rich text
957                // (or spans with no style overrides) renders better via
958                // the standard multiline path which has more mature
959                // positioning logic.
960                let use_rich = node.style.rich_text_spans.as_ref().is_some_and(|spans| {
961                    spans.len() > 1
962                        || spans.iter().any(|s| {
963                            s.font_size.is_some()
964                                || s.font_family.is_some()
965                                || s.font_weight.is_some()
966                                || s.font_style.is_some()
967                                || s.text_color.is_some()
968                                || s.underline
969                        })
970                });
971                if use_rich {
972                    if let Some(ref spans) = node.style.rich_text_spans {
973                        render_rich_multiline(
974                            val_x,
975                            val_w,
976                            val_h,
977                            lines,
978                            first_line_of_para,
979                            spans,
980                            *font_size,
981                            *text_align,
982                            *font_family,
983                            mapper,
984                            abs_y + val_y_offset,
985                            &node.style,
986                            &node_config,
987                            ops,
988                        );
989                    }
990                } else {
991                    render_multiline(
992                        val_x,
993                        val_pdf_y,
994                        val_w,
995                        val_h,
996                        lines,
997                        first_line_of_para,
998                        *font_size,
999                        *text_align,
1000                        *font_family,
1001                        is_bold,
1002                        mapper,
1003                        abs_y + val_y_offset,
1004                        &node.style,
1005                        &node_config,
1006                        ops,
1007                    );
1008                }
1009            }
1010            LayoutContent::Image { .. } if config.field_values_only => {}
1011            LayoutContent::Image { data, mime_type } => {
1012                let img_name = format!("XImg{}", images.len());
1013                ops.extend(crate::image_bridge::render_image_ops(
1014                    &img_name, abs_x, pdf_y, w, h,
1015                ));
1016                images.push(ImageInfo {
1017                    name: img_name,
1018                    data: data.clone(),
1019                    mime_type: mime_type.clone(),
1020                });
1021            }
1022            LayoutContent::Draw(_) if config.field_values_only => {}
1023            LayoutContent::Draw(draw_content) => {
1024                render_draw(
1025                    draw_content,
1026                    abs_x,
1027                    pdf_y,
1028                    w,
1029                    h,
1030                    &node.style,
1031                    &node_config,
1032                    ops,
1033                );
1034            }
1035            LayoutContent::None => {}
1036        }
1037
1038        // Restore graphics state (removes per-node clip rect).
1039        ops.extend_from_slice(b"Q\n");
1040
1041        if !node.children.is_empty() {
1042            // Children are laid out relative to the content area (after insets),
1043            // so offset by the parent's margin insets (XFA <margin leftInset/topInset>).
1044            // Apply both left and top insets symmetrically when entering the
1045            // child coordinate space.
1046            let child_origin_x = abs_x + node.style.inset_left_pt.unwrap_or(0.0);
1047            let child_origin_y = abs_y + node.style.inset_top_pt.unwrap_or(0.0);
1048            render_nodes(
1049                &node.children,
1050                child_origin_x,
1051                child_origin_y,
1052                mapper,
1053                config,
1054                ops,
1055                images,
1056            );
1057        }
1058    }
1059}
1060
1061/// Emit a rectangle path with optional rounded corners.
1062fn emit_rect_path(ops: &mut Vec<u8>, x: f64, y: f64, w: f64, h: f64, radius: f64) {
1063    if radius <= 0.0 {
1064        write_ops(
1065            ops,
1066            format_args!("{:.2} {:.2} {:.2} {:.2} re\n", x, y, w, h),
1067        );
1068    } else {
1069        let r = radius.min(w / 2.0).min(h / 2.0);
1070        let k = r * 0.5522847498;
1071        write_ops(
1072            ops,
1073            format_args!(
1074                "{:.2} {:.2} m\n\
1075                 {:.2} {:.2} l\n\
1076                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1077                 {:.2} {:.2} l\n\
1078                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1079                 {:.2} {:.2} l\n\
1080                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1081                 {:.2} {:.2} l\n\
1082                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
1083                 h\n",
1084                x,
1085                y + r,
1086                x,
1087                y + h - r,
1088                x,
1089                y + h - r + k,
1090                x + r - k,
1091                y + h,
1092                x + r,
1093                y + h,
1094                x + w - r,
1095                y + h,
1096                x + w - r + k,
1097                y + h,
1098                x + w,
1099                y + h - r + k,
1100                x + w,
1101                y + h - r,
1102                x + w,
1103                y + r,
1104                x + w,
1105                y + r - k,
1106                x + w - r + k,
1107                y,
1108                x + w - r,
1109                y,
1110                x + r,
1111                y,
1112                x + r - k,
1113                y,
1114                x,
1115                y + r - k,
1116                x,
1117                y + r,
1118            ),
1119        );
1120    }
1121}
1122
1123/// Draw individual border edges with optional per-edge colors and widths.
1124#[allow(clippy::too_many_arguments)]
1125fn emit_individual_edges(
1126    ops: &mut Vec<u8>,
1127    x: f64,
1128    y: f64,
1129    w: f64,
1130    h: f64,
1131    edges: &[bool; 4],
1132    colors: Option<&[[f64; 3]; 4]>,
1133    widths: Option<&[f64; 4]>,
1134    default_width: f64,
1135) {
1136    if edges[0] {
1137        let ww = widths.map(|w| w[0]).unwrap_or(default_width);
1138        if let Some(c) = colors.map(|c| &c[0]) {
1139            write_ops(
1140                ops,
1141                format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1142            );
1143        } else {
1144            write_ops(ops, format_args!("{:.2} w\n", ww));
1145        }
1146        write_ops(
1147            ops,
1148            format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y + h, x + w, y + h),
1149        );
1150    }
1151    if edges[1] {
1152        let ww = widths.map(|w| w[1]).unwrap_or(default_width);
1153        if let Some(c) = colors.map(|c| &c[1]) {
1154            write_ops(
1155                ops,
1156                format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1157            );
1158        } else {
1159            write_ops(ops, format_args!("{:.2} w\n", ww));
1160        }
1161        write_ops(
1162            ops,
1163            format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x + w, y, x + w, y + h),
1164        );
1165    }
1166    if edges[2] {
1167        let ww = widths.map(|w| w[2]).unwrap_or(default_width);
1168        if let Some(c) = colors.map(|c| &c[2]) {
1169            write_ops(
1170                ops,
1171                format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1172            );
1173        } else {
1174            write_ops(ops, format_args!("{:.2} w\n", ww));
1175        }
1176        write_ops(
1177            ops,
1178            format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y, x + w, y),
1179        );
1180    }
1181    if edges[3] {
1182        let ww = widths.map(|w| w[3]).unwrap_or(default_width);
1183        if let Some(c) = colors.map(|c| &c[3]) {
1184            write_ops(
1185                ops,
1186                format_args!("{:.2} w\n{:.3} {:.3} {:.3} RG\n", ww, c[0], c[1], c[2]),
1187            );
1188        } else {
1189            write_ops(ops, format_args!("{:.2} w\n", ww));
1190        }
1191        write_ops(
1192            ops,
1193            format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y, x, y + h),
1194        );
1195    }
1196}
1197
1198fn apply_border_dash(ops: &mut Vec<u8>, style: Option<&str>) {
1199    match style {
1200        Some("dashed") => write_ops(ops, format_args!("[3 2] 0 d\n")),
1201        Some("dotted") => write_ops(ops, format_args!("[1 1] 0 d\n")),
1202        _ => {}
1203    }
1204}
1205
1206fn reset_border_dash(ops: &mut Vec<u8>, style: Option<&str>) {
1207    if matches!(style, Some("dashed") | Some("dotted")) {
1208        write_ops(ops, format_args!("[] 0 d\n"));
1209    }
1210}
1211
1212/// Emit a 3D "lowered" or "raised" border (XFA edge stroke attribute).
1213///
1214/// "lowered" — top/left edges are dark (shadow), bottom/right are light (highlight).
1215/// "raised"  — top/left edges are light, bottom/right are dark.
1216fn emit_3d_border(
1217    ops: &mut Vec<u8>,
1218    x: f64,
1219    y: f64,
1220    w: f64,
1221    h: f64,
1222    line_w: f64,
1223    style: Option<&str>,
1224) {
1225    let dark = [0.502, 0.502, 0.502]; // mid-gray shadow
1226    let light = [0.831, 0.831, 0.831]; // light-gray highlight
1227    let (tl, br) = match style {
1228        Some("lowered") => (dark, light),
1229        _ => (light, dark), // raised
1230    };
1231    write_ops(ops, format_args!("{:.2} w\n", line_w));
1232    // Top edge (tl color)
1233    write_ops(
1234        ops,
1235        format_args!(
1236            "{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m {:.2} {:.2} l S\n",
1237            tl[0],
1238            tl[1],
1239            tl[2],
1240            x,
1241            y + h,
1242            x + w,
1243            y + h,
1244        ),
1245    );
1246    // Left edge (tl color)
1247    write_ops(
1248        ops,
1249        format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x, y + h, x, y,),
1250    );
1251    // Bottom edge (br color)
1252    write_ops(
1253        ops,
1254        format_args!(
1255            "{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m {:.2} {:.2} l S\n",
1256            br[0],
1257            br[1],
1258            br[2],
1259            x,
1260            y,
1261            x + w,
1262            y,
1263        ),
1264    );
1265    // Right edge (br color)
1266    write_ops(
1267        ops,
1268        format_args!("{:.2} {:.2} m {:.2} {:.2} l S\n", x + w, y, x + w, y + h,),
1269    );
1270}
1271
1272/// Select the PDF font resource reference for a node.
1273///
1274/// Uses the embedded font from `font_map` when the typeface is resolved,
1275/// otherwise falls back to the standard Base14 fonts (F1/F2/F3).
1276fn resolve_font_ref<'a>(
1277    font_map: &'a HashMap<String, String>,
1278    node_style: &FormNodeStyle,
1279    font_family: FontFamily,
1280) -> &'a str {
1281    if let Some(typeface) = &node_style.font_family {
1282        // Try variant-specific key first (includes weight/posture).
1283        let vkey = font_variant_key(
1284            typeface,
1285            node_style.font_weight.as_deref(),
1286            node_style.font_style.as_deref(),
1287        );
1288        if let Some(mapped) = font_map.get(&vkey) {
1289            return mapped;
1290        }
1291        // Fallback to base typeface name.
1292        if let Some(mapped) = font_map.get(typeface) {
1293            return mapped;
1294        }
1295    }
1296    match font_family {
1297        FontFamily::Serif => "/F1",
1298        FontFamily::SansSerif => "/F2",
1299        FontFamily::Monospace => "/F3",
1300    }
1301}
1302
1303/// Emit PDF text state operators for fontHorizontalScale (Tz) and letterSpacing (Tc).
1304/// Only emits operators when values differ from defaults (100% scale, 0 spacing).
1305fn emit_text_style_ops(node_style: &FormNodeStyle, ops: &mut Vec<u8>) {
1306    if let Some(h_scale) = node_style.font_horizontal_scale {
1307        if (h_scale - 1.0).abs() > 0.001 {
1308            write_ops(ops, format_args!("{:.1} Tz\n", h_scale * 100.0));
1309        }
1310    }
1311    if let Some(spacing) = node_style.letter_spacing_pt {
1312        if spacing.abs() > 0.001 {
1313            write_ops(ops, format_args!("{:.3} Tc\n", spacing));
1314        }
1315    }
1316}
1317
1318/// Returns true if this node requests bold weight.
1319fn is_bold_style(node_style: &FormNodeStyle) -> bool {
1320    node_style.font_weight.as_deref() == Some("bold")
1321}
1322
1323/// Returns true when the resolved resource came from an actual bold variant in
1324/// the font map, so synthetic bold stroking is unnecessary.
1325fn style_uses_real_bold_variant(
1326    font_map: &HashMap<String, String>,
1327    node_style: &FormNodeStyle,
1328) -> bool {
1329    if !is_bold_style(node_style) {
1330        return false;
1331    }
1332    let Some(typeface) = node_style.font_family.as_deref() else {
1333        return false;
1334    };
1335    let vkey = font_variant_key(
1336        typeface,
1337        node_style.font_weight.as_deref(),
1338        node_style.font_style.as_deref(),
1339    );
1340    font_map.contains_key(&vkey)
1341}
1342
1343/// Emit synthetic bold operators: fill+stroke rendering mode with thin stroke.
1344/// Uses text rendering mode 2 (fill then stroke) to simulate bold weight when
1345/// the actual bold font variant is unavailable.
1346fn emit_synthetic_bold_ops(
1347    font_map: &HashMap<String, String>,
1348    node_style: &FormNodeStyle,
1349    font_size: f64,
1350    text_color: &[f64; 3],
1351    ops: &mut Vec<u8>,
1352) {
1353    if is_bold_style(node_style) && !style_uses_real_bold_variant(font_map, node_style) {
1354        let stroke_w = font_size * 0.03;
1355        write_ops(
1356            ops,
1357            format_args!(
1358                "2 Tr\n{:.4} w\n{:.3} {:.3} {:.3} RG\n",
1359                stroke_w, text_color[0], text_color[1], text_color[2],
1360            ),
1361        );
1362    }
1363}
1364
1365/// Reset synthetic bold state back to fill-only rendering.
1366fn reset_synthetic_bold_ops(
1367    font_map: &HashMap<String, String>,
1368    node_style: &FormNodeStyle,
1369    ops: &mut Vec<u8>,
1370) {
1371    if is_bold_style(node_style) && !style_uses_real_bold_variant(font_map, node_style) {
1372        write_ops(ops, format_args!("0 Tr\n"));
1373    }
1374}
1375
1376/// Reset text style operators to defaults after a BT/ET block (for safety).
1377fn reset_text_style_ops(node_style: &FormNodeStyle, ops: &mut Vec<u8>) {
1378    if node_style
1379        .font_horizontal_scale
1380        .is_some_and(|s| (s - 1.0).abs() > 0.001)
1381    {
1382        write_ops(ops, format_args!("100 Tz\n"));
1383    }
1384    if node_style
1385        .letter_spacing_pt
1386        .is_some_and(|s| s.abs() > 0.001)
1387    {
1388        write_ops(ops, format_args!("0 Tc\n"));
1389    }
1390}
1391
1392/// Calculate the ascender height in points for a given font size and metrics.
1393fn ascender_pt(font_metrics: &FontMetrics, font_size: f64) -> f64 {
1394    if let (Some(asc), Some(upem)) = (font_metrics.resolved_ascender, font_metrics.resolved_upem) {
1395        if upem > 0 {
1396            return asc as f64 / upem as f64 * font_size;
1397        }
1398    }
1399    font_size
1400}
1401
1402/// Build a `FontMetrics` with resolved data injected from `config.font_metrics_data`.
1403///
1404/// Uses a variant key (including weight/posture) to look up the correct font
1405/// metrics. Falls back to the base typeface name if no variant entry exists.
1406fn build_font_metrics(
1407    font_size: f64,
1408    font_family: FontFamily,
1409    node_style: &FormNodeStyle,
1410    config: &XfaRenderConfig,
1411) -> FontMetrics {
1412    let mut metrics = FontMetrics {
1413        size: font_size,
1414        typeface: font_family,
1415        ..Default::default()
1416    };
1417    if let Some(typeface) = &node_style.font_family {
1418        let vkey = font_variant_key(
1419            typeface,
1420            node_style.font_weight.as_deref(),
1421            node_style.font_style.as_deref(),
1422        );
1423        let data = config
1424            .font_metrics_data
1425            .get(&vkey)
1426            .or_else(|| config.font_metrics_data.get(typeface));
1427        if let Some(data) = data {
1428            metrics.resolved_widths = Some(data.widths.clone());
1429            metrics.resolved_upem = Some(data.upem);
1430            metrics.resolved_ascender = Some(data.ascender);
1431            metrics.resolved_descender = Some(data.descender);
1432        }
1433    }
1434    metrics
1435}
1436
1437/// Whether a caption's own `<font>` styles its label (XFA 3.3 §7.4). Default-ON;
1438/// opt out with `XFA_CAPTION_OWN_FONT=0|off|false` to restore the legacy
1439/// behaviour where the caption borrowed the field's font.
1440fn caption_own_font_enabled() -> bool {
1441    !matches!(
1442        std::env::var("XFA_CAPTION_OWN_FONT").as_deref(),
1443        Ok("0") | Ok("off") | Ok("false") | Ok("OFF") | Ok("FALSE")
1444    )
1445}
1446
1447fn caption_font_for_content(
1448    content: &LayoutContent,
1449    node_style: &FormNodeStyle,
1450    config: &XfaRenderConfig,
1451) -> (f64, FontFamily) {
1452    let (field_size, field_family) = match content {
1453        LayoutContent::Field {
1454            font_size,
1455            font_family,
1456            ..
1457        }
1458        | LayoutContent::WrappedText {
1459            font_size,
1460            font_family,
1461            ..
1462        } => (*font_size, *font_family),
1463        _ => (
1464            node_style.font_size.unwrap_or(config.default_font_size),
1465            FontFamily::SansSerif,
1466        ),
1467    };
1468    // The caption's own typeface wins over the field's — otherwise an Arial
1469    // caption on a Times field is classified Serif and renders serif.
1470    if caption_own_font_enabled() {
1471        if let Some(cap_face) = &node_style.caption_font_family {
1472            return (
1473                node_style.caption_font_size.unwrap_or(field_size),
1474                classify_font_family(cap_face),
1475            );
1476        }
1477    }
1478    (field_size, field_family)
1479}
1480
1481fn effective_caption_reserve(
1482    style: &FormNodeStyle,
1483    font_size: f64,
1484    font_family: FontFamily,
1485    config: &XfaRenderConfig,
1486) -> f64 {
1487    let caption_text = match style.caption_text.as_deref() {
1488        Some(text) if !text.is_empty() => text,
1489        _ => return 0.0,
1490    };
1491    if let Some(reserve) = style.caption_reserve {
1492        return reserve.max(0.0);
1493    }
1494    let fs = if font_size > 0.0 {
1495        font_size
1496    } else {
1497        config.default_font_size
1498    };
1499    let metrics = build_font_metrics(fs, font_family, style, config);
1500    match style.caption_placement.as_deref().unwrap_or("left") {
1501        "left" | "right" => metrics.measure_width(caption_text),
1502        "top" | "bottom" => metrics.line_height_pt(),
1503        _ => 0.0,
1504    }
1505}
1506
1507/// Compute the offset and size of the value area within a field that has a caption.
1508///
1509/// Returns (dx, dy, value_width, value_height) where dx/dy are the offsets from
1510/// the field origin to the value area origin.
1511fn caption_value_offset(
1512    style: &FormNodeStyle,
1513    reserve: f64,
1514    w: f64,
1515    h: f64,
1516) -> (f64, f64, f64, f64) {
1517    if reserve <= 0.0 || style.caption_text.is_none() {
1518        return (0.0, 0.0, w, h);
1519    }
1520    match style.caption_placement.as_deref().unwrap_or("left") {
1521        "left" => (reserve, 0.0, (w - reserve).max(0.0), h),
1522        "right" => (0.0, 0.0, (w - reserve).max(0.0), h),
1523        "top" => (0.0, reserve, w, (h - reserve).max(0.0)),
1524        "bottom" => (0.0, 0.0, w, (h - reserve).max(0.0)),
1525        _ => (0.0, 0.0, w, h),
1526    }
1527}
1528
1529/// Render field caption text (shared across all field types).
1530///
1531/// This renders `<caption>` text at the placement offset (left/right/top/bottom)
1532/// relative to the node's full inner rectangle.
1533///
1534/// Field captions are emitted after the field body so the editable value-area
1535/// fill and border cannot paint over them. XFA 3.3 §7.4 treats caption reserve
1536/// as a separate region inside the field allocation rectangle. (#818)
1537#[allow(clippy::too_many_arguments)]
1538fn render_caption(
1539    x: f64,
1540    pdf_y: f64,
1541    w: f64,
1542    h: f64,
1543    caption_reserve: f64,
1544    font_size: f64,
1545    font_family: FontFamily,
1546    node_style: &FormNodeStyle,
1547    config: &XfaRenderConfig,
1548    ops: &mut Vec<u8>,
1549) {
1550    let caption_text = match &node_style.caption_text {
1551        Some(t) if !t.is_empty() => t,
1552        _ => return,
1553    };
1554    let caption_placement = node_style.caption_placement.as_deref().unwrap_or("left");
1555    let fs = if font_size > 0.0 {
1556        font_size
1557    } else {
1558        config.default_font_size
1559    };
1560    // Resolve the caption's font from ITS OWN typeface when specified, so it is
1561    // not drawn in the field's face (the field's `<font>` styles the value, not
1562    // the caption). Layout/placement still uses the field's node_style.
1563    let cap_owned;
1564    let cap_style: &FormNodeStyle = match &node_style.caption_font_family {
1565        Some(cf) if caption_own_font_enabled() => {
1566            cap_owned = FormNodeStyle {
1567                font_family: Some(cf.clone()),
1568                ..node_style.clone()
1569            };
1570            &cap_owned
1571        }
1572        _ => node_style,
1573    };
1574    let metrics = build_font_metrics(fs, font_family, cap_style, config);
1575    let font_ref = resolve_font_ref(&config.font_map, cap_style, font_family);
1576    let idh_metrics = lookup_font_metrics(cap_style, config);
1577
1578    // Determine the caption bounding box.
1579    let (cap_x, cap_y, cap_w, cap_h) = match caption_placement {
1580        "left" => (x, pdf_y, caption_reserve, h),
1581        "right" => (x + w - caption_reserve, pdf_y, caption_reserve, h),
1582        "top" => (x, pdf_y + h - caption_reserve, w, caption_reserve),
1583        "bottom" => (x, pdf_y, w, caption_reserve),
1584        _ => (x, pdf_y, caption_reserve, h),
1585    };
1586
1587    // For multi-line captions (contains newlines or wider than caption area),
1588    // wrap and render line-by-line.  Single-line captions use the fast path.
1589    let is_multiline = caption_text.contains('\n') || metrics.measure_width(caption_text) > cap_w;
1590
1591    if is_multiline {
1592        let line_height = node_style
1593            .line_height_pt
1594            .unwrap_or_else(|| metrics.line_height_pt());
1595        let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
1596        let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
1597        let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
1598        let usable_w = (cap_w - pad_left - pad_right).max(1.0);
1599
1600        // Wrap text paragraphs.
1601        let layout = xfa_layout_engine::text::wrap_text(
1602            caption_text,
1603            usable_w,
1604            &metrics,
1605            text_indent,
1606            node_style.line_height_pt,
1607        );
1608
1609        if layout.lines.is_empty() {
1610            return;
1611        }
1612
1613        let asc_pt = ascender_pt(&metrics, fs);
1614        let space_above = node_style.space_above_pt.unwrap_or(0.0);
1615        let total_text_h = layout.lines.len() as f64 * line_height;
1616
1617        // Vertical start position (PDF y, top-of-first-line baseline).
1618        let first_line_pdf_y = match node_style.v_align {
1619            Some(VerticalAlign::Middle) => {
1620                cap_y + cap_h - asc_pt - space_above - (cap_h - space_above - total_text_h) / 2.0
1621                    + (cap_h - space_above - total_text_h) / 2.0
1622            }
1623            Some(VerticalAlign::Bottom) => cap_y + total_text_h - asc_pt,
1624            _ => cap_y + cap_h - asc_pt - space_above,
1625        };
1626
1627        let tc = node_style
1628            .text_color
1629            .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
1630            .unwrap_or(config.text_color);
1631
1632        write_ops(
1633            ops,
1634            format_args!(
1635                "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1636                tc[0], tc[1], tc[2], font_ref, fs
1637            ),
1638        );
1639        emit_text_style_ops(node_style, ops);
1640
1641        let text_x_base = cap_x + pad_left;
1642        let mut prev_x = text_x_base;
1643        for (i, line) in layout.lines.iter().enumerate() {
1644            let is_para_start = layout.first_line_of_para.get(i).copied().unwrap_or(false);
1645            let indent = if is_para_start { text_indent } else { 0.0 };
1646            let text_x = text_x_base + indent;
1647            let line_y = first_line_pdf_y - (i as f64 * line_height);
1648            if i == 0 {
1649                write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
1650            } else {
1651                let dx = text_x - prev_x;
1652                write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -line_height));
1653            }
1654            prev_x = text_x;
1655            let encoded = pdf_encode_text(line, idh_metrics);
1656            write_ops(ops, format_args!("{} Tj\n", encoded));
1657        }
1658
1659        reset_text_style_ops(node_style, ops);
1660        ops.extend_from_slice(b"ET\n");
1661    } else {
1662        // Single-line fast path.
1663        let asc_pt = ascender_pt(&metrics, fs);
1664        let text_y = cap_y + cap_h - asc_pt;
1665
1666        let encoded = pdf_encode_text(caption_text, idh_metrics);
1667        write_ops(
1668            ops,
1669            format_args!(
1670                "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1671                config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
1672            ),
1673        );
1674        emit_text_style_ops(node_style, ops);
1675        write_ops(
1676            ops,
1677            format_args!("{:.2} {:.2} Td\n{} Tj\n", cap_x, text_y, encoded),
1678        );
1679        reset_text_style_ops(node_style, ops);
1680        ops.extend_from_slice(b"ET\n");
1681    }
1682}
1683
1684#[allow(clippy::too_many_arguments)]
1685fn render_field(
1686    x: f64,
1687    pdf_y: f64,
1688    w: f64,
1689    h: f64,
1690    value: &str,
1691    font_size: f64,
1692    font_family: FontFamily,
1693    node_style: &FormNodeStyle,
1694    config: &XfaRenderConfig,
1695    ops: &mut Vec<u8>,
1696) {
1697    let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
1698    let border_style = node_style.border_style.as_deref();
1699
1700    if !config.field_values_only {
1701        // fix(#809): flattening should only paint explicit template fills.
1702        // The light-gray interactive widget default is a viewer affordance, not a
1703        // flatten artifact in Adobe/pdfRest output.
1704        if let Some(bg) = config.background_color {
1705            write_ops(
1706                ops,
1707                format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
1708            );
1709            emit_rect_path(ops, x, pdf_y, w, h, border_radius);
1710            ops.extend_from_slice(b"f\n");
1711        }
1712        if config.draw_borders && config.border_width > 0.0 {
1713            if matches!(border_style, Some("lowered") | Some("raised")) {
1714                emit_3d_border(ops, x, pdf_y, w, h, config.border_width, border_style);
1715            } else {
1716                write_ops(
1717                    ops,
1718                    format_args!(
1719                        "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
1720                        config.border_width,
1721                        config.border_color[0],
1722                        config.border_color[1],
1723                        config.border_color[2],
1724                    ),
1725                );
1726                let per_edge = node_style.border_colors.map(|cs| {
1727                    cs.map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
1728                });
1729                let per_edge_widths = node_style.border_widths.as_ref();
1730                apply_border_dash(ops, border_style);
1731                let edges = node_style.border_edges;
1732                if per_edge.is_some() || per_edge_widths.is_some() {
1733                    emit_individual_edges(
1734                        ops,
1735                        x,
1736                        pdf_y,
1737                        w,
1738                        h,
1739                        &edges,
1740                        per_edge.as_ref(),
1741                        per_edge_widths,
1742                        config.border_width,
1743                    );
1744                } else if edges[0] && edges[1] && edges[2] && edges[3] {
1745                    emit_rect_path(ops, x, pdf_y, w, h, border_radius);
1746                    ops.extend_from_slice(b"S\n");
1747                } else {
1748                    emit_individual_edges(
1749                        ops,
1750                        x,
1751                        pdf_y,
1752                        w,
1753                        h,
1754                        &edges,
1755                        None,
1756                        None,
1757                        config.border_width,
1758                    );
1759                }
1760                reset_border_dash(ops, border_style);
1761            }
1762        }
1763    } // end if !config.field_values_only
1764    if !value.is_empty() {
1765        let fs = if font_size > 0.0 {
1766            font_size
1767        } else {
1768            config.default_font_size
1769        };
1770        // Insets already applied by render_nodes — x, w, h are the value
1771        // area inside margin insets.  Only para marginLeft/Right apply here.
1772        let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
1773        let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
1774        let space_above = node_style.space_above_pt.unwrap_or(0.0);
1775        let content_w = (w - pad_left - pad_right).max(0.0);
1776        let metrics = build_font_metrics(fs, font_family, node_style, config);
1777        let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
1778        let text_w = metrics.measure_width(value);
1779
1780        let idh_metrics = lookup_font_metrics(node_style, config);
1781
1782        if text_w <= content_w || content_w <= 0.0 {
1783            let line_h = metrics.line_height_pt();
1784            let asc_pt = ascender_pt(&metrics, fs);
1785            let text_y = match node_style.v_align {
1786                Some(VerticalAlign::Middle) => {
1787                    pdf_y + space_above + (h - space_above - line_h) / 2.0
1788                }
1789                Some(VerticalAlign::Bottom) => pdf_y + space_above,
1790                _ => pdf_y + h - space_above - asc_pt,
1791            };
1792            let encoded = pdf_encode_text(value, idh_metrics);
1793            write_ops(
1794                ops,
1795                format_args!(
1796                    "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1797                    config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
1798                ),
1799            );
1800            emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
1801            emit_text_style_ops(node_style, ops);
1802            write_ops(
1803                ops,
1804                format_args!("{:.2} {:.2} Td\n{} Tj\n", x + pad_left, text_y, encoded),
1805            );
1806            reset_text_style_ops(node_style, ops);
1807            reset_synthetic_bold_ops(&config.font_map, node_style, ops);
1808            ops.extend_from_slice(b"ET\n");
1809        } else {
1810            let lines = wrap_text(value, content_w, &metrics);
1811            let line_height = metrics.line_height_pt();
1812            let asc_pt = ascender_pt(&metrics, fs);
1813            let total_content_h = lines.len() as f64 * line_height;
1814            let text_start_y = match node_style.v_align {
1815                Some(VerticalAlign::Middle) => {
1816                    pdf_y + space_above + (h - space_above - total_content_h) / 2.0
1817                }
1818                Some(VerticalAlign::Bottom) => pdf_y + space_above,
1819                _ => pdf_y + h - space_above - total_content_h,
1820            };
1821            write_ops(
1822                ops,
1823                format_args!(
1824                    "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
1825                    config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
1826                ),
1827            );
1828            emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
1829            emit_text_style_ops(node_style, ops);
1830            write_ops(
1831                ops,
1832                format_args!(
1833                    "{:.2} {:.2} Td\n",
1834                    x + pad_left,
1835                    text_start_y + total_content_h - asc_pt,
1836                ),
1837            );
1838            for (i, line) in lines.iter().enumerate() {
1839                if i > 0 {
1840                    write_ops(ops, format_args!("0 {:.2} Td\n", -line_height));
1841                }
1842                let line_top = h - space_above - asc_pt - (i as f64 * line_height);
1843                if line_top < 0.0 {
1844                    break;
1845                }
1846                let encoded = pdf_encode_text(line, idh_metrics);
1847                write_ops(ops, format_args!("{} Tj\n", encoded));
1848            }
1849            reset_text_style_ops(node_style, ops);
1850            reset_synthetic_bold_ops(&config.font_map, node_style, ops);
1851            ops.extend_from_slice(b"ET\n");
1852        }
1853    }
1854}
1855
1856/// Draw a check mark symbol inside a checkbox/radio bounding box.
1857///
1858/// Supported marks (XFA §8.2): check, circle, cross, diamond, square, star.
1859#[allow(clippy::too_many_arguments)]
1860fn draw_check_mark(
1861    mark: &str,
1862    x: f64,
1863    y: f64,
1864    w: f64,
1865    h: f64,
1866    m: f64,
1867    color: [f64; 3],
1868    ops: &mut Vec<u8>,
1869) {
1870    write_ops(
1871        ops,
1872        format_args!(
1873            "{:.3} {:.3} {:.3} RG\n{:.3} {:.3} {:.3} rg\n",
1874            color[0], color[1], color[2], color[0], color[1], color[2]
1875        ),
1876    );
1877    let cx = x + w / 2.0;
1878    let cy = y + h / 2.0;
1879    match mark {
1880        "check" => {
1881            // Checkmark: three line segments
1882            let lw = (w.min(h) * 0.08).max(0.5);
1883            write_ops(ops, format_args!("{:.2} w\n", lw));
1884            write_ops(
1885                ops,
1886                format_args!(
1887                    "{:.2} {:.2} m\n{:.2} {:.2} l\n{:.2} {:.2} l\nS\n",
1888                    x + m,
1889                    cy,
1890                    cx - m * 0.3,
1891                    y + m,
1892                    x + w - m,
1893                    y + h - m,
1894                ),
1895            );
1896        }
1897        "circle" => {
1898            let r = (w.min(h) / 2.0 - m).max(1.0);
1899            let k = r * 0.5523; // bezier approx for circle
1900            write_ops(ops, format_args!(
1901                "{:.2} {:.2} m\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\nf\n",
1902                cx + r, cy,
1903                cx + r, cy + k, cx + k, cy + r, cx, cy + r,
1904                cx - k, cy + r, cx - r, cy + k, cx - r, cy,
1905                cx - r, cy - k, cx - k, cy - r, cx, cy - r,
1906                cx + k, cy - r, cx + r, cy - k, cx + r, cy,
1907            ));
1908        }
1909        "diamond" => {
1910            let d = (w.min(h) / 2.0 - m).max(1.0);
1911            write_ops(
1912                ops,
1913                format_args!(
1914                    "{:.2} {:.2} m\n{:.2} {:.2} l\n{:.2} {:.2} l\n{:.2} {:.2} l\nf\n",
1915                    cx,
1916                    cy + d,
1917                    cx - d,
1918                    cy,
1919                    cx,
1920                    cy - d,
1921                    cx + d,
1922                    cy,
1923                ),
1924            );
1925        }
1926        "square" => {
1927            let s = (w.min(h) - 2.0 * m).max(1.0);
1928            write_ops(
1929                ops,
1930                format_args!("{:.2} {:.2} {:.2} {:.2} re\nf\n", x + m, y + m, s, s,),
1931            );
1932        }
1933        "star" => {
1934            // Simplified 5-point star via cross pattern
1935            let lw = (w.min(h) * 0.08).max(0.5);
1936            write_ops(ops, format_args!("{:.2} w\n", lw));
1937            write_ops(
1938                ops,
1939                format_args!(
1940                    "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1941                    x + m,
1942                    y + m,
1943                    x + w - m,
1944                    y + h - m,
1945                    x + w - m,
1946                    y + m,
1947                    x + m,
1948                    y + h - m,
1949                ),
1950            );
1951            write_ops(
1952                ops,
1953                format_args!(
1954                    "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1955                    cx,
1956                    y + m * 0.5,
1957                    cx,
1958                    y + h - m * 0.5,
1959                ),
1960            );
1961        }
1962        _ => {
1963            // Default "cross"
1964            let lw = (w.min(h) * 0.08).max(0.5);
1965            write_ops(ops, format_args!("{:.2} w\n", lw));
1966            write_ops(
1967                ops,
1968                format_args!(
1969                    "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
1970                    x + m,
1971                    y + m,
1972                    x + w - m,
1973                    y + h - m,
1974                    x + w - m,
1975                    y + m,
1976                    x + m,
1977                    y + h - m,
1978                ),
1979            );
1980        }
1981    }
1982}
1983
1984/// Render a checkbox widget (XFA `<checkButton>`).
1985///
1986/// # Checked/unchecked state (XFA-F5-03)
1987///
1988/// The checked state is resolved by `is_check_button_checked`, which compares
1989/// the bound `value` against:
1990/// - `node_style.check_button_on_value` — the "on" value string from the XFA
1991///   template's `<items>` or `<value><integer>1</integer></value>` entry.
1992/// - A set of conventional truthy strings: "1", "true", "yes", "on" (case-
1993///   insensitive) when no explicit on-value is set.
1994///
1995/// When checked, `draw_check_mark` renders the mark symbol according to
1996/// `config.check_button_mark` (XFA `<checkButton mark="check|cross|…">`).
1997/// Supported marks: check (default), cross, circle, diamond, square, star.
1998///
1999/// When unchecked, only the outer rectangle border is drawn.
2000///
2001/// Background fill is only applied when `node_style.bg_color` is explicitly set
2002/// (i.e. the XFA template has a `<fill>` element).  Global config background
2003/// is intentionally ignored to avoid regression with check/radio controls.
2004#[allow(clippy::too_many_arguments)]
2005fn render_checkbox(
2006    x: f64,
2007    pdf_y: f64,
2008    w: f64,
2009    h: f64,
2010    value: &str,
2011    node_style: &FormNodeStyle,
2012    config: &XfaRenderConfig,
2013    ops: &mut Vec<u8>,
2014) {
2015    let bw = config.border_width;
2016    // Only explicit template fill (<fill>/<border><fill>) should paint widget
2017    // background for check/radio controls. Do not inherit global/default field
2018    // background config here; that caused checkbox/radio regressions (#fill).
2019    write_ops(ops, format_args!("q\n"));
2020    if let Some((r_u8, g_u8, b_u8)) = node_style.bg_color {
2021        let bg = [
2022            r_u8 as f64 / 255.0,
2023            g_u8 as f64 / 255.0,
2024            b_u8 as f64 / 255.0,
2025        ];
2026        write_ops(
2027            ops,
2028            format_args!(
2029                "{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
2030                bg[0], bg[1], bg[2], x, pdf_y, w, h
2031            ),
2032        );
2033    }
2034    write_ops(
2035        ops,
2036        format_args!(
2037            "{:.2} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
2038            bw,
2039            config.border_color[0],
2040            config.border_color[1],
2041            config.border_color[2],
2042            x,
2043            pdf_y,
2044            w,
2045            h
2046        ),
2047    );
2048    let checked = is_check_button_checked(value, FieldKind::Checkbox, node_style);
2049    if checked {
2050        let mark = config
2051            .check_button_mark
2052            .as_deref()
2053            .unwrap_or(default_check_button_mark(FieldKind::Checkbox));
2054        let m = w.min(h) * 0.15;
2055        let color = config.text_color;
2056        draw_check_mark(mark, x, pdf_y, w, h, m, color, ops);
2057    }
2058    write_ops(ops, format_args!("Q\n"));
2059}
2060
2061/// Render a radio-button widget (XFA `<checkButton shape="round">`).
2062///
2063/// # Selected state (XFA-F5-03)
2064///
2065/// Selected state is resolved identically to checkboxes via
2066/// `is_check_button_checked` using `FieldKind::Radio`.
2067///
2068/// When selected, a filled inner circle (smaller radius, ~55% of outer) is
2069/// drawn using 4 Bézier curves (`c` operator) unless `node_style.check_button_mark`
2070/// overrides the symbol type (e.g. `mark="cross"` draws an ✕ instead).
2071///
2072/// When deselected, only the outer circle border is drawn (stroke only, no fill).
2073///
2074/// Background fill follows the same rule as checkboxes: only `node_style.bg_color`
2075/// (explicit template fill) is honoured; global config background is ignored.
2076#[allow(clippy::too_many_arguments)]
2077fn render_radio(
2078    x: f64,
2079    pdf_y: f64,
2080    w: f64,
2081    h: f64,
2082    value: &str,
2083    node_style: &FormNodeStyle,
2084    config: &XfaRenderConfig,
2085    ops: &mut Vec<u8>,
2086) {
2087    let bw = config.border_width;
2088    let cx = x + w / 2.0;
2089    let cy = pdf_y + h / 2.0;
2090    let r = w.min(h) / 2.0;
2091
2092    // Draw circle using 4 Bezier curves (standard circle approximation).
2093    let k = 0.5523; // kappa ≈ 4*(√2-1)/3
2094    let kx = r * k;
2095    let ky = r * k;
2096    // Only explicit template fill should paint radio background. Do not use
2097    // inherited/global background config for radio controls.
2098    write_ops(ops, format_args!("q\n",));
2099    if let Some((r_u8, g_u8, b_u8)) = node_style.bg_color {
2100        let bg = [
2101            r_u8 as f64 / 255.0,
2102            g_u8 as f64 / 255.0,
2103            b_u8 as f64 / 255.0,
2104        ];
2105        write_ops(
2106            ops,
2107            format_args!(
2108                "{:.3} {:.3} {:.3} rg\n\
2109                 {:.2} {:.2} m\n\
2110                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2111                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2112                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2113                 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2114                 f\n",
2115                bg[0],
2116                bg[1],
2117                bg[2],
2118                cx + r,
2119                cy,
2120                cx + r,
2121                cy + ky,
2122                cx + kx,
2123                cy + r,
2124                cx,
2125                cy + r,
2126                cx - kx,
2127                cy + r,
2128                cx - r,
2129                cy + ky,
2130                cx - r,
2131                cy,
2132                cx - r,
2133                cy - ky,
2134                cx - kx,
2135                cy - r,
2136                cx,
2137                cy - r,
2138                cx + kx,
2139                cy - r,
2140                cx + r,
2141                cy - ky,
2142                cx + r,
2143                cy,
2144            ),
2145        );
2146    }
2147    write_ops(
2148        ops,
2149        format_args!(
2150            "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
2151            bw, config.border_color[0], config.border_color[1], config.border_color[2],
2152        ),
2153    );
2154    write_ops(
2155        ops,
2156        format_args!(
2157            "{:.2} {:.2} m\n\
2158             {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2159             {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2160             {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2161             {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2162             S\n",
2163            cx + r,
2164            cy,
2165            cx + r,
2166            cy + ky,
2167            cx + kx,
2168            cy + r,
2169            cx,
2170            cy + r,
2171            cx - kx,
2172            cy + r,
2173            cx - r,
2174            cy + ky,
2175            cx - r,
2176            cy,
2177            cx - r,
2178            cy - ky,
2179            cx - kx,
2180            cy - r,
2181            cx,
2182            cy - r,
2183            cx + kx,
2184            cy - r,
2185            cx + r,
2186            cy - ky,
2187            cx + r,
2188            cy,
2189        ),
2190    );
2191
2192    let checked = is_check_button_checked(value, FieldKind::Radio, node_style);
2193    if checked {
2194        let mark = config
2195            .check_button_mark
2196            .as_deref()
2197            .unwrap_or(default_check_button_mark(FieldKind::Radio));
2198        if mark == "circle" {
2199            // fixes #798: Acrobat uses a filled inner circle for asserted
2200            // round radios unless the template overrides `mark`.
2201            let ir = r * 0.4;
2202            let ikx = ir * k;
2203            let iky = ir * k;
2204            write_ops(
2205                ops,
2206                format_args!(
2207                    "{:.3} {:.3} {:.3} rg\n\
2208                     {:.2} {:.2} m\n\
2209                     {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2210                     {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2211                     {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2212                     {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
2213                     f\n",
2214                    config.text_color[0],
2215                    config.text_color[1],
2216                    config.text_color[2],
2217                    cx + ir,
2218                    cy,
2219                    cx + ir,
2220                    cy + iky,
2221                    cx + ikx,
2222                    cy + ir,
2223                    cx,
2224                    cy + ir,
2225                    cx - ikx,
2226                    cy + ir,
2227                    cx - ir,
2228                    cy + iky,
2229                    cx - ir,
2230                    cy,
2231                    cx - ir,
2232                    cy - iky,
2233                    cx - ikx,
2234                    cy - ir,
2235                    cx,
2236                    cy - ir,
2237                    cx + ikx,
2238                    cy - ir,
2239                    cx + ir,
2240                    cy - iky,
2241                    cx + ir,
2242                    cy,
2243                ),
2244            );
2245        } else {
2246            let m = w.min(h) * 0.15;
2247            draw_check_mark(mark, x, pdf_y, w, h, m, config.text_color, ops);
2248        }
2249    }
2250    write_ops(ops, format_args!("Q\n"));
2251}
2252
2253fn default_check_button_mark(field_kind: FieldKind) -> &'static str {
2254    match field_kind {
2255        FieldKind::Radio => "circle",
2256        _ => "cross",
2257    }
2258}
2259
2260fn is_check_button_checked(value: &str, field_kind: FieldKind, node_style: &FormNodeStyle) -> bool {
2261    // fixes #798: XFA 3.3 §11.2.1 / §17.8 says checkButton state is driven by
2262    // the template's `<items>` list, not by hardcoded 1/0-only semantics.
2263    let on_value = node_style.check_button_on_value.as_deref().unwrap_or("1");
2264    let off_value = node_style.check_button_off_value.as_deref().unwrap_or("");
2265    let neutral_value = node_style
2266        .check_button_neutral_value
2267        .as_deref()
2268        .unwrap_or("");
2269    if value == on_value {
2270        return true;
2271    }
2272    if value == off_value {
2273        return false;
2274    }
2275    if field_kind == FieldKind::Checkbox && value == neutral_value {
2276        return false;
2277    }
2278
2279    false
2280}
2281
2282/// Render a dropdown/listbox widget (XFA `<choiceList>`).
2283///
2284/// # Selected value display (XFA-F5-03)
2285///
2286/// The dropdown renders its **display value** (the human-readable label),
2287/// not the raw data value.  Per XFA Spec 3.3 §7.7, a `<choiceList>` has two
2288/// parallel item lists:
2289/// - `save_items` — the programmatic values bound to the data model.
2290/// - `display_items` — the labels shown to the user.
2291///
2292/// Resolution order:
2293/// 1. Look up `value` in `save_items`; if found, render `display_items[idx]`.
2294/// 2. If no match, render `value` as-is (fallback for pre-filled raw text).
2295/// 3. If the resolved display value is empty, nothing is rendered (matches
2296///    Adobe Acrobat behaviour for empty choice fields).
2297///
2298/// The dropdown arrow indicator (triangle) is rendered via `/F2` (Symbol font)
2299/// so it appears even when the main font map does not include Symbol.
2300#[allow(clippy::too_many_arguments)]
2301fn render_dropdown(
2302    x: f64,
2303    pdf_y: f64,
2304    w: f64,
2305    h: f64,
2306    value: &str,
2307    font_size: f64,
2308    font_family: FontFamily,
2309    node_style: &FormNodeStyle,
2310    config: &XfaRenderConfig,
2311    ops: &mut Vec<u8>,
2312    display_items: &[String],
2313    save_items: &[String],
2314) {
2315    // XFA 3.3 §7.7: choiceList display value resolution.
2316    // If the field value matches a save item, use the corresponding display item.
2317    let display_value = if let Some(idx) = save_items.iter().position(|s| s == value) {
2318        display_items.get(idx).map(|s| s.as_str()).unwrap_or(value)
2319    } else {
2320        value
2321    };
2322
2323    // Adobe behavior: empty dropdowns are invisible
2324    if display_value.is_empty() {
2325        return;
2326    }
2327    let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
2328
2329    if let Some(bg) = &config.background_color {
2330        write_ops(
2331            ops,
2332            format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
2333        );
2334        emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2335        ops.extend_from_slice(b"f\n");
2336    }
2337
2338    if config.draw_borders && config.border_width > 0.0 {
2339        write_ops(
2340            ops,
2341            format_args!(
2342                "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
2343                config.border_width,
2344                config.border_color[0],
2345                config.border_color[1],
2346                config.border_color[2],
2347            ),
2348        );
2349        emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2350        ops.extend_from_slice(b"S\n");
2351    }
2352
2353    let arrow_w = h.min(12.0);
2354
2355    if !display_value.is_empty() {
2356        let fs = if font_size > 0.0 {
2357            font_size
2358        } else {
2359            config.default_font_size
2360        };
2361        let _metrics = build_font_metrics(fs, font_family, node_style, config);
2362        let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2363        let idh_metrics = lookup_font_metrics(node_style, config);
2364
2365        let v_offset = pdf_y + h / 2.0 - fs / 2.0;
2366        let encoded = pdf_encode_text(display_value, idh_metrics);
2367        write_ops(
2368            ops,
2369            format_args!(
2370                "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2371                config.text_color[0], config.text_color[1], config.text_color[2], font_ref, fs,
2372            ),
2373        );
2374        emit_synthetic_bold_ops(&config.font_map, node_style, fs, &config.text_color, ops);
2375        emit_text_style_ops(node_style, ops);
2376        write_ops(
2377            ops,
2378            format_args!("{:.2} {:.2} Td\n{} Tj\n", x + 2.0, v_offset, encoded),
2379        );
2380        reset_text_style_ops(node_style, ops);
2381        reset_synthetic_bold_ops(&config.font_map, node_style, ops);
2382        ops.extend_from_slice(b"ET\n");
2383    }
2384
2385    let arrow_x = x + w - arrow_w - 1.0;
2386    let arrow_y_center = pdf_y + h / 2.0;
2387    let arrow_size = arrow_w * 0.6;
2388    let arrow_char = "\u{25BC}";
2389    write_ops(
2390        ops,
2391        format_args!(
2392            "BT\n/F2 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
2393            arrow_size,
2394            arrow_x,
2395            arrow_y_center - arrow_size / 2.0,
2396            arrow_char
2397        ),
2398    );
2399}
2400
2401#[allow(clippy::too_many_arguments)]
2402fn render_button(
2403    x: f64,
2404    pdf_y: f64,
2405    w: f64,
2406    h: f64,
2407    value: &str,
2408    font_size: f64,
2409    font_family: FontFamily,
2410    node_style: &FormNodeStyle,
2411    config: &XfaRenderConfig,
2412    ops: &mut Vec<u8>,
2413) {
2414    // Adobe behavior: empty buttons are invisible
2415    if value.is_empty() {
2416        return;
2417    }
2418    let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
2419    let bw = config.border_width.max(0.0);
2420
2421    // Use the node's bg_color (from <border><fill><color>) when available;
2422    // otherwise fall back to computed shading from the config border color.
2423    let fill_color = if let Some((r, g, b)) = node_style.bg_color {
2424        [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]
2425    } else {
2426        [
2427            (config.border_color[0] + 0.3).min(1.0),
2428            (config.border_color[1] + 0.3).min(1.0),
2429            (config.border_color[2] + 0.3).min(1.0),
2430        ]
2431    };
2432    let border_color = node_style
2433        .border_color
2434        .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2435        .unwrap_or([
2436            fill_color[0] * 0.6,
2437            fill_color[1] * 0.6,
2438            fill_color[2] * 0.6,
2439        ]);
2440
2441    write_ops(
2442        ops,
2443        format_args!(
2444            "q\n{:.3} {:.3} {:.3} rg\n",
2445            fill_color[0], fill_color[1], fill_color[2]
2446        ),
2447    );
2448    emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2449    ops.extend_from_slice(b"f\n");
2450
2451    write_ops(
2452        ops,
2453        format_args!(
2454            "{:.3} {:.3} {:.3} RG\n{:.2} w\n",
2455            border_color[0], border_color[1], border_color[2], bw
2456        ),
2457    );
2458    emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2459    ops.extend_from_slice(b"S\n");
2460
2461    if !value.is_empty() {
2462        let fs = if font_size > 0.0 {
2463            font_size
2464        } else {
2465            config.default_font_size
2466        };
2467        let metrics = build_font_metrics(fs, font_family, node_style, config);
2468        let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2469        let idh_metrics = lookup_font_metrics(node_style, config);
2470        let text_w = metrics.measure_width(value);
2471        let text_x = x + (w - text_w) / 2.0;
2472        let v_offset = pdf_y + h / 2.0 - fs / 2.0;
2473        let encoded = pdf_encode_text(value, idh_metrics);
2474        let tc = node_style
2475            .text_color
2476            .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2477            .unwrap_or(config.text_color);
2478        write_ops(
2479            ops,
2480            format_args!(
2481                "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2482                tc[0], tc[1], tc[2], font_ref, fs,
2483            ),
2484        );
2485        emit_text_style_ops(node_style, ops);
2486        write_ops(
2487            ops,
2488            format_args!("{:.2} {:.2} Td\n{} Tj\n", text_x, v_offset, encoded),
2489        );
2490        reset_text_style_ops(node_style, ops);
2491        ops.extend_from_slice(b"ET\n");
2492    }
2493    // Balance the `q` pushed at the top of this function. Without this the
2494    // button leaves an extra graphics-state frame on the stack so the caller's
2495    // outer `Q` (render_nodes) ends up popping this frame instead of the
2496    // per-node clip frame. The button's clip region then leaks across sibling
2497    // fields — see 053ecab3: every TextField rendered after the button ended
2498    // up clipped to the empty intersection of its own rect and the button's.
2499    ops.extend_from_slice(b"Q\n");
2500}
2501
2502#[allow(clippy::too_many_arguments)]
2503/// Teken een barcode in het veld.
2504///
2505/// Een barcodeveld werd herkend en op maat gebracht en daarna als gewone tekst
2506/// getekend: het formulier was niet scanbaar, wat de enige reden is dat er een
2507/// barcode op staat. Zie #147.
2508///
2509/// Valt terug op niets tekenen wanneer de waarde leeg is of tekens bevat die
2510/// Code 39 niet kent. Stil weglaten van tekens zou een barcode opleveren die
2511/// naar iets anders scant dan er staat, en dat is erger dan geen barcode -- dat
2512/// merk je pas bij de klant met een handscanner in de hand.
2513fn render_barcode(
2514    x: f64,
2515    pdf_y: f64,
2516    w: f64,
2517    h: f64,
2518    value: &str,
2519    _node_style: &FormNodeStyle,
2520    config: &XfaRenderConfig,
2521    ops: &mut Vec<u8>,
2522) {
2523    if value.is_empty() || w <= 0.0 || h <= 0.0 {
2524        return;
2525    }
2526    let Some(elementen) = crate::barcode::encode_code39(value) else {
2527        return;
2528    };
2529    let modules = crate::barcode::total_modules(&elementen);
2530    if modules == 0 {
2531        return;
2532    }
2533    // Schaal de code op de veldbreedte. Smaller dan een kwart punt per module
2534    // is niet meer te scannen; dan tekenen we liever niets dan een streepjespatroon
2535    // waar niemand iets aan heeft.
2536    let module_breedte = w / f64::from(modules);
2537    if module_breedte < 0.25 {
2538        return;
2539    }
2540
2541    write_ops(
2542        ops,
2543        format_args!(
2544            "{:.3} {:.3} {:.3} rg\n",
2545            config.text_color[0], config.text_color[1], config.text_color[2]
2546        ),
2547    );
2548    let mut cursor = x;
2549    for e in &elementen {
2550        let breedte = module_breedte * f64::from(e.modules);
2551        if e.is_bar {
2552            write_ops(
2553                ops,
2554                format_args!("{cursor:.2} {pdf_y:.2} {breedte:.2} {h:.2} re\n"),
2555            );
2556        }
2557        cursor += breedte;
2558    }
2559    ops.extend_from_slice(b"f\n");
2560}
2561
2562#[allow(clippy::too_many_arguments)]
2563fn render_signature(
2564    x: f64,
2565    pdf_y: f64,
2566    w: f64,
2567    h: f64,
2568    value: &str,
2569    node_style: &FormNodeStyle,
2570    config: &XfaRenderConfig,
2571    ops: &mut Vec<u8>,
2572) {
2573    let border_radius = node_style.border_radius_pt.unwrap_or(0.0);
2574
2575    // An empty signature field draws no content, but it does keep the border
2576    // its own template declares.
2577    //
2578    // This opened with `if value.is_empty() { return; }`, commented "Adobe
2579    // behavior: empty signatures are invisible". That is right about the
2580    // *content* -- nothing was signed, so there is nothing to show. It also
2581    // threw away the border the form's author had specified.
2582    //
2583    // After flattening the interactivity is gone. If the box goes with it, a
2584    // reader can no longer see that a signature was expected -- and the
2585    // template said so. `draw_borders` comes from `effective_border_width` on
2586    // this field's own style: where there is none we still draw nothing and the
2587    // behaviour is exactly what it was. See #148.
2588    if value.is_empty() {
2589        if config.draw_borders && config.border_width > 0.0 {
2590            write_ops(
2591                ops,
2592                format_args!(
2593                    "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
2594                    config.border_width,
2595                    config.border_color[0],
2596                    config.border_color[1],
2597                    config.border_color[2],
2598                ),
2599            );
2600            emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2601            ops.extend_from_slice(b"S\n");
2602        }
2603        return;
2604    }
2605
2606    if let Some(bg) = &config.background_color {
2607        write_ops(
2608            ops,
2609            format_args!("{:.3} {:.3} {:.3} rg\n", bg[0], bg[1], bg[2]),
2610        );
2611        emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2612        ops.extend_from_slice(b"f\n");
2613    }
2614
2615    write_ops(
2616        ops,
2617        format_args!(
2618            "{:.2} w\n{:.3} {:.3} {:.3} RG\n",
2619            config.border_width,
2620            config.border_color[0],
2621            config.border_color[1],
2622            config.border_color[2],
2623        ),
2624    );
2625    write_ops(ops, format_args!("[4 2] 0 d\n"));
2626    emit_rect_path(ops, x, pdf_y, w, h, border_radius);
2627    ops.extend_from_slice(b"S\n");
2628    write_ops(ops, format_args!("[] 0 d\n"));
2629
2630    if !value.is_empty() {
2631        let fs = node_style.font_size.unwrap_or(config.default_font_size);
2632        // XFA spec: margin_left_pt determines left padding for text, not config.text_padding
2633        let text_x = x + node_style.margin_left_pt.unwrap_or(0.0);
2634        let v_offset = pdf_y + h / 2.0 - fs / 2.0;
2635        write_ops(
2636            ops,
2637            format_args!(
2638                "BT\n{:.3} {:.3} {:.3} rg\n/F1 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
2639                config.text_color[0],
2640                config.text_color[1],
2641                config.text_color[2],
2642                fs,
2643                text_x,
2644                v_offset,
2645                pdf_escape(value)
2646            ),
2647        );
2648    }
2649}
2650
2651/// Render a single-line static text element (XFA `<draw>` with text content).
2652///
2653/// # Font selection (XFA-F5-02)
2654///
2655/// Font is resolved in priority order:
2656/// 1. `config.font_map` keyed by the node's resolved `FontFamily` enum (via
2657///    `resolve_font_ref`). This maps XFA typeface names to PDF `/XFA_Fn` refs
2658///    built by `font_bridge` during the flatten pipeline.
2659/// 2. Fallback to the globally configured `config.default_font` (e.g.
2660///    `"Helvetica"` for built-in Type1 fonts).
2661///
2662/// Font size comes from `node_style.font_size` with `config.default_font_size`
2663/// as a fallback.
2664///
2665/// # Character / word spacing
2666///
2667/// Character and word spacing operators (`Tc`, `Tw`) are NOT emitted by this
2668/// function.  XFA does not expose character/word spacing in its data model, so
2669/// the PDF default (0 pt spacing) is used.  Spacing is effectively controlled
2670/// by the font metrics and layout engine word-wrapping instead.
2671///
2672/// # Line wrapping
2673///
2674/// Single-line text nodes are never wrapped by the render bridge.  All
2675/// word-wrapping decisions are made in the layout engine (`xfa-layout-engine`)
2676/// before the `LayoutDom` is produced.  Multi-line output arrives as
2677/// `LayoutContent::WrappedText` with pre-computed `lines`.
2678#[allow(clippy::too_many_arguments)]
2679fn render_text(
2680    x: f64,
2681    pdf_y: f64,
2682    _w: f64,
2683    h: f64,
2684    text: &str,
2685    node_style: &FormNodeStyle,
2686    config: &XfaRenderConfig,
2687    ops: &mut Vec<u8>,
2688) {
2689    if text.is_empty() {
2690        return;
2691    }
2692    let fs = node_style.font_size.unwrap_or(config.default_font_size);
2693    // XFA spec: margin_left_pt determines left padding for text, not config.text_padding
2694    let p = node_style.margin_left_pt.unwrap_or(0.0);
2695    let font_family = match node_style.font_family.as_deref() {
2696        Some(f) if f.contains("Courier") || f.contains("Mono") => FontFamily::Monospace,
2697        Some(f)
2698            if f.contains("Helvetica")
2699                || f.contains("Arial")
2700                || f.contains("Sans")
2701                || f.contains("Myriad") =>
2702        {
2703            FontFamily::SansSerif
2704        }
2705        _ => FontFamily::Serif,
2706    };
2707    let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2708    let tc = node_style
2709        .text_color
2710        .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2711        .unwrap_or(config.text_color);
2712    let metrics = build_font_metrics(fs, font_family, node_style, config);
2713    let asc_pt = ascender_pt(&metrics, fs);
2714    let line_h = metrics.line_height_pt();
2715    let idh_metrics = lookup_font_metrics(node_style, config);
2716    let encoded = pdf_encode_text(text, idh_metrics);
2717    let desc_pt =
2718        if let (Some(desc), Some(upem)) = (metrics.resolved_descender, metrics.resolved_upem) {
2719            if upem > 0 {
2720                desc as f64 / upem as f64 * fs
2721            } else {
2722                fs * 0.2
2723            }
2724        } else {
2725            fs * 0.2
2726        };
2727    let text_y = match node_style.v_align {
2728        Some(VerticalAlign::Middle) => pdf_y + (h - line_h) / 2.0,
2729        Some(VerticalAlign::Bottom) => pdf_y + desc_pt,
2730        _ => pdf_y + h - p - asc_pt,
2731    };
2732    write_ops(
2733        ops,
2734        format_args!(
2735            "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2736            tc[0], tc[1], tc[2], font_ref, fs,
2737        ),
2738    );
2739    emit_synthetic_bold_ops(&config.font_map, node_style, fs, &tc, ops);
2740    write_ops(
2741        ops,
2742        format_args!("{:.2} {:.2} Td\n{} Tj\n", x + p, text_y, encoded),
2743    );
2744    reset_synthetic_bold_ops(&config.font_map, node_style, ops);
2745    ops.extend_from_slice(b"ET\n");
2746    let text_x = x + p;
2747    let text_y = pdf_y + p;
2748    let line_thickness = (fs * 0.05).max(0.5);
2749    if node_style.underline {
2750        let underline_y = text_y - desc_pt;
2751        let text_w = metrics.measure_width(text);
2752        write_ops(
2753            ops,
2754            format_args!(
2755                "BT\n{:.3} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nET\n",
2756                line_thickness,
2757                tc[0],
2758                tc[1],
2759                tc[2],
2760                text_x,
2761                underline_y,
2762                text_x + text_w,
2763                underline_y,
2764            ),
2765        );
2766    }
2767    if node_style.line_through {
2768        let mid_y = text_y + fs * 0.5 - asc_pt * 0.1;
2769        let text_w = metrics.measure_width(text);
2770        write_ops(
2771            ops,
2772            format_args!(
2773                "BT\n{:.3} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nET\n",
2774                line_thickness,
2775                tc[0],
2776                tc[1],
2777                tc[2],
2778                text_x,
2779                mid_y,
2780                text_x + text_w,
2781                mid_y,
2782            ),
2783        );
2784    }
2785}
2786
2787/// Render pre-wrapped multiline text from a `LayoutContent::WrappedText` node.
2788///
2789/// # Line-spacing / Td offsets (XFA-F5-02)
2790///
2791/// Each line is positioned with a PDF `Td` operator:
2792/// - **First line**: absolute `Td` (`text_x`, `first_line_pdf_y`) places the
2793///   baseline at the resolved starting Y coordinate.
2794/// - **Subsequent lines**: relative `Td` (0, `-line_height`) advances downward
2795///   by `line_height` (in PDF's coordinate system the Y axis grows upward, so
2796///   a negative delta moves down).
2797///
2798/// `line_height` is taken from `node_style.line_height_pt` if explicitly set,
2799/// otherwise derived from the resolved font's ascender + descender scaled to
2800/// the current font size (`font_metrics.line_height_pt()`).
2801///
2802/// # Paragraph indentation
2803///
2804/// When `first_line_of_para[i]` is `true`, the first character of line `i` is
2805/// shifted right by `node_style.text_indent_pt`.  All other lines are indented
2806/// by `pad_left` (margin-left).
2807///
2808/// # Character / word spacing
2809///
2810/// Neither `Tc` nor `Tw` are emitted.  XFA does not surface these properties.
2811#[allow(clippy::too_many_arguments)]
2812fn render_multiline(
2813    x: f64,
2814    _pdf_y: f64,
2815    container_width: f64,
2816    container_height: f64,
2817    lines: &[String],
2818    first_line_of_para: &[bool],
2819    font_size: f64,
2820    text_align: TextAlign,
2821    font_family: FontFamily,
2822    _is_bold: bool,
2823    mapper: &CoordinateMapper,
2824    abs_y_xfa: f64,
2825    node_style: &FormNodeStyle,
2826    config: &XfaRenderConfig,
2827    ops: &mut Vec<u8>,
2828) {
2829    if lines.is_empty() {
2830        return;
2831    }
2832    // Insets already applied by render_nodes — x, container_width, and
2833    // abs_y_xfa are the value area inside margin insets.
2834    let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
2835    let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
2836    let space_above = node_style.space_above_pt.unwrap_or(0.0);
2837    let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
2838    let font_metrics = build_font_metrics(font_size, font_family, node_style, config);
2839    let line_height = node_style
2840        .line_height_pt
2841        .unwrap_or_else(|| font_metrics.line_height_pt());
2842    let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2843    let tc = node_style
2844        .text_color
2845        .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2846        .unwrap_or(config.text_color);
2847    write_ops(
2848        ops,
2849        format_args!(
2850            "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2851            tc[0], tc[1], tc[2], font_ref, font_size
2852        ),
2853    );
2854    emit_synthetic_bold_ops(&config.font_map, node_style, font_size, &tc, ops);
2855    emit_text_style_ops(node_style, ops);
2856    let ascender_pt = if let (Some(asc), Some(upem)) =
2857        (font_metrics.resolved_ascender, font_metrics.resolved_upem)
2858    {
2859        if upem > 0 {
2860            asc as f64 / upem as f64 * font_size
2861        } else {
2862            font_size
2863        }
2864    } else {
2865        font_size
2866    };
2867    let total_text_h = lines.len() as f64 * line_height;
2868    let first_line_y_xfa = match node_style.v_align {
2869        Some(VerticalAlign::Middle) => {
2870            abs_y_xfa
2871                + space_above
2872                + (container_height - space_above - total_text_h) / 2.0
2873                + ascender_pt
2874        }
2875        Some(VerticalAlign::Bottom) => abs_y_xfa + container_height - total_text_h + ascender_pt,
2876        _ => abs_y_xfa + space_above + ascender_pt,
2877    };
2878    let first_line_pdf_y = mapper.xfa_to_pdf_y(first_line_y_xfa, 0.0);
2879    let content_w = (container_width - pad_left - pad_right).max(0.0);
2880    let idh_metrics = lookup_font_metrics(node_style, config);
2881    let mut prev_x = x + pad_left;
2882    for (i, line) in lines.iter().enumerate() {
2883        let is_para_start = first_line_of_para.get(i).copied().unwrap_or(false);
2884        let indent_offset = if is_para_start { text_indent } else { 0.0 };
2885        let line_y = first_line_pdf_y - (i as f64 * line_height);
2886        let line_w = font_metrics.measure_width(line);
2887        let text_x = match text_align {
2888            TextAlign::Center => {
2889                x + pad_left + indent_offset + ((content_w - indent_offset - line_w) / 2.0).max(0.0)
2890            }
2891            TextAlign::Right => x + pad_left + (content_w - line_w).max(0.0),
2892            _ => x + pad_left + indent_offset,
2893        };
2894        if i == 0 {
2895            write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
2896        } else {
2897            let dx = text_x - prev_x;
2898            write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -line_height));
2899        }
2900        prev_x = text_x;
2901        let encoded = pdf_encode_text(line, idh_metrics);
2902        write_ops(ops, format_args!("{} Tj\n", encoded));
2903    }
2904    reset_text_style_ops(node_style, ops);
2905    reset_synthetic_bold_ops(&config.font_map, node_style, ops);
2906    ops.extend_from_slice(b"ET\n");
2907}
2908
2909/// Render multiline rich text with per-span font/color/weight switching.
2910#[allow(clippy::too_many_arguments)]
2911fn render_rich_multiline(
2912    x: f64,
2913    container_width: f64,
2914    container_height: f64,
2915    lines: &[String],
2916    first_line_of_para: &[bool],
2917    spans: &[RichTextSpan],
2918    font_size: f64,
2919    text_align: TextAlign,
2920    font_family: FontFamily,
2921    mapper: &CoordinateMapper,
2922    abs_y_xfa: f64,
2923    node_style: &FormNodeStyle,
2924    config: &XfaRenderConfig,
2925    ops: &mut Vec<u8>,
2926) {
2927    if lines.is_empty() || spans.is_empty() {
2928        return;
2929    }
2930    // Insets already applied by render_nodes — x, container_width, and
2931    // abs_y_xfa are the value area inside margin insets.
2932    let pad_left = node_style.margin_left_pt.unwrap_or(0.0);
2933    let pad_right = node_style.margin_right_pt.unwrap_or(0.0);
2934    let space_above = node_style.space_above_pt.unwrap_or(0.0);
2935    let text_indent = node_style.text_indent_pt.unwrap_or(0.0);
2936    let font_metrics = build_font_metrics(font_size, font_family, node_style, config);
2937    let line_height = node_style
2938        .line_height_pt
2939        .unwrap_or_else(|| font_metrics.line_height_pt());
2940    let asc_pt = if let (Some(asc), Some(upem)) =
2941        (font_metrics.resolved_ascender, font_metrics.resolved_upem)
2942    {
2943        if upem > 0 {
2944            asc as f64 / upem as f64 * font_size
2945        } else {
2946            font_size
2947        }
2948    } else {
2949        font_size
2950    };
2951    let total_text_h = lines.len() as f64 * line_height;
2952    let first_line_y_xfa = match node_style.v_align {
2953        Some(VerticalAlign::Middle) => {
2954            abs_y_xfa + space_above + (container_height - space_above - total_text_h) / 2.0 + asc_pt
2955        }
2956        Some(VerticalAlign::Bottom) => abs_y_xfa + container_height - total_text_h + asc_pt,
2957        _ => abs_y_xfa + space_above + asc_pt,
2958    };
2959    let first_line_pdf_y = mapper.xfa_to_pdf_y(first_line_y_xfa, 0.0);
2960    let content_w = (container_width - pad_left - pad_right).max(0.0);
2961    let line_segments = map_spans_to_lines(spans, lines);
2962
2963    ops.extend_from_slice(b"BT\n");
2964    let base_font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
2965    let base_tc = node_style
2966        .text_color
2967        .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
2968        .unwrap_or(config.text_color);
2969    let idh_metrics = lookup_font_metrics(node_style, config);
2970
2971    let mut cur_font_ref = base_font_ref;
2972    let mut cur_fs = font_size;
2973    // Text rise (`Ts`) starts at zero; see the span loop below.
2974    let mut cur_rise: f64 = 0.0;
2975    let mut cur_tc = base_tc;
2976    write_ops(
2977        ops,
2978        format_args!(
2979            "{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
2980            cur_tc[0], cur_tc[1], cur_tc[2], cur_font_ref, cur_fs,
2981        ),
2982    );
2983    emit_text_style_ops(node_style, ops);
2984
2985    let mut prev_x = x + pad_left;
2986    for (i, line) in lines.iter().enumerate() {
2987        let is_para_start = first_line_of_para.get(i).copied().unwrap_or(false);
2988        let indent_offset = if is_para_start { text_indent } else { 0.0 };
2989        let line_y = first_line_pdf_y - (i as f64 * line_height);
2990        let line_w = font_metrics.measure_width(line);
2991        let text_x = match text_align {
2992            TextAlign::Center => {
2993                x + pad_left + indent_offset + ((content_w - indent_offset - line_w) / 2.0).max(0.0)
2994            }
2995            TextAlign::Right => x + pad_left + (content_w - line_w).max(0.0),
2996            _ => x + pad_left + indent_offset,
2997        };
2998        if i == 0 {
2999            write_ops(ops, format_args!("{:.2} {:.2} Td\n", text_x, line_y));
3000        } else {
3001            let dx = text_x - prev_x;
3002            write_ops(ops, format_args!("{:.2} {:.2} Td\n", dx, -line_height));
3003        }
3004        prev_x = text_x;
3005
3006        if let Some(segs) = line_segments.get(i) {
3007            if segs.is_empty() {
3008                let encoded = pdf_encode_text(line, idh_metrics);
3009                write_ops(ops, format_args!("{} Tj\n", encoded));
3010                continue;
3011            }
3012            for seg in segs {
3013                let span = &spans[seg.span_idx];
3014                let span_family = span
3015                    .font_family
3016                    .as_deref()
3017                    .map(classify_font_family)
3018                    .unwrap_or(font_family);
3019                let span_style = span_to_node_style(span, node_style);
3020                let span_font_ref = resolve_font_ref(&config.font_map, &span_style, span_family);
3021                let span_fs = span.font_size.unwrap_or(font_size);
3022                let span_tc = span
3023                    .text_color
3024                    .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
3025                    .unwrap_or(base_tc);
3026
3027                if span_font_ref != cur_font_ref || (span_fs - cur_fs).abs() > 0.01 {
3028                    write_ops(ops, format_args!("{} {:.1} Tf\n", span_font_ref, span_fs));
3029                    cur_font_ref = span_font_ref;
3030                    cur_fs = span_fs;
3031                }
3032                // Baseline shift for `<sub>` and `<sup>` (#151).
3033                //
3034                // `Ts` is the text rise of ISO 32000-2 §9.3.1: it holds from
3035                // here until it is set again, so we write it only on a change
3036                // and put it back to zero at the end. Without that last step
3037                // every line after a superscript would ride up too.
3038                //
3039                // The shift is a fraction of the font size, so a superscript in
3040                // 8pt text does not sit as high as one in 24pt.
3041                let span_rise = span.baseline_shift * span_fs;
3042                if (span_rise - cur_rise).abs() > 0.01 {
3043                    write_ops(ops, format_args!("{span_rise:.2} Ts\n"));
3044                    cur_rise = span_rise;
3045                }
3046                if (span_tc[0] - cur_tc[0]).abs() > 0.001
3047                    || (span_tc[1] - cur_tc[1]).abs() > 0.001
3048                    || (span_tc[2] - cur_tc[2]).abs() > 0.001
3049                {
3050                    write_ops(
3051                        ops,
3052                        format_args!("{:.3} {:.3} {:.3} rg\n", span_tc[0], span_tc[1], span_tc[2]),
3053                    );
3054                    cur_tc = span_tc;
3055                }
3056                let is_span_bold = span.font_weight.as_deref() == Some("bold");
3057                let span_has_real_bold =
3058                    style_uses_real_bold_variant(&config.font_map, &span_style);
3059                if is_span_bold && !span_has_real_bold {
3060                    let stroke_w = span_fs * 0.03;
3061                    write_ops(
3062                        ops,
3063                        format_args!(
3064                            "2 Tr\n{:.4} w\n{:.3} {:.3} {:.3} RG\n",
3065                            stroke_w, span_tc[0], span_tc[1], span_tc[2],
3066                        ),
3067                    );
3068                }
3069                let encoded = pdf_encode_text(&seg.text, idh_metrics);
3070                write_ops(ops, format_args!("{} Tj\n", encoded));
3071                if is_span_bold && !span_has_real_bold {
3072                    write_ops(ops, format_args!("0 Tr\n"));
3073                }
3074            }
3075        } else {
3076            let encoded = pdf_encode_text(line, idh_metrics);
3077            write_ops(ops, format_args!("{} Tj\n", encoded));
3078        }
3079    }
3080    reset_text_style_ops(node_style, ops);
3081    // `Ts` belongs to the text state and outlives `ET`. Without this reset
3082    // all text after a superscript rides up -- across the whole page, not just
3083    // this field.
3084    if cur_rise.abs() > 0.01 {
3085        ops.extend_from_slice(b"0 Ts\n");
3086    }
3087    ops.extend_from_slice(b"ET\n");
3088}
3089
3090fn span_to_node_style(span: &RichTextSpan, base: &FormNodeStyle) -> FormNodeStyle {
3091    let mut style = base.clone();
3092    if let Some(ref fam) = span.font_family {
3093        style.font_family = Some(fam.clone());
3094    }
3095    if let Some(ref w) = span.font_weight {
3096        style.font_weight = Some(w.clone());
3097    }
3098    if let Some(ref s) = span.font_style {
3099        style.font_style = Some(s.clone());
3100    }
3101    style
3102}
3103
3104fn classify_font_family(name: &str) -> FontFamily {
3105    if name.contains("Courier") || name.contains("Mono") {
3106        FontFamily::Monospace
3107    } else if name.contains("Helvetica")
3108        || name.contains("Arial")
3109        || name.contains("Sans")
3110        || name.contains("Myriad")
3111    {
3112        FontFamily::SansSerif
3113    } else {
3114        FontFamily::Serif
3115    }
3116}
3117
3118struct LineSpanSegment {
3119    text: String,
3120    span_idx: usize,
3121}
3122
3123fn leading_whitespace_len(s: &str) -> usize {
3124    s.char_indices()
3125        .find(|(_, ch)| !ch.is_whitespace())
3126        .map(|(idx, _)| idx)
3127        .unwrap_or(s.len())
3128}
3129
3130fn map_spans_to_lines(spans: &[RichTextSpan], lines: &[String]) -> Vec<Vec<LineSpanSegment>> {
3131    let mut result = Vec::with_capacity(lines.len());
3132    let mut span_idx = 0_usize;
3133    let mut span_off = 0_usize;
3134
3135    for line in lines {
3136        while span_idx < spans.len() {
3137            if spans[span_idx].text == "\n" || span_off >= spans[span_idx].text.len() {
3138                span_idx += 1;
3139                span_off = 0;
3140            } else {
3141                break;
3142            }
3143        }
3144
3145        let mut segs: Vec<LineSpanSegment> = Vec::new();
3146        let mut line_pos = 0_usize;
3147
3148        while line_pos < line.len() && span_idx < spans.len() {
3149            let span = &spans[span_idx];
3150            if span.text == "\n" {
3151                span_idx += 1;
3152                span_off = 0;
3153                continue;
3154            }
3155            let span_rest = &span.text[span_off..];
3156            let line_rest = &line[line_pos..];
3157
3158            let common = line_rest
3159                .chars()
3160                .zip(span_rest.chars())
3161                .take_while(|(a, b)| a == b || (a.is_whitespace() && b.is_whitespace()))
3162                .count();
3163
3164            if common > 0 {
3165                let common_str: String = line_rest.chars().take(common).collect();
3166                let common_line_byte_len = common_str.len();
3167                let common_span_byte_len: usize =
3168                    span_rest.chars().take(common).map(char::len_utf8).sum();
3169                segs.push(LineSpanSegment {
3170                    text: common_str,
3171                    span_idx,
3172                });
3173                line_pos += common_line_byte_len;
3174                span_off += common_span_byte_len;
3175                if span_off >= span.text.len() {
3176                    span_idx += 1;
3177                    span_off = 0;
3178                }
3179            } else {
3180                let span_skip = leading_whitespace_len(span_rest);
3181                if span_skip > 0 {
3182                    span_off += span_skip;
3183                    if span_off >= span.text.len() {
3184                        span_idx += 1;
3185                        span_off = 0;
3186                    }
3187                    continue;
3188                }
3189
3190                let line_skip = leading_whitespace_len(line_rest);
3191                if line_skip > 0 {
3192                    segs.push(LineSpanSegment {
3193                        text: line_rest[..line_skip].to_string(),
3194                        span_idx,
3195                    });
3196                    line_pos += line_skip;
3197                } else {
3198                    segs.push(LineSpanSegment {
3199                        text: line_rest.to_string(),
3200                        span_idx: 0,
3201                    });
3202                    break;
3203                }
3204            }
3205        }
3206
3207        result.push(segs);
3208
3209        while span_idx < spans.len() {
3210            let span = &spans[span_idx];
3211            if span.text == "\n" {
3212                break;
3213            }
3214            let rest = &span.text[span_off..];
3215            let skip = leading_whitespace_len(rest);
3216            if skip > 0 {
3217                span_off += skip;
3218                if span_off >= span.text.len() {
3219                    span_idx += 1;
3220                    span_off = 0;
3221                }
3222            } else {
3223                break;
3224            }
3225        }
3226    }
3227
3228    result
3229}
3230
3231fn wrap_text(text: &str, max_width: f64, metrics: &FontMetrics) -> Vec<String> {
3232    let mut lines = Vec::new();
3233    let mut current = String::new();
3234    for word in text.split_whitespace() {
3235        if current.is_empty() {
3236            current = word.to_string();
3237        } else {
3238            let candidate = format!("{} {}", current, word);
3239            if metrics.measure_width(&candidate) <= max_width {
3240                current = candidate;
3241            } else {
3242                lines.push(current);
3243                current = word.to_string();
3244            }
3245        }
3246    }
3247    if !current.is_empty() {
3248        lines.push(current);
3249    }
3250    if lines.is_empty() && !text.is_empty() {
3251        lines.push(text.to_string());
3252    }
3253    lines
3254}
3255
3256fn push_pdf_string_byte(out: &mut String, b: u8) {
3257    match b {
3258        b'(' => out.push_str("\\("),
3259        b')' => out.push_str("\\)"),
3260        b'\\' => out.push_str("\\\\"),
3261        0x20..=0x7E => out.push(b as char),
3262        _ => {
3263            use std::fmt::Write;
3264            let _ = write!(out, "\\{:03o}", b);
3265        }
3266    }
3267}
3268
3269fn pdf_escape_with_simple_encoding(s: &str, unicode_to_code: Option<&HashMap<u16, u8>>) -> String {
3270    let mut out = String::with_capacity(s.len());
3271    for c in s.chars() {
3272        if let Some(map) = unicode_to_code {
3273            let mapped = u16::try_from(c as u32)
3274                .ok()
3275                .and_then(|cp| map.get(&cp).copied())
3276                // Keep WinAnsi fallback for punctuation/shared code points.
3277                .or_else(|| unicode_to_winansi(c));
3278            if let Some(b) = mapped {
3279                push_pdf_string_byte(&mut out, b);
3280            } else {
3281                out.push('?');
3282            }
3283            continue;
3284        }
3285
3286        match c {
3287            '(' => out.push_str("\\("),
3288            ')' => out.push_str("\\)"),
3289            '\\' => out.push_str("\\\\"),
3290            '\x20'..='\x7e' => out.push(c),
3291            _ => {
3292                if let Some(b) = unicode_to_winansi(c) {
3293                    push_pdf_string_byte(&mut out, b);
3294                } else {
3295                    out.push('?');
3296                }
3297            }
3298        }
3299    }
3300    out
3301}
3302
3303fn pdf_escape(s: &str) -> String {
3304    pdf_escape_with_simple_encoding(s, None)
3305}
3306
3307/// Encode text for a PDF content stream, choosing Identity-H (hex glyph IDs)
3308/// when the font has embedded data, or WinAnsi parenthesized string otherwise.
3309fn pdf_encode_text(s: &str, metrics: Option<&FontMetricsData>) -> String {
3310    if let Some(data) = metrics {
3311        if let Some(ref font_bytes) = data.font_data {
3312            if let Ok(face) = ttf_parser::Face::parse(font_bytes, data.face_index) {
3313                let mut hex = String::with_capacity(s.len() * 4 + 2);
3314                hex.push('<');
3315                for ch in s.chars() {
3316                    let gid = face.glyph_index(ch).map(|g| g.0).unwrap_or(0);
3317                    use std::fmt::Write;
3318                    let _ = write!(hex, "{:04X}", gid);
3319                }
3320                hex.push('>');
3321                return hex;
3322            }
3323        }
3324    }
3325    let simple_map = metrics.and_then(|m| m.simple_unicode_to_code.as_ref());
3326    format!("({})", pdf_escape_with_simple_encoding(s, simple_map))
3327}
3328
3329/// Look up font metrics for a typeface from the render config.
3330///
3331/// Tries the variant key (with weight/posture) first, then falls back to
3332/// the plain typeface name.
3333///
3334/// NOTE: We intentionally return simple-font metrics even when `font_data` is
3335/// `None`. In that case `pdf_encode_text()` uses simple-font byte encoding
3336/// fallback (e.g. `/Differences`) rather than Identity-H glyph IDs.
3337fn lookup_font_metrics<'a>(
3338    node_style: &FormNodeStyle,
3339    config: &'a XfaRenderConfig,
3340) -> Option<&'a FontMetricsData> {
3341    node_style.font_family.as_ref().and_then(|tf| {
3342        let vkey = font_variant_key(
3343            tf,
3344            node_style.font_weight.as_deref(),
3345            node_style.font_style.as_deref(),
3346        );
3347        config
3348            .font_metrics_data
3349            .get(&vkey)
3350            .or_else(|| config.font_metrics_data.get(tf))
3351    })
3352}
3353
3354/// XFA 3.3 §2.6 — render a coordless `<line>` value by spanning the draw's
3355/// laid-out box (the cell-grid rules in table-style forms). Default-on; opt out
3356/// with `XFA_DRAW_LINE_SPAN=0|off|false|no` to restore the legacy zero-length
3357/// point (byte-identical render).
3358fn draw_line_span_enabled() -> bool {
3359    !matches!(
3360        std::env::var("XFA_DRAW_LINE_SPAN").as_deref(),
3361        Ok("0") | Ok("off") | Ok("false") | Ok("no")
3362    )
3363}
3364
3365#[allow(clippy::too_many_arguments)]
3366fn render_draw(
3367    draw_content: &DrawContent,
3368    abs_x: f64,
3369    pdf_y: f64,
3370    container_w: f64,
3371    container_h: f64,
3372    node_style: &FormNodeStyle,
3373    config: &XfaRenderConfig,
3374    ops: &mut Vec<u8>,
3375) {
3376    match draw_content {
3377        DrawContent::Text(text) => {
3378            if !text.is_empty() {
3379                let fs = node_style.font_size.unwrap_or(config.default_font_size);
3380                let font_family = match node_style.font_family.as_deref() {
3381                    Some(f) if f.contains("Courier") || f.contains("Mono") => FontFamily::Monospace,
3382                    Some(f)
3383                        if f.contains("Helvetica")
3384                            || f.contains("Arial")
3385                            || f.contains("Sans")
3386                            || f.contains("Myriad") =>
3387                    {
3388                        FontFamily::SansSerif
3389                    }
3390                    _ => FontFamily::Serif,
3391                };
3392                let font_ref = resolve_font_ref(&config.font_map, node_style, font_family);
3393                let tc = node_style
3394                    .text_color
3395                    .map(|(r, g, b)| [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0])
3396                    .unwrap_or(config.text_color);
3397                let idh_metrics = lookup_font_metrics(node_style, config);
3398                let encoded = pdf_encode_text(text, idh_metrics);
3399                write_ops(
3400                    ops,
3401                    format_args!(
3402                        "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
3403                        tc[0], tc[1], tc[2], font_ref, fs,
3404                    ),
3405                );
3406                emit_synthetic_bold_ops(&config.font_map, node_style, fs, &tc, ops);
3407                write_ops(
3408                    ops,
3409                    format_args!("{:.2} {:.2} Td\n{} Tj\n", abs_x, pdf_y, encoded),
3410                );
3411                reset_synthetic_bold_ops(&config.font_map, node_style, ops);
3412                ops.extend_from_slice(b"ET\n");
3413            }
3414        }
3415        DrawContent::Line { x1, y1, x2, y2 } => {
3416            // XFA 3.3 §2.6 — a `<line>` value carries NO x1/y1/x2/y2 attributes:
3417            // the line spans the draw's content box corner-to-corner per its
3418            // `slope` (default `\`, top-left→bottom-right). The parser therefore
3419            // yields `Line{0,0,0,0}` for every authored grid rule (the `FFLineN`
3420            // draws in gov forms), which the legacy path below strokes as a
3421            // zero-length point with butt caps => the whole cell grid vanishes.
3422            // When the span flag is on and the coords are absent, substitute the
3423            // laid-out box extent; for the axis-aligned h=0 / w=0 grid lines
3424            // this is exactly the horizontal / vertical rule. Stroke weight and
3425            // colour come from the line's `<edge>` (parsed into the node style,
3426            // mirroring the rectangle path). Opt out with
3427            // XFA_DRAW_LINE_SPAN=0|off|false|no to restore the byte-identical
3428            // legacy point.
3429            let coordless = *x1 == 0.0 && *y1 == 0.0 && *x2 == 0.0 && *y2 == 0.0;
3430            let span =
3431                draw_line_span_enabled() && coordless && (container_w > 0.0 || container_h > 0.0);
3432            if span {
3433                if let Some((r, g, b)) = node_style.border_color {
3434                    write_ops(
3435                        ops,
3436                        format_args!(
3437                            "{:.4} {:.4} {:.4} RG\n",
3438                            r as f64 / 255.0,
3439                            g as f64 / 255.0,
3440                            b as f64 / 255.0
3441                        ),
3442                    );
3443                }
3444                if let Some(w_pt) = node_style.border_width_pt {
3445                    write_ops(ops, format_args!("{:.2} w\n", w_pt));
3446                }
3447            }
3448            let (lx1, ly1, lx2, ly2) = if span {
3449                (0.0, 0.0, container_w, container_h)
3450            } else {
3451                (*x1, *y1, *x2, *y2)
3452            };
3453            let start_x = abs_x + lx1;
3454            let start_y = pdf_y + container_h - ly1;
3455            let end_x = abs_x + lx2;
3456            let end_y = pdf_y + container_h - ly2;
3457            write_ops(
3458                ops,
3459                format_args!(
3460                    "{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
3461                    start_x, start_y, end_x, end_y
3462                ),
3463            );
3464        }
3465        DrawContent::Rectangle { x, y, w, h, radius } => {
3466            let rx = abs_x + x;
3467            let ry = pdf_y + container_h - y - h;
3468            // Apply border color from <value><rectangle><edge><color>.
3469            if let Some((r, g, b)) = node_style.border_color {
3470                write_ops(
3471                    ops,
3472                    format_args!(
3473                        "{:.4} {:.4} {:.4} RG\n",
3474                        r as f64 / 255.0,
3475                        g as f64 / 255.0,
3476                        b as f64 / 255.0
3477                    ),
3478                );
3479            }
3480            if let Some(w_pt) = node_style.border_width_pt {
3481                write_ops(ops, format_args!("{:.2} w\n", w_pt));
3482            }
3483            if *radius <= 0.0 {
3484                write_ops(
3485                    ops,
3486                    format_args!("{:.2} {:.2} {:.2} {:.2} re\nS\n", rx, ry, w, h),
3487                );
3488            } else {
3489                let r = radius.min(w / 2.0).min(h / 2.0);
3490                let k = r * 0.5522847498;
3491                write_ops(
3492                    ops,
3493                    format_args!(
3494                        "{:.2} {:.2} m\n\
3495                         {:.2} {:.2} l\n\
3496                         {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3497                         {:.2} {:.2} l\n\
3498                         {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3499                         {:.2} {:.2} l\n\
3500                         {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3501                         {:.2} {:.2} l\n\
3502                         {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\n\
3503                         h\nS\n",
3504                        rx,
3505                        ry + r,
3506                        rx,
3507                        ry + h - r,
3508                        rx,
3509                        ry + h - r + k,
3510                        rx + r - k,
3511                        ry + h,
3512                        rx + r,
3513                        ry + h,
3514                        rx + w - r,
3515                        ry + h,
3516                        rx + w - r + k,
3517                        ry + h,
3518                        rx + w,
3519                        ry + h - r + k,
3520                        rx + w,
3521                        ry + h - r,
3522                        rx + w,
3523                        ry + r,
3524                        rx + w,
3525                        ry + r - k,
3526                        rx + w - r + k,
3527                        ry,
3528                        rx + w - r,
3529                        ry,
3530                        rx + r,
3531                        ry,
3532                        rx + r - k,
3533                        ry,
3534                        rx,
3535                        ry + r - k,
3536                        rx,
3537                        ry + r,
3538                    ),
3539                );
3540            }
3541        }
3542        DrawContent::Arc {
3543            x,
3544            y,
3545            w,
3546            h,
3547            start_angle,
3548            sweep_angle,
3549        } => {
3550            let cx = abs_x + x + w / 2.0;
3551            let cy = pdf_y + container_h - y - h / 2.0;
3552            let rx = w / 2.0;
3553            let ry = h / 2.0;
3554            let start_rad = start_angle.to_radians();
3555            let sweep_rad = sweep_angle.to_radians();
3556            let end_angle = start_rad + sweep_rad;
3557            let k = 0.5522847498;
3558            let cos_start = start_rad.cos();
3559            let sin_start = start_rad.sin();
3560            let cos_end = end_angle.cos();
3561            let sin_end = end_angle.sin();
3562            let p1x = cx + rx * cos_start;
3563            let p1y = cy + ry * sin_start;
3564            let p2x = cx + rx * cos_end;
3565            let p2y = cy + ry * sin_end;
3566            let cp1x = cx - rx * k * sin_start;
3567            let cp1y = cy + ry * k * cos_start;
3568            let cp2x = cx + rx * k * sin_end;
3569            let cp2y = cy - ry * k * cos_end;
3570            write_ops(
3571                ops,
3572                format_args!(
3573                    "{:.2} {:.2} m\n{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c\nS\n",
3574                    p1x, p1y, cp1x, cp1y, cp2x, cp2y, p2x, p2y
3575                ),
3576            );
3577        }
3578    }
3579}
3580
3581pub(crate) fn unicode_to_winansi(c: char) -> Option<u8> {
3582    let cp = c as u32;
3583    if (0xA0..=0xFF).contains(&cp) {
3584        return Some(cp as u8);
3585    }
3586    match c {
3587        '\u{20AC}' => Some(0x80),
3588        '\u{201A}' => Some(0x82),
3589        '\u{0192}' => Some(0x83),
3590        '\u{201E}' => Some(0x84),
3591        '\u{2026}' => Some(0x85),
3592        '\u{2020}' => Some(0x86),
3593        '\u{2021}' => Some(0x87),
3594        '\u{02C6}' => Some(0x88),
3595        '\u{2030}' => Some(0x89),
3596        '\u{0160}' => Some(0x8A),
3597        '\u{2039}' => Some(0x8B),
3598        '\u{0152}' => Some(0x8C),
3599        '\u{017D}' => Some(0x8E),
3600        '\u{2018}' => Some(0x91),
3601        '\u{2019}' => Some(0x92),
3602        '\u{201C}' => Some(0x93),
3603        '\u{201D}' => Some(0x94),
3604        '\u{2022}' => Some(0x95),
3605        '\u{2013}' => Some(0x96),
3606        '\u{2014}' => Some(0x97),
3607        '\u{02DC}' => Some(0x98),
3608        '\u{2122}' => Some(0x99),
3609        '\u{0161}' => Some(0x9A),
3610        '\u{203A}' => Some(0x9B),
3611        '\u{0153}' => Some(0x9C),
3612        '\u{017E}' => Some(0x9E),
3613        '\u{0178}' => Some(0x9F),
3614        _ => None,
3615    }
3616}
3617
3618fn write_ops(buf: &mut Vec<u8>, args: std::fmt::Arguments<'_>) {
3619    use std::io::Write;
3620    let _ = buf.write_fmt(args);
3621}
3622
3623#[cfg(test)]
3624mod tests {
3625    use super::*;
3626    use xfa_layout_engine::form::FormNodeId;
3627    use xfa_layout_engine::types::Rect;
3628
3629    fn make_page(nodes: Vec<LayoutNode>) -> LayoutPage {
3630        LayoutPage {
3631            width: 612.0,
3632            height: 792.0,
3633            nodes,
3634            runtime_instantiated: false,
3635        }
3636    }
3637
3638    fn make_field_node(x: f64, y: f64, w: f64, h: f64, value: &str) -> LayoutNode {
3639        LayoutNode {
3640            form_node: FormNodeId(0),
3641            rect: Rect::new(x, y, w, h),
3642            name: "field1".to_string(),
3643            content: LayoutContent::Field {
3644                value: value.to_string(),
3645                field_kind: FieldKind::Text,
3646                font_size: 0.0,
3647                font_family: FontFamily::Serif,
3648            },
3649            children: vec![],
3650            style: Default::default(),
3651            display_items: vec![],
3652            save_items: vec![],
3653        }
3654    }
3655
3656    fn make_styled_field(
3657        x: f64,
3658        y: f64,
3659        w: f64,
3660        h: f64,
3661        value: &str,
3662        style: FormNodeStyle,
3663    ) -> LayoutNode {
3664        LayoutNode {
3665            form_node: FormNodeId(0),
3666            rect: Rect::new(x, y, w, h),
3667            name: "styled".to_string(),
3668            content: LayoutContent::Field {
3669                value: value.to_string(),
3670                field_kind: FieldKind::Text,
3671                font_size: 10.0,
3672                font_family: FontFamily::Serif,
3673            },
3674            children: vec![],
3675            style,
3676            display_items: vec![],
3677            save_items: vec![],
3678        }
3679    }
3680
3681    fn make_styled_field_kind(
3682        x: f64,
3683        y: f64,
3684        w: f64,
3685        h: f64,
3686        value: &str,
3687        field_kind: FieldKind,
3688        style: FormNodeStyle,
3689    ) -> LayoutNode {
3690        LayoutNode {
3691            form_node: FormNodeId(0),
3692            rect: Rect::new(x, y, w, h),
3693            name: "styled-kind".to_string(),
3694            content: LayoutContent::Field {
3695                value: value.to_string(),
3696                field_kind,
3697                font_size: 10.0,
3698                font_family: FontFamily::Serif,
3699            },
3700            children: vec![],
3701            style,
3702            display_items: vec![],
3703            save_items: vec![],
3704        }
3705    }
3706
3707    fn make_styled_checkbox(
3708        x: f64,
3709        y: f64,
3710        w: f64,
3711        h: f64,
3712        value: &str,
3713        style: FormNodeStyle,
3714    ) -> LayoutNode {
3715        LayoutNode {
3716            form_node: FormNodeId(0),
3717            rect: Rect::new(x, y, w, h),
3718            name: "checkbox".to_string(),
3719            content: LayoutContent::Field {
3720                value: value.to_string(),
3721                field_kind: FieldKind::Checkbox,
3722                font_size: 10.0,
3723                font_family: FontFamily::Serif,
3724            },
3725            children: vec![],
3726            style,
3727            display_items: vec![],
3728            save_items: vec![],
3729        }
3730    }
3731
3732    fn make_styled_radio(
3733        x: f64,
3734        y: f64,
3735        w: f64,
3736        h: f64,
3737        value: &str,
3738        style: FormNodeStyle,
3739    ) -> LayoutNode {
3740        LayoutNode {
3741            form_node: FormNodeId(0),
3742            rect: Rect::new(x, y, w, h),
3743            name: "radio".to_string(),
3744            content: LayoutContent::Field {
3745                value: value.to_string(),
3746                field_kind: FieldKind::Radio,
3747                font_size: 10.0,
3748                font_family: FontFamily::Serif,
3749            },
3750            children: vec![],
3751            style,
3752            display_items: vec![],
3753            save_items: vec![],
3754        }
3755    }
3756
3757    fn make_styled_button(
3758        x: f64,
3759        y: f64,
3760        w: f64,
3761        h: f64,
3762        value: &str,
3763        style: FormNodeStyle,
3764    ) -> LayoutNode {
3765        LayoutNode {
3766            form_node: FormNodeId(0),
3767            rect: Rect::new(x, y, w, h),
3768            name: "button".to_string(),
3769            content: LayoutContent::Field {
3770                value: value.to_string(),
3771                field_kind: FieldKind::Button,
3772                font_size: 10.0,
3773                font_family: FontFamily::Serif,
3774            },
3775            children: vec![],
3776            style,
3777            display_items: vec![],
3778            save_items: vec![],
3779        }
3780    }
3781
3782    #[test]
3783    fn coordinate_mapping() {
3784        let mapper = CoordinateMapper::new(792.0, 612.0);
3785        assert!((mapper.xfa_to_pdf_y(0.0, 20.0) - 772.0).abs() < 0.001);
3786    }
3787
3788    fn overlay_str(page: &LayoutPage) -> String {
3789        let o = generate_page_overlay(page, &XfaRenderConfig::default()).unwrap();
3790        String::from_utf8_lossy(&o.content_stream).into_owned()
3791    }
3792
3793    #[test]
3794    fn empty_page_overlay() {
3795        let s = overlay_str(&make_page(vec![]));
3796        assert!(s.starts_with("q\n") && s.ends_with("Q\n"));
3797    }
3798
3799    #[test]
3800    fn field_renders_text() {
3801        let s = overlay_str(&make_page(vec![make_field_node(
3802            10.0, 10.0, 100.0, 20.0, "Hello",
3803        )]));
3804        assert!(s.contains("(Hello) Tj") && s.contains("BT") && s.contains("ET"));
3805    }
3806
3807    #[test]
3808    fn empty_field_no_text() {
3809        let s = overlay_str(&make_page(vec![make_field_node(
3810            10.0, 10.0, 100.0, 20.0, "",
3811        )]));
3812        assert!(!s.contains("BT"));
3813    }
3814
3815    #[test]
3816    fn dropdown_renders_display_item_for_matching_save_value() {
3817        let node = LayoutNode {
3818            form_node: FormNodeId(0),
3819            rect: Rect::new(10.0, 10.0, 100.0, 20.0),
3820            name: "choice".to_string(),
3821            content: LayoutContent::Field {
3822                value: "CA".to_string(),
3823                field_kind: FieldKind::Dropdown,
3824                font_size: 10.0,
3825                font_family: FontFamily::Serif,
3826            },
3827            children: vec![],
3828            style: Default::default(),
3829            display_items: vec!["California".to_string(), "Nevada".to_string()],
3830            save_items: vec!["CA".to_string(), "NV".to_string()],
3831        };
3832
3833        let s = overlay_str(&make_page(vec![node]));
3834        assert!(
3835            s.contains("(California) Tj"),
3836            "dropdown should render display item: {s}"
3837        );
3838        assert!(
3839            !s.contains("(CA) Tj"),
3840            "dropdown should not render raw save value: {s}"
3841        );
3842    }
3843
3844    #[test]
3845    fn all_overlays() {
3846        let layout = LayoutDom {
3847            pages: vec![
3848                make_page(vec![make_field_node(0.0, 0.0, 50.0, 20.0, "P1")]),
3849                make_page(vec![make_field_node(0.0, 0.0, 50.0, 20.0, "P2")]),
3850            ],
3851        };
3852        assert_eq!(
3853            generate_all_overlays(&layout, &XfaRenderConfig::default())
3854                .unwrap()
3855                .len(),
3856            2
3857        );
3858    }
3859
3860    #[test]
3861    fn pdf_escape_winansi_encoding() {
3862        assert_eq!(pdf_escape("Hello"), "Hello");
3863        assert_eq!(pdf_escape("a(b)c\\d"), "a\\(b\\)c\\\\d");
3864        assert_eq!(pdf_escape("\u{2013}"), "\\226");
3865        assert_eq!(pdf_escape("\u{2022}"), "\\225");
3866        assert_eq!(pdf_escape("\u{00A9}"), "\\251");
3867        assert_eq!(pdf_escape("\u{4E16}"), "?");
3868    }
3869
3870    fn styled_overlay_str(node: LayoutNode) -> String {
3871        let o = generate_page_overlay(&make_page(vec![node]), &XfaRenderConfig::default()).unwrap();
3872        String::from_utf8_lossy(&o.content_stream).into_owned()
3873    }
3874
3875    fn styled_overlay_str_with_config(node: LayoutNode, config: XfaRenderConfig) -> String {
3876        let o = generate_page_overlay(&make_page(vec![node]), &config).unwrap();
3877        String::from_utf8_lossy(&o.content_stream).into_owned()
3878    }
3879
3880    #[test]
3881    fn rounded_border_emits_bezier() {
3882        let style = FormNodeStyle {
3883            border_width_pt: Some(1.0),
3884            border_radius_pt: Some(5.0),
3885            ..Default::default()
3886        };
3887        let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "Hi", style));
3888        assert!(s.contains(" c\n"), "expected Bezier");
3889        assert!(s.contains("h\n"), "expected close-path");
3890    }
3891
3892    #[test]
3893    fn button_default_border_radius_is_zero() {
3894        let s = styled_overlay_str(make_styled_button(
3895            10.0,
3896            10.0,
3897            100.0,
3898            20.0,
3899            "Click",
3900            FormNodeStyle::default(),
3901        ));
3902        assert!(
3903            !s.contains(" c\n"),
3904            "default button border radius should stay square: {s}"
3905        );
3906    }
3907
3908    #[test]
3909    fn button_with_caption_renders_even_when_value_is_empty() {
3910        let style = FormNodeStyle {
3911            caption_text: Some("Click".to_string()),
3912            ..Default::default()
3913        };
3914        let s = styled_overlay_str(make_styled_button(10.0, 10.0, 100.0, 20.0, "", style));
3915        assert!(
3916            s.contains("(Click) Tj"),
3917            "button caption should render as label: {s}"
3918        );
3919    }
3920
3921    #[test]
3922    fn caption_uses_its_own_font_not_the_field_font() {
3923        // Field font = Times New Roman (serif); caption font = Arial (sans).
3924        // The caption label must bind the Arial resource, otherwise it renders
3925        // serif (the dominant residual defect family on dense gov forms).
3926        let mk = || {
3927            let mut cfg = XfaRenderConfig::default();
3928            let mut fm = HashMap::new();
3929            fm.insert("Times New Roman".to_string(), "/Fserif".to_string());
3930            fm.insert("Arial".to_string(), "/Fsans".to_string());
3931            cfg.font_map = Arc::new(fm);
3932            let style = FormNodeStyle {
3933                font_family: Some("Times New Roman".to_string()),
3934                caption_text: Some("Name".to_string()),
3935                caption_placement: Some("top".to_string()),
3936                caption_reserve: Some(12.0),
3937                caption_font_family: Some("Arial".to_string()),
3938                ..Default::default()
3939            };
3940            (make_styled_field(10.0, 10.0, 200.0, 30.0, "", style), cfg)
3941        };
3942        // resource of the Tf governing the "(Name)" caption draw
3943        let font_of_name = |s: &str| -> String {
3944            let i = s.find("(Name) Tj").expect("caption must render");
3945            let pre = &s[..i];
3946            let tf = pre.rfind(" Tf").expect("a Tf before the caption");
3947            pre[..tf]
3948                .rsplit('/')
3949                .next()
3950                .unwrap()
3951                .split_whitespace()
3952                .next()
3953                .unwrap()
3954                .to_string()
3955        };
3956
3957        // Default-ON: caption binds the Arial (sans) resource.
3958        std::env::remove_var("XFA_CAPTION_OWN_FONT");
3959        let (node, cfg) = mk();
3960        assert_eq!(
3961            font_of_name(&styled_overlay_str_with_config(node, cfg)),
3962            "Fsans",
3963            "caption must render in its own Arial face"
3964        );
3965
3966        // Opt-out restores the legacy field-font behaviour (serif).
3967        std::env::set_var("XFA_CAPTION_OWN_FONT", "0");
3968        let (node, cfg) = mk();
3969        assert_eq!(
3970            font_of_name(&styled_overlay_str_with_config(node, cfg)),
3971            "Fserif",
3972            "opt-out must restore the field font for the caption"
3973        );
3974        std::env::remove_var("XFA_CAPTION_OWN_FONT");
3975    }
3976
3977    #[test]
3978    fn dashed_border_emits_dash_pattern() {
3979        let style = FormNodeStyle {
3980            border_width_pt: Some(1.0),
3981            border_style: Some("dashed".to_string()),
3982            ..Default::default()
3983        };
3984        let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "Hi", style));
3985        assert!(s.contains("[3 2] 0 d"), "expected dash");
3986        assert!(s.contains("[] 0 d"), "expected reset");
3987    }
3988
3989    #[test]
3990    fn field_per_edge_widths_render_without_uniform_border_width() {
3991        let style = FormNodeStyle {
3992            border_widths: Some([1.0, 2.0, 1.0, 3.0]),
3993            border_edges: [false, true, false, true],
3994            ..Default::default()
3995        };
3996        let s = styled_overlay_str(make_styled_field(10.0, 10.0, 100.0, 20.0, "", style));
3997        assert!(s.contains("2.00 w"), "right edge width should be used: {s}");
3998        assert!(s.contains("3.00 w"), "left edge width should be used: {s}");
3999        assert!(
4000            s.contains("110.00 762.00 m 110.00 782.00 l S"),
4001            "right edge should render even without border_width_pt: {s}"
4002        );
4003        assert!(
4004            s.contains("10.00 762.00 m 10.00 782.00 l S"),
4005            "left edge should render even without border_width_pt: {s}"
4006        );
4007    }
4008
4009    #[test]
4010    fn container_per_edge_widths_render_without_uniform_border_width() {
4011        let node = LayoutNode {
4012            form_node: FormNodeId(0),
4013            rect: Rect::new(10.0, 10.0, 100.0, 20.0),
4014            name: "box".to_string(),
4015            content: LayoutContent::None,
4016            children: vec![],
4017            style: FormNodeStyle {
4018                border_widths: Some([1.0, 2.0, 1.0, 3.0]),
4019                border_edges: [false, true, false, true],
4020                ..Default::default()
4021            },
4022            display_items: vec![],
4023            save_items: vec![],
4024        };
4025        let s = styled_overlay_str(node);
4026        assert!(s.contains("2.00 w"), "right edge width should be used: {s}");
4027        assert!(s.contains("3.00 w"), "left edge width should be used: {s}");
4028        assert!(
4029            s.contains("110.00 762.00 m 110.00 782.00 l S"),
4030            "right edge should render for non-field nodes: {s}"
4031        );
4032        assert!(
4033            s.contains("10.00 762.00 m 10.00 782.00 l S"),
4034            "left edge should render for non-field nodes: {s}"
4035        );
4036    }
4037
4038    #[test]
4039    fn para_margins_applied() {
4040        let style = FormNodeStyle {
4041            margin_left_pt: Some(5.0),
4042            margin_right_pt: Some(3.0),
4043            space_above_pt: Some(2.0),
4044            ..Default::default()
4045        };
4046        let s = styled_overlay_str(make_styled_field(10.0, 10.0, 200.0, 30.0, "Test", style));
4047        assert!(s.contains("15.00"), "expected margin_left offset 10+5=15");
4048    }
4049
4050    #[test]
4051    fn top_caption_renders_after_field_fill() {
4052        let style = FormNodeStyle {
4053            caption_text: Some("PROJECT INFORMATION/NAME".to_string()),
4054            caption_placement: Some("top".to_string()),
4055            caption_reserve: Some(12.0),
4056            bg_color: Some((12, 34, 56)),
4057            ..Default::default()
4058        };
4059        let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
4060        let fill_idx = s
4061            .find("0.047 0.133 0.220 rg")
4062            .expect("explicit field fill should be present");
4063        let caption_idx = s
4064            .find("(PROJECT INFORMATION/NAME) Tj")
4065            .expect("caption text should render");
4066        assert!(
4067            caption_idx > fill_idx,
4068            "caption should render after the field fill so it stays visible: {s}"
4069        );
4070    }
4071
4072    #[test]
4073    fn left_caption_stays_in_pre_body_render_path() {
4074        let style = FormNodeStyle {
4075            caption_text: Some("Field 1".to_string()),
4076            caption_placement: Some("left".to_string()),
4077            bg_color: Some((12, 34, 56)),
4078            ..Default::default()
4079        };
4080        let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
4081        let caption_idx = s.find("(Field 1) Tj").expect("caption text should render");
4082        let fill_idx = s
4083            .find("0.047 0.133 0.220 rg")
4084            .expect("explicit field fill should be present");
4085        assert!(
4086            caption_idx < fill_idx,
4087            "left captions should keep the legacy pre-body ordering: {s}"
4088        );
4089    }
4090
4091    #[test]
4092    fn left_caption_without_explicit_reserve_shifts_field_body() {
4093        let style = FormNodeStyle {
4094            caption_text: Some("Field 1".to_string()),
4095            caption_placement: Some("left".to_string()),
4096            bg_color: Some((12, 34, 56)),
4097            ..Default::default()
4098        };
4099        let config = XfaRenderConfig::default();
4100        let reserve = effective_caption_reserve(&style, 10.0, FontFamily::Serif, &config);
4101        let metrics = build_font_metrics(10.0, FontFamily::Serif, &style, &config);
4102        assert!(
4103            (reserve - metrics.measure_width("Field 1")).abs() < 0.01,
4104            "auto reserve should match caption width for simple left captions"
4105        );
4106
4107        let s = styled_overlay_str(make_styled_field(10.0, 100.0, 200.0, 30.0, "", style));
4108        let mapper = CoordinateMapper::new(792.0, 612.0);
4109        let expected_fill = format!(
4110            "{:.2} {:.2} {:.2} 30.00 re",
4111            10.0 + reserve,
4112            mapper.xfa_to_pdf_y(100.0, 30.0),
4113            200.0 - reserve
4114        );
4115        assert!(
4116            s.contains(&expected_fill),
4117            "field body should be shifted right by the caption reserve: {s}"
4118        );
4119    }
4120
4121    #[test]
4122    fn button_caption_does_not_shrink_button_body() {
4123        let style = FormNodeStyle {
4124            caption_text: Some("Click".to_string()),
4125            caption_placement: Some("left".to_string()),
4126            bg_color: Some((12, 34, 56)),
4127            ..Default::default()
4128        };
4129        let config = XfaRenderConfig::default();
4130        let reserve = effective_caption_reserve(&style, 10.0, FontFamily::Serif, &config);
4131        assert!(
4132            reserve > 0.0,
4133            "button caption should still have measurable text"
4134        );
4135
4136        let s = styled_overlay_str(make_styled_button(10.0, 100.0, 200.0, 30.0, "", style));
4137        let mapper = CoordinateMapper::new(792.0, 612.0);
4138        let expected_fill = format!(
4139            "{:.2} {:.2} 200.00 30.00 re",
4140            10.0,
4141            mapper.xfa_to_pdf_y(100.0, 30.0)
4142        );
4143        assert!(
4144            s.contains(&expected_fill),
4145            "button body should keep the full field width because its caption is rendered internally: {s}"
4146        );
4147    }
4148
4149    #[test]
4150    fn top_caption_uses_full_inner_rect_height() {
4151        let style = FormNodeStyle {
4152            caption_text: Some("TOP CAPTION".to_string()),
4153            caption_placement: Some("top".to_string()),
4154            caption_reserve: Some(12.0),
4155            ..Default::default()
4156        };
4157        let s = styled_overlay_str(make_styled_field(
4158            10.0,
4159            100.0,
4160            200.0,
4161            30.0,
4162            "",
4163            style.clone(),
4164        ));
4165        let config = XfaRenderConfig::default();
4166        let metrics = build_font_metrics(10.0, FontFamily::Serif, &style, &config);
4167        let asc_pt = ascender_pt(&metrics, 10.0);
4168        let mapper = CoordinateMapper::new(792.0, 612.0);
4169        let inner_pdf_y = mapper.xfa_to_pdf_y(100.0, 30.0);
4170        let expected = format!(
4171            "{:.2} {:.2} Td\n(TOP CAPTION) Tj",
4172            10.0,
4173            inner_pdf_y + 30.0 - asc_pt
4174        );
4175        assert!(
4176            s.contains(&expected),
4177            "top caption should be positioned against the full inner rect, not the value area: {s}"
4178        );
4179    }
4180
4181    #[test]
4182    fn v_align_middle() {
4183        let style = FormNodeStyle {
4184            v_align: Some(VerticalAlign::Middle),
4185            ..Default::default()
4186        };
4187        let s = styled_overlay_str(make_styled_field(0.0, 0.0, 200.0, 40.0, "Mid", style));
4188        assert!(s.contains("(Mid) Tj"));
4189    }
4190
4191    #[test]
4192    fn text_field_without_explicit_fill_has_no_default_background() {
4193        let s = styled_overlay_str(make_styled_field(
4194            10.0,
4195            10.0,
4196            100.0,
4197            20.0,
4198            "Hi",
4199            FormNodeStyle::default(),
4200        ));
4201        assert!(
4202            !s.contains("0.949 0.949 0.949 rg"),
4203            "flatten output should not synthesize an interactive default field fill: {s}"
4204        );
4205    }
4206
4207    #[test]
4208    fn numeric_field_without_explicit_fill_has_no_default_background() {
4209        let s = styled_overlay_str(make_styled_field_kind(
4210            10.0,
4211            10.0,
4212            100.0,
4213            20.0,
4214            "42",
4215            FieldKind::NumericEdit,
4216            FormNodeStyle::default(),
4217        ));
4218        assert!(
4219            !s.contains("0.949 0.949 0.949 rg"),
4220            "numeric fields should also require explicit template fill to paint a background: {s}"
4221        );
4222    }
4223
4224    #[test]
4225    fn password_field_masks_plaintext_value() {
4226        let s = styled_overlay_str(make_styled_field_kind(
4227            10.0,
4228            10.0,
4229            100.0,
4230            20.0,
4231            "secret",
4232            FieldKind::PasswordEdit,
4233            FormNodeStyle::default(),
4234        ));
4235        assert!(
4236            !s.contains("(secret) Tj"),
4237            "password fields must not emit plaintext into the content stream: {s}"
4238        );
4239        assert!(
4240            s.contains("(\\225\\225\\225\\225\\225\\225) Tj"),
4241            "password fields should render bullet masking instead of plaintext: {s}"
4242        );
4243    }
4244
4245    #[test]
4246    fn explicit_white_field_background_is_preserved() {
4247        let s = styled_overlay_str(make_styled_field(
4248            10.0,
4249            10.0,
4250            100.0,
4251            20.0,
4252            "Hi",
4253            FormNodeStyle {
4254                bg_color: Some((255, 255, 255)),
4255                ..Default::default()
4256            },
4257        ));
4258        assert!(
4259            s.contains("1.000 1.000 1.000 rg"),
4260            "explicit white field fills should stay white: {s}"
4261        );
4262    }
4263
4264    #[test]
4265    fn checkbox_does_not_use_edit_field_default_background() {
4266        let s = styled_overlay_str(make_styled_checkbox(
4267            10.0,
4268            10.0,
4269            20.0,
4270            20.0,
4271            "0",
4272            FormNodeStyle {
4273                border_width_pt: Some(0.25),
4274                ..Default::default()
4275            },
4276        ));
4277        assert!(
4278            !s.contains("0.949 0.949 0.949 rg"),
4279            "non-edit widgets should not inherit the text field gray fill: {s}"
4280        );
4281    }
4282
4283    #[test]
4284    fn checkbox_explicit_background_fill_is_rendered() {
4285        let s = styled_overlay_str(make_styled_checkbox(
4286            10.0,
4287            10.0,
4288            20.0,
4289            20.0,
4290            "0",
4291            FormNodeStyle {
4292                bg_color: Some((255, 255, 255)),
4293                ..Default::default()
4294            },
4295        ));
4296        assert!(
4297            s.contains("1.000 1.000 1.000 rg"),
4298            "checkbox fill color should be emitted when bg_color is present: {s}"
4299        );
4300        assert!(
4301            s.contains("20.00 20.00 re\nf"),
4302            "checkbox background should be painted as a filled rectangle before the border: {s}"
4303        );
4304    }
4305
4306    #[test]
4307    fn radio_explicit_background_fill_is_rendered() {
4308        let s = styled_overlay_str(make_styled_radio(
4309            10.0,
4310            10.0,
4311            20.0,
4312            20.0,
4313            "N",
4314            FormNodeStyle {
4315                bg_color: Some((255, 255, 255)),
4316                check_button_on_value: Some("Y".to_string()),
4317                check_button_off_value: Some("N".to_string()),
4318                ..Default::default()
4319            },
4320        ));
4321        assert!(
4322            s.contains("1.000 1.000 1.000 rg"),
4323            "radio fill color should be emitted when bg_color is present: {s}"
4324        );
4325        assert!(
4326            s.contains(" c\n") && s.contains("\nf\n"),
4327            "radio background should be painted as a filled circle path before the border: {s}"
4328        );
4329    }
4330
4331    #[test]
4332    fn checkbox_ignores_global_background_without_explicit_fill() {
4333        let config = XfaRenderConfig {
4334            background_color: Some([0.949, 0.949, 0.949]),
4335            ..Default::default()
4336        };
4337        let s = styled_overlay_str_with_config(
4338            make_styled_checkbox(10.0, 10.0, 20.0, 20.0, "0", FormNodeStyle::default()),
4339            config,
4340        );
4341        assert!(
4342            !s.contains("0.949 0.949 0.949 rg"),
4343            "checkbox should only fill from explicit style.bg_color: {s}"
4344        );
4345    }
4346
4347    #[test]
4348    fn radio_ignores_global_background_without_explicit_fill() {
4349        let config = XfaRenderConfig {
4350            background_color: Some([0.949, 0.949, 0.949]),
4351            ..Default::default()
4352        };
4353        let s = styled_overlay_str_with_config(
4354            make_styled_radio(
4355                10.0,
4356                10.0,
4357                20.0,
4358                20.0,
4359                "N",
4360                FormNodeStyle {
4361                    check_button_on_value: Some("Y".to_string()),
4362                    check_button_off_value: Some("N".to_string()),
4363                    ..Default::default()
4364                },
4365            ),
4366            config,
4367        );
4368        assert!(
4369            !s.contains("0.949 0.949 0.949 rg"),
4370            "radio should only fill from explicit style.bg_color: {s}"
4371        );
4372    }
4373
4374    #[test]
4375    fn pdf_escape_polish_chars_fallback() {
4376        // Without Identity-H font data, Polish chars should fall back to '?'
4377        assert_eq!(pdf_escape("łżść"), "????");
4378    }
4379
4380    #[test]
4381    fn pdf_encode_text_winansi_fallback() {
4382        // Without font data, pdf_encode_text wraps in parentheses like pdf_escape
4383        let encoded = pdf_encode_text("Hello", None);
4384        assert_eq!(encoded, "(Hello)");
4385    }
4386
4387    #[test]
4388    fn pdf_encode_text_identity_h() {
4389        // With Identity-H font data, text should be encoded as hex glyph IDs
4390        let metrics = FontMetricsData {
4391            widths: vec![500; 256],
4392            upem: 1000,
4393            ascender: 800,
4394            descender: -200,
4395            font_data: None,
4396            face_index: 0,
4397            simple_unicode_to_code: None,
4398        };
4399        // Without font_data, should fall back to WinAnsi
4400        let encoded = pdf_encode_text("AB", Some(&metrics));
4401        assert_eq!(encoded, "(AB)");
4402    }
4403
4404    #[test]
4405    fn pdf_encode_text_simple_encoding_fallback() {
4406        // Simulate a simple-font custom encoding that maps U+0163 (ţ) to byte 0x80.
4407        let mut custom_map = HashMap::new();
4408        custom_map.insert(0x0163, 0x80);
4409        let metrics = FontMetricsData {
4410            widths: vec![500; 256],
4411            upem: 1000,
4412            ascender: 800,
4413            descender: -200,
4414            font_data: None,
4415            face_index: 0,
4416            simple_unicode_to_code: Some(custom_map),
4417        };
4418        let encoded = pdf_encode_text("ţ", Some(&metrics));
4419        assert_eq!(encoded, "(\\200)");
4420    }
4421
4422    #[test]
4423    fn rich_text_span_mapping_preserves_space_after_bold_label() {
4424        let spans = vec![
4425            RichTextSpan {
4426                text: "Instructions:".to_string(),
4427                font_size: None,
4428                font_family: None,
4429                font_weight: Some("bold".to_string()),
4430                font_style: None,
4431                text_color: None,
4432                underline: false,
4433                line_through: false,
4434                baseline_shift: 0.0,
4435            },
4436            RichTextSpan {
4437                text: "This form is for your use.".to_string(),
4438                font_size: None,
4439                font_family: None,
4440                font_weight: Some("normal".to_string()),
4441                font_style: None,
4442                text_color: None,
4443                underline: false,
4444                line_through: false,
4445                baseline_shift: 0.0,
4446            },
4447        ];
4448        let lines = vec!["Instructions: This form is for your use.".to_string()];
4449
4450        let mapped = map_spans_to_lines(&spans, &lines);
4451
4452        assert_eq!(mapped.len(), 1);
4453        assert_eq!(mapped[0].len(), 3);
4454        assert_eq!(mapped[0][0].text, "Instructions:");
4455        assert_eq!(mapped[0][0].span_idx, 0);
4456        assert_eq!(mapped[0][1].text, " ");
4457        assert_eq!(mapped[0][1].span_idx, 1);
4458        assert_eq!(mapped[0][2].text, "This form is for your use.");
4459        assert_eq!(mapped[0][2].span_idx, 1);
4460    }
4461
4462    #[test]
4463    fn rich_text_span_mapping_treats_nbsp_spaceruns_as_normal_spaces() {
4464        let spans = vec![
4465            RichTextSpan {
4466                text: "Instructions:".to_string(),
4467                font_size: None,
4468                font_family: None,
4469                font_weight: Some("bold".to_string()),
4470                font_style: None,
4471                text_color: None,
4472                underline: false,
4473                line_through: false,
4474                baseline_shift: 0.0,
4475            },
4476            RichTextSpan {
4477                text: "This form is for your use.".to_string(),
4478                font_size: None,
4479                font_family: None,
4480                font_weight: Some("normal".to_string()),
4481                font_style: None,
4482                text_color: None,
4483                underline: false,
4484                line_through: false,
4485                baseline_shift: 0.0,
4486            },
4487            RichTextSpan {
4488                text: "\u{00A0}\u{00A0}".to_string(),
4489                font_size: None,
4490                font_family: None,
4491                font_weight: Some("normal".to_string()),
4492                font_style: None,
4493                text_color: None,
4494                underline: false,
4495                line_through: false,
4496                baseline_shift: 0.0,
4497            },
4498            RichTextSpan {
4499                text: "Mail in at least 14 days before".to_string(),
4500                font_size: None,
4501                font_family: None,
4502                font_weight: Some("normal".to_string()),
4503                font_style: None,
4504                text_color: None,
4505                underline: false,
4506                line_through: false,
4507                baseline_shift: 0.0,
4508            },
4509        ];
4510        let lines = vec![
4511            "Instructions: This form is for your use.".to_string(),
4512            "Mail in at least 14 days before".to_string(),
4513        ];
4514
4515        let mapped = map_spans_to_lines(&spans, &lines);
4516
4517        assert_eq!(mapped.len(), 2);
4518        assert_eq!(mapped[0][0].span_idx, 0);
4519        assert_eq!(mapped[0][2].span_idx, 1);
4520        assert_eq!(mapped[1].len(), 1);
4521        assert_eq!(mapped[1][0].text, "Mail in at least 14 days before");
4522        assert_eq!(mapped[1][0].span_idx, 3);
4523    }
4524
4525    #[test]
4526    fn real_bold_font_variant_skips_synthetic_bold_stroke() {
4527        let mut config = XfaRenderConfig::default();
4528        let mut fm = HashMap::new();
4529        fm.insert("Arial_Bold_Normal".to_string(), "/XFA_Fbold".to_string());
4530        config.font_map = Arc::new(fm);
4531
4532        let s = styled_overlay_str_with_config(
4533            make_styled_field(
4534                10.0,
4535                10.0,
4536                200.0,
4537                20.0,
4538                "Bold",
4539                FormNodeStyle {
4540                    font_family: Some("Arial".to_string()),
4541                    font_weight: Some("bold".to_string()),
4542                    ..Default::default()
4543                },
4544            ),
4545            config,
4546        );
4547
4548        assert!(
4549            !s.contains("2 Tr"),
4550            "actual bold variants should not get synthetic stroke bolding: {s}"
4551        );
4552        assert!(
4553            s.contains("/XFA_Fbold 10.0 Tf"),
4554            "expected real bold resource: {s}"
4555        );
4556    }
4557
4558    #[test]
4559    fn container_insets_offset_children() {
4560        // A parent container with leftInset=10, topInset=5 should offset
4561        // child positions by those amounts during rendering.
4562        let child = LayoutNode {
4563            form_node: FormNodeId(1),
4564            rect: Rect::new(0.0, 0.0, 50.0, 20.0),
4565            name: "child".to_string(),
4566            content: LayoutContent::Field {
4567                value: "Test".to_string(),
4568                field_kind: FieldKind::Text,
4569                font_size: 10.0,
4570                font_family: FontFamily::Serif,
4571            },
4572            children: vec![],
4573            style: Default::default(),
4574            display_items: vec![],
4575            save_items: vec![],
4576        };
4577        let parent = LayoutNode {
4578            form_node: FormNodeId(0),
4579            rect: Rect::new(100.0, 200.0, 200.0, 100.0),
4580            name: "parent".to_string(),
4581            content: LayoutContent::None,
4582            children: vec![child],
4583            style: FormNodeStyle {
4584                inset_left_pt: Some(10.0),
4585                inset_top_pt: Some(5.0),
4586                ..Default::default()
4587            },
4588            display_items: vec![],
4589            save_items: vec![],
4590        };
4591        let s = overlay_str(&make_page(vec![parent]));
4592        // Child field at (0,0) rendered within parent at (100,200) with leftInset=10.
4593        // Text x = parent_x + inset_left = 100 + 10 = 110 (no default padding per XFA spec)
4594        assert!(
4595            s.contains("110.00"),
4596            "child x should include parent left inset offset: {s}"
4597        );
4598    }
4599
4600    #[test]
4601    fn field_insets_reduce_text_wrap_width() {
4602        // A field with leftInset=8, rightInset=8 on a 100pt-wide box should
4603        // offset text x by inset_left + pad_left.
4604        let node = LayoutNode {
4605            form_node: FormNodeId(0),
4606            rect: Rect::new(10.0, 10.0, 100.0, 30.0),
4607            name: "field".to_string(),
4608            content: LayoutContent::Field {
4609                value: "Hello".to_string(),
4610                field_kind: FieldKind::Text,
4611                font_size: 10.0,
4612                font_family: FontFamily::Serif,
4613            },
4614            children: vec![],
4615            style: FormNodeStyle {
4616                inset_left_pt: Some(8.0),
4617                inset_right_pt: Some(8.0),
4618                ..Default::default()
4619            },
4620            display_items: vec![],
4621            save_items: vec![],
4622        };
4623        let s = overlay_str(&make_page(vec![node]));
4624        // Text x = field_x + inset_left = 10 + 8 = 18 (no default padding per XFA spec)
4625        assert!(
4626            s.contains("18.00"),
4627            "text x should include field left inset: {s}"
4628        );
4629    }
4630
4631    #[test]
4632    fn checkbox_border_width_respects_style() {
4633        let s = styled_overlay_str(make_styled_checkbox(
4634            10.0,
4635            10.0,
4636            20.0,
4637            20.0,
4638            "0",
4639            FormNodeStyle {
4640                border_width_pt: Some(0.25),
4641                ..Default::default()
4642            },
4643        ));
4644        assert!(
4645            s.contains("\n0.25 w\n"),
4646            "checkbox should use styled border width: {s}"
4647        );
4648        assert!(
4649            !s.contains("\n0.50 w\n"),
4650            "checkbox should not fall back to default 0.5pt border width: {s}"
4651        );
4652        assert!(
4653            !s.contains("\n1.00 w\n"),
4654            "checkbox should not clamp to 1pt border width: {s}"
4655        );
4656    }
4657
4658    #[test]
4659    fn container_children_y_offset_includes_inset() {
4660        let child = LayoutNode {
4661            form_node: FormNodeId(1),
4662            rect: Rect::new(0.0, 0.0, 50.0, 20.0),
4663            name: "child-box".to_string(),
4664            content: LayoutContent::None,
4665            children: vec![],
4666            style: FormNodeStyle {
4667                border_width_pt: Some(1.0),
4668                ..Default::default()
4669            },
4670            display_items: vec![],
4671            save_items: vec![],
4672        };
4673        let parent = LayoutNode {
4674            form_node: FormNodeId(0),
4675            rect: Rect::new(100.0, 200.0, 200.0, 100.0),
4676            name: "parent".to_string(),
4677            content: LayoutContent::None,
4678            children: vec![child],
4679            style: FormNodeStyle {
4680                inset_top_pt: Some(10.0),
4681                ..Default::default()
4682            },
4683            display_items: vec![],
4684            save_items: vec![],
4685        };
4686        let s = overlay_str(&make_page(vec![parent]));
4687        assert!(
4688            s.contains("100.00 562.00 50.00 20.00 re"),
4689            "child y should include parent inset_top offset: {s}"
4690        );
4691        assert!(
4692            !s.contains("100.00 572.00 50.00 20.00 re"),
4693            "child y should no longer ignore parent inset_top offset: {s}"
4694        );
4695    }
4696
4697    #[test]
4698    fn checkbox_mark_style_controls_rendered_symbol() {
4699        let default_overlay = styled_overlay_str(make_styled_checkbox(
4700            10.0,
4701            10.0,
4702            20.0,
4703            20.0,
4704            "1",
4705            FormNodeStyle::default(),
4706        ));
4707        let circle_overlay = styled_overlay_str(make_styled_checkbox(
4708            10.0,
4709            10.0,
4710            20.0,
4711            20.0,
4712            "1",
4713            FormNodeStyle {
4714                check_button_mark: Some("circle".to_string()),
4715                ..Default::default()
4716            },
4717        ));
4718
4719        assert!(
4720            !default_overlay.contains(" c\n"),
4721            "default checkbox mark should not emit Bezier circle commands: {default_overlay}"
4722        );
4723        assert!(
4724            circle_overlay.contains(" c\n"),
4725            "circle checkbox mark should emit Bezier circle commands: {circle_overlay}"
4726        );
4727    }
4728
4729    #[test]
4730    fn unchecked_checkbox_with_empty_value_still_draws_outline() {
4731        let overlay = styled_overlay_str(make_styled_checkbox(
4732            10.0,
4733            10.0,
4734            20.0,
4735            20.0,
4736            "",
4737            FormNodeStyle::default(),
4738        ));
4739
4740        assert!(
4741            overlay.contains("10.00 762.00 20.00 20.00 re"),
4742            "unchecked checkbox should still render its outline: {overlay}"
4743        );
4744    }
4745
4746    #[test]
4747    fn checkbox_checked_state_uses_template_item_values() {
4748        let checked_overlay = styled_overlay_str(make_styled_checkbox(
4749            10.0,
4750            10.0,
4751            20.0,
4752            20.0,
4753            "Yes",
4754            FormNodeStyle {
4755                check_button_on_value: Some("Yes".to_string()),
4756                check_button_off_value: Some("No".to_string()),
4757                ..Default::default()
4758            },
4759        ));
4760        let unchecked_overlay = styled_overlay_str(make_styled_checkbox(
4761            10.0,
4762            10.0,
4763            20.0,
4764            20.0,
4765            "No",
4766            FormNodeStyle {
4767                check_button_on_value: Some("Yes".to_string()),
4768                check_button_off_value: Some("No".to_string()),
4769                ..Default::default()
4770            },
4771        ));
4772
4773        assert!(
4774            checked_overlay.matches(" l\nS\n").count() >= 2,
4775            "asserted template on-value should render the check/cross mark: {checked_overlay}"
4776        );
4777        assert!(
4778            unchecked_overlay.matches(" l\nS\n").count() < 3,
4779            "template off-value should not render the asserted mark: {unchecked_overlay}"
4780        );
4781    }
4782
4783    #[test]
4784    fn radio_explicit_mark_overrides_default_circle() {
4785        let default_overlay = styled_overlay_str(make_styled_radio(
4786            10.0,
4787            10.0,
4788            20.0,
4789            20.0,
4790            "Y",
4791            FormNodeStyle {
4792                check_button_on_value: Some("Y".to_string()),
4793                check_button_off_value: Some("N".to_string()),
4794                ..Default::default()
4795            },
4796        ));
4797        let cross_overlay = styled_overlay_str(make_styled_radio(
4798            10.0,
4799            10.0,
4800            20.0,
4801            20.0,
4802            "Y",
4803            FormNodeStyle {
4804                check_button_mark: Some("cross".to_string()),
4805                check_button_on_value: Some("Y".to_string()),
4806                check_button_off_value: Some("N".to_string()),
4807                ..Default::default()
4808            },
4809        ));
4810
4811        assert!(
4812            default_overlay.matches(" c\n").count() >= 8,
4813            "default radio should render outer circle plus filled inner circle: {default_overlay}"
4814        );
4815        assert!(
4816            cross_overlay.matches(" l\nS\n").count() >= 2,
4817            "explicit radio mark should render the requested symbol: {cross_overlay}"
4818        );
4819    }
4820
4821    // ── RenderTree tests (#1104) ──────────────────────────────────────────────
4822
4823    #[test]
4824    fn render_tree_page_dimensions() {
4825        let page = make_page(vec![]);
4826        let layout = LayoutDom { pages: vec![page] };
4827        let tree = layout_dom_to_render_tree(&layout, &XfaRenderConfig::default());
4828        assert_eq!(tree.pages.len(), 1);
4829        match &tree.pages[0] {
4830            RenderNode::Page { width, height, .. } => {
4831                assert!((*width - 612.0).abs() < 0.01, "width should be 612pt");
4832                assert!((*height - 792.0).abs() < 0.01, "height should be 792pt");
4833            }
4834            other => panic!("expected Page node, got {other:?}"),
4835        }
4836    }
4837
4838    #[test]
4839    fn render_tree_text_node_captured() {
4840        let node = LayoutNode {
4841            form_node: xfa_layout_engine::form::FormNodeId(0),
4842            rect: xfa_layout_engine::types::Rect::new(10.0, 20.0, 100.0, 15.0),
4843            name: "lbl".to_string(),
4844            content: LayoutContent::Text("Hello tree".to_string()),
4845            children: vec![],
4846            style: Default::default(),
4847            display_items: vec![],
4848            save_items: vec![],
4849        };
4850        let layout = LayoutDom {
4851            pages: vec![LayoutPage {
4852                width: 612.0,
4853                height: 792.0,
4854                nodes: vec![node],
4855                runtime_instantiated: false,
4856            }],
4857        };
4858        let tree = layout_dom_to_render_tree(&layout, &XfaRenderConfig::default());
4859        let debug = tree.to_debug_string();
4860        assert!(
4861            debug.contains("Hello tree"),
4862            "debug string should contain text content: {debug}"
4863        );
4864        assert!(
4865            debug.contains("Text("),
4866            "debug string should contain Text node: {debug}"
4867        );
4868    }
4869
4870    // ── Text fidelity tests (#1105) ───────────────────────────────────────────
4871
4872    #[test]
4873    fn text_node_emits_correct_font_and_size_operators() {
4874        // A text field with font_size=12 should emit /F1 12.0 Tf (or the default
4875        // font reference) in the content stream.
4876        let node = LayoutNode {
4877            form_node: xfa_layout_engine::form::FormNodeId(0),
4878            rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
4879            name: "f".to_string(),
4880            content: LayoutContent::Field {
4881                value: "test value".to_string(),
4882                field_kind: FieldKind::Text,
4883                font_size: 12.0,
4884                font_family: xfa_layout_engine::text::FontFamily::Serif,
4885            },
4886            children: vec![],
4887            style: Default::default(),
4888            display_items: vec![],
4889            save_items: vec![],
4890        };
4891        let s = overlay_str(&make_page(vec![node]));
4892        // Content stream must contain a Tf operator and the size 12.0
4893        assert!(
4894            s.contains("Tf"),
4895            "should contain Tf font-select operator: {s}"
4896        );
4897        assert!(
4898            s.contains("12.0 Tf"),
4899            "should use specified font size 12.0: {s}"
4900        );
4901        assert!(
4902            s.contains("(test value) Tj"),
4903            "should render the value: {s}"
4904        );
4905    }
4906
4907    #[test]
4908    fn multiline_text_has_correct_td_offsets() {
4909        // A WrappedText node with two lines should emit two Td position operators.
4910        let node = LayoutNode {
4911            form_node: xfa_layout_engine::form::FormNodeId(0),
4912            rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 40.0),
4913            name: "ml".to_string(),
4914            content: LayoutContent::WrappedText {
4915                lines: vec!["line one".to_string(), "line two".to_string()],
4916                first_line_of_para: vec![true, false],
4917                font_size: 10.0,
4918                text_align: xfa_layout_engine::types::TextAlign::Left,
4919                font_family: xfa_layout_engine::text::FontFamily::Serif,
4920                space_above_pt: None,
4921                space_below_pt: None,
4922                // Synthetic test node, not produced from a form field.
4923                from_field: false,
4924            },
4925            children: vec![],
4926            style: Default::default(),
4927            display_items: vec![],
4928            save_items: vec![],
4929        };
4930        let s = overlay_str(&make_page(vec![node]));
4931        // Both lines should be rendered.
4932        assert!(s.contains("(line one) Tj"), "first line missing: {s}");
4933        assert!(s.contains("(line two) Tj"), "second line missing: {s}");
4934        // There should be at least two Td operators (one per line).
4935        let td_count = s.matches(" Td\n").count();
4936        assert!(td_count >= 2, "expected ≥2 Td operators for two lines: {s}");
4937    }
4938
4939    // ── Widget rendering tests (#1106) ────────────────────────────────────────
4940
4941    #[test]
4942    fn checkbox_checked_renders_nonempty_stream() {
4943        let node = make_styled_checkbox(
4944            10.0,
4945            10.0,
4946            20.0,
4947            20.0,
4948            "1",
4949            FormNodeStyle {
4950                check_button_on_value: Some("1".to_string()),
4951                check_button_off_value: Some("0".to_string()),
4952                border_width_pt: Some(0.5),
4953                ..Default::default()
4954            },
4955        );
4956        let s = overlay_str(&make_page(vec![node]));
4957        assert!(!s.is_empty(), "checked checkbox overlay must not be empty");
4958        assert!(s.contains("re"), "checkbox must emit a rectangle: {s}");
4959    }
4960
4961    #[test]
4962    fn radio_selected_renders_nonempty_stream() {
4963        let node = make_styled_radio(
4964            10.0,
4965            10.0,
4966            20.0,
4967            20.0,
4968            "yes",
4969            FormNodeStyle {
4970                check_button_on_value: Some("yes".to_string()),
4971                check_button_off_value: Some("no".to_string()),
4972                ..Default::default()
4973            },
4974        );
4975        let s = overlay_str(&make_page(vec![node]));
4976        assert!(!s.is_empty(), "selected radio overlay must not be empty");
4977        // Selected radio should render a filled inner circle (Bezier 'c' operators).
4978        assert!(
4979            s.contains(" c\n"),
4980            "selected radio must render circular fill: {s}"
4981        );
4982    }
4983
4984    #[test]
4985    fn dropdown_selected_value_renders_nonempty_stream() {
4986        let node = LayoutNode {
4987            form_node: xfa_layout_engine::form::FormNodeId(0),
4988            rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
4989            name: "dd".to_string(),
4990            content: LayoutContent::Field {
4991                value: "opt1".to_string(),
4992                field_kind: FieldKind::Dropdown,
4993                font_size: 10.0,
4994                font_family: xfa_layout_engine::text::FontFamily::Serif,
4995            },
4996            children: vec![],
4997            style: Default::default(),
4998            display_items: vec!["Option 1".to_string()],
4999            save_items: vec!["opt1".to_string()],
5000        };
5001        let s = overlay_str(&make_page(vec![node]));
5002        assert!(!s.is_empty(), "dropdown overlay must not be empty");
5003        assert!(
5004            s.contains("(Option 1) Tj"),
5005            "dropdown must render display label: {s}"
5006        );
5007    }
5008
5009    // ── Field rendering tests (#1107) ─────────────────────────────────────────
5010
5011    #[test]
5012    fn text_field_renders_bound_value() {
5013        let node = LayoutNode {
5014            form_node: xfa_layout_engine::form::FormNodeId(0),
5015            rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
5016            name: "name_field".to_string(),
5017            content: LayoutContent::Field {
5018                value: "John Doe".to_string(),
5019                field_kind: FieldKind::Text,
5020                font_size: 10.0,
5021                font_family: xfa_layout_engine::text::FontFamily::Serif,
5022            },
5023            children: vec![],
5024            style: Default::default(),
5025            display_items: vec![],
5026            save_items: vec![],
5027        };
5028        let s = overlay_str(&make_page(vec![node]));
5029        assert!(
5030            s.contains("(John Doe) Tj"),
5031            "text field must render its bound value: {s}"
5032        );
5033    }
5034
5035    #[test]
5036    fn numeric_field_formats_value_with_default_pattern() {
5037        // NumericEdit with no explicit pattern: trailing zeros stripped.
5038        let node = LayoutNode {
5039            form_node: xfa_layout_engine::form::FormNodeId(0),
5040            rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 80.0, 20.0),
5041            name: "amount".to_string(),
5042            content: LayoutContent::Field {
5043                value: "42.00000000".to_string(),
5044                field_kind: FieldKind::NumericEdit,
5045                font_size: 10.0,
5046                font_family: xfa_layout_engine::text::FontFamily::Serif,
5047            },
5048            children: vec![],
5049            style: Default::default(),
5050            display_items: vec![],
5051            save_items: vec![],
5052        };
5053        let s = overlay_str(&make_page(vec![node]));
5054        // format_numeric_default strips trailing zeros → "42"
5055        assert!(
5056            s.contains("(42) Tj"),
5057            "numeric field should render cleaned value: {s}"
5058        );
5059    }
5060
5061    #[test]
5062    fn date_field_renders_value_with_format_pattern() {
5063        // DateField: value rendered with the format_pattern if set, else raw.
5064        // Here we simulate a date field with a raw value and no pattern: must render value as-is.
5065        let node = LayoutNode {
5066            form_node: xfa_layout_engine::form::FormNodeId(0),
5067            rect: xfa_layout_engine::types::Rect::new(10.0, 10.0, 100.0, 20.0),
5068            name: "date_field".to_string(),
5069            content: LayoutContent::Field {
5070                value: "2024-01-15".to_string(),
5071                field_kind: FieldKind::DateTimePicker,
5072                font_size: 10.0,
5073                font_family: xfa_layout_engine::text::FontFamily::Serif,
5074            },
5075            children: vec![],
5076            style: Default::default(),
5077            display_items: vec![],
5078            save_items: vec![],
5079        };
5080        let s = overlay_str(&make_page(vec![node]));
5081        assert!(
5082            s.contains("(2024-01-15) Tj"),
5083            "date field must render its value: {s}"
5084        );
5085    }
5086
5087    #[test]
5088    fn draw_line_span_gate_default_on_and_opt_out() {
5089        // A coordless <line/> (the parser yields Line{0,0,0,0} because XFA
5090        // <line> values have no x1/y1/x2/y2) inside a horizontal grid box
5091        // (w=200, h=0) at abs_x=30, pdf_y=50, carrying the authored 6.01pt
5092        // (≈2.12mm) edge weight in the node style. Default-on must span the box
5093        // edge-to-edge (30 -> 230) at that weight; opt-out restores the legacy
5094        // zero-length point at the box origin with no weight emitted.
5095        let line = DrawContent::Line {
5096            x1: 0.0,
5097            y1: 0.0,
5098            x2: 0.0,
5099            y2: 0.0,
5100        };
5101        let style = FormNodeStyle {
5102            border_width_pt: Some(6.01),
5103            border_color: Some((0, 0, 0)),
5104            ..Default::default()
5105        };
5106        let config = XfaRenderConfig::default();
5107
5108        // Default (flag unset) → spans the 200pt box at the authored weight.
5109        std::env::remove_var("XFA_DRAW_LINE_SPAN");
5110        let mut ops_on = Vec::new();
5111        render_draw(&line, 30.0, 50.0, 200.0, 0.0, &style, &config, &mut ops_on);
5112        let s_on = String::from_utf8(ops_on).unwrap();
5113        assert!(s_on.contains("6.01 w\n"), "authored stroke weight:\n{s_on}");
5114        assert!(
5115            s_on.contains("30.00 50.00 m\n230.00 50.00 l\nS\n"),
5116            "horizontal grid rule must span the box (30 -> 230):\n{s_on}"
5117        );
5118
5119        // Opt-out → legacy zero-length point at the box origin, no weight.
5120        std::env::set_var("XFA_DRAW_LINE_SPAN", "0");
5121        let mut ops_off = Vec::new();
5122        render_draw(&line, 30.0, 50.0, 200.0, 0.0, &style, &config, &mut ops_off);
5123        std::env::remove_var("XFA_DRAW_LINE_SPAN");
5124        let s_off = String::from_utf8(ops_off).unwrap();
5125        assert!(
5126            !s_off.contains(" w\n"),
5127            "opt-out emits no stroke weight:\n{s_off}"
5128        );
5129        assert!(
5130            s_off.contains("30.00 50.00 m\n30.00 50.00 l\nS\n"),
5131            "opt-out = legacy zero-length point:\n{s_off}"
5132        );
5133    }
5134
5135    /// A render config with borders on or off. Came across from the branch
5136    /// alongside the barcode work; the call was resolved into the merge and the
5137    /// definition was not, so the build failed on a name that existed nowhere.
5138    fn config_with_border(border: bool) -> XfaRenderConfig {
5139        XfaRenderConfig {
5140            draw_borders: border,
5141            border_width: if border { 1.0 } else { 0.0 },
5142            ..XfaRenderConfig::default()
5143        }
5144    }
5145
5146    #[test]
5147    fn an_empty_signature_draws_the_border_its_template_declares() {
5148        // This used to yield nothing: `if value.is_empty() { return; }` sat in
5149        // front of everything, so the border the author had specified went with
5150        // the content. After flattening a reader then cannot see that a
5151        // signature was expected at all.
5152        let mut ops = Vec::new();
5153        render_signature(
5154            10.0,
5155            20.0,
5156            100.0,
5157            30.0,
5158            "",
5159            &FormNodeStyle::default(),
5160            &config_with_border(true),
5161            &mut ops,
5162        );
5163        let out = String::from_utf8_lossy(&ops);
5164        assert!(
5165            !ops.is_empty(),
5166            "an empty signature field with a border drew nothing"
5167        );
5168        assert!(
5169            out.contains(" RG"),
5170            "no stroke colour set, so no border is drawn: {out}"
5171        );
5172        assert!(
5173            out.trim_end().ends_with('S'),
5174            "the path is never stroked (no S operator): {out}"
5175        );
5176    }
5177
5178    #[test]
5179    fn without_a_declared_border_nothing_changes() {
5180        // The counter-proof, and at the same time what protects the number:
5181        // where the template declares no border we still draw nothing. Without
5182        // this test "always draw a box" would pass too, and that would move
5183        // SSIM against the oracle on every form with a deliberately invisible
5184        // signature field.
5185        let mut ops = Vec::new();
5186        render_signature(
5187            10.0,
5188            20.0,
5189            100.0,
5190            30.0,
5191            "",
5192            &FormNodeStyle::default(),
5193            &config_with_border(false),
5194            &mut ops,
5195        );
5196        assert!(
5197            ops.is_empty(),
5198            "something was drawn while the template declares no border: {}",
5199            String::from_utf8_lossy(&ops)
5200        );
5201    }
5202
5203    #[test]
5204    fn a_filled_signature_still_draws_its_content() {
5205        let mut ops = Vec::new();
5206        render_signature(
5207            10.0,
5208            20.0,
5209            100.0,
5210            30.0,
5211            "J. de Winter",
5212            &FormNodeStyle::default(),
5213            &config_with_border(true),
5214            &mut ops,
5215        );
5216        assert!(!ops.is_empty(), "een ingevulde handtekening tekende niets");
5217    }
5218
5219    // ---- barcode renderen (#147) ----
5220
5221    #[test]
5222    fn een_barcodeveld_tekent_balken_en_geen_tekst() {
5223        let mut ops = Vec::new();
5224        render_barcode(
5225            10.0,
5226            20.0,
5227            200.0,
5228            40.0,
5229            "ABC-123",
5230            &FormNodeStyle::default(),
5231            &XfaRenderConfig::default(),
5232            &mut ops,
5233        );
5234        let uit = String::from_utf8_lossy(&ops);
5235        assert!(!ops.is_empty(), "er is niets getekend");
5236        assert!(
5237            uit.contains(" re\n"),
5238            "geen rechthoeken; er staan geen balken: {uit}"
5239        );
5240        assert!(uit.trim_end().ends_with('f'), "het pad wordt niet gevuld");
5241        assert!(
5242            !uit.contains(" Tj"),
5243            "er wordt tekst getekend in plaats van een barcode"
5244        );
5245    }
5246
5247    #[test]
5248    fn een_onbekend_teken_tekent_niets() {
5249        // Stil weglaten zou een barcode geven die naar iets anders scant dan er
5250        // staat. Dat merk je pas met een handscanner bij de klant.
5251        let mut ops = Vec::new();
5252        render_barcode(
5253            10.0,
5254            20.0,
5255            200.0,
5256            40.0,
5257            "hallo!",
5258            &FormNodeStyle::default(),
5259            &XfaRenderConfig::default(),
5260            &mut ops,
5261        );
5262        assert!(
5263            ops.is_empty(),
5264            "er is toch iets getekend: {}",
5265            String::from_utf8_lossy(&ops)
5266        );
5267    }
5268
5269    #[test]
5270    fn een_te_smal_veld_tekent_niets() {
5271        // Onder een kwart punt per module is de code niet meer te scannen. Dan
5272        // is een streepjespatroon tekenen erger dan niets: het ziet eruit als
5273        // een werkende barcode.
5274        let mut ops = Vec::new();
5275        render_barcode(
5276            10.0,
5277            20.0,
5278            2.0,
5279            40.0,
5280            "ABC-123",
5281            &FormNodeStyle::default(),
5282            &XfaRenderConfig::default(),
5283            &mut ops,
5284        );
5285        assert!(
5286            ops.is_empty(),
5287            "een onleesbaar smalle barcode werd toch getekend"
5288        );
5289    }
5290
5291    #[test]
5292    fn de_balken_passen_binnen_de_veldbreedte() {
5293        // Zou de schaling misgaan, dan loopt de code buiten zijn veld en over
5294        // de tekst ernaast heen -- zichtbaar fout, maar niet als je alleen
5295        // controleert dát er balken staan.
5296        let mut ops = Vec::new();
5297        let (x, breedte) = (10.0_f64, 200.0_f64);
5298        render_barcode(
5299            x,
5300            20.0,
5301            breedte,
5302            40.0,
5303            "123",
5304            &FormNodeStyle::default(),
5305            &XfaRenderConfig::default(),
5306            &mut ops,
5307        );
5308        let uit = String::from_utf8_lossy(&ops);
5309        let mut rechtsterand = x;
5310        for regel in uit.lines().filter(|l| l.ends_with(" re")) {
5311            let d: Vec<f64> = regel
5312                .trim_end_matches(" re")
5313                .split(' ')
5314                .filter_map(|t| t.parse().ok())
5315                .collect();
5316            if d.len() == 4 {
5317                rechtsterand = rechtsterand.max(d[0] + d[2]);
5318            }
5319        }
5320        assert!(
5321            rechtsterand <= x + breedte + 0.5,
5322            "de barcode loopt tot {rechtsterand} en het veld eindigt op {}",
5323            x + breedte
5324        );
5325    }
5326}