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