Skip to main content

pdf_xfa/
paint_bridge.rs

1//! Abstract paint commands for XFA layout rendering.
2//!
3//! Converts XFA layout output into renderer-agnostic paint commands.
4//! These commands can be consumed by any backend: Device trait, content stream,
5//! SVG, etc.
6
7// Copyright (c) 2026 Innovation Trigger B.V.
8//
9// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
10// the PDFluent Commercial Licence. See the LICENSE file in this repository --
11// that file travels with the copy you received, which a URL does not.
12
13use pdf_interpret::color::Color;
14use xfa_layout_engine::form::{FieldKind, FormNodeStyle};
15use xfa_layout_engine::layout::{LayoutContent, LayoutNode, LayoutPage};
16use xfa_layout_engine::text::{FontFamily, FontMetrics};
17use xfa_layout_engine::types::TextAlign;
18
19use crate::render_bridge::XfaRenderConfig;
20
21/// An abstract rendering command from XFA layout.
22/// Can be consumed by any renderer (Device trait, content stream, SVG, etc.)
23#[derive(Debug, Clone)]
24pub enum XfaPaintCommand {
25    /// Fill a rectangle with a solid color.
26    FillRect {
27        /// X coordinate in PDF points.
28        x: f64,
29        /// Y coordinate in PDF points (bottom-left origin).
30        y: f64,
31        /// Width.
32        w: f64,
33        /// Height.
34        h: f64,
35        /// Fill color.
36        color: Color,
37    },
38    /// Stroke a rectangle outline.
39    StrokeRect {
40        /// X coordinate in PDF points.
41        x: f64,
42        /// Y coordinate in PDF points (bottom-left origin).
43        y: f64,
44        /// Width.
45        w: f64,
46        /// Height.
47        h: f64,
48        /// Stroke color.
49        color: Color,
50        /// Line width.
51        width: f64,
52    },
53    /// Draw a text string at a position.
54    DrawText {
55        /// X coordinate in PDF points.
56        x: f64,
57        /// Y coordinate in PDF points (bottom-left origin).
58        y: f64,
59        /// Text content.
60        text: String,
61        /// Font family for selecting the PDF font resource.
62        font_family: FontFamily,
63        /// Font size in points.
64        font_size: f64,
65        /// Text color.
66        color: Color,
67    },
68    /// Draw multiple lines of text with alignment support.
69    DrawMultilineText {
70        /// X coordinate of the container left edge in PDF points.
71        x: f64,
72        /// Y coordinate of the first baseline in PDF points (bottom-left origin).
73        y: f64,
74        /// Text lines.
75        lines: Vec<String>,
76        /// Font family for selecting the PDF font resource.
77        font_family: FontFamily,
78        /// Font size in points.
79        font_size: f64,
80        /// Line height in points.
81        line_height: f64,
82        /// Text color.
83        color: Color,
84        /// Horizontal text alignment.
85        text_align: TextAlign,
86        /// Container width for alignment calculation.
87        container_width: f64,
88        /// Text padding from container edges.
89        text_padding: f64,
90    },
91    /// Draw an image.
92    DrawImage {
93        /// X coordinate in PDF points.
94        x: f64,
95        /// Y coordinate in PDF points (bottom-left origin).
96        y: f64,
97        /// Width.
98        w: f64,
99        /// Height.
100        h: f64,
101        /// Raw image data.
102        image_data: Vec<u8>,
103        /// MIME type ("image/jpeg" or "image/png").
104        mime_type: String,
105    },
106    /// Draw a checkbox or radio button.
107    DrawCheckbox {
108        /// X coordinate in PDF points.
109        x: f64,
110        /// Y coordinate in PDF points (bottom-left origin).
111        y: f64,
112        /// Width.
113        w: f64,
114        /// Height.
115        h: f64,
116        /// Whether the checkbox is checked.
117        checked: bool,
118        /// Border color (RGB 0-1).
119        border_color: [f64; 3],
120        /// Checkmark color (RGB 0-1).
121        check_color: [f64; 3],
122        /// Border line width.
123        border_width: f64,
124    },
125}
126
127/// Create a per-node config by applying XFA template style overrides to the
128/// global config. Returns the original config unchanged if the node has no
129/// style overrides (common case — avoids allocation).
130fn apply_node_style(config: &XfaRenderConfig, style: &FormNodeStyle) -> XfaRenderConfig {
131    let mut cfg = config.clone();
132
133    // Apply background color — skip white (would cover underlying page content).
134    if let Some((r, g, b)) = style.bg_color {
135        if !(r >= 250 && g >= 250 && b >= 250) {
136            cfg.background_color = Some([r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]);
137        }
138    }
139
140    // Apply border from the XFA template.
141    // Only draw borders when explicitly specified; otherwise match Adobe behavior.
142    cfg.draw_borders = false;
143    if let Some(bw) = style.border_width_pt {
144        if bw > 0.0 {
145            cfg.border_width = bw;
146            cfg.draw_borders = true;
147            if let Some((r, g, b)) = style.border_color {
148                cfg.border_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
149            }
150        }
151    }
152
153    // Apply text color.
154    if let Some((r, g, b)) = style.text_color {
155        cfg.text_color = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
156    }
157
158    if let Some(mark) = &style.check_button_mark {
159        cfg.check_button_mark = Some(mark.clone());
160    }
161
162    cfg
163}
164
165/// Convert an XFA layout page into abstract paint commands.
166pub fn layout_to_commands(page: &LayoutPage, config: &XfaRenderConfig) -> Vec<XfaPaintCommand> {
167    let mut commands = Vec::new();
168    let page_height = page.height;
169    for node in &page.nodes {
170        emit_node_commands(node, 0.0, 0.0, page_height, config, &mut commands);
171    }
172    commands
173}
174
175fn emit_node_commands(
176    node: &LayoutNode,
177    parent_x: f64,
178    parent_y: f64,
179    page_height: f64,
180    config: &XfaRenderConfig,
181    commands: &mut Vec<XfaPaintCommand>,
182) {
183    let abs_x = node.rect.x + parent_x;
184    let abs_y = node.rect.y + parent_y;
185    let w = node.rect.width;
186    let h = node.rect.height;
187    // Convert from top-left (XFA) to bottom-left (PDF) origin
188    let pdf_y = page_height - abs_y - h;
189
190    // Apply per-node style overrides from the XFA template.
191    let node_config = apply_node_style(config, &node.style);
192
193    // Draw background fill and borders for non-Field nodes (Draw, Subform, etc.)
194    // Only when the XFA template explicitly defines bg/border styles.
195    // Fields handle their own bg/borders below.
196    if !matches!(node.content, LayoutContent::Field { .. }) {
197        // Background: only from explicit node style (set by apply_node_style).
198        if let Some(bg) = &node_config.background_color {
199            commands.push(XfaPaintCommand::FillRect {
200                x: abs_x,
201                y: pdf_y,
202                w,
203                h,
204                color: Color::from_device_rgb(bg[0] as f32, bg[1] as f32, bg[2] as f32),
205            });
206        }
207        // Borders: only when the XFA template explicitly set border_width_pt.
208        if let Some(bw) = node.style.border_width_pt {
209            if bw > 0.0 && w > 0.0 && h > 0.0 {
210                let bc = node
211                    .style
212                    .border_color
213                    .map_or([0.0, 0.0, 0.0], |(r, g, b)| {
214                        [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0]
215                    });
216                commands.push(XfaPaintCommand::StrokeRect {
217                    x: abs_x,
218                    y: pdf_y,
219                    w,
220                    h,
221                    color: Color::from_device_rgb(bc[0] as f32, bc[1] as f32, bc[2] as f32),
222                    width: bw,
223                });
224            }
225        }
226    }
227
228    match &node.content {
229        LayoutContent::Field {
230            value,
231            field_kind,
232            font_size,
233            font_family,
234        } => match field_kind {
235            FieldKind::Checkbox | FieldKind::Radio => {
236                let checked = !value.is_empty()
237                    && !value.eq_ignore_ascii_case("0")
238                    && !value.eq_ignore_ascii_case("off")
239                    && !value.eq_ignore_ascii_case("false");
240                commands.push(XfaPaintCommand::DrawCheckbox {
241                    x: abs_x,
242                    y: pdf_y,
243                    w,
244                    h,
245                    checked,
246                    border_color: node_config.border_color,
247                    check_color: node_config.text_color,
248                    border_width: node_config.border_width.max(0.5),
249                });
250            }
251            _ => {
252                emit_field_commands(
253                    abs_x,
254                    pdf_y,
255                    w,
256                    h,
257                    value,
258                    *font_size,
259                    *font_family,
260                    &node_config,
261                    commands,
262                );
263            }
264        },
265        LayoutContent::Text(text) => {
266            if !text.is_empty() {
267                let text_color = make_color(&node_config.text_color);
268                commands.push(XfaPaintCommand::DrawText {
269                    x: abs_x + node_config.text_padding,
270                    y: pdf_y + node_config.text_padding,
271                    text: text.clone(),
272                    font_family: FontFamily::SansSerif,
273                    font_size: node_config.default_font_size,
274                    color: text_color,
275                });
276            }
277        }
278        LayoutContent::WrappedText {
279            lines,
280            font_size,
281            text_align,
282            font_family,
283            ..
284        } => {
285            let fs = *font_size;
286            let line_height = fs * 1.2;
287            if !lines.is_empty() {
288                let text_color = make_color(&node_config.text_color);
289                // Place first baseline at font_size below the element's XFA top.
290                // Do NOT add text_padding vertically: draw elements often have tight
291                // height budgets (h ≈ font_size).
292                let first_line_y = page_height - abs_y - fs;
293                commands.push(XfaPaintCommand::DrawMultilineText {
294                    x: abs_x,
295                    y: first_line_y,
296                    lines: lines.clone(),
297                    font_family: *font_family,
298                    font_size: fs,
299                    line_height,
300                    color: text_color,
301                    text_align: *text_align,
302                    container_width: w,
303                    text_padding: node_config.text_padding,
304                });
305            }
306        }
307        LayoutContent::Image { .. } => {}
308        LayoutContent::Draw(_) => {}
309        LayoutContent::None => {}
310    }
311
312    // Pass the GLOBAL config to children, not node_config — background_color
313    // and other style properties should not cascade from parent to children.
314    for child in &node.children {
315        emit_node_commands(child, abs_x, abs_y, page_height, config, commands);
316    }
317}
318
319#[allow(clippy::too_many_arguments)]
320fn emit_field_commands(
321    x: f64,
322    pdf_y: f64,
323    w: f64,
324    h: f64,
325    value: &str,
326    font_size: f64,
327    font_family: FontFamily,
328    config: &XfaRenderConfig,
329    commands: &mut Vec<XfaPaintCommand>,
330) {
331    // Background fill.
332    if let Some(bg) = &config.background_color {
333        commands.push(XfaPaintCommand::FillRect {
334            x,
335            y: pdf_y,
336            w,
337            h,
338            color: Color::from_device_rgb(bg[0] as f32, bg[1] as f32, bg[2] as f32),
339        });
340    }
341    // Border.
342    if config.draw_borders && config.border_width > 0.0 {
343        commands.push(XfaPaintCommand::StrokeRect {
344            x,
345            y: pdf_y,
346            w,
347            h,
348            color: make_color(&config.border_color),
349            width: config.border_width,
350        });
351    }
352    // Text.
353    if !value.is_empty() {
354        let fs = if font_size > 0.0 {
355            font_size
356        } else {
357            config.default_font_size
358        };
359        let p = config.text_padding;
360        let content_w = (w - p * 2.0).max(0.0);
361        let metrics = FontMetrics {
362            size: fs,
363            typeface: font_family,
364            ..Default::default()
365        };
366        let text_w = metrics.measure_width(value);
367        let text_color = make_color(&config.text_color);
368
369        if text_w <= content_w || content_w <= 0.0 {
370            // Single line — fits within field.
371            commands.push(XfaPaintCommand::DrawText {
372                x: x + p,
373                y: pdf_y + p,
374                text: value.to_string(),
375                font_family,
376                font_size: fs,
377                color: text_color,
378            });
379        } else {
380            // Multi-line: word-wrap within field width.
381            let lines = wrap_text(value, content_w, &metrics);
382            let line_height = fs * 1.2;
383            commands.push(XfaPaintCommand::DrawMultilineText {
384                x,
385                y: pdf_y + h - p - fs,
386                lines,
387                font_family,
388                font_size: fs,
389                line_height,
390                color: text_color,
391                text_align: TextAlign::Left,
392                container_width: w,
393                text_padding: p,
394            });
395        }
396    }
397}
398
399fn make_color(rgb: &[f64; 3]) -> Color {
400    Color::from_device_rgb(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32)
401}
402
403/// Word-wrap text to fit within `max_width` using `metrics` for measurement.
404fn wrap_text(text: &str, max_width: f64, metrics: &FontMetrics) -> Vec<String> {
405    let mut lines = Vec::new();
406    let mut current = String::new();
407
408    for word in text.split_whitespace() {
409        if current.is_empty() {
410            current = word.to_string();
411        } else {
412            let candidate = format!("{} {}", current, word);
413            if metrics.measure_width(&candidate) <= max_width {
414                current = candidate;
415            } else {
416                lines.push(current);
417                current = word.to_string();
418            }
419        }
420    }
421    if !current.is_empty() {
422        lines.push(current);
423    }
424    if lines.is_empty() && !text.is_empty() {
425        lines.push(text.to_string());
426    }
427    lines
428}
429
430/// Map a font family to the PDF font resource reference.
431fn font_family_to_ref(ff: FontFamily) -> &'static str {
432    match ff {
433        FontFamily::Serif => "/F1",
434        FontFamily::SansSerif => "/F2",
435        FontFamily::Monospace => "/F3",
436    }
437}
438
439/// Execute paint commands and return PDF content stream bytes.
440///
441/// The returned content stream includes the save/normalize state (q/Q) wrappers.
442/// Image XObjects are referenced as /Im0, /Im1, etc. and must be added
443/// to the page's resource dictionary separately.
444pub fn execute_commands(commands: &[XfaPaintCommand]) -> Vec<u8> {
445    let mut ops = Vec::new();
446    ops.extend_from_slice(b"q\n");
447
448    let mut image_index = 0usize;
449
450    for cmd in commands {
451        match cmd {
452            XfaPaintCommand::FillRect { x, y, w, h, color } => {
453                let rgba = color.to_rgba();
454                let [r, g, b, _] = rgba.to_rgba8();
455                ops.extend(
456                    format!(
457                        "{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
458                        r as f32 / 255.0,
459                        g as f32 / 255.0,
460                        b as f32 / 255.0,
461                        x,
462                        y,
463                        w,
464                        h
465                    )
466                    .bytes(),
467                );
468            }
469            XfaPaintCommand::StrokeRect {
470                x,
471                y,
472                w,
473                h,
474                color,
475                width,
476            } => {
477                let rgba = color.to_rgba();
478                let [r, g, b, _] = rgba.to_rgba8();
479                ops.extend(format!("{:.2} w\n", width).bytes());
480                ops.extend(
481                    format!(
482                        "{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
483                        r as f32 / 255.0,
484                        g as f32 / 255.0,
485                        b as f32 / 255.0,
486                        x,
487                        y,
488                        w,
489                        h
490                    )
491                    .bytes(),
492                );
493            }
494            XfaPaintCommand::DrawText {
495                x,
496                y,
497                text,
498                font_family,
499                font_size,
500                color,
501            } => {
502                let rgba = color.to_rgba();
503                let [r, g, b, _] = rgba.to_rgba8();
504                let font_ref = font_family_to_ref(*font_family);
505                ops.extend(
506                    format!(
507                        "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
508                        r as f32 / 255.0,
509                        g as f32 / 255.0,
510                        b as f32 / 255.0,
511                        font_ref,
512                        font_size,
513                        x,
514                        y,
515                        pdf_escape(text)
516                    )
517                    .bytes(),
518                );
519            }
520            XfaPaintCommand::DrawMultilineText {
521                x,
522                y,
523                lines,
524                font_family,
525                font_size,
526                line_height,
527                color,
528                text_align,
529                container_width,
530                text_padding,
531            } => {
532                let rgba = color.to_rgba();
533                let [r, g, b, _] = rgba.to_rgba8();
534                let font_ref = font_family_to_ref(*font_family);
535                let p = *text_padding;
536                let content_w = (container_width - p * 2.0).max(0.0);
537                let metrics = FontMetrics {
538                    size: *font_size,
539                    typeface: *font_family,
540                    ..Default::default()
541                };
542                ops.extend(
543                    format!(
544                        "BT\n{:.3} {:.3} {:.3} rg\n{} {:.1} Tf\n",
545                        r as f32 / 255.0,
546                        g as f32 / 255.0,
547                        b as f32 / 255.0,
548                        font_ref,
549                        font_size
550                    )
551                    .bytes(),
552                );
553                let mut prev_tx = x + p;
554                for (i, line) in lines.iter().enumerate() {
555                    let line_w = metrics.measure_width(line);
556                    let text_x = match text_align {
557                        TextAlign::Center => x + p + ((content_w - line_w) / 2.0).max(0.0),
558                        TextAlign::Right => x + p + (content_w - line_w).max(0.0),
559                        _ => x + p,
560                    };
561                    if i == 0 {
562                        ops.extend(format!("{:.2} {:.2} Td\n", text_x, y).bytes());
563                    } else {
564                        let dx = text_x - prev_tx;
565                        ops.extend(format!("{:.2} {:.2} Td\n", dx, -line_height).bytes());
566                    }
567                    prev_tx = text_x;
568                    ops.extend(format!("({}) Tj\n", pdf_escape(line)).bytes());
569                }
570                ops.extend_from_slice(b"ET\n");
571            }
572            XfaPaintCommand::DrawImage {
573                x,
574                y,
575                w,
576                h,
577                image_data: _,
578                mime_type: _,
579            } => {
580                ops.extend(
581                    format!(
582                        "q\n{:.2} 0 0 {:.2} {:.2} {:.2} cm\n/Im{} Do\nQ\n",
583                        w, h, x, y, image_index
584                    )
585                    .bytes(),
586                );
587                image_index += 1;
588            }
589            XfaPaintCommand::DrawCheckbox {
590                x,
591                y,
592                w,
593                h,
594                checked,
595                border_color,
596                check_color,
597                border_width,
598            } => {
599                // Draw checkbox border (square box).
600                ops.extend(
601                    format!(
602                        "q\n{:.2} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
603                        border_width, border_color[0], border_color[1], border_color[2], x, y, w, h
604                    )
605                    .bytes(),
606                );
607                if *checked {
608                    // Draw an X mark inside the box.
609                    let m = w.min(*h) * 0.15;
610                    ops.extend(
611                        format!(
612                            "{:.2} w\n{:.3} {:.3} {:.3} RG\n\
613                             {:.2} {:.2} m {:.2} {:.2} l S\n\
614                             {:.2} {:.2} m {:.2} {:.2} l S\n",
615                            border_width.max(1.0),
616                            check_color[0],
617                            check_color[1],
618                            check_color[2],
619                            x + m,
620                            y + m,
621                            x + w - m,
622                            y + h - m,
623                            x + m,
624                            y + h - m,
625                            x + w - m,
626                            y + m,
627                        )
628                        .bytes(),
629                    );
630                }
631                ops.extend_from_slice(b"Q\n");
632            }
633        }
634    }
635
636    ops.extend_from_slice(b"Q\n");
637    ops
638}
639
640/// Escape a Unicode string for use inside a PDF literal string `(…)`.
641///
642/// The fonts we register use WinAnsiEncoding, so every character must be
643/// mapped to its single-byte WinAnsi code point. Characters outside the
644/// WinAnsi range are replaced with `?`. Bytes outside printable ASCII
645/// (0x20–0x7E) are emitted as octal escapes `\NNN`.
646fn pdf_escape(s: &str) -> String {
647    let mut r = String::with_capacity(s.len());
648    for c in s.chars() {
649        match c {
650            '(' => r.push_str("\\("),
651            ')' => r.push_str("\\)"),
652            '\\' => r.push_str("\\\\"),
653            // Printable ASCII passes through directly.
654            '\x20'..='\x7e' => r.push(c),
655            _ => {
656                if let Some(b) = unicode_to_winansi(c) {
657                    // Emit as octal escape for non-ASCII WinAnsi bytes.
658                    use std::fmt::Write;
659                    let _ = write!(r, "\\{:03o}", b);
660                } else {
661                    r.push('?');
662                }
663            }
664        }
665    }
666    r
667}
668
669/// Map a Unicode code point to its WinAnsiEncoding byte value.
670///
671/// Returns `None` for characters that have no WinAnsi representation.
672/// Covers the 0x80–0x9F range (where WinAnsi differs from Latin-1) and
673/// the 0xA0–0xFF Latin-1 supplement range.
674fn unicode_to_winansi(c: char) -> Option<u8> {
675    // Latin-1 Supplement range 0xA0–0xFF maps 1:1.
676    let cp = c as u32;
677    if (0xA0..=0xFF).contains(&cp) {
678        return Some(cp as u8);
679    }
680    // WinAnsi 0x80–0x9F special mappings (Windows-1252).
681    match c {
682        '\u{20AC}' => Some(0x80), // €
683        '\u{201A}' => Some(0x82), // ‚
684        '\u{0192}' => Some(0x83), // ƒ
685        '\u{201E}' => Some(0x84), // „
686        '\u{2026}' => Some(0x85), // …
687        '\u{2020}' => Some(0x86), // †
688        '\u{2021}' => Some(0x87), // ‡
689        '\u{02C6}' => Some(0x88), // ˆ
690        '\u{2030}' => Some(0x89), // ‰
691        '\u{0160}' => Some(0x8A), // Š
692        '\u{2039}' => Some(0x8B), // ‹
693        '\u{0152}' => Some(0x8C), // Œ
694        '\u{017D}' => Some(0x8E), // Ž
695        '\u{2018}' => Some(0x91), // '
696        '\u{2019}' => Some(0x92), // '
697        '\u{201C}' => Some(0x93), // "
698        '\u{201D}' => Some(0x94), // "
699        '\u{2022}' => Some(0x95), // •  (bullet)
700        '\u{2013}' => Some(0x96), // –  (en-dash)
701        '\u{2014}' => Some(0x97), // —  (em-dash)
702        '\u{02DC}' => Some(0x98), // ˜
703        '\u{2122}' => Some(0x99), // ™
704        '\u{0161}' => Some(0x9A), // š
705        '\u{203A}' => Some(0x9B), // ›
706        '\u{0153}' => Some(0x9C), // œ
707        '\u{017E}' => Some(0x9E), // ž
708        '\u{0178}' => Some(0x9F), // Ÿ
709        _ => None,
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use crate::render_bridge::XfaRenderConfig;
717    use xfa_layout_engine::layout::{LayoutNode, LayoutPage};
718    use xfa_layout_engine::types::Rect;
719
720    fn test_config() -> XfaRenderConfig {
721        XfaRenderConfig {
722            default_font: "Helvetica".into(),
723            default_font_size: 10.0,
724            draw_borders: true,
725            border_width: 0.5,
726            border_color: [0.0, 0.0, 0.0],
727            text_color: [0.0, 0.0, 0.0],
728            background_color: Some([1.0, 1.0, 1.0]),
729            text_padding: 2.0,
730            font_map: std::sync::Arc::new(std::collections::HashMap::new()),
731            font_metrics_data: std::sync::Arc::new(std::collections::HashMap::new()),
732            check_button_mark: None,
733            field_values_only: false,
734        }
735    }
736
737    fn field_node(name: &str, x: f64, y: f64, w: f64, h: f64, value: &str) -> LayoutNode {
738        LayoutNode {
739            form_node: xfa_layout_engine::form::FormNodeId(0),
740            rect: Rect {
741                x,
742                y,
743                width: w,
744                height: h,
745            },
746            name: name.into(),
747            content: LayoutContent::Field {
748                value: value.into(),
749                field_kind: xfa_layout_engine::form::FieldKind::Text,
750                font_size: 0.0,
751                font_family: xfa_layout_engine::text::FontFamily::Serif,
752            },
753            children: vec![],
754            style: Default::default(),
755            display_items: vec![],
756            save_items: vec![],
757        }
758    }
759
760    fn field_node_with_border(
761        name: &str,
762        x: f64,
763        y: f64,
764        w: f64,
765        h: f64,
766        value: &str,
767    ) -> LayoutNode {
768        LayoutNode {
769            form_node: xfa_layout_engine::form::FormNodeId(0),
770            rect: Rect {
771                x,
772                y,
773                width: w,
774                height: h,
775            },
776            name: name.into(),
777            content: LayoutContent::Field {
778                value: value.into(),
779                field_kind: xfa_layout_engine::form::FieldKind::Text,
780                font_size: 0.0,
781                font_family: xfa_layout_engine::text::FontFamily::Serif,
782            },
783            children: vec![],
784            style: FormNodeStyle {
785                border_width_pt: Some(0.5),
786                border_color: Some((0, 0, 0)),
787                ..Default::default()
788            },
789            display_items: vec![],
790            save_items: vec![],
791        }
792    }
793
794    #[test]
795    fn field_with_value_and_border_emits_fill_stroke_text() {
796        let page = LayoutPage {
797            width: 612.0,
798            height: 792.0,
799            nodes: vec![field_node_with_border(
800                "name", 10.0, 10.0, 200.0, 20.0, "Hello",
801            )],
802            runtime_instantiated: false,
803        };
804        let cmds = layout_to_commands(&page, &test_config());
805        assert_eq!(cmds.len(), 3); // FillRect + StrokeRect + DrawText
806        assert!(matches!(cmds[0], XfaPaintCommand::FillRect { .. }));
807        assert!(matches!(cmds[1], XfaPaintCommand::StrokeRect { .. }));
808        assert!(matches!(cmds[2], XfaPaintCommand::DrawText { .. }));
809    }
810
811    #[test]
812    fn field_without_border_style_no_stroke() {
813        let page = LayoutPage {
814            width: 612.0,
815            height: 792.0,
816            nodes: vec![field_node("name", 10.0, 10.0, 200.0, 20.0, "Hello")],
817            runtime_instantiated: false,
818        };
819        let cmds = layout_to_commands(&page, &test_config());
820        // Default FormNodeStyle has no border_width_pt → no StrokeRect
821        assert_eq!(cmds.len(), 2); // FillRect + DrawText
822        assert!(matches!(cmds[0], XfaPaintCommand::FillRect { .. }));
823        assert!(matches!(cmds[1], XfaPaintCommand::DrawText { .. }));
824    }
825
826    #[test]
827    fn empty_field_no_text_command() {
828        let page = LayoutPage {
829            width: 612.0,
830            height: 792.0,
831            nodes: vec![field_node("name", 10.0, 10.0, 200.0, 20.0, "")],
832            runtime_instantiated: false,
833        };
834        let cmds = layout_to_commands(&page, &test_config());
835        assert_eq!(cmds.len(), 1); // FillRect only, no border, no text
836    }
837
838    #[test]
839    fn transparent_background_no_fill() {
840        let mut config = test_config();
841        config.background_color = None;
842        let page = LayoutPage {
843            width: 612.0,
844            height: 792.0,
845            nodes: vec![field_node("name", 10.0, 10.0, 200.0, 20.0, "Hi")],
846            runtime_instantiated: false,
847        };
848        let cmds = layout_to_commands(&page, &config);
849        assert_eq!(cmds.len(), 1); // DrawText only, no FillRect, no border
850        assert!(matches!(cmds[0], XfaPaintCommand::DrawText { .. }));
851    }
852
853    #[test]
854    fn multiline_text_emits_multiline_command() {
855        let page = LayoutPage {
856            width: 612.0,
857            height: 792.0,
858            nodes: vec![LayoutNode {
859                form_node: xfa_layout_engine::form::FormNodeId(0),
860                rect: Rect {
861                    x: 10.0,
862                    y: 10.0,
863                    width: 200.0,
864                    height: 60.0,
865                },
866                name: "memo".into(),
867                content: LayoutContent::WrappedText {
868                    lines: vec!["Line 1".into(), "Line 2".into()],
869                    first_line_of_para: vec![true, false],
870                    font_size: 10.0,
871                    text_align: xfa_layout_engine::types::TextAlign::Left,
872                    font_family: xfa_layout_engine::text::FontFamily::SansSerif,
873                    space_above_pt: None,
874                    space_below_pt: None,
875                    from_field: false,
876                },
877                children: vec![],
878                style: Default::default(),
879                display_items: vec![],
880                save_items: vec![],
881            }],
882            runtime_instantiated: false,
883        };
884        let cmds = layout_to_commands(&page, &test_config());
885        // FillRect (from bg) + DrawMultilineText
886        assert_eq!(cmds.len(), 2);
887        assert!(matches!(cmds[0], XfaPaintCommand::FillRect { .. }));
888        assert!(matches!(cmds[1], XfaPaintCommand::DrawMultilineText { .. }));
889    }
890
891    #[test]
892    fn multiple_nodes_coordinate_mapping() {
893        let page = LayoutPage {
894            width: 612.0,
895            height: 792.0,
896            nodes: vec![
897                field_node("a", 10.0, 10.0, 100.0, 20.0, "A"),
898                field_node("b", 10.0, 40.0, 100.0, 20.0, "B"),
899            ],
900            runtime_instantiated: false,
901        };
902        let cmds = layout_to_commands(&page, &test_config());
903        // 2 fields × 2 commands each = 4 (FillRect + DrawText, no borders)
904        assert_eq!(cmds.len(), 4);
905        // Second field has lower y in XFA, so higher pdf_y
906        if let XfaPaintCommand::FillRect { y: y1, .. } = &cmds[0] {
907            if let XfaPaintCommand::FillRect { y: y2, .. } = &cmds[2] {
908                assert!(
909                    y1 > y2,
910                    "first field (y=10) should have higher PDF y than second (y=40)"
911                );
912            }
913        }
914    }
915
916    #[test]
917    fn checkbox_emits_checkbox_command() {
918        let page = LayoutPage {
919            width: 612.0,
920            height: 792.0,
921            nodes: vec![LayoutNode {
922                form_node: xfa_layout_engine::form::FormNodeId(0),
923                rect: Rect {
924                    x: 10.0,
925                    y: 10.0,
926                    width: 15.0,
927                    height: 15.0,
928                },
929                name: "check1".into(),
930                content: LayoutContent::Field {
931                    value: "1".into(),
932                    field_kind: FieldKind::Checkbox,
933                    font_size: 10.0,
934                    font_family: FontFamily::SansSerif,
935                },
936                children: vec![],
937                style: Default::default(),
938                display_items: vec![],
939                save_items: vec![],
940            }],
941            runtime_instantiated: false,
942        };
943        let cmds = layout_to_commands(&page, &test_config());
944        assert_eq!(cmds.len(), 1);
945        assert!(matches!(
946            cmds[0],
947            XfaPaintCommand::DrawCheckbox { checked: true, .. }
948        ));
949    }
950
951    #[test]
952    fn pdf_escape_winansi_encoding() {
953        // ASCII passes through.
954        assert_eq!(pdf_escape("Hello"), "Hello");
955        // Parentheses and backslash are escaped.
956        assert_eq!(pdf_escape("a(b)c\\d"), "a\\(b\\)c\\\\d");
957        // En-dash U+2013 → WinAnsi 0x96 → octal \226.
958        assert_eq!(pdf_escape("\u{2013}"), "\\226");
959        // Bullet U+2022 → WinAnsi 0x95 → octal \225.
960        assert_eq!(pdf_escape("\u{2022}"), "\\225");
961        // Latin-1: © U+00A9 → WinAnsi 0xA9 → octal \251.
962        assert_eq!(pdf_escape("\u{00A9}"), "\\251");
963        // Unmapped character → '?'.
964        assert_eq!(pdf_escape("\u{4E16}"), "?"); // CJK char
965    }
966
967    #[test]
968    fn child_coordinates_accumulate() {
969        let page = LayoutPage {
970            width: 612.0,
971            height: 792.0,
972            nodes: vec![LayoutNode {
973                form_node: xfa_layout_engine::form::FormNodeId(0),
974                rect: Rect {
975                    x: 50.0,
976                    y: 100.0,
977                    width: 200.0,
978                    height: 200.0,
979                },
980                name: "parent".into(),
981                content: LayoutContent::None,
982                children: vec![LayoutNode {
983                    form_node: xfa_layout_engine::form::FormNodeId(1),
984                    rect: Rect {
985                        x: 10.0,
986                        y: 10.0,
987                        width: 100.0,
988                        height: 20.0,
989                    },
990                    name: "child".into(),
991                    content: LayoutContent::Field {
992                        value: "Test".into(),
993                        field_kind: FieldKind::Text,
994                        font_size: 10.0,
995                        font_family: FontFamily::SansSerif,
996                    },
997                    children: vec![],
998                    style: Default::default(),
999                    display_items: vec![],
1000                    save_items: vec![],
1001                }],
1002                style: Default::default(),
1003                display_items: vec![],
1004                save_items: vec![],
1005            }],
1006            runtime_instantiated: false,
1007        };
1008        let config = XfaRenderConfig::default();
1009        let cmds = layout_to_commands(&page, &config);
1010        // Child should have x = 50 + 10 = 60 (accumulated)
1011        let text_cmd = cmds
1012            .iter()
1013            .find(|c| matches!(c, XfaPaintCommand::DrawText { .. }));
1014        assert!(text_cmd.is_some());
1015        if let Some(XfaPaintCommand::DrawText { x, .. }) = text_cmd {
1016            // x should be 60 (no default padding per XFA spec)
1017            assert!(
1018                (*x - 60.0).abs() < 0.1,
1019                "child x should be parent(50) + child(10) = 60, got {}",
1020                x
1021            );
1022        }
1023    }
1024}