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