Skip to main content

forme/layout/
mod.rs

1//! # Page-Aware Layout Engine
2//!
3//! This is the heart of Forme and the reason it exists.
4//!
5//! ## The Problem With Every Other Engine
6//!
7//! Most PDF renderers do this:
8//! 1. Lay out all content on an infinitely tall canvas
9//! 2. Slice the canvas into pages
10//! 3. Try to fix the things that broke at slice points
11//!
12//! Step 3 is where everything falls apart. Flexbox layouts collapse because
13//! the flex algorithm ran on the pre-sliced dimensions. Table rows get split
14//! in the wrong places. Headers don't repeat. Content gets "mashed together."
15//!
16//! ## How Forme Works
17//!
18//! Forme never creates an infinite canvas. The layout algorithm is:
19//!
20//! 1. Open a page with known dimensions and remaining space
21//! 2. Place each child node. Before placing, ask: "does this fit?"
22//! 3. If it fits: place it, reduce remaining space
23//! 4. If it doesn't fit and is unbreakable: start a new page, place it there
24//! 5. If it doesn't fit and is breakable: place what fits, split the rest
25//!    to a new page, and RE-RUN flex layout on both fragments
26//! 6. For tables: when splitting, clone the header rows onto the new page
27//!
28//! The key insight in step 5: when a flex container splits across pages,
29//! BOTH fragments get their own independent flex layout pass. This is why
30//! react-pdf's flex breaks on page wrap — it runs flex once on the whole
31//! container and then slices, so the flex calculations are wrong on both
32//! halves. We run flex AFTER splitting.
33
34pub mod flex;
35pub mod grid;
36pub mod page_break;
37
38use std::cell::RefCell;
39use std::collections::HashMap;
40
41use serde::Serialize;
42
43use crate::font::FontContext;
44use crate::model::*;
45use crate::style::*;
46use crate::text::bidi;
47use crate::text::shaping;
48use crate::text::{BrokenLine, RunBrokenLine, StyledChar, TextLayout};
49
50/// A bookmark entry collected during layout.
51#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub struct BookmarkEntry {
54    pub title: String,
55    pub page_index: usize,
56    pub y: f64,
57}
58
59// ── Serializable layout metadata (for debug overlays / dev tools) ───
60
61/// Complete layout metadata for all pages.
62#[derive(Debug, Clone, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct LayoutInfo {
65    pub pages: Vec<PageInfo>,
66}
67
68/// Layout metadata for a single page.
69#[derive(Debug, Clone, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct PageInfo {
72    pub width: f64,
73    pub height: f64,
74    pub content_x: f64,
75    pub content_y: f64,
76    pub content_width: f64,
77    pub content_height: f64,
78    pub elements: Vec<ElementInfo>,
79}
80
81/// Serializable snapshot of ResolvedStyle for the inspector panel.
82#[derive(Debug, Clone, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct ElementStyleInfo {
85    // Box model
86    pub margin: Edges,
87    pub padding: Edges,
88    pub border_width: Edges,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub width: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub height: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub min_width: Option<f64>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub min_height: Option<f64>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub max_width: Option<f64>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub max_height: Option<f64>,
101    // Flex
102    pub flex_direction: FlexDirection,
103    pub justify_content: JustifyContent,
104    pub align_items: AlignItems,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub align_self: Option<AlignItems>,
107    pub flex_wrap: FlexWrap,
108    pub align_content: AlignContent,
109    pub flex_grow: f64,
110    pub flex_shrink: f64,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub flex_basis: Option<String>,
113    pub gap: f64,
114    pub row_gap: f64,
115    pub column_gap: f64,
116    // Text
117    pub font_family: String,
118    pub font_size: f64,
119    pub font_weight: u32,
120    pub font_style: FontStyle,
121    pub line_height: f64,
122    pub text_align: TextAlign,
123    pub letter_spacing: f64,
124    pub text_decoration: TextDecoration,
125    pub text_transform: TextTransform,
126    // Visual
127    pub color: Color,
128    pub background_color: Option<Color>,
129    pub border_color: EdgeValues<Color>,
130    pub border_radius: CornerValues,
131    pub opacity: f64,
132    // Positioning
133    pub position: Position,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub top: Option<f64>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub right: Option<f64>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub bottom: Option<f64>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub left: Option<f64>,
142    // Overflow
143    pub overflow: Overflow,
144    // Page behavior
145    pub breakable: bool,
146    pub break_before: bool,
147    pub min_widow_lines: u32,
148    pub min_orphan_lines: u32,
149}
150
151fn size_constraint_to_str(sc: &SizeConstraint) -> Option<String> {
152    match sc {
153        SizeConstraint::Auto => None,
154        SizeConstraint::Fixed(v) => Some(format!("{v}")),
155    }
156}
157
158impl ElementStyleInfo {
159    fn from_resolved(style: &ResolvedStyle) -> Self {
160        ElementStyleInfo {
161            margin: style.margin.to_edges(),
162            padding: style.padding,
163            border_width: style.border_width,
164            width: size_constraint_to_str(&style.width),
165            height: size_constraint_to_str(&style.height),
166            min_width: if style.min_width > 0.0 {
167                Some(style.min_width)
168            } else {
169                None
170            },
171            min_height: if style.min_height > 0.0 {
172                Some(style.min_height)
173            } else {
174                None
175            },
176            max_width: if style.max_width.is_finite() {
177                Some(style.max_width)
178            } else {
179                None
180            },
181            max_height: if style.max_height.is_finite() {
182                Some(style.max_height)
183            } else {
184                None
185            },
186            flex_direction: style.flex_direction,
187            justify_content: style.justify_content,
188            align_items: style.align_items,
189            align_self: style.align_self,
190            flex_wrap: style.flex_wrap,
191            align_content: style.align_content,
192            flex_grow: style.flex_grow,
193            flex_shrink: style.flex_shrink,
194            flex_basis: size_constraint_to_str(&style.flex_basis),
195            gap: style.gap,
196            row_gap: style.row_gap,
197            column_gap: style.column_gap,
198            font_family: style.font_family.clone(),
199            font_size: style.font_size,
200            font_weight: style.font_weight,
201            font_style: style.font_style,
202            line_height: style.line_height,
203            text_align: style.text_align,
204            letter_spacing: style.letter_spacing,
205            text_decoration: style.text_decoration,
206            text_transform: style.text_transform,
207            color: style.color,
208            background_color: style.background_color,
209            border_color: style.border_color,
210            border_radius: style.border_radius,
211            opacity: style.opacity,
212            position: style.position,
213            top: style.top,
214            right: style.right,
215            bottom: style.bottom,
216            left: style.left,
217            overflow: style.overflow,
218            breakable: style.breakable,
219            break_before: style.break_before,
220            min_widow_lines: style.min_widow_lines,
221            min_orphan_lines: style.min_orphan_lines,
222        }
223    }
224}
225
226impl Default for ElementStyleInfo {
227    fn default() -> Self {
228        ElementStyleInfo {
229            margin: Edges::default(),
230            padding: Edges::default(),
231            border_width: Edges::default(),
232            width: None,
233            height: None,
234            min_width: None,
235            min_height: None,
236            max_width: None,
237            max_height: None,
238            flex_direction: FlexDirection::default(),
239            justify_content: JustifyContent::default(),
240            align_items: AlignItems::default(),
241            align_self: None,
242            flex_wrap: FlexWrap::default(),
243            align_content: AlignContent::default(),
244            flex_grow: 0.0,
245            flex_shrink: 1.0,
246            flex_basis: None,
247            gap: 0.0,
248            row_gap: 0.0,
249            column_gap: 0.0,
250            font_family: "Helvetica".to_string(),
251            font_size: 12.0,
252            font_weight: 400,
253            font_style: FontStyle::default(),
254            line_height: 1.4,
255            text_align: TextAlign::default(),
256            letter_spacing: 0.0,
257            text_decoration: TextDecoration::None,
258            text_transform: TextTransform::None,
259            color: Color::BLACK,
260            background_color: None,
261            border_color: EdgeValues::uniform(Color::BLACK),
262            border_radius: CornerValues::uniform(0.0),
263            opacity: 1.0,
264            position: Position::default(),
265            top: None,
266            right: None,
267            bottom: None,
268            left: None,
269            overflow: Overflow::default(),
270            breakable: false,
271            break_before: false,
272            min_widow_lines: 2,
273            min_orphan_lines: 2,
274        }
275    }
276}
277
278/// Layout metadata for a single positioned element (hierarchical).
279#[derive(Debug, Clone, Serialize)]
280#[serde(rename_all = "camelCase")]
281pub struct ElementInfo {
282    pub x: f64,
283    pub y: f64,
284    pub width: f64,
285    pub height: f64,
286    /// DrawCommand-based kind (Rect, Text, Image, etc.) for backward compat.
287    pub kind: String,
288    /// Logical node type (View, Text, Image, TableRow, etc.).
289    pub node_type: String,
290    /// Resolved style snapshot for the inspector panel.
291    pub style: ElementStyleInfo,
292    /// Child elements (preserves hierarchy).
293    pub children: Vec<ElementInfo>,
294    /// Source code location for click-to-source.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub source_location: Option<SourceLocation>,
297    /// Text content extracted from TextLine draw commands (for component tree).
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub text_content: Option<String>,
300    /// Optional hyperlink URL.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub href: Option<String>,
303    /// Optional bookmark title.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub bookmark: Option<String>,
306}
307
308impl LayoutInfo {
309    /// Extract serializable layout metadata from laid-out pages.
310    pub fn from_pages(pages: &[LayoutPage]) -> Self {
311        LayoutInfo {
312            pages: pages
313                .iter()
314                .map(|page| {
315                    let (page_w, page_h) = page.config.size.dimensions();
316                    let content_x = page.config.margin.left;
317                    let content_y = page.config.margin.top;
318                    let content_width = page_w - page.config.margin.horizontal();
319                    let content_height = page_h - page.config.margin.vertical();
320
321                    let elements = Self::build_element_tree(&page.elements);
322
323                    PageInfo {
324                        width: page_w,
325                        height: page_h,
326                        content_x,
327                        content_y,
328                        content_width,
329                        content_height,
330                        elements,
331                    }
332                })
333                .collect(),
334        }
335    }
336
337    fn build_element_tree(elems: &[LayoutElement]) -> Vec<ElementInfo> {
338        elems
339            .iter()
340            .map(|elem| {
341                let kind = match &elem.draw {
342                    DrawCommand::None => "None",
343                    DrawCommand::Rect { .. } => "Rect",
344                    DrawCommand::Text { .. } => "Text",
345                    DrawCommand::Image { .. } => "Image",
346                    DrawCommand::ImagePlaceholder => "ImagePlaceholder",
347                    DrawCommand::Svg { .. } => "Svg",
348                    DrawCommand::Barcode { .. } => "Barcode",
349                    DrawCommand::QrCode { .. } => "QrCode",
350                    DrawCommand::Chart { .. } => "Chart",
351                    DrawCommand::Watermark { .. } => "Watermark",
352                    DrawCommand::FormField { .. } => "FormField",
353                };
354                let text_content = match &elem.draw {
355                    DrawCommand::Text { lines, .. } => {
356                        let text: String = lines
357                            .iter()
358                            .flat_map(|line| {
359                                line.glyphs.iter().flat_map(|g| {
360                                    // Use cluster_text for ligatures (e.g., "fi" → 2 chars)
361                                    g.cluster_text.as_deref().unwrap_or("").chars().chain(
362                                        if g.cluster_text.is_none() {
363                                            Some(g.char_value)
364                                        } else {
365                                            None
366                                        },
367                                    )
368                                })
369                            })
370                            .collect();
371                        if text.is_empty() {
372                            None
373                        } else {
374                            Some(text)
375                        }
376                    }
377                    _ => None,
378                };
379                let node_type = elem.node_type.clone().unwrap_or_else(|| kind.to_string());
380                let style = elem
381                    .resolved_style
382                    .as_ref()
383                    .map(ElementStyleInfo::from_resolved)
384                    .unwrap_or_default();
385                ElementInfo {
386                    x: elem.x,
387                    y: elem.y,
388                    width: elem.width,
389                    height: elem.height,
390                    kind: kind.to_string(),
391                    node_type,
392                    style,
393                    children: Self::build_element_tree(&elem.children),
394                    source_location: elem.source_location.clone(),
395                    text_content,
396                    href: elem.href.clone(),
397                    bookmark: elem.bookmark.clone(),
398                }
399            })
400            .collect()
401    }
402}
403
404/// A fully laid-out page ready for PDF serialization.
405#[derive(Debug, Clone)]
406pub struct LayoutPage {
407    pub width: f64,
408    pub height: f64,
409    pub elements: Vec<LayoutElement>,
410    /// Fixed header nodes to inject after layout (internal use).
411    pub(crate) fixed_header: Vec<(Node, f64)>,
412    /// Fixed footer nodes to inject after layout (internal use).
413    pub(crate) fixed_footer: Vec<(Node, f64)>,
414    /// Watermark nodes to inject after layout (internal use).
415    pub(crate) watermarks: Vec<Node>,
416    /// Page config needed for fixed element layout (internal use).
417    pub(crate) config: PageConfig,
418}
419
420/// A positioned element on a page.
421#[derive(Debug, Clone)]
422pub struct LayoutElement {
423    /// Absolute position on the page (top-left corner).
424    pub x: f64,
425    pub y: f64,
426    /// Dimensions including padding and border, excluding margin.
427    pub width: f64,
428    pub height: f64,
429    /// The visual properties to draw.
430    pub draw: DrawCommand,
431    /// Child elements (positioned relative to page, not parent).
432    pub children: Vec<LayoutElement>,
433    /// Logical node type for dev tools (e.g. "View", "Text", "Image").
434    pub node_type: Option<String>,
435    /// Resolved style snapshot for inspector panel.
436    pub resolved_style: Option<ResolvedStyle>,
437    /// Source code location for click-to-source in the dev inspector.
438    pub source_location: Option<SourceLocation>,
439    /// Optional hyperlink URL for link annotations.
440    pub href: Option<String>,
441    /// Optional bookmark title for PDF outline entries.
442    pub bookmark: Option<String>,
443    /// Optional alt text for images and SVGs (accessibility).
444    pub alt: Option<String>,
445    /// Whether this is a table header row (for tagged PDF: TH vs TD).
446    pub is_header_row: bool,
447    /// Overflow behavior (Visible or Hidden). When Hidden, PDF clips children.
448    pub overflow: Overflow,
449    /// Opacity for the entire element including its children (0.0–1.0). The
450    /// PDF serializer wraps `write_element` in a `q\n/GS{n} gs ... Q` block
451    /// when this is < 1.0, so descendants render at the cumulative alpha.
452    /// Default is 1.0 (no extra wrap).
453    pub opacity: f64,
454}
455
456/// Return a human-readable name for a NodeKind variant.
457fn node_kind_name(kind: &NodeKind) -> &'static str {
458    match kind {
459        NodeKind::View => "View",
460        NodeKind::Text { .. } => "Text",
461        NodeKind::Image { .. } => "Image",
462        NodeKind::Table { .. } => "Table",
463        NodeKind::TableRow { .. } => "TableRow",
464        NodeKind::TableCell { .. } => "TableCell",
465        NodeKind::Fixed {
466            position: FixedPosition::Header,
467        } => "FixedHeader",
468        NodeKind::Fixed {
469            position: FixedPosition::Footer,
470        } => "FixedFooter",
471        NodeKind::Page { .. } => "Page",
472        NodeKind::PageBreak => "PageBreak",
473        NodeKind::Svg { .. } => "Svg",
474        NodeKind::Canvas { .. } => "Canvas",
475        NodeKind::Barcode { .. } => "Barcode",
476        NodeKind::QrCode { .. } => "QrCode",
477        NodeKind::BarChart { .. } => "BarChart",
478        NodeKind::LineChart { .. } => "LineChart",
479        NodeKind::PieChart { .. } => "PieChart",
480        NodeKind::AreaChart { .. } => "AreaChart",
481        NodeKind::DotPlot { .. } => "DotPlot",
482        NodeKind::Watermark { .. } => "Watermark",
483        NodeKind::TextField { .. } => "TextField",
484        NodeKind::Checkbox { .. } => "Checkbox",
485        NodeKind::Dropdown { .. } => "Dropdown",
486        NodeKind::RadioButton { .. } => "RadioButton",
487    }
488}
489
490/// Configuration for an interactive PDF form field.
491#[derive(Debug, Clone)]
492pub enum FormFieldType {
493    TextField {
494        value: Option<String>,
495        placeholder: Option<String>,
496        multiline: bool,
497        password: bool,
498        read_only: bool,
499        max_length: Option<u32>,
500        font_size: f64,
501    },
502    Checkbox {
503        checked: bool,
504        read_only: bool,
505    },
506    Dropdown {
507        options: Vec<String>,
508        value: Option<String>,
509        read_only: bool,
510        font_size: f64,
511    },
512    RadioButton {
513        value: String,
514        checked: bool,
515        read_only: bool,
516    },
517}
518
519/// What to actually draw for this element.
520#[derive(Debug, Clone)]
521pub enum DrawCommand {
522    /// Nothing to draw (just a layout container).
523    None,
524    /// Draw a rectangle (background, border).
525    Rect {
526        background: Option<Color>,
527        border_width: Edges,
528        border_color: EdgeValues<Color>,
529        border_radius: CornerValues,
530        opacity: f64,
531        /// Optional drop shadow rendered before the background. Boxed
532        /// to keep the `DrawCommand` enum's largest variant size down.
533        box_shadow: Option<Box<crate::style::BoxShadow>>,
534        /// Optional gradient paint. When `Some`, takes precedence over
535        /// `background` (solid color). Boxed for the same enum-size
536        /// reason as `box_shadow`.
537        background_gradient: Option<Box<crate::style::Background>>,
538    },
539    /// Draw text.
540    Text {
541        lines: Vec<TextLine>,
542        color: Color,
543        text_decoration: TextDecoration,
544        opacity: f64,
545    },
546    /// Draw an image.
547    Image {
548        image_data: crate::image_loader::LoadedImage,
549    },
550    /// Draw a grey placeholder rectangle (fallback when image loading fails).
551    ImagePlaceholder,
552    /// Draw SVG vector graphics.
553    Svg {
554        commands: Vec<crate::svg::SvgCommand>,
555        width: f64,
556        height: f64,
557        /// When true, clip content to [0, 0, width, height] (used by Canvas).
558        clip: bool,
559    },
560    /// Draw a 1D barcode as filled rectangles.
561    Barcode {
562        bars: Vec<u8>,
563        bar_width: f64,
564        height: f64,
565        color: Color,
566    },
567    /// Draw a QR code as filled rectangles.
568    QrCode {
569        modules: Vec<Vec<bool>>,
570        module_size: f64,
571        color: Color,
572    },
573    /// Draw a chart as a list of drawing primitives.
574    Chart {
575        primitives: Vec<crate::chart::ChartPrimitive>,
576    },
577    /// Draw a watermark (rotated text with opacity).
578    Watermark {
579        lines: Vec<TextLine>,
580        color: Color,
581        opacity: f64,
582        angle_rad: f64,
583        /// Font family used (for PDF font registration).
584        font_family: String,
585    },
586    /// An interactive PDF form field (AcroForm widget annotation).
587    FormField {
588        field_type: FormFieldType,
589        name: String,
590    },
591}
592
593#[derive(Debug, Clone)]
594pub struct TextLine {
595    pub x: f64,
596    pub y: f64,
597    pub glyphs: Vec<PositionedGlyph>,
598    pub width: f64,
599    pub height: f64,
600    /// Extra width added to each space character for justification (PDF `Tw` operator).
601    pub word_spacing: f64,
602}
603
604#[derive(Debug, Clone)]
605pub struct PositionedGlyph {
606    /// Glyph ID. For custom fonts with shaping, this is a real GID from GSUB.
607    /// For standard fonts, this is `char as u16` (Unicode codepoint).
608    pub glyph_id: u16,
609    /// X position relative to line start.
610    pub x_offset: f64,
611    /// Y offset from GPOS (e.g., mark positioning). Usually 0.0.
612    pub y_offset: f64,
613    /// Actual advance width of this glyph in points (from shaping or font metrics).
614    pub x_advance: f64,
615    pub font_size: f64,
616    pub font_family: String,
617    pub font_weight: u32,
618    pub font_style: FontStyle,
619    /// The character this glyph represents. For ligatures, the first char of the cluster.
620    pub char_value: char,
621    /// Per-glyph color (for text runs with different colors).
622    pub color: Option<Color>,
623    /// Per-glyph href (for inline links within runs).
624    pub href: Option<String>,
625    /// Per-glyph text decoration (for runs with different decorations).
626    pub text_decoration: TextDecoration,
627    /// Letter spacing applied to this glyph.
628    pub letter_spacing: f64,
629    /// For ligature glyphs, the full cluster text (e.g., "fi" for an fi ligature).
630    /// `None` for 1:1 char-to-glyph mappings.
631    pub cluster_text: Option<String>,
632}
633
634/// Shift a layout element and all its nested content (children, text lines)
635/// down by `dy` points. Used to reposition footer elements after layout.
636fn offset_element_y(el: &mut LayoutElement, dy: f64) {
637    el.y += dy;
638    if let DrawCommand::Text { ref mut lines, .. } = el.draw {
639        for line in lines.iter_mut() {
640            line.y += dy;
641        }
642    }
643    for child in &mut el.children {
644        offset_element_y(child, dy);
645    }
646}
647
648/// Shift a layout element and all its nested content horizontally by `dx` points.
649#[allow(dead_code)]
650fn offset_element_x(el: &mut LayoutElement, dx: f64) {
651    el.x += dx;
652    if let DrawCommand::Text { ref mut lines, .. } = el.draw {
653        for line in lines.iter_mut() {
654            line.x += dx;
655        }
656    }
657    for child in &mut el.children {
658        offset_element_x(child, dx);
659    }
660}
661
662/// After flex-grow expands an element's height, redistribute its children
663/// vertically according to its justify-content setting. Only meaningful for
664/// column containers whose height was just increased by flex-grow.
665fn reapply_justify_content(elem: &mut LayoutElement) {
666    let style = match elem.resolved_style {
667        Some(ref s) => s,
668        None => return,
669    };
670    if matches!(style.justify_content, JustifyContent::FlexStart) {
671        return;
672    }
673    if elem.children.is_empty() {
674        return;
675    }
676
677    let padding_top = style.padding.top + style.border_width.top;
678    let padding_bottom = style.padding.bottom + style.border_width.bottom;
679    let inner_h = elem.height - padding_top - padding_bottom;
680    let content_top = elem.y + padding_top;
681
682    // Find the span of children content
683    let last_child = &elem.children[elem.children.len() - 1];
684    let children_bottom = last_child.y + last_child.height;
685    let children_span = children_bottom - content_top;
686    let slack = inner_h - children_span;
687    if slack < 0.001 {
688        return;
689    }
690
691    let n = elem.children.len();
692    let offsets: Vec<f64> = match style.justify_content {
693        JustifyContent::FlexEnd => vec![slack; n],
694        JustifyContent::Center => vec![slack / 2.0; n],
695        JustifyContent::SpaceBetween => {
696            if n <= 1 {
697                vec![0.0; n]
698            } else {
699                let per_gap = slack / (n - 1) as f64;
700                (0..n).map(|i| i as f64 * per_gap).collect()
701            }
702        }
703        JustifyContent::SpaceAround => {
704            let space = slack / n as f64;
705            (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
706        }
707        JustifyContent::SpaceEvenly => {
708            let space = slack / (n + 1) as f64;
709            (0..n).map(|i| (i + 1) as f64 * space).collect()
710        }
711        JustifyContent::FlexStart => unreachable!(),
712    };
713
714    for (i, child) in elem.children.iter_mut().enumerate() {
715        let dy = offsets[i];
716        if dy.abs() > 0.001 {
717            offset_element_y(child, dy);
718        }
719    }
720}
721
722/// Apply a text transform to a string.
723fn apply_text_transform(text: &str, transform: TextTransform) -> String {
724    match transform {
725        TextTransform::None => text.to_string(),
726        TextTransform::Uppercase => text.to_uppercase(),
727        TextTransform::Lowercase => text.to_lowercase(),
728        TextTransform::Capitalize => {
729            let mut result = String::with_capacity(text.len());
730            let mut prev_is_whitespace = true;
731            for ch in text.chars() {
732                if prev_is_whitespace && ch.is_alphabetic() {
733                    for upper in ch.to_uppercase() {
734                        result.push(upper);
735                    }
736                } else {
737                    result.push(ch);
738                }
739                prev_is_whitespace = ch.is_whitespace();
740            }
741            result
742        }
743    }
744}
745
746/// Sentinel character for `{{pageNumber}}` placeholder.
747/// A single char that is atomic (can't be split by line breaking), measured
748/// as the width of "00", and recognized by the PDF serializer for replacement.
749pub const PAGE_NUMBER_SENTINEL: char = '\x02';
750
751/// Sentinel character for `{{totalPages}}` placeholder.
752pub const TOTAL_PAGES_SENTINEL: char = '\x03';
753
754/// Replace page number placeholders with single sentinel characters.
755/// The sentinels are measured as the width of "00" by the font system,
756/// are atomic (single char, so line breaking can't split them), and are
757/// replaced with actual values by the PDF serializer.
758fn substitute_page_placeholders(text: &str) -> String {
759    if text.contains("{{pageNumber}}") || text.contains("{{totalPages}}") {
760        text.replace("{{pageNumber}}", &PAGE_NUMBER_SENTINEL.to_string())
761            .replace("{{totalPages}}", &TOTAL_PAGES_SENTINEL.to_string())
762    } else {
763        text.to_string()
764    }
765}
766
767/// Apply a text transform to a single character, given whether it's the first
768/// letter of a word (for Capitalize).
769fn apply_char_transform(ch: char, transform: TextTransform, is_word_start: bool) -> char {
770    match transform {
771        TextTransform::None => ch,
772        TextTransform::Uppercase => ch.to_uppercase().next().unwrap_or(ch),
773        TextTransform::Lowercase => ch.to_lowercase().next().unwrap_or(ch),
774        TextTransform::Capitalize => {
775            if is_word_start && ch.is_alphabetic() {
776                ch.to_uppercase().next().unwrap_or(ch)
777            } else {
778                ch
779            }
780        }
781    }
782}
783
784/// The main layout engine.
785pub struct LayoutEngine {
786    text_layout: TextLayout,
787    image_dim_cache: RefCell<HashMap<String, (u32, u32)>>,
788}
789
790/// Tracks where we are on the current page during layout.
791#[derive(Debug, Clone)]
792struct PageCursor {
793    config: PageConfig,
794    content_width: f64,
795    content_height: f64,
796    y: f64,
797    elements: Vec<LayoutElement>,
798    fixed_header: Vec<(Node, f64)>,
799    fixed_footer: Vec<(Node, f64)>,
800    /// Watermark nodes stored for repetition on every page.
801    watermarks: Vec<Node>,
802    content_x: f64,
803    content_y: f64,
804    /// Extra Y offset applied on continuation pages (e.g. parent view's padding+border)
805    continuation_top_offset: f64,
806}
807
808impl PageCursor {
809    fn new(config: &PageConfig) -> Self {
810        let (page_w, page_h) = config.size.dimensions();
811        let content_width = page_w - config.margin.horizontal();
812        let content_height = page_h - config.margin.vertical();
813
814        Self {
815            config: config.clone(),
816            content_width,
817            content_height,
818            y: 0.0,
819            elements: Vec::new(),
820            fixed_header: Vec::new(),
821            fixed_footer: Vec::new(),
822            watermarks: Vec::new(),
823            content_x: config.margin.left,
824            content_y: config.margin.top,
825            continuation_top_offset: 0.0,
826        }
827    }
828
829    fn remaining_height(&self) -> f64 {
830        let footer_height: f64 = self.fixed_footer.iter().map(|(_, h)| *h).sum();
831        (self.content_height - self.y - footer_height).max(0.0)
832    }
833
834    fn finalize(&self) -> LayoutPage {
835        let (page_w, page_h) = self.config.size.dimensions();
836        LayoutPage {
837            width: page_w,
838            height: page_h,
839            elements: self.elements.clone(),
840            fixed_header: self.fixed_header.clone(),
841            fixed_footer: self.fixed_footer.clone(),
842            watermarks: self.watermarks.clone(),
843            config: self.config.clone(),
844        }
845    }
846
847    fn new_page(&self) -> Self {
848        let mut cursor = PageCursor::new(&self.config);
849        cursor.fixed_header = self.fixed_header.clone();
850        cursor.fixed_footer = self.fixed_footer.clone();
851        cursor.watermarks = self.watermarks.clone();
852        cursor.continuation_top_offset = self.continuation_top_offset;
853
854        let header_height: f64 = cursor.fixed_header.iter().map(|(_, h)| *h).sum();
855        cursor.y = header_height + cursor.continuation_top_offset;
856
857        cursor
858    }
859}
860
861impl Default for LayoutEngine {
862    fn default() -> Self {
863        Self::new()
864    }
865}
866
867impl LayoutEngine {
868    pub fn new() -> Self {
869        Self {
870            text_layout: TextLayout::new(),
871            image_dim_cache: RefCell::new(HashMap::new()),
872        }
873    }
874
875    /// Look up cached image dimensions, or load and cache them.
876    fn get_image_dimensions(&self, src: &str) -> Option<(u32, u32)> {
877        if let Some(dims) = self.image_dim_cache.borrow().get(src) {
878            return Some(*dims);
879        }
880        if let Ok(dims) = crate::image_loader::load_image_dimensions(src) {
881            self.image_dim_cache
882                .borrow_mut()
883                .insert(src.to_string(), dims);
884            Some(dims)
885        } else {
886            None
887        }
888    }
889
890    /// Main entry point: lay out a document into pages.
891    pub fn layout(&self, document: &Document, font_context: &FontContext) -> Vec<LayoutPage> {
892        let mut pages: Vec<LayoutPage> = Vec::new();
893        let mut cursor = PageCursor::new(&document.default_page);
894
895        // Build a root resolved style from document default_style + lang
896        let base = document.default_style.clone().unwrap_or_default();
897        let root_style = Style {
898            lang: base.lang.clone().or(document.metadata.lang.clone()),
899            ..base
900        }
901        .resolve(None, cursor.content_width);
902
903        for node in &document.children {
904            match &node.kind {
905                NodeKind::Page { config } => {
906                    if !cursor.elements.is_empty() || cursor.y > 0.0 {
907                        pages.push(cursor.finalize());
908                    }
909                    cursor = PageCursor::new(config);
910
911                    // Build a page-level root style that carries document lang
912                    // AND has a fixed height matching the page content area.
913                    // The fixed height ensures flex-grow page-level detection
914                    // works correctly (layout_children uses parent height).
915                    // Resolve the Page node's own style so properties like
916                    // fontFamily set on <Page style={...}> inherit to children.
917                    let mut page_root = node.style.resolve(Some(&root_style), cursor.content_width);
918                    page_root.height = SizeConstraint::Fixed(cursor.content_height);
919
920                    let cx = cursor.content_x;
921                    let cw = cursor.content_width;
922                    self.layout_children(
923                        &node.children,
924                        &node.style,
925                        &mut cursor,
926                        &mut pages,
927                        cx,
928                        cw,
929                        Some(&page_root),
930                        font_context,
931                    );
932                }
933                NodeKind::PageBreak => {
934                    pages.push(cursor.finalize());
935                    cursor = cursor.new_page();
936                }
937                _ => {
938                    let cx = cursor.content_x;
939                    let cw = cursor.content_width;
940                    self.layout_node(
941                        node,
942                        &mut cursor,
943                        &mut pages,
944                        cx,
945                        cw,
946                        Some(&root_style),
947                        font_context,
948                        None,
949                        None,
950                    );
951                }
952            }
953        }
954
955        if !cursor.elements.is_empty() || cursor.y > 0.0 {
956            pages.push(cursor.finalize());
957        }
958
959        self.inject_fixed_elements(&mut pages, font_context);
960
961        pages
962    }
963
964    #[allow(clippy::too_many_arguments)]
965    fn layout_node(
966        &self,
967        node: &Node,
968        cursor: &mut PageCursor,
969        pages: &mut Vec<LayoutPage>,
970        x: f64,
971        available_width: f64,
972        parent_style: Option<&ResolvedStyle>,
973        font_context: &FontContext,
974        cross_axis_height: Option<f64>,
975        forced_outer_width: Option<f64>,
976    ) {
977        let mut style = node.style.resolve(parent_style, available_width);
978
979        // When a flex row stretches a child, inject the cross-axis height so
980        // justify-content, flex-grow, and other height-dependent logic works.
981        if let Some(h) = cross_axis_height {
982            if matches!(style.height, SizeConstraint::Auto) {
983                style.height = SizeConstraint::Fixed(h);
984            }
985        }
986
987        // When a flex parent has already resolved this child's outer width
988        // (via flex-basis / flex-grow / flex-shrink distribution), override
989        // style.width so layout_view uses the distributed value instead of
990        // re-resolving the raw percentage against the constrained width.
991        if let Some(w) = forced_outer_width {
992            style.width = SizeConstraint::Fixed(w);
993        }
994
995        if style.break_before {
996            pages.push(cursor.finalize());
997            *cursor = cursor.new_page();
998        }
999
1000        match &node.kind {
1001            NodeKind::PageBreak => {
1002                pages.push(cursor.finalize());
1003                *cursor = cursor.new_page();
1004            }
1005
1006            NodeKind::Fixed { position } => {
1007                let height = self.measure_node_height(node, available_width, &style, font_context);
1008                match position {
1009                    FixedPosition::Header => {
1010                        cursor.fixed_header.push((node.clone(), height));
1011                        cursor.y += height;
1012                    }
1013                    FixedPosition::Footer => {
1014                        cursor.fixed_footer.push((node.clone(), height));
1015                    }
1016                }
1017            }
1018
1019            NodeKind::Watermark { .. } => {
1020                // Watermarks take zero layout height — just store on cursor for injection
1021                cursor.watermarks.push(node.clone());
1022            }
1023
1024            NodeKind::TextField {
1025                name,
1026                value,
1027                placeholder,
1028                width: field_w,
1029                height: field_h,
1030                multiline,
1031                password,
1032                read_only,
1033                max_length,
1034                font_size,
1035            } => {
1036                self.layout_form_field(
1037                    node,
1038                    &style,
1039                    cursor,
1040                    pages,
1041                    x,
1042                    *field_w,
1043                    *field_h,
1044                    DrawCommand::FormField {
1045                        field_type: FormFieldType::TextField {
1046                            value: value.clone(),
1047                            placeholder: placeholder.clone(),
1048                            multiline: *multiline,
1049                            password: *password,
1050                            read_only: *read_only,
1051                            max_length: *max_length,
1052                            font_size: *font_size,
1053                        },
1054                        name: name.clone(),
1055                    },
1056                    "TextField",
1057                );
1058            }
1059
1060            NodeKind::Checkbox {
1061                name,
1062                checked,
1063                width: field_w,
1064                height: field_h,
1065                read_only,
1066            } => {
1067                self.layout_form_field(
1068                    node,
1069                    &style,
1070                    cursor,
1071                    pages,
1072                    x,
1073                    *field_w,
1074                    *field_h,
1075                    DrawCommand::FormField {
1076                        field_type: FormFieldType::Checkbox {
1077                            checked: *checked,
1078                            read_only: *read_only,
1079                        },
1080                        name: name.clone(),
1081                    },
1082                    "Checkbox",
1083                );
1084            }
1085
1086            NodeKind::Dropdown {
1087                name,
1088                options,
1089                value,
1090                width: field_w,
1091                height: field_h,
1092                read_only,
1093                font_size,
1094            } => {
1095                self.layout_form_field(
1096                    node,
1097                    &style,
1098                    cursor,
1099                    pages,
1100                    x,
1101                    *field_w,
1102                    *field_h,
1103                    DrawCommand::FormField {
1104                        field_type: FormFieldType::Dropdown {
1105                            options: options.clone(),
1106                            value: value.clone(),
1107                            read_only: *read_only,
1108                            font_size: *font_size,
1109                        },
1110                        name: name.clone(),
1111                    },
1112                    "Dropdown",
1113                );
1114            }
1115
1116            NodeKind::RadioButton {
1117                name,
1118                value,
1119                checked,
1120                width: field_w,
1121                height: field_h,
1122                read_only,
1123            } => {
1124                self.layout_form_field(
1125                    node,
1126                    &style,
1127                    cursor,
1128                    pages,
1129                    x,
1130                    *field_w,
1131                    *field_h,
1132                    DrawCommand::FormField {
1133                        field_type: FormFieldType::RadioButton {
1134                            value: value.clone(),
1135                            checked: *checked,
1136                            read_only: *read_only,
1137                        },
1138                        name: name.clone(),
1139                    },
1140                    "RadioButton",
1141                );
1142            }
1143
1144            NodeKind::Text {
1145                content,
1146                href,
1147                runs,
1148            } => {
1149                self.layout_text(
1150                    content,
1151                    href.as_deref(),
1152                    runs,
1153                    &style,
1154                    cursor,
1155                    pages,
1156                    x,
1157                    available_width,
1158                    font_context,
1159                    node.source_location.as_ref(),
1160                    node.bookmark.as_deref(),
1161                );
1162            }
1163
1164            NodeKind::Image { width, height, .. } => {
1165                self.layout_image(
1166                    node,
1167                    &style,
1168                    cursor,
1169                    pages,
1170                    x,
1171                    available_width,
1172                    *width,
1173                    *height,
1174                );
1175            }
1176
1177            NodeKind::Table { columns } => {
1178                self.layout_table(
1179                    node,
1180                    &style,
1181                    columns,
1182                    cursor,
1183                    pages,
1184                    x,
1185                    available_width,
1186                    font_context,
1187                );
1188            }
1189
1190            NodeKind::View | NodeKind::Page { .. } => {
1191                self.layout_view(
1192                    node,
1193                    &style,
1194                    cursor,
1195                    pages,
1196                    x,
1197                    available_width,
1198                    font_context,
1199                );
1200            }
1201
1202            NodeKind::TableRow { .. } | NodeKind::TableCell { .. } => {
1203                self.layout_view(
1204                    node,
1205                    &style,
1206                    cursor,
1207                    pages,
1208                    x,
1209                    available_width,
1210                    font_context,
1211                );
1212            }
1213
1214            NodeKind::Svg {
1215                width: svg_w,
1216                height: svg_h,
1217                view_box,
1218                content,
1219            } => {
1220                self.layout_svg(
1221                    node,
1222                    &style,
1223                    cursor,
1224                    pages,
1225                    x,
1226                    available_width,
1227                    *svg_w,
1228                    *svg_h,
1229                    view_box.as_deref(),
1230                    content,
1231                );
1232            }
1233
1234            NodeKind::Barcode {
1235                data,
1236                format,
1237                width: explicit_width,
1238                height: bar_height,
1239            } => {
1240                self.layout_barcode(
1241                    node,
1242                    &style,
1243                    cursor,
1244                    pages,
1245                    x,
1246                    available_width,
1247                    data,
1248                    *format,
1249                    *explicit_width,
1250                    *bar_height,
1251                );
1252            }
1253
1254            NodeKind::QrCode {
1255                data,
1256                size: explicit_size,
1257            } => {
1258                self.layout_qrcode(
1259                    node,
1260                    &style,
1261                    cursor,
1262                    pages,
1263                    x,
1264                    available_width,
1265                    data,
1266                    *explicit_size,
1267                );
1268            }
1269
1270            NodeKind::Canvas {
1271                width: canvas_w,
1272                height: canvas_h,
1273                operations,
1274            } => {
1275                self.layout_canvas(
1276                    node,
1277                    &style,
1278                    cursor,
1279                    pages,
1280                    x,
1281                    available_width,
1282                    *canvas_w,
1283                    *canvas_h,
1284                    operations,
1285                );
1286            }
1287
1288            NodeKind::BarChart {
1289                data,
1290                width: chart_w,
1291                height: chart_h,
1292                color,
1293                show_labels,
1294                show_values,
1295                show_grid,
1296                title,
1297            } => {
1298                let config = crate::chart::bar::BarChartConfig {
1299                    color: color.clone(),
1300                    show_labels: *show_labels,
1301                    show_values: *show_values,
1302                    show_grid: *show_grid,
1303                    title: title.clone(),
1304                };
1305                let primitives = crate::chart::bar::build(*chart_w, *chart_h, data, &config);
1306                self.layout_chart(
1307                    node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "BarChart",
1308                );
1309            }
1310
1311            NodeKind::LineChart {
1312                series,
1313                labels,
1314                width: chart_w,
1315                height: chart_h,
1316                show_points,
1317                show_grid,
1318                title,
1319            } => {
1320                let config = crate::chart::line::LineChartConfig {
1321                    show_points: *show_points,
1322                    show_grid: *show_grid,
1323                    title: title.clone(),
1324                };
1325                let primitives =
1326                    crate::chart::line::build(*chart_w, *chart_h, series, labels, &config);
1327                self.layout_chart(
1328                    node,
1329                    &style,
1330                    cursor,
1331                    pages,
1332                    x,
1333                    *chart_w,
1334                    *chart_h,
1335                    primitives,
1336                    "LineChart",
1337                );
1338            }
1339
1340            NodeKind::PieChart {
1341                data,
1342                width: chart_w,
1343                height: chart_h,
1344                donut,
1345                show_legend,
1346                title,
1347            } => {
1348                let config = crate::chart::pie::PieChartConfig {
1349                    donut: *donut,
1350                    show_legend: *show_legend,
1351                    title: title.clone(),
1352                };
1353                let primitives = crate::chart::pie::build(*chart_w, *chart_h, data, &config);
1354                self.layout_chart(
1355                    node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "PieChart",
1356                );
1357            }
1358
1359            NodeKind::AreaChart {
1360                series,
1361                labels,
1362                width: chart_w,
1363                height: chart_h,
1364                show_grid,
1365                title,
1366            } => {
1367                let config = crate::chart::area::AreaChartConfig {
1368                    show_grid: *show_grid,
1369                    title: title.clone(),
1370                };
1371                let primitives =
1372                    crate::chart::area::build(*chart_w, *chart_h, series, labels, &config);
1373                self.layout_chart(
1374                    node,
1375                    &style,
1376                    cursor,
1377                    pages,
1378                    x,
1379                    *chart_w,
1380                    *chart_h,
1381                    primitives,
1382                    "AreaChart",
1383                );
1384            }
1385
1386            NodeKind::DotPlot {
1387                groups,
1388                width: chart_w,
1389                height: chart_h,
1390                x_min,
1391                x_max,
1392                y_min,
1393                y_max,
1394                x_label,
1395                y_label,
1396                show_legend,
1397                dot_size,
1398            } => {
1399                let config = crate::chart::dot::DotPlotConfig {
1400                    x_min: *x_min,
1401                    x_max: *x_max,
1402                    y_min: *y_min,
1403                    y_max: *y_max,
1404                    x_label: x_label.clone(),
1405                    y_label: y_label.clone(),
1406                    show_legend: *show_legend,
1407                    dot_size: *dot_size,
1408                };
1409                let primitives = crate::chart::dot::build(*chart_w, *chart_h, groups, &config);
1410                self.layout_chart(
1411                    node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "DotPlot",
1412                );
1413            }
1414        }
1415    }
1416
1417    #[allow(clippy::too_many_arguments)]
1418    fn layout_view(
1419        &self,
1420        node: &Node,
1421        style: &ResolvedStyle,
1422        cursor: &mut PageCursor,
1423        pages: &mut Vec<LayoutPage>,
1424        x: f64,
1425        available_width: f64,
1426        font_context: &FontContext,
1427    ) {
1428        let padding = &style.padding;
1429        let margin = &style.margin.to_edges();
1430        let border = &style.border_width;
1431
1432        let outer_width = match style.width {
1433            SizeConstraint::Fixed(w) => w,
1434            SizeConstraint::Auto => available_width - margin.horizontal(),
1435        };
1436        let inner_width = outer_width - padding.horizontal() - border.horizontal();
1437
1438        let children_height =
1439            self.measure_children_height(&node.children, inner_width, style, font_context);
1440        let total_height = match style.height {
1441            SizeConstraint::Fixed(h) => h,
1442            SizeConstraint::Auto => children_height + padding.vertical() + border.vertical(),
1443        };
1444
1445        let node_x = x + margin.left;
1446
1447        let fits = total_height <= cursor.remaining_height() - margin.vertical();
1448
1449        if fits || !style.breakable {
1450            if !fits && !style.breakable {
1451                pages.push(cursor.finalize());
1452                *cursor = cursor.new_page();
1453            }
1454
1455            // Snapshot-and-collect: lay out children first, then wrap in parent
1456            let rect_y = cursor.content_y + cursor.y + margin.top;
1457            let snapshot = cursor.elements.len();
1458
1459            let saved_y = cursor.y;
1460            cursor.y += margin.top + padding.top + border.top;
1461
1462            let children_x = node_x + padding.left + border.left;
1463            let is_grid =
1464                matches!(style.display, Display::Grid) && style.grid_template_columns.is_some();
1465            if is_grid {
1466                self.layout_grid_children(
1467                    &node.children,
1468                    style,
1469                    cursor,
1470                    pages,
1471                    children_x,
1472                    inner_width,
1473                    font_context,
1474                );
1475            } else {
1476                self.layout_children(
1477                    &node.children,
1478                    &node.style,
1479                    cursor,
1480                    pages,
1481                    children_x,
1482                    inner_width,
1483                    Some(style),
1484                    font_context,
1485                );
1486            }
1487
1488            // Collect child elements that were pushed during layout
1489            let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
1490
1491            let rect_element = LayoutElement {
1492                x: node_x,
1493                y: rect_y,
1494                width: outer_width,
1495                height: total_height,
1496                draw: DrawCommand::Rect {
1497                    background: style.background_color,
1498                    border_width: style.border_width,
1499                    border_color: style.border_color,
1500                    border_radius: style.border_radius,
1501                    opacity: 1.0,
1502                    box_shadow: style.box_shadow.map(Box::new),
1503                    background_gradient: style.background.clone().map(Box::new),
1504                },
1505                children: child_elements,
1506                node_type: Some(node_kind_name(&node.kind).to_string()),
1507                resolved_style: Some(style.clone()),
1508                source_location: node.source_location.clone(),
1509                href: node.href.clone(),
1510                bookmark: node.bookmark.clone(),
1511                alt: None,
1512                is_header_row: false,
1513                overflow: style.overflow,
1514                opacity: style.opacity,
1515            };
1516            cursor.elements.push(rect_element);
1517
1518            cursor.y = saved_y + total_height + margin.vertical();
1519        } else {
1520            self.layout_breakable_view(
1521                node,
1522                style,
1523                cursor,
1524                pages,
1525                node_x,
1526                outer_width,
1527                inner_width,
1528                font_context,
1529            );
1530        }
1531    }
1532
1533    #[allow(clippy::too_many_arguments)]
1534    fn layout_breakable_view(
1535        &self,
1536        node: &Node,
1537        style: &ResolvedStyle,
1538        cursor: &mut PageCursor,
1539        pages: &mut Vec<LayoutPage>,
1540        node_x: f64,
1541        outer_width: f64,
1542        inner_width: f64,
1543        font_context: &FontContext,
1544    ) {
1545        let padding = &style.padding;
1546        let border = &style.border_width;
1547        let margin = &style.margin.to_edges();
1548
1549        // Save state before child layout for page-break detection
1550        let initial_page_count = pages.len();
1551        let snapshot = cursor.elements.len();
1552        let rect_start_y = cursor.content_y + cursor.y + margin.top;
1553
1554        cursor.y += margin.top + padding.top + border.top;
1555        let prev_continuation_offset = cursor.continuation_top_offset;
1556        cursor.continuation_top_offset = padding.top + border.top;
1557
1558        // Emit a zero-height marker element so the bookmark gets into the PDF outline
1559        if node.bookmark.is_some() {
1560            cursor.elements.push(LayoutElement {
1561                x: node_x,
1562                y: cursor.content_y + cursor.y,
1563                width: 0.0,
1564                height: 0.0,
1565                draw: DrawCommand::None,
1566                children: vec![],
1567                node_type: None,
1568                resolved_style: None,
1569                source_location: None,
1570                href: None,
1571                bookmark: node.bookmark.clone(),
1572                alt: None,
1573                is_header_row: false,
1574                overflow: Overflow::default(),
1575                opacity: 1.0,
1576            });
1577        }
1578
1579        let children_x = node_x + padding.left + border.left;
1580        let is_grid =
1581            matches!(style.display, Display::Grid) && style.grid_template_columns.is_some();
1582        if is_grid {
1583            self.layout_grid_children(
1584                &node.children,
1585                style,
1586                cursor,
1587                pages,
1588                children_x,
1589                inner_width,
1590                font_context,
1591            );
1592        } else {
1593            self.layout_children(
1594                &node.children,
1595                &node.style,
1596                cursor,
1597                pages,
1598                children_x,
1599                inner_width,
1600                Some(style),
1601                font_context,
1602            );
1603        }
1604
1605        cursor.continuation_top_offset = prev_continuation_offset;
1606
1607        // Check if this view has any visual styling worth wrapping
1608        let has_visual = style.background_color.is_some()
1609            || style.border_width.top > 0.0
1610            || style.border_width.right > 0.0
1611            || style.border_width.bottom > 0.0
1612            || style.border_width.left > 0.0;
1613        // Also wrap when flex_grow > 0 so the flex-grow code finds a proper wrapper element
1614        let needs_wrapper = has_visual || style.flex_grow > 0.0;
1615
1616        if !needs_wrapper {
1617            // No visual styling and no flex-grow — skip wrapping
1618            cursor.y += padding.bottom + border.bottom + margin.bottom;
1619            return;
1620        }
1621
1622        let draw_cmd = DrawCommand::Rect {
1623            background: style.background_color,
1624            border_width: style.border_width,
1625            border_color: style.border_color,
1626            border_radius: style.border_radius,
1627            opacity: 1.0,
1628            box_shadow: style.box_shadow.map(Box::new),
1629            background_gradient: style.background.clone().map(Box::new),
1630        };
1631
1632        if pages.len() == initial_page_count {
1633            // No page breaks: simple wrap (same as non-breakable path)
1634            let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
1635            let rect_height =
1636                cursor.content_y + cursor.y + padding.bottom + border.bottom - rect_start_y;
1637            cursor.elements.push(LayoutElement {
1638                x: node_x,
1639                y: rect_start_y,
1640                width: outer_width,
1641                height: rect_height,
1642                draw: draw_cmd,
1643                children: child_elements,
1644                node_type: Some(node_kind_name(&node.kind).to_string()),
1645                resolved_style: Some(style.clone()),
1646                source_location: node.source_location.clone(),
1647                href: node.href.clone(),
1648                bookmark: node.bookmark.clone(),
1649                alt: None,
1650                is_header_row: false,
1651                overflow: style.overflow,
1652                opacity: style.opacity,
1653            });
1654        } else {
1655            // Page breaks occurred: wrap elements on each page with clone semantics
1656
1657            // A. First page — wrap elements from snapshot onward
1658            let page = &mut pages[initial_page_count];
1659            let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
1660            let page_content_bottom =
1661                page.config.margin.top + (page.height - page.config.margin.vertical()) - footer_h;
1662            let our_elements: Vec<LayoutElement> = page.elements.drain(snapshot..).collect();
1663            if !our_elements.is_empty() {
1664                let rect_height = page_content_bottom - rect_start_y;
1665                page.elements.push(LayoutElement {
1666                    x: node_x,
1667                    y: rect_start_y,
1668                    width: outer_width,
1669                    height: rect_height,
1670                    draw: draw_cmd.clone(),
1671                    children: our_elements,
1672                    node_type: Some(node_kind_name(&node.kind).to_string()),
1673                    resolved_style: Some(style.clone()),
1674                    source_location: node.source_location.clone(),
1675                    href: node.href.clone(),
1676                    bookmark: node.bookmark.clone(),
1677                    alt: None,
1678                    is_header_row: false,
1679                    overflow: Overflow::default(),
1680                    opacity: 1.0,
1681                });
1682            }
1683
1684            // B. Intermediate pages — wrap ALL elements
1685            for page in &mut pages[initial_page_count + 1..] {
1686                let header_h: f64 = page.fixed_header.iter().map(|(_, h)| *h).sum();
1687                let content_top = page.config.margin.top + header_h;
1688                let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
1689                let content_bottom = page.config.margin.top
1690                    + (page.height - page.config.margin.vertical())
1691                    - footer_h;
1692                let all_elements: Vec<LayoutElement> = page.elements.drain(..).collect();
1693                if !all_elements.is_empty() {
1694                    page.elements.push(LayoutElement {
1695                        x: node_x,
1696                        y: content_top,
1697                        width: outer_width,
1698                        height: content_bottom - content_top,
1699                        draw: draw_cmd.clone(),
1700                        children: all_elements,
1701                        node_type: Some(node_kind_name(&node.kind).to_string()),
1702                        resolved_style: Some(style.clone()),
1703                        source_location: node.source_location.clone(),
1704                        href: None,
1705                        bookmark: None,
1706                        alt: None,
1707                        is_header_row: false,
1708                        overflow: Overflow::default(),
1709                        opacity: 1.0,
1710                    });
1711                }
1712            }
1713
1714            // C. Current page (cursor.elements) — wrap ALL elements
1715            let all_elements: Vec<LayoutElement> = cursor.elements.drain(..).collect();
1716            if !all_elements.is_empty() {
1717                let header_h: f64 = cursor.fixed_header.iter().map(|(_, h)| *h).sum();
1718                let content_top = cursor.content_y + header_h;
1719                let rect_height =
1720                    cursor.content_y + cursor.y + padding.bottom + border.bottom - content_top;
1721                cursor.elements.push(LayoutElement {
1722                    x: node_x,
1723                    y: content_top,
1724                    width: outer_width,
1725                    height: rect_height,
1726                    draw: draw_cmd,
1727                    children: all_elements,
1728                    node_type: Some(node_kind_name(&node.kind).to_string()),
1729                    resolved_style: Some(style.clone()),
1730                    source_location: node.source_location.clone(),
1731                    href: None,
1732                    bookmark: None,
1733                    alt: None,
1734                    is_header_row: false,
1735                    overflow: Overflow::default(),
1736                    opacity: 1.0,
1737                });
1738            }
1739        }
1740
1741        cursor.y += padding.bottom + border.bottom + margin.bottom;
1742    }
1743
1744    #[allow(clippy::too_many_arguments)]
1745    fn layout_children(
1746        &self,
1747        children: &[Node],
1748        _parent_raw_style: &Style,
1749        cursor: &mut PageCursor,
1750        pages: &mut Vec<LayoutPage>,
1751        content_x: f64,
1752        available_width: f64,
1753        parent_style: Option<&ResolvedStyle>,
1754        font_context: &FontContext,
1755    ) {
1756        // Save parent content box position for absolute children
1757        let parent_box_y = cursor.content_y + cursor.y;
1758        let parent_box_x = content_x;
1759
1760        // Separate absolute vs flow children
1761        let (flow_children, abs_children): (Vec<&Node>, Vec<&Node>) = children
1762            .iter()
1763            .partition(|child| !matches!(child.style.position, Some(Position::Absolute)));
1764
1765        let direction = parent_style
1766            .map(|s| s.flex_direction)
1767            .unwrap_or(FlexDirection::Column);
1768
1769        let row_gap = parent_style.map(|s| s.row_gap).unwrap_or(0.0);
1770        let column_gap = parent_style.map(|s| s.column_gap).unwrap_or(0.0);
1771
1772        // First pass: flow children
1773        match direction {
1774            FlexDirection::Column | FlexDirection::ColumnReverse => {
1775                let items: Vec<&Node> = if matches!(direction, FlexDirection::ColumnReverse) {
1776                    flow_children.into_iter().rev().collect()
1777                } else {
1778                    flow_children
1779                };
1780
1781                let justify = parent_style
1782                    .map(|s| s.justify_content)
1783                    .unwrap_or(JustifyContent::FlexStart);
1784                let align = parent_style
1785                    .map(|s| s.align_items)
1786                    .unwrap_or(AlignItems::Stretch);
1787
1788                let start_y = cursor.y;
1789                let initial_pages = pages.len();
1790
1791                // Track each child's element range for align-items adjustment
1792                let mut child_ranges: Vec<(usize, usize)> = Vec::new();
1793
1794                for (i, child) in items.iter().enumerate() {
1795                    if i > 0 {
1796                        cursor.y += row_gap;
1797                    }
1798                    let child_start = cursor.elements.len();
1799
1800                    // Auto margins take priority over align-items for cross-axis positioning.
1801                    // For column flex, horizontal auto margins center or push the child.
1802                    let child_margin = &child.style.resolve(parent_style, available_width).margin;
1803                    let has_auto_h = child_margin.has_auto_horizontal();
1804
1805                    // For align-items Center/FlexEnd, measure child width and adjust x.
1806                    // Returns (child_x, layout_width): layout_width is what we pass
1807                    // to layout_node. For Fixed-width children (incl. percentage),
1808                    // we pass available_width so percentages re-resolve correctly.
1809                    // For Auto-width children, we pass the intrinsic width so they
1810                    // don't stretch to fill the parent.
1811                    let (child_x, layout_w) = if has_auto_h {
1812                        let child_style = child.style.resolve(parent_style, available_width);
1813                        let has_explicit_width =
1814                            matches!(child_style.width, SizeConstraint::Fixed(_));
1815                        let intrinsic = self
1816                            .measure_intrinsic_width(child, &child_style, font_context)
1817                            .min(available_width);
1818                        let w = match child_style.width {
1819                            SizeConstraint::Fixed(fw) => fw,
1820                            SizeConstraint::Auto => intrinsic,
1821                        };
1822                        let lw = if has_explicit_width {
1823                            available_width
1824                        } else {
1825                            w
1826                        };
1827                        let fixed_h = child_margin.horizontal();
1828                        let slack = (available_width - w - fixed_h).max(0.0);
1829                        let auto_left = child_margin.left.is_auto();
1830                        let auto_right = child_margin.right.is_auto();
1831                        let ml = match (auto_left, auto_right) {
1832                            (true, true) => slack / 2.0,
1833                            (true, false) => slack,
1834                            (false, true) => 0.0,
1835                            (false, false) => 0.0,
1836                        };
1837                        (content_x + child_margin.left.resolve() + ml, lw)
1838                    } else if !matches!(align, AlignItems::Stretch | AlignItems::FlexStart) {
1839                        let child_style = child.style.resolve(parent_style, available_width);
1840                        let has_explicit_width =
1841                            matches!(child_style.width, SizeConstraint::Fixed(_));
1842                        let intrinsic = self
1843                            .measure_intrinsic_width(child, &child_style, font_context)
1844                            .min(available_width);
1845                        let w = match child_style.width {
1846                            SizeConstraint::Fixed(fw) => fw,
1847                            SizeConstraint::Auto => intrinsic,
1848                        };
1849                        let lw = if has_explicit_width {
1850                            available_width
1851                        } else {
1852                            w
1853                        };
1854                        match align {
1855                            AlignItems::Center => (content_x + (available_width - w) / 2.0, lw),
1856                            AlignItems::FlexEnd => (content_x + available_width - w, lw),
1857                            _ => (content_x, available_width),
1858                        }
1859                    } else {
1860                        (content_x, available_width)
1861                    };
1862
1863                    self.layout_node(
1864                        child,
1865                        cursor,
1866                        pages,
1867                        child_x,
1868                        layout_w,
1869                        parent_style,
1870                        font_context,
1871                        None,
1872                        None,
1873                    );
1874
1875                    child_ranges.push((child_start, cursor.elements.len()));
1876                }
1877
1878                // flex-grow: distribute extra vertical space proportionally
1879                // Compute container inner height from parent style or page content area
1880                let container_inner_h: Option<f64> = parent_style
1881                    .and_then(|ps| match ps.height {
1882                        SizeConstraint::Fixed(h) => {
1883                            Some(h - ps.padding.vertical() - ps.border_width.vertical())
1884                        }
1885                        SizeConstraint::Auto => None,
1886                    })
1887                    .or_else(|| {
1888                        // Page-level: use remaining content height from start
1889                        if parent_style.is_none() {
1890                            Some(cursor.content_height - start_y)
1891                        } else {
1892                            None
1893                        }
1894                    });
1895
1896                if let Some(inner_h) = container_inner_h {
1897                    if pages.len() == initial_pages {
1898                        let child_styles: Vec<ResolvedStyle> = items
1899                            .iter()
1900                            .map(|child| child.style.resolve(parent_style, available_width))
1901                            .collect();
1902                        let total_grow: f64 = child_styles.iter().map(|s| s.flex_grow).sum();
1903                        if total_grow > 0.0 {
1904                            let children_total = cursor.y - start_y;
1905                            let slack = (inner_h - children_total).max(0.0);
1906                            if slack > 0.0 {
1907                                let mut cumulative_shift = 0.0_f64;
1908                                for (i, cs) in child_styles.iter().enumerate() {
1909                                    let (start, end) = child_ranges[i];
1910                                    if cumulative_shift > 0.001 {
1911                                        for j in start..end {
1912                                            offset_element_y(
1913                                                &mut cursor.elements[j],
1914                                                cumulative_shift,
1915                                            );
1916                                        }
1917                                    }
1918                                    if cs.flex_grow > 0.0 {
1919                                        let extra = slack * (cs.flex_grow / total_grow);
1920                                        // Expand the container element's height
1921                                        if start < end {
1922                                            let elem = &mut cursor.elements[end - 1];
1923                                            elem.height += extra;
1924                                            reapply_justify_content(elem);
1925                                        }
1926                                        cumulative_shift += extra;
1927                                    }
1928                                }
1929                                cursor.y += cumulative_shift;
1930                            }
1931                        }
1932                    }
1933                }
1934
1935                // justify-content: redistribute children vertically when parent has fixed height
1936                let needs_justify =
1937                    !matches!(justify, JustifyContent::FlexStart) && pages.len() == initial_pages;
1938                if needs_justify {
1939                    // Use container_inner_h if available, otherwise compute from parent style
1940                    let justify_inner_h = container_inner_h.or_else(|| {
1941                        parent_style.and_then(|ps| match ps.height {
1942                            SizeConstraint::Fixed(h) => {
1943                                Some(h - ps.padding.vertical() - ps.border_width.vertical())
1944                            }
1945                            SizeConstraint::Auto => None,
1946                        })
1947                    });
1948                    if let Some(inner_h) = justify_inner_h {
1949                        let children_total = cursor.y - start_y;
1950                        let slack = inner_h - children_total;
1951                        if slack > 0.0 {
1952                            let n = child_ranges.len();
1953                            let offsets: Vec<f64> = match justify {
1954                                JustifyContent::FlexEnd => vec![slack; n],
1955                                JustifyContent::Center => vec![slack / 2.0; n],
1956                                JustifyContent::SpaceBetween => {
1957                                    if n <= 1 {
1958                                        vec![0.0; n]
1959                                    } else {
1960                                        let per_gap = slack / (n - 1) as f64;
1961                                        (0..n).map(|i| i as f64 * per_gap).collect()
1962                                    }
1963                                }
1964                                JustifyContent::SpaceAround => {
1965                                    let space = slack / n as f64;
1966                                    (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
1967                                }
1968                                JustifyContent::SpaceEvenly => {
1969                                    let space = slack / (n + 1) as f64;
1970                                    (0..n).map(|i| (i + 1) as f64 * space).collect()
1971                                }
1972                                JustifyContent::FlexStart => vec![0.0; n],
1973                            };
1974                            for (i, &(start, end)) in child_ranges.iter().enumerate() {
1975                                let dy = offsets[i];
1976                                if dy.abs() > 0.001 {
1977                                    for j in start..end {
1978                                        offset_element_y(&mut cursor.elements[j], dy);
1979                                    }
1980                                }
1981                            }
1982                            cursor.y += *offsets.last().unwrap_or(&0.0);
1983                        }
1984                    }
1985                }
1986            }
1987
1988            FlexDirection::Row | FlexDirection::RowReverse => {
1989                let flow_owned: Vec<Node> = flow_children.into_iter().cloned().collect();
1990                self.layout_flex_row(
1991                    &flow_owned,
1992                    cursor,
1993                    pages,
1994                    content_x,
1995                    available_width,
1996                    parent_style,
1997                    column_gap,
1998                    row_gap,
1999                    font_context,
2000                );
2001            }
2002        }
2003
2004        // Second pass: absolute children
2005        for abs_child in &abs_children {
2006            let abs_style = abs_child.style.resolve(parent_style, available_width);
2007
2008            // Measure intrinsic size
2009            let child_width = match abs_style.width {
2010                SizeConstraint::Fixed(w) => w,
2011                SizeConstraint::Auto => {
2012                    // If both left and right are set, stretch width
2013                    if let (Some(l), Some(r)) = (abs_style.left, abs_style.right) {
2014                        (available_width - l - r).max(0.0)
2015                    } else {
2016                        self.measure_intrinsic_width(abs_child, &abs_style, font_context)
2017                    }
2018                }
2019            };
2020
2021            let child_height = match abs_style.height {
2022                SizeConstraint::Fixed(h) => h,
2023                SizeConstraint::Auto => {
2024                    self.measure_node_height(abs_child, child_width, &abs_style, font_context)
2025                }
2026            };
2027
2028            // Determine position relative to parent content box
2029            let abs_x = if let Some(l) = abs_style.left {
2030                parent_box_x + l
2031            } else if let Some(r) = abs_style.right {
2032                parent_box_x + available_width - r - child_width
2033            } else {
2034                parent_box_x
2035            };
2036
2037            // Compute parent inner height for bottom positioning
2038            let parent_inner_height = parent_style
2039                .and_then(|ps| match ps.height {
2040                    SizeConstraint::Fixed(h) => {
2041                        Some(h - ps.padding.vertical() - ps.border_width.vertical())
2042                    }
2043                    SizeConstraint::Auto => None,
2044                })
2045                .unwrap_or(cursor.content_y + cursor.y - parent_box_y);
2046
2047            let abs_y = if let Some(t) = abs_style.top {
2048                parent_box_y + t
2049            } else if let Some(b) = abs_style.bottom {
2050                parent_box_y + parent_inner_height - b - child_height
2051            } else {
2052                parent_box_y
2053            };
2054
2055            // Lay out the absolute child into a temporary cursor
2056            let mut abs_cursor = PageCursor::new(&cursor.config);
2057            abs_cursor.y = 0.0;
2058            abs_cursor.content_x = abs_x;
2059            abs_cursor.content_y = abs_y;
2060
2061            self.layout_node(
2062                abs_child,
2063                &mut abs_cursor,
2064                &mut Vec::new(),
2065                abs_x,
2066                child_width,
2067                parent_style,
2068                font_context,
2069                None,
2070                None,
2071            );
2072
2073            // Add absolute elements to the current cursor (renders on top)
2074            cursor.elements.extend(abs_cursor.elements);
2075        }
2076    }
2077
2078    #[allow(clippy::too_many_arguments)]
2079    fn layout_flex_row(
2080        &self,
2081        children: &[Node],
2082        cursor: &mut PageCursor,
2083        pages: &mut Vec<LayoutPage>,
2084        content_x: f64,
2085        available_width: f64,
2086        parent_style: Option<&ResolvedStyle>,
2087        column_gap: f64,
2088        row_gap: f64,
2089        font_context: &FontContext,
2090    ) {
2091        if children.is_empty() {
2092            return;
2093        }
2094
2095        let flex_wrap = parent_style
2096            .map(|s| s.flex_wrap)
2097            .unwrap_or(FlexWrap::NoWrap);
2098
2099        // Phase 1: resolve styles and measure base widths for all items
2100        // flex_basis takes precedence over width for flex items (per CSS spec)
2101        let items: Vec<FlexItem> = children
2102            .iter()
2103            .map(|child| {
2104                let style = child.style.resolve(parent_style, available_width);
2105                let base_width = match style.flex_basis {
2106                    SizeConstraint::Fixed(w) => w,
2107                    SizeConstraint::Auto => match style.width {
2108                        SizeConstraint::Fixed(w) => w,
2109                        SizeConstraint::Auto => {
2110                            self.measure_intrinsic_width(child, &style, font_context)
2111                        }
2112                    },
2113                };
2114                let min_content_width = self.measure_min_content_width(child, &style, font_context);
2115                FlexItem {
2116                    node: child,
2117                    style,
2118                    base_width,
2119                    min_content_width,
2120                }
2121            })
2122            .collect();
2123
2124        // Phase 2: determine wrap lines
2125        let base_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
2126        let lines = match flex_wrap {
2127            FlexWrap::NoWrap => {
2128                vec![flex::WrapLine {
2129                    start: 0,
2130                    end: items.len(),
2131                }]
2132            }
2133            FlexWrap::Wrap => flex::partition_into_lines(&base_widths, column_gap, available_width),
2134            FlexWrap::WrapReverse => {
2135                let mut l = flex::partition_into_lines(&base_widths, column_gap, available_width);
2136                l.reverse();
2137                l
2138            }
2139        };
2140
2141        if lines.is_empty() {
2142            return;
2143        }
2144
2145        // Phase 3: lay out each line
2146        let justify = parent_style.map(|s| s.justify_content).unwrap_or_default();
2147
2148        // We need mutable final_widths per line, so collect into a vec
2149        let mut final_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
2150
2151        let initial_pages_count = pages.len();
2152        let flex_start_y = cursor.y;
2153        let mut line_infos: Vec<(usize, usize, f64)> = Vec::new();
2154
2155        for (line_idx, line) in lines.iter().enumerate() {
2156            let line_items = &items[line.start..line.end];
2157            let line_count = line.end - line.start;
2158            let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
2159            let distributable = available_width - line_gap;
2160
2161            // Flex distribution for this line
2162            let total_base: f64 = line_items.iter().map(|i| i.base_width).sum();
2163            let remaining = distributable - total_base;
2164
2165            if remaining > 0.0 {
2166                let total_grow: f64 = line_items.iter().map(|i| i.style.flex_grow).sum();
2167                if total_grow > 0.0 {
2168                    for (j, item) in line_items.iter().enumerate() {
2169                        final_widths[line.start + j] =
2170                            item.base_width + remaining * (item.style.flex_grow / total_grow);
2171                    }
2172                }
2173            } else if remaining < 0.0 {
2174                let total_shrink: f64 = line_items
2175                    .iter()
2176                    .map(|i| i.style.flex_shrink * i.base_width)
2177                    .sum();
2178                if total_shrink > 0.0 {
2179                    for (j, item) in line_items.iter().enumerate() {
2180                        let factor = (item.style.flex_shrink * item.base_width) / total_shrink;
2181                        let w = item.base_width + remaining * factor;
2182                        let floor = item.style.min_width.max(item.min_content_width);
2183                        final_widths[line.start + j] = w.max(floor);
2184                    }
2185                }
2186            }
2187
2188            // Measure line height
2189            let line_height: f64 = line_items
2190                .iter()
2191                .enumerate()
2192                .map(|(j, item)| {
2193                    let fw = final_widths[line.start + j];
2194                    self.measure_node_height(item.node, fw, &item.style, font_context)
2195                        + item.style.margin.vertical()
2196                })
2197                .fold(0.0f64, f64::max);
2198
2199            // Page break check for this line
2200            if line_height > cursor.remaining_height() {
2201                pages.push(cursor.finalize());
2202                *cursor = cursor.new_page();
2203            }
2204
2205            // Add row_gap between lines (not before first)
2206            if line_idx > 0 {
2207                cursor.y += row_gap;
2208            }
2209
2210            let row_start_y = cursor.y;
2211
2212            // Justify-content for this line
2213            let actual_total: f64 = (line.start..line.end).map(|i| final_widths[i]).sum();
2214            let slack = available_width - actual_total - line_gap;
2215
2216            let (start_offset, between_extra) = match justify {
2217                JustifyContent::FlexStart => (0.0, 0.0),
2218                JustifyContent::FlexEnd => (slack, 0.0),
2219                JustifyContent::Center => (slack / 2.0, 0.0),
2220                JustifyContent::SpaceBetween => {
2221                    if line_count > 1 {
2222                        (0.0, slack / (line_count as f64 - 1.0))
2223                    } else {
2224                        (0.0, 0.0)
2225                    }
2226                }
2227                JustifyContent::SpaceAround => {
2228                    let s = slack / line_count as f64;
2229                    (s / 2.0, s)
2230                }
2231                JustifyContent::SpaceEvenly => {
2232                    let s = slack / (line_count as f64 + 1.0);
2233                    (s, s)
2234                }
2235            };
2236
2237            let line_elem_start = cursor.elements.len();
2238            let mut x = content_x + start_offset;
2239
2240            for (j, item) in line_items.iter().enumerate() {
2241                if j > 0 {
2242                    x += column_gap + between_extra;
2243                }
2244
2245                let fw = final_widths[line.start + j];
2246
2247                let align = item
2248                    .style
2249                    .align_self
2250                    .unwrap_or(parent_style.map(|s| s.align_items).unwrap_or_default());
2251
2252                let item_height =
2253                    self.measure_node_height(item.node, fw, &item.style, font_context);
2254
2255                // Auto margins on cross axis take priority over align-items
2256                let has_auto_v = item.style.margin.has_auto_vertical();
2257                let y_offset = if has_auto_v {
2258                    let fixed_v = item.style.margin.vertical();
2259                    let slack = (line_height - item_height - fixed_v).max(0.0);
2260                    let auto_top = item.style.margin.top.is_auto();
2261                    let auto_bottom = item.style.margin.bottom.is_auto();
2262                    match (auto_top, auto_bottom) {
2263                        (true, true) => slack / 2.0,
2264                        (true, false) => slack,
2265                        (false, true) => 0.0,
2266                        (false, false) => 0.0,
2267                    }
2268                } else {
2269                    match align {
2270                        AlignItems::FlexStart => 0.0,
2271                        AlignItems::FlexEnd => {
2272                            line_height - item_height - item.style.margin.vertical()
2273                        }
2274                        AlignItems::Center => {
2275                            (line_height - item_height - item.style.margin.vertical()) / 2.0
2276                        }
2277                        AlignItems::Stretch => 0.0,
2278                        AlignItems::Baseline => 0.0,
2279                    }
2280                };
2281
2282                // When stretch applies and item has no explicit height, pass
2283                // the cross-axis height so inner layout sees a fixed container.
2284                // Auto margins prevent stretch.
2285                let cross_h = if matches!(align, AlignItems::Stretch)
2286                    && matches!(item.style.height, SizeConstraint::Auto)
2287                    && !has_auto_v
2288                {
2289                    let stretch_h = line_height - item.style.margin.vertical();
2290                    if stretch_h > item_height {
2291                        Some(stretch_h)
2292                    } else {
2293                        None
2294                    }
2295                } else {
2296                    None
2297                };
2298
2299                let saved_y = cursor.y;
2300                cursor.y = row_start_y + y_offset;
2301
2302                self.layout_node(
2303                    item.node,
2304                    cursor,
2305                    pages,
2306                    x,
2307                    available_width,
2308                    parent_style,
2309                    font_context,
2310                    cross_h,
2311                    Some(fw),
2312                );
2313
2314                cursor.y = saved_y;
2315                x += fw;
2316            }
2317
2318            cursor.y = row_start_y + line_height;
2319            line_infos.push((line_elem_start, cursor.elements.len(), line_height));
2320        }
2321
2322        // Apply align-content redistribution for wrapped flex lines
2323        if pages.len() == initial_pages_count && !line_infos.is_empty() {
2324            let align_content = parent_style.map(|s| s.align_content).unwrap_or_default();
2325            if !matches!(align_content, AlignContent::FlexStart)
2326                && !matches!(flex_wrap, FlexWrap::NoWrap)
2327            {
2328                if let Some(parent) = parent_style {
2329                    if let SizeConstraint::Fixed(container_h) = parent.height {
2330                        let inner_h = container_h
2331                            - parent.padding.vertical()
2332                            - parent.border_width.vertical();
2333                        let total_used = cursor.y - flex_start_y;
2334                        let slack = inner_h - total_used;
2335                        if slack > 0.0 {
2336                            let n = line_infos.len();
2337                            let offsets: Vec<f64> = match align_content {
2338                                AlignContent::FlexEnd => vec![slack; n],
2339                                AlignContent::Center => vec![slack / 2.0; n],
2340                                AlignContent::SpaceBetween => {
2341                                    if n <= 1 {
2342                                        vec![0.0; n]
2343                                    } else {
2344                                        let per_gap = slack / (n - 1) as f64;
2345                                        (0..n).map(|i| i as f64 * per_gap).collect()
2346                                    }
2347                                }
2348                                AlignContent::SpaceAround => {
2349                                    let space = slack / n as f64;
2350                                    (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
2351                                }
2352                                AlignContent::SpaceEvenly => {
2353                                    let space = slack / (n + 1) as f64;
2354                                    (0..n).map(|i| (i + 1) as f64 * space).collect()
2355                                }
2356                                AlignContent::Stretch => {
2357                                    let extra = slack / n as f64;
2358                                    (0..n).map(|i| i as f64 * extra).collect()
2359                                }
2360                                AlignContent::FlexStart => vec![0.0; n],
2361                            };
2362                            for (i, &(start, end, _)) in line_infos.iter().enumerate() {
2363                                let dy = offsets[i];
2364                                if dy.abs() > 0.001 {
2365                                    for j in start..end {
2366                                        offset_element_y(&mut cursor.elements[j], dy);
2367                                    }
2368                                }
2369                            }
2370                            cursor.y += *offsets.last().unwrap_or(&0.0);
2371                        }
2372                    }
2373                }
2374            }
2375        }
2376    }
2377
2378    #[allow(clippy::too_many_arguments)]
2379    fn layout_table(
2380        &self,
2381        node: &Node,
2382        style: &ResolvedStyle,
2383        column_defs: &[ColumnDef],
2384        cursor: &mut PageCursor,
2385        pages: &mut Vec<LayoutPage>,
2386        x: f64,
2387        available_width: f64,
2388        font_context: &FontContext,
2389    ) {
2390        let padding = &style.padding;
2391        let margin = &style.margin.to_edges();
2392        let border = &style.border_width;
2393
2394        let table_x = x + margin.left;
2395        let table_width = match style.width {
2396            SizeConstraint::Fixed(w) => w,
2397            SizeConstraint::Auto => available_width - margin.horizontal(),
2398        };
2399        let inner_width = table_width - padding.horizontal() - border.horizontal();
2400
2401        let col_widths = self.resolve_column_widths(column_defs, inner_width, &node.children);
2402
2403        let mut header_rows: Vec<&Node> = Vec::new();
2404        let mut body_rows: Vec<&Node> = Vec::new();
2405
2406        for child in &node.children {
2407            match &child.kind {
2408                NodeKind::TableRow { is_header: true } => header_rows.push(child),
2409                _ => body_rows.push(child),
2410            }
2411        }
2412
2413        cursor.y += margin.top + padding.top + border.top;
2414
2415        let cell_x_start = table_x + padding.left + border.left;
2416        for header_row in &header_rows {
2417            self.layout_table_row(
2418                header_row,
2419                &col_widths,
2420                style,
2421                cursor,
2422                cell_x_start,
2423                font_context,
2424                pages,
2425            );
2426        }
2427
2428        for body_row in &body_rows {
2429            let row_height =
2430                self.measure_table_row_height(body_row, &col_widths, style, font_context);
2431
2432            if row_height > cursor.remaining_height() {
2433                pages.push(cursor.finalize());
2434                *cursor = cursor.new_page();
2435
2436                cursor.y += padding.top + border.top;
2437                for header_row in &header_rows {
2438                    self.layout_table_row(
2439                        header_row,
2440                        &col_widths,
2441                        style,
2442                        cursor,
2443                        cell_x_start,
2444                        font_context,
2445                        pages,
2446                    );
2447                }
2448            }
2449
2450            self.layout_table_row(
2451                body_row,
2452                &col_widths,
2453                style,
2454                cursor,
2455                cell_x_start,
2456                font_context,
2457                pages,
2458            );
2459        }
2460
2461        cursor.y += padding.bottom + border.bottom + margin.bottom;
2462    }
2463
2464    #[allow(clippy::too_many_arguments)]
2465    fn layout_table_row(
2466        &self,
2467        row: &Node,
2468        col_widths: &[f64],
2469        parent_style: &ResolvedStyle,
2470        cursor: &mut PageCursor,
2471        start_x: f64,
2472        font_context: &FontContext,
2473        pages: &mut Vec<LayoutPage>,
2474    ) {
2475        let row_style = row
2476            .style
2477            .resolve(Some(parent_style), col_widths.iter().sum());
2478
2479        let row_height = self.measure_table_row_height(row, col_widths, parent_style, font_context);
2480        let row_y = cursor.content_y + cursor.y;
2481        let total_width: f64 = col_widths.iter().sum();
2482
2483        let is_header = matches!(row.kind, NodeKind::TableRow { is_header: true });
2484
2485        // Snapshot before laying out cells — we'll collect them as row children
2486        let row_snapshot = cursor.elements.len();
2487
2488        let mut all_overflow_pages: Vec<LayoutPage> = Vec::new();
2489        let mut cell_x = start_x;
2490        for (i, cell) in row.children.iter().enumerate() {
2491            let col_width = col_widths.get(i).copied().unwrap_or(0.0);
2492
2493            let cell_style = cell.style.resolve(Some(&row_style), col_width);
2494
2495            // Snapshot before cell content — we'll collect as cell children
2496            let cell_snapshot = cursor.elements.len();
2497
2498            let inner_width =
2499                col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
2500
2501            let content_x = cell_x + cell_style.padding.left + cell_style.border_width.left;
2502            let saved_y = cursor.y;
2503            cursor.y += cell_style.padding.top + cell_style.border_width.top;
2504
2505            // Save cursor state in case cell content triggers page breaks
2506            let cursor_before_cell = cursor.clone();
2507            let mut cell_pages: Vec<LayoutPage> = Vec::new();
2508            for child in &cell.children {
2509                self.layout_node(
2510                    child,
2511                    cursor,
2512                    &mut cell_pages,
2513                    content_x,
2514                    inner_width,
2515                    Some(&cell_style),
2516                    font_context,
2517                    None,
2518                    None,
2519                );
2520            }
2521
2522            // If cell content triggered page breaks, collect overflow and restore cursor
2523            if !cell_pages.is_empty() {
2524                let post_break_elements = std::mem::take(&mut cursor.elements);
2525                if let Some(last_page) = cell_pages.last_mut() {
2526                    last_page.elements.extend(post_break_elements);
2527                }
2528                all_overflow_pages.extend(cell_pages);
2529                *cursor = cursor_before_cell;
2530            }
2531
2532            cursor.y = saved_y;
2533
2534            // Collect cell content elements
2535            let cell_children: Vec<LayoutElement> =
2536                cursor.elements.drain(cell_snapshot..).collect();
2537
2538            // Always push a cell element (with or without visual styling) to preserve hierarchy
2539            cursor.elements.push(LayoutElement {
2540                x: cell_x,
2541                y: row_y,
2542                width: col_width,
2543                height: row_height,
2544                draw: if cell_style.background_color.is_some()
2545                    || cell_style.border_width.horizontal() > 0.0
2546                    || cell_style.border_width.vertical() > 0.0
2547                {
2548                    DrawCommand::Rect {
2549                        background: cell_style.background_color,
2550                        border_width: cell_style.border_width,
2551                        border_color: cell_style.border_color,
2552                        border_radius: cell_style.border_radius,
2553                        opacity: 1.0,
2554                        box_shadow: cell_style.box_shadow.map(Box::new),
2555                        background_gradient: cell_style.background.clone().map(Box::new),
2556                    }
2557                } else {
2558                    DrawCommand::None
2559                },
2560                children: cell_children,
2561                node_type: Some("TableCell".to_string()),
2562                resolved_style: Some(cell_style.clone()),
2563                source_location: cell.source_location.clone(),
2564                href: None,
2565                bookmark: cell.bookmark.clone(),
2566                alt: None,
2567                is_header_row: is_header,
2568                overflow: Overflow::default(),
2569                opacity: 1.0,
2570            });
2571
2572            cell_x += col_width;
2573        }
2574
2575        // Collect all cell elements as row children
2576        let row_children: Vec<LayoutElement> = cursor.elements.drain(row_snapshot..).collect();
2577        cursor.elements.push(LayoutElement {
2578            x: start_x,
2579            y: row_y,
2580            width: total_width,
2581            height: row_height,
2582            draw: if let Some(bg) = row_style.background_color {
2583                DrawCommand::Rect {
2584                    background: Some(bg),
2585                    border_width: Edges::default(),
2586                    border_color: EdgeValues::uniform(Color::BLACK),
2587                    border_radius: CornerValues::uniform(0.0),
2588                    opacity: 1.0,
2589                    box_shadow: row_style.box_shadow.map(Box::new),
2590                    background_gradient: row_style.background.clone().map(Box::new),
2591                }
2592            } else {
2593                DrawCommand::None
2594            },
2595            children: row_children,
2596            node_type: Some("TableRow".to_string()),
2597            resolved_style: Some(row_style.clone()),
2598            source_location: row.source_location.clone(),
2599            href: None,
2600            bookmark: row.bookmark.clone(),
2601            alt: None,
2602            is_header_row: is_header,
2603            overflow: row_style.overflow,
2604            opacity: row_style.opacity,
2605        });
2606
2607        // Append any overflow pages from cells that exceeded page height
2608        pages.extend(all_overflow_pages);
2609
2610        cursor.y += row_height;
2611    }
2612
2613    #[allow(clippy::too_many_arguments)]
2614    fn layout_text(
2615        &self,
2616        content: &str,
2617        href: Option<&str>,
2618        runs: &[TextRun],
2619        style: &ResolvedStyle,
2620        cursor: &mut PageCursor,
2621        pages: &mut Vec<LayoutPage>,
2622        x: f64,
2623        available_width: f64,
2624        font_context: &FontContext,
2625        source_location: Option<&SourceLocation>,
2626        bookmark: Option<&str>,
2627    ) {
2628        let margin = &style.margin.to_edges();
2629        let text_x = x + margin.left;
2630        // Honor an explicit/resolved fixed width for the text box; only fall back
2631        // to available_width when width is Auto. In a flex row, available_width is
2632        // the parent row's content width (used for percentage resolution) while the
2633        // child's own distributed width arrives via style.width — see layout_node's
2634        // forced_outer_width. layout_view already works this way; this keeps leaf
2635        // text consistent so textAlign/justify use the real box, not the row width.
2636        let text_width = match style.width {
2637            SizeConstraint::Fixed(w) => (w - margin.horizontal()).max(0.0),
2638            SizeConstraint::Auto => available_width - margin.horizontal(),
2639        };
2640
2641        cursor.y += margin.top;
2642
2643        // Runs path: if runs are provided, use multi-style line breaking
2644        if !runs.is_empty() {
2645            self.layout_text_runs(
2646                runs,
2647                href,
2648                style,
2649                cursor,
2650                pages,
2651                text_x,
2652                text_width,
2653                font_context,
2654                source_location,
2655                bookmark,
2656            );
2657            cursor.y += margin.bottom;
2658            return;
2659        }
2660
2661        let content = substitute_page_placeholders(content);
2662        let transformed = apply_text_transform(&content, style.text_transform);
2663        let justify = matches!(style.text_align, TextAlign::Justify);
2664        let lines = match style.line_breaking {
2665            LineBreaking::Optimal => self.text_layout.break_into_lines_optimal(
2666                font_context,
2667                &transformed,
2668                text_width,
2669                style.font_size,
2670                &style.font_family,
2671                style.font_weight,
2672                style.font_style,
2673                style.letter_spacing,
2674                style.hyphens,
2675                style.lang.as_deref(),
2676                justify,
2677            ),
2678            LineBreaking::Greedy => self.text_layout.break_into_lines(
2679                font_context,
2680                &transformed,
2681                text_width,
2682                style.font_size,
2683                &style.font_family,
2684                style.font_weight,
2685                style.font_style,
2686                style.letter_spacing,
2687                style.hyphens,
2688                style.lang.as_deref(),
2689            ),
2690        };
2691
2692        // Apply text overflow truncation (single-line modes)
2693        let lines = match style.text_overflow {
2694            TextOverflow::Ellipsis => self.text_layout.truncate_with_ellipsis(
2695                font_context,
2696                lines,
2697                text_width,
2698                style.font_size,
2699                &style.font_family,
2700                style.font_weight,
2701                style.font_style,
2702                style.letter_spacing,
2703            ),
2704            TextOverflow::Clip => self.text_layout.truncate_clip(
2705                font_context,
2706                lines,
2707                text_width,
2708                style.font_size,
2709                &style.font_family,
2710                style.font_weight,
2711                style.font_style,
2712                style.letter_spacing,
2713            ),
2714            TextOverflow::Wrap => lines,
2715        };
2716
2717        let line_height = style.font_size * style.line_height;
2718
2719        // Widow/orphan control: decide how to break before placing lines
2720        let line_heights: Vec<f64> = vec![line_height; lines.len()];
2721        let decision = page_break::decide_break(
2722            cursor.remaining_height(),
2723            &line_heights,
2724            true,
2725            style.min_orphan_lines as usize,
2726            style.min_widow_lines as usize,
2727        );
2728
2729        // Snapshot-and-collect: accumulate line elements, wrap in parent
2730        let mut snapshot = cursor.elements.len();
2731        let mut container_start_y = cursor.content_y + cursor.y;
2732        let mut is_first_element = true;
2733
2734        // Handle move-to-next-page decision (orphan control)
2735        if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
2736            pages.push(cursor.finalize());
2737            *cursor = cursor.new_page();
2738            snapshot = cursor.elements.len();
2739            container_start_y = cursor.content_y + cursor.y;
2740        }
2741
2742        // For split decisions, track the widow/orphan-adjusted first break point
2743        let forced_break_at = match decision {
2744            page_break::BreakDecision::Split {
2745                items_on_current_page,
2746            } => Some(items_on_current_page),
2747            _ => None,
2748        };
2749        let mut first_break_done = false;
2750
2751        for (line_idx, line) in lines.iter().enumerate() {
2752            // Widow/orphan-controlled first break, then normal overflow checks
2753            let needs_break = if let Some(break_at) = forced_break_at {
2754                if !first_break_done && line_idx == break_at {
2755                    true
2756                } else {
2757                    line_height > cursor.remaining_height()
2758                }
2759            } else {
2760                line_height > cursor.remaining_height()
2761            };
2762
2763            if needs_break {
2764                first_break_done = true;
2765                // Flush accumulated lines into a Text container on this page
2766                let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
2767                if !line_elements.is_empty() {
2768                    let container_height = cursor.content_y + cursor.y - container_start_y;
2769                    cursor.elements.push(LayoutElement {
2770                        x: text_x,
2771                        y: container_start_y,
2772                        width: text_width,
2773                        height: container_height,
2774                        draw: DrawCommand::None,
2775                        children: line_elements,
2776                        node_type: Some("Text".to_string()),
2777                        resolved_style: Some(style.clone()),
2778                        source_location: source_location.cloned(),
2779                        href: href.map(|s| s.to_string()),
2780                        bookmark: if is_first_element {
2781                            bookmark.map(|s| s.to_string())
2782                        } else {
2783                            None
2784                        },
2785                        alt: None,
2786                        is_header_row: false,
2787                        overflow: Overflow::default(),
2788                        opacity: 1.0,
2789                    });
2790                    is_first_element = false;
2791                }
2792
2793                pages.push(cursor.finalize());
2794                *cursor = cursor.new_page();
2795
2796                // Reset snapshot for new page
2797                snapshot = cursor.elements.len();
2798                container_start_y = cursor.content_y + cursor.y;
2799            }
2800
2801            let glyphs = self.build_positioned_glyphs_single_style(line, style, href, font_context);
2802
2803            // Use actual rendered width from glyphs for alignment (may differ from
2804            // line.width when per-char measurement is used for line breaking but
2805            // shaping is used for glyph placement).
2806            let rendered_width = if glyphs.is_empty() {
2807                line.width
2808            } else {
2809                let last = &glyphs[glyphs.len() - 1];
2810                (last.x_offset + last.x_advance).max(line.width * 0.5)
2811            };
2812
2813            let line_x = match style.text_align {
2814                TextAlign::Left => text_x,
2815                TextAlign::Right => text_x + text_width - rendered_width,
2816                TextAlign::Center => text_x + (text_width - rendered_width) / 2.0,
2817                TextAlign::Justify => text_x,
2818            };
2819
2820            // Justify: compute extra word spacing so the line fills the column width.
2821            // Use the sum of natural glyph advances (what PDF Tj actually renders)
2822            // rather than KP-adjusted positions, which bake justification into
2823            // char_positions and make slack ≈ 0.
2824            //
2825            // User-set `word_spacing` is the base; when text is justified, the
2826            // computed slack-per-space is added on top.
2827            let is_last_line = line_idx == lines.len() - 1;
2828            let user_ws = style.word_spacing;
2829            let (justified_width, word_spacing) =
2830                if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
2831                    let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
2832                    let (natural_width, space_count) = if let Some(idx) = last_non_space {
2833                        let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
2834                        let s = glyphs[..=idx]
2835                            .iter()
2836                            .filter(|g| g.char_value == ' ')
2837                            .count();
2838                        (w, s)
2839                    } else {
2840                        (0.0, 0)
2841                    };
2842                    let slack = text_width - natural_width;
2843                    let ws = if space_count > 0 && slack.abs() > 0.01 {
2844                        slack / space_count as f64
2845                    } else {
2846                        0.0
2847                    };
2848                    (text_width, user_ws + ws)
2849                } else {
2850                    (rendered_width, user_ws)
2851                };
2852
2853            let text_line = TextLine {
2854                x: line_x,
2855                y: cursor.content_y + cursor.y + style.font_size,
2856                glyphs,
2857                width: justified_width,
2858                height: line_height,
2859                word_spacing,
2860            };
2861
2862            cursor.elements.push(LayoutElement {
2863                x: line_x,
2864                y: cursor.content_y + cursor.y,
2865                width: justified_width,
2866                height: line_height,
2867                draw: DrawCommand::Text {
2868                    lines: vec![text_line],
2869                    color: style.color,
2870                    text_decoration: style.text_decoration,
2871                    opacity: 1.0,
2872                },
2873                children: vec![],
2874                node_type: Some("TextLine".to_string()),
2875                resolved_style: Some(style.clone()),
2876                source_location: None,
2877                href: href.map(|s| s.to_string()),
2878                bookmark: None,
2879                alt: None,
2880                is_header_row: false,
2881                overflow: Overflow::default(),
2882                opacity: 1.0,
2883            });
2884
2885            cursor.y += line_height;
2886        }
2887
2888        // Wrap remaining lines into a Text container
2889        let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
2890        if !line_elements.is_empty() {
2891            let container_height = cursor.content_y + cursor.y - container_start_y;
2892            cursor.elements.push(LayoutElement {
2893                x: text_x,
2894                y: container_start_y,
2895                width: text_width,
2896                height: container_height,
2897                draw: DrawCommand::None,
2898                children: line_elements,
2899                node_type: Some("Text".to_string()),
2900                resolved_style: Some(style.clone()),
2901                source_location: source_location.cloned(),
2902                href: href.map(|s| s.to_string()),
2903                bookmark: if is_first_element {
2904                    bookmark.map(|s| s.to_string())
2905                } else {
2906                    None
2907                },
2908                alt: None,
2909                is_header_row: false,
2910                overflow: Overflow::default(),
2911                opacity: 1.0,
2912            });
2913        }
2914
2915        cursor.y += margin.bottom;
2916    }
2917
2918    /// Layout text runs with per-run styling.
2919    #[allow(clippy::too_many_arguments)]
2920    fn layout_text_runs(
2921        &self,
2922        runs: &[TextRun],
2923        parent_href: Option<&str>,
2924        style: &ResolvedStyle,
2925        cursor: &mut PageCursor,
2926        pages: &mut Vec<LayoutPage>,
2927        text_x: f64,
2928        text_width: f64,
2929        font_context: &FontContext,
2930        source_location: Option<&SourceLocation>,
2931        bookmark: Option<&str>,
2932    ) {
2933        // Build StyledChar list from runs
2934        let mut styled_chars: Vec<StyledChar> = Vec::new();
2935        for run in runs {
2936            let run_style = run.style.resolve(Some(style), text_width);
2937            let run_href = run.href.as_deref().or(parent_href);
2938            let transform = run_style.text_transform;
2939            let run_content = substitute_page_placeholders(&run.content);
2940            let mut prev_is_whitespace = true;
2941            for ch in run_content.chars() {
2942                let transformed_ch = apply_char_transform(ch, transform, prev_is_whitespace);
2943                prev_is_whitespace = ch.is_whitespace();
2944                styled_chars.push(StyledChar {
2945                    ch: transformed_ch,
2946                    font_family: run_style.font_family.clone(),
2947                    font_size: run_style.font_size,
2948                    font_weight: run_style.font_weight,
2949                    font_style: run_style.font_style,
2950                    color: run_style.color,
2951                    href: run_href.map(|s| s.to_string()),
2952                    text_decoration: run_style.text_decoration,
2953                    letter_spacing: run_style.letter_spacing,
2954                });
2955            }
2956        }
2957
2958        // Break into lines
2959        let justify = matches!(style.text_align, TextAlign::Justify);
2960        let broken_lines = match style.line_breaking {
2961            LineBreaking::Optimal => self.text_layout.break_runs_into_lines_optimal(
2962                font_context,
2963                &styled_chars,
2964                text_width,
2965                style.hyphens,
2966                style.lang.as_deref(),
2967                justify,
2968            ),
2969            LineBreaking::Greedy => self.text_layout.break_runs_into_lines(
2970                font_context,
2971                &styled_chars,
2972                text_width,
2973                style.hyphens,
2974                style.lang.as_deref(),
2975            ),
2976        };
2977
2978        // Apply text overflow truncation (single-line modes)
2979        let broken_lines = match style.text_overflow {
2980            TextOverflow::Ellipsis => {
2981                self.text_layout
2982                    .truncate_runs_with_ellipsis(font_context, broken_lines, text_width)
2983            }
2984            TextOverflow::Clip => {
2985                self.text_layout
2986                    .truncate_runs_clip(font_context, broken_lines, text_width)
2987            }
2988            TextOverflow::Wrap => broken_lines,
2989        };
2990
2991        let line_height = style.font_size * style.line_height;
2992
2993        // Widow/orphan control for text runs
2994        let line_heights: Vec<f64> = vec![line_height; broken_lines.len()];
2995        let decision = page_break::decide_break(
2996            cursor.remaining_height(),
2997            &line_heights,
2998            true,
2999            style.min_orphan_lines as usize,
3000            style.min_widow_lines as usize,
3001        );
3002
3003        let mut snapshot = cursor.elements.len();
3004        let mut container_start_y = cursor.content_y + cursor.y;
3005        let mut is_first_element = true;
3006
3007        if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
3008            pages.push(cursor.finalize());
3009            *cursor = cursor.new_page();
3010            snapshot = cursor.elements.len();
3011            container_start_y = cursor.content_y + cursor.y;
3012        }
3013
3014        let forced_break_at = match decision {
3015            page_break::BreakDecision::Split {
3016                items_on_current_page,
3017            } => Some(items_on_current_page),
3018            _ => None,
3019        };
3020        let mut first_break_done = false;
3021
3022        for (line_idx, run_line) in broken_lines.iter().enumerate() {
3023            let needs_break = if let Some(break_at) = forced_break_at {
3024                if !first_break_done && line_idx == break_at {
3025                    true
3026                } else {
3027                    line_height > cursor.remaining_height()
3028                }
3029            } else {
3030                line_height > cursor.remaining_height()
3031            };
3032
3033            if needs_break {
3034                first_break_done = true;
3035                let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3036                if !line_elements.is_empty() {
3037                    let container_height = cursor.content_y + cursor.y - container_start_y;
3038                    cursor.elements.push(LayoutElement {
3039                        x: text_x,
3040                        y: container_start_y,
3041                        width: text_width,
3042                        height: container_height,
3043                        draw: DrawCommand::None,
3044                        children: line_elements,
3045                        node_type: Some("Text".to_string()),
3046                        resolved_style: Some(style.clone()),
3047                        source_location: source_location.cloned(),
3048                        href: parent_href.map(|s| s.to_string()),
3049                        bookmark: if is_first_element {
3050                            bookmark.map(|s| s.to_string())
3051                        } else {
3052                            None
3053                        },
3054                        alt: None,
3055                        is_header_row: false,
3056                        overflow: Overflow::default(),
3057                        opacity: 1.0,
3058                    });
3059                    is_first_element = false;
3060                }
3061
3062                pages.push(cursor.finalize());
3063                *cursor = cursor.new_page();
3064
3065                snapshot = cursor.elements.len();
3066                container_start_y = cursor.content_y + cursor.y;
3067            }
3068
3069            let line_x = match style.text_align {
3070                TextAlign::Left => text_x,
3071                TextAlign::Right => text_x + text_width - run_line.width,
3072                TextAlign::Center => text_x + (text_width - run_line.width) / 2.0,
3073                TextAlign::Justify => text_x,
3074            };
3075
3076            let glyphs = self.build_positioned_glyphs_runs(run_line, font_context, style.direction);
3077
3078            // Justify: compute extra word spacing so the line fills the column width.
3079            // Use the sum of natural glyph advances (what PDF Tj actually renders)
3080            // rather than KP-adjusted line width.
3081            //
3082            // User-set `word_spacing` is the base; when text is justified, the
3083            // computed slack-per-space is added on top.
3084            let is_last_line = line_idx == broken_lines.len() - 1;
3085            let user_ws = style.word_spacing;
3086            let (justified_width, word_spacing) =
3087                if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
3088                    let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
3089                    let (natural_width, space_count) = if let Some(idx) = last_non_space {
3090                        let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
3091                        let s = glyphs[..=idx]
3092                            .iter()
3093                            .filter(|g| g.char_value == ' ')
3094                            .count();
3095                        (w, s)
3096                    } else {
3097                        (0.0, 0)
3098                    };
3099                    let slack = text_width - natural_width;
3100                    let ws = if space_count > 0 && slack.abs() > 0.01 {
3101                        slack / space_count as f64
3102                    } else {
3103                        0.0
3104                    };
3105                    (text_width, user_ws + ws)
3106                } else {
3107                    (run_line.width, user_ws)
3108                };
3109
3110            let text_line = TextLine {
3111                x: line_x,
3112                y: cursor.content_y + cursor.y + style.font_size,
3113                glyphs,
3114                width: justified_width,
3115                height: line_height,
3116                word_spacing,
3117            };
3118
3119            // Determine text decoration: use the run's decoration if any glyph has one
3120            let text_dec = run_line
3121                .chars
3122                .iter()
3123                .find(|sc| !matches!(sc.text_decoration, TextDecoration::None))
3124                .map(|sc| sc.text_decoration)
3125                .unwrap_or(style.text_decoration);
3126
3127            cursor.elements.push(LayoutElement {
3128                x: line_x,
3129                y: cursor.content_y + cursor.y,
3130                width: justified_width,
3131                height: line_height,
3132                draw: DrawCommand::Text {
3133                    lines: vec![text_line],
3134                    color: style.color,
3135                    text_decoration: text_dec,
3136                    opacity: 1.0,
3137                },
3138                children: vec![],
3139                node_type: Some("TextLine".to_string()),
3140                resolved_style: Some(style.clone()),
3141                source_location: None,
3142                href: parent_href.map(|s| s.to_string()),
3143                bookmark: None,
3144                alt: None,
3145                is_header_row: false,
3146                overflow: Overflow::default(),
3147                opacity: 1.0,
3148            });
3149
3150            cursor.y += line_height;
3151        }
3152
3153        let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3154        if !line_elements.is_empty() {
3155            let container_height = cursor.content_y + cursor.y - container_start_y;
3156            cursor.elements.push(LayoutElement {
3157                x: text_x,
3158                y: container_start_y,
3159                width: text_width,
3160                height: container_height,
3161                draw: DrawCommand::None,
3162                children: line_elements,
3163                node_type: Some("Text".to_string()),
3164                resolved_style: Some(style.clone()),
3165                source_location: source_location.cloned(),
3166                href: parent_href.map(|s| s.to_string()),
3167                bookmark: if is_first_element {
3168                    bookmark.map(|s| s.to_string())
3169                } else {
3170                    None
3171                },
3172                alt: None,
3173                is_header_row: false,
3174                overflow: Overflow::default(),
3175                opacity: 1.0,
3176            });
3177        }
3178    }
3179
3180    /// Build PositionedGlyphs for a single-style BrokenLine.
3181    /// For custom fonts, shapes the line text to get real glyph IDs.
3182    /// For standard fonts, uses char-as-u16 glyph IDs.
3183    fn build_positioned_glyphs_single_style(
3184        &self,
3185        line: &BrokenLine,
3186        style: &ResolvedStyle,
3187        href: Option<&str>,
3188        font_context: &FontContext,
3189    ) -> Vec<PositionedGlyph> {
3190        let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
3191        let line_text: String = line.chars.iter().collect();
3192        let direction = style.direction;
3193        // Check if BiDi processing is needed
3194        let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
3195
3196        // Segment by font — handles both explicit fallback chains and
3197        // automatic builtin font fallback (Noto Sans for non-Latin chars)
3198        let font_runs = crate::font::fallback::segment_by_font(
3199            &line.chars,
3200            &style.font_family,
3201            style.font_weight,
3202            italic,
3203            font_context.registry(),
3204        );
3205        let needs_per_char_fallback = font_runs.len() > 1
3206            || (font_runs.len() == 1 && font_runs[0].family != style.font_family);
3207
3208        // Per-char fallback path: segment by font within each BiDi run
3209        if needs_per_char_fallback {
3210            let bidi_runs = if has_bidi {
3211                bidi::analyze_bidi(&line_text, direction)
3212            } else {
3213                vec![crate::text::bidi::BidiRun {
3214                    char_start: 0,
3215                    char_end: line.chars.len(),
3216                    level: unicode_bidi::Level::ltr(),
3217                    is_rtl: false,
3218                }]
3219            };
3220
3221            let mut all_glyphs = Vec::new();
3222            let mut bidi_levels = Vec::new();
3223            let mut x = 0.0_f64;
3224
3225            // Process each BiDi run
3226            for bidi_run in &bidi_runs {
3227                // Within this BiDi run, sub-segment by font
3228                for font_run in &font_runs {
3229                    // Intersect font_run with bidi_run
3230                    let start = font_run.start.max(bidi_run.char_start);
3231                    let end = font_run.end.min(bidi_run.char_end);
3232                    if start >= end {
3233                        continue;
3234                    }
3235
3236                    let sub_chars: Vec<char> = line.chars[start..end].to_vec();
3237                    let sub_text: String = sub_chars.iter().collect();
3238                    let resolved_family = &font_run.family;
3239
3240                    if let Some(font_data) =
3241                        font_context.font_data(resolved_family, style.font_weight, italic)
3242                    {
3243                        if let Some(shaped) = shaping::shape_text_with_direction(
3244                            &sub_text,
3245                            font_data,
3246                            bidi_run.is_rtl,
3247                        ) {
3248                            let units_per_em = font_context.units_per_em(
3249                                resolved_family,
3250                                style.font_weight,
3251                                italic,
3252                            );
3253                            let scale = style.font_size / units_per_em as f64;
3254
3255                            for sg in &shaped {
3256                                let cluster = sg.cluster as usize;
3257                                let char_value = sub_chars.get(cluster).copied().unwrap_or(' ');
3258
3259                                let cluster_text = if shaped.len() < sub_chars.len() {
3260                                    let cluster_end =
3261                                        self.find_cluster_end(&shaped, sg, sub_chars.len());
3262                                    if cluster_end > cluster + 1 {
3263                                        Some(
3264                                            sub_chars[cluster..cluster_end]
3265                                                .iter()
3266                                                .collect::<String>(),
3267                                        )
3268                                    } else {
3269                                        None
3270                                    }
3271                                } else {
3272                                    None
3273                                };
3274
3275                                let glyph_x = x + sg.x_offset as f64 * scale;
3276                                let glyph_y = sg.y_offset as f64 * scale;
3277                                let advance = sg.x_advance as f64 * scale + style.letter_spacing;
3278
3279                                all_glyphs.push(PositionedGlyph {
3280                                    glyph_id: sg.glyph_id,
3281                                    x_offset: glyph_x,
3282                                    y_offset: glyph_y,
3283                                    x_advance: advance,
3284                                    font_size: style.font_size,
3285                                    font_family: resolved_family.clone(),
3286                                    font_weight: style.font_weight,
3287                                    font_style: style.font_style,
3288                                    char_value,
3289                                    color: Some(style.color),
3290                                    href: href.map(|s| s.to_string()),
3291                                    text_decoration: style.text_decoration,
3292                                    letter_spacing: style.letter_spacing,
3293                                    cluster_text,
3294                                });
3295                                bidi_levels.push(bidi_run.level);
3296                                x += advance;
3297                            }
3298                            continue;
3299                        }
3300                    }
3301
3302                    // Fallback: standard font or shaping failure for this sub-segment
3303                    for i in start..end {
3304                        let ch = line.chars[i];
3305                        let glyph_x = x;
3306                        let char_width = font_context.char_width(
3307                            ch,
3308                            resolved_family,
3309                            style.font_weight,
3310                            italic,
3311                            style.font_size,
3312                        );
3313                        let advance = char_width + style.letter_spacing;
3314                        all_glyphs.push(PositionedGlyph {
3315                            glyph_id: ch as u16,
3316                            x_offset: glyph_x,
3317                            y_offset: 0.0,
3318                            x_advance: advance,
3319                            font_size: style.font_size,
3320                            font_family: resolved_family.clone(),
3321                            font_weight: style.font_weight,
3322                            font_style: style.font_style,
3323                            char_value: ch,
3324                            color: Some(style.color),
3325                            href: href.map(|s| s.to_string()),
3326                            text_decoration: style.text_decoration,
3327                            letter_spacing: style.letter_spacing,
3328                            cluster_text: None,
3329                        });
3330                        bidi_levels.push(bidi_run.level);
3331                        x += advance;
3332                    }
3333                }
3334            }
3335
3336            // Apply BiDi visual reordering if needed
3337            if has_bidi && !all_glyphs.is_empty() {
3338                all_glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
3339                bidi::reposition_after_reorder(&mut all_glyphs, 0.0);
3340            }
3341            return all_glyphs;
3342        }
3343
3344        // Original single-font path (no comma in font_family)
3345        // Try shaping for custom fonts
3346        if let Some(font_data) =
3347            font_context.font_data(&style.font_family, style.font_weight, italic)
3348        {
3349            if has_bidi {
3350                // BiDi path: analyze runs, shape each with correct direction
3351                let bidi_runs = bidi::analyze_bidi(&line_text, direction);
3352                let units_per_em =
3353                    font_context.units_per_em(&style.font_family, style.font_weight, italic);
3354                let scale = style.font_size / units_per_em as f64;
3355
3356                let mut all_glyphs = Vec::new();
3357                let mut bidi_levels = Vec::new();
3358                let mut x = 0.0_f64;
3359
3360                for run in &bidi_runs {
3361                    let run_chars: Vec<char> = line.chars[run.char_start..run.char_end].to_vec();
3362                    let run_text: String = run_chars.iter().collect();
3363
3364                    if let Some(shaped) =
3365                        shaping::shape_text_with_direction(&run_text, font_data, run.is_rtl)
3366                    {
3367                        for sg in &shaped {
3368                            let cluster = sg.cluster as usize;
3369                            let char_value = run_chars.get(cluster).copied().unwrap_or(' ');
3370
3371                            let cluster_text = if shaped.len() < run_chars.len() {
3372                                let cluster_end =
3373                                    self.find_cluster_end(&shaped, sg, run_chars.len());
3374                                if cluster_end > cluster + 1 {
3375                                    Some(run_chars[cluster..cluster_end].iter().collect::<String>())
3376                                } else {
3377                                    None
3378                                }
3379                            } else {
3380                                None
3381                            };
3382
3383                            let glyph_x = x + sg.x_offset as f64 * scale;
3384                            let glyph_y = sg.y_offset as f64 * scale;
3385                            let advance = sg.x_advance as f64 * scale + style.letter_spacing;
3386
3387                            all_glyphs.push(PositionedGlyph {
3388                                glyph_id: sg.glyph_id,
3389                                x_offset: glyph_x,
3390                                y_offset: glyph_y,
3391                                x_advance: advance,
3392                                font_size: style.font_size,
3393                                font_family: style.font_family.clone(),
3394                                font_weight: style.font_weight,
3395                                font_style: style.font_style,
3396                                char_value,
3397                                color: Some(style.color),
3398                                href: href.map(|s| s.to_string()),
3399                                text_decoration: style.text_decoration,
3400                                letter_spacing: style.letter_spacing,
3401                                cluster_text,
3402                            });
3403                            bidi_levels.push(run.level);
3404
3405                            x += advance;
3406                        }
3407                    }
3408                }
3409
3410                // Reorder glyphs visually and reposition
3411                let mut glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
3412                bidi::reposition_after_reorder(&mut glyphs, 0.0);
3413                return glyphs;
3414            }
3415
3416            // Pure LTR path: shape normally
3417            if let Some(shaped) = shaping::shape_text(&line_text, font_data) {
3418                let units_per_em =
3419                    font_context.units_per_em(&style.font_family, style.font_weight, italic);
3420                let scale = style.font_size / units_per_em as f64;
3421
3422                return self.shaped_glyphs_to_positioned(
3423                    &shaped,
3424                    &line.chars,
3425                    &line.char_positions,
3426                    scale,
3427                    style.font_size,
3428                    &style.font_family,
3429                    style.font_weight,
3430                    style.font_style,
3431                    Some(style.color),
3432                    href,
3433                    style.text_decoration,
3434                    style.letter_spacing,
3435                );
3436            }
3437        }
3438
3439        // Fallback: standard fonts or shaping failure
3440        let mut glyphs: Vec<PositionedGlyph> = line
3441            .chars
3442            .iter()
3443            .enumerate()
3444            .map(|(j, ch)| {
3445                let glyph_x = line.char_positions.get(j).copied().unwrap_or(0.0);
3446                let char_width = font_context.char_width(
3447                    *ch,
3448                    &style.font_family,
3449                    style.font_weight,
3450                    italic,
3451                    style.font_size,
3452                );
3453                PositionedGlyph {
3454                    glyph_id: *ch as u16,
3455                    x_offset: glyph_x,
3456                    y_offset: 0.0,
3457                    x_advance: char_width,
3458                    font_size: style.font_size,
3459                    font_family: style.font_family.clone(),
3460                    font_weight: style.font_weight,
3461                    font_style: style.font_style,
3462                    char_value: *ch,
3463                    color: Some(style.color),
3464                    href: href.map(|s| s.to_string()),
3465                    text_decoration: style.text_decoration,
3466                    letter_spacing: style.letter_spacing,
3467                    cluster_text: None,
3468                }
3469            })
3470            .collect();
3471
3472        // For standard fonts with BiDi text, still reorder visually
3473        if has_bidi && !glyphs.is_empty() {
3474            let bidi_runs = bidi::analyze_bidi(&line_text, direction);
3475            let mut levels = Vec::with_capacity(glyphs.len());
3476            let mut char_idx = 0;
3477            for run in &bidi_runs {
3478                for _ in run.char_start..run.char_end {
3479                    if char_idx < glyphs.len() {
3480                        levels.push(run.level);
3481                        char_idx += 1;
3482                    }
3483                }
3484            }
3485            // Pad if needed
3486            while levels.len() < glyphs.len() {
3487                levels.push(unicode_bidi::Level::ltr());
3488            }
3489            glyphs = bidi::reorder_line_glyphs(glyphs, &levels);
3490            bidi::reposition_after_reorder(&mut glyphs, 0.0);
3491        }
3492
3493        glyphs
3494    }
3495
3496    /// Build PositionedGlyphs for a multi-style RunBrokenLine.
3497    /// Shapes contiguous runs of the same custom font, with BiDi support.
3498    /// When a StyledChar has a comma-separated font_family, resolves each
3499    /// character to a single font before grouping for shaping.
3500    fn build_positioned_glyphs_runs(
3501        &self,
3502        run_line: &RunBrokenLine,
3503        font_context: &FontContext,
3504        direction: Direction,
3505    ) -> Vec<PositionedGlyph> {
3506        let chars = &run_line.chars;
3507        if chars.is_empty() {
3508            return vec![];
3509        }
3510
3511        // Pre-resolve per-char font families from comma chains.
3512        // This produces a vec of resolved single family names, one per char.
3513        let resolved_families: Vec<String> = chars
3514            .iter()
3515            .map(|sc| {
3516                if !sc.font_family.contains(',') {
3517                    sc.font_family.clone()
3518                } else {
3519                    let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
3520                    let (_, family) = font_context.registry().resolve_for_char(
3521                        &sc.font_family,
3522                        sc.ch,
3523                        sc.font_weight,
3524                        italic,
3525                    );
3526                    family
3527                }
3528            })
3529            .collect();
3530
3531        let line_text: String = chars.iter().map(|c| c.ch).collect();
3532        let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
3533        let bidi_runs = if has_bidi {
3534            Some(bidi::analyze_bidi(&line_text, direction))
3535        } else {
3536            None
3537        };
3538
3539        let mut glyphs = Vec::new();
3540        let mut bidi_levels = Vec::new();
3541        let mut i = 0;
3542
3543        while i < chars.len() {
3544            let sc = &chars[i];
3545            let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
3546            let resolved_family = &resolved_families[i];
3547
3548            // Determine if this char is in an RTL BiDi run
3549            let is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
3550                runs.iter()
3551                    .any(|r| i >= r.char_start && i < r.char_end && r.is_rtl)
3552            });
3553
3554            // Check for custom font with shaping (using resolved single family)
3555            if let Some(font_data) = font_context.font_data(resolved_family, sc.font_weight, italic)
3556            {
3557                // Find contiguous run with same resolved font AND same BiDi direction
3558                let run_start = i;
3559                let mut run_end = i + 1;
3560                while run_end < chars.len() {
3561                    let next = &chars[run_end];
3562                    let next_italic =
3563                        matches!(next.font_style, FontStyle::Italic | FontStyle::Oblique);
3564                    let next_is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
3565                        runs.iter()
3566                            .any(|r| run_end >= r.char_start && run_end < r.char_end && r.is_rtl)
3567                    });
3568                    // Group by resolved family, not original comma chain
3569                    if resolved_families[run_end] == *resolved_family
3570                        && next.font_weight == sc.font_weight
3571                        && next_italic == italic
3572                        && (next.font_size - sc.font_size).abs() < 0.001
3573                        && next_is_rtl == is_rtl
3574                    {
3575                        run_end += 1;
3576                    } else {
3577                        break;
3578                    }
3579                }
3580
3581                let run_text: String = chars[run_start..run_end].iter().map(|c| c.ch).collect();
3582                if let Some(shaped) =
3583                    shaping::shape_text_with_direction(&run_text, font_data, is_rtl)
3584                {
3585                    let units_per_em =
3586                        font_context.units_per_em(resolved_family, sc.font_weight, italic);
3587                    let scale = sc.font_size / units_per_em as f64;
3588
3589                    // Build char positions for this run segment
3590                    let run_chars: Vec<char> =
3591                        chars[run_start..run_end].iter().map(|c| c.ch).collect();
3592                    let run_positions: Vec<f64> = (run_start..run_end)
3593                        .map(|j| run_line.char_positions.get(j).copied().unwrap_or(0.0))
3594                        .collect();
3595
3596                    // Build glyphs with resolved single family on each glyph
3597                    let mut run_glyphs = self.shaped_glyphs_to_positioned_runs(
3598                        &shaped,
3599                        &chars[run_start..run_end],
3600                        &run_chars,
3601                        &run_positions,
3602                        scale,
3603                    );
3604                    // Override font_family to the resolved single family
3605                    for g in &mut run_glyphs {
3606                        g.font_family = resolved_family.clone();
3607                    }
3608                    // Track BiDi levels for each glyph
3609                    let run_level = if is_rtl {
3610                        unicode_bidi::Level::rtl()
3611                    } else {
3612                        unicode_bidi::Level::ltr()
3613                    };
3614                    for _ in &run_glyphs {
3615                        bidi_levels.push(run_level);
3616                    }
3617                    glyphs.extend(run_glyphs);
3618                    i = run_end;
3619                    continue;
3620                }
3621            }
3622
3623            // Fallback: unshaped glyph (using resolved family)
3624            let glyph_x = run_line.char_positions.get(i).copied().unwrap_or(0.0);
3625            let char_width = font_context.char_width(
3626                sc.ch,
3627                resolved_family,
3628                sc.font_weight,
3629                italic,
3630                sc.font_size,
3631            );
3632            glyphs.push(PositionedGlyph {
3633                glyph_id: sc.ch as u16,
3634                x_offset: glyph_x,
3635                y_offset: 0.0,
3636                x_advance: char_width,
3637                font_size: sc.font_size,
3638                font_family: resolved_family.clone(),
3639                font_weight: sc.font_weight,
3640                font_style: sc.font_style,
3641                char_value: sc.ch,
3642                color: Some(sc.color),
3643                href: sc.href.clone(),
3644                text_decoration: sc.text_decoration,
3645                letter_spacing: sc.letter_spacing,
3646                cluster_text: None,
3647            });
3648            bidi_levels.push(if is_rtl {
3649                unicode_bidi::Level::rtl()
3650            } else {
3651                unicode_bidi::Level::ltr()
3652            });
3653            i += 1;
3654        }
3655
3656        // Apply BiDi visual reordering if needed
3657        if has_bidi && !glyphs.is_empty() {
3658            glyphs = bidi::reorder_line_glyphs(glyphs, &bidi_levels);
3659            bidi::reposition_after_reorder(&mut glyphs, 0.0);
3660        }
3661
3662        glyphs
3663    }
3664
3665    /// Convert shaped glyphs to PositionedGlyphs for single-style text.
3666    #[allow(clippy::too_many_arguments)]
3667    fn shaped_glyphs_to_positioned(
3668        &self,
3669        shaped: &[shaping::ShapedGlyph],
3670        chars: &[char],
3671        _char_positions: &[f64],
3672        scale: f64,
3673        font_size: f64,
3674        font_family: &str,
3675        font_weight: u32,
3676        font_style: FontStyle,
3677        color: Option<Color>,
3678        href: Option<&str>,
3679        text_decoration: TextDecoration,
3680        letter_spacing: f64,
3681    ) -> Vec<PositionedGlyph> {
3682        let mut result = Vec::with_capacity(shaped.len());
3683        let mut x = 0.0_f64;
3684
3685        for sg in shaped {
3686            let cluster = sg.cluster as usize;
3687            let char_value = chars.get(cluster).copied().unwrap_or(' ');
3688
3689            // Determine cluster text for ligatures
3690            let cluster_text = if shaped.len() < chars.len() {
3691                // There are fewer glyphs than chars: likely ligatures.
3692                // Find end of this cluster.
3693                let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
3694                if cluster_end > cluster + 1 {
3695                    Some(chars[cluster..cluster_end].iter().collect::<String>())
3696                } else {
3697                    None
3698                }
3699            } else {
3700                None
3701            };
3702
3703            // Use shaped position
3704            let glyph_x = x + sg.x_offset as f64 * scale;
3705            let glyph_y = sg.y_offset as f64 * scale;
3706            let advance = sg.x_advance as f64 * scale + letter_spacing;
3707
3708            result.push(PositionedGlyph {
3709                glyph_id: sg.glyph_id,
3710                x_offset: glyph_x,
3711                y_offset: glyph_y,
3712                x_advance: advance,
3713                font_size,
3714                font_family: font_family.to_string(),
3715                font_weight,
3716                font_style,
3717                char_value,
3718                color,
3719                href: href.map(|s| s.to_string()),
3720                text_decoration,
3721                letter_spacing,
3722                cluster_text,
3723            });
3724
3725            x += advance;
3726        }
3727
3728        result
3729    }
3730
3731    /// Convert shaped glyphs to PositionedGlyphs for multi-style runs.
3732    fn shaped_glyphs_to_positioned_runs(
3733        &self,
3734        shaped: &[shaping::ShapedGlyph],
3735        styled_chars: &[StyledChar],
3736        chars: &[char],
3737        char_positions: &[f64],
3738        scale: f64,
3739    ) -> Vec<PositionedGlyph> {
3740        let mut result = Vec::with_capacity(shaped.len());
3741        // Use the first char position as the base offset for this run
3742        let base_x = char_positions.first().copied().unwrap_or(0.0);
3743        let mut x = 0.0_f64;
3744
3745        for sg in shaped {
3746            let cluster = sg.cluster as usize;
3747            let sc = styled_chars.get(cluster).unwrap_or(&styled_chars[0]);
3748            let char_value = chars.get(cluster).copied().unwrap_or(' ');
3749
3750            let cluster_text = if shaped.len() < chars.len() {
3751                let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
3752                if cluster_end > cluster + 1 {
3753                    Some(chars[cluster..cluster_end].iter().collect::<String>())
3754                } else {
3755                    None
3756                }
3757            } else {
3758                None
3759            };
3760
3761            let glyph_x = base_x + x + sg.x_offset as f64 * scale;
3762            let glyph_y = sg.y_offset as f64 * scale;
3763            let advance = sg.x_advance as f64 * scale + sc.letter_spacing;
3764
3765            result.push(PositionedGlyph {
3766                glyph_id: sg.glyph_id,
3767                x_offset: glyph_x,
3768                y_offset: glyph_y,
3769                x_advance: advance,
3770                font_size: sc.font_size,
3771                font_family: sc.font_family.clone(),
3772                font_weight: sc.font_weight,
3773                font_style: sc.font_style,
3774                char_value,
3775                color: Some(sc.color),
3776                href: sc.href.clone(),
3777                text_decoration: sc.text_decoration,
3778                letter_spacing: sc.letter_spacing,
3779                cluster_text,
3780            });
3781
3782            x += advance;
3783        }
3784
3785        result
3786    }
3787
3788    /// Find the end index of a cluster in shaped glyphs.
3789    fn find_cluster_end(
3790        &self,
3791        shaped: &[shaping::ShapedGlyph],
3792        current: &shaping::ShapedGlyph,
3793        num_chars: usize,
3794    ) -> usize {
3795        // Find the next glyph's cluster value
3796        for sg in shaped {
3797            if sg.cluster > current.cluster {
3798                return sg.cluster as usize;
3799            }
3800        }
3801        // Last glyph: cluster extends to end of text
3802        num_chars
3803    }
3804
3805    #[allow(clippy::too_many_arguments)]
3806    fn layout_image(
3807        &self,
3808        node: &Node,
3809        style: &ResolvedStyle,
3810        cursor: &mut PageCursor,
3811        pages: &mut Vec<LayoutPage>,
3812        x: f64,
3813        available_width: f64,
3814        explicit_width: Option<f64>,
3815        explicit_height: Option<f64>,
3816    ) {
3817        let margin = &style.margin.to_edges();
3818
3819        // Try to load the image from the node's src field
3820        let src = match &node.kind {
3821            NodeKind::Image { src, .. } => src.as_str(),
3822            _ => "",
3823        };
3824
3825        let loaded = if !src.is_empty() {
3826            crate::image_loader::load_image(src).ok()
3827        } else {
3828            None
3829        };
3830
3831        // Compute display dimensions with aspect ratio preservation
3832        let (img_width, img_height) = if let Some(ref img) = loaded {
3833            let intrinsic_w = img.width_px as f64;
3834            let intrinsic_h = img.height_px as f64;
3835            let aspect = if intrinsic_w > 0.0 {
3836                intrinsic_h / intrinsic_w
3837            } else {
3838                0.75
3839            };
3840
3841            match (explicit_width, explicit_height) {
3842                (Some(w), Some(h)) => (w, h),
3843                (Some(w), None) => (w, w * aspect),
3844                (None, Some(h)) => (h / aspect, h),
3845                (None, None) => {
3846                    let max_w = available_width - margin.horizontal();
3847                    let w = intrinsic_w.min(max_w);
3848                    (w, w * aspect)
3849                }
3850            }
3851        } else {
3852            // Fallback dimensions when image can't be loaded
3853            let w = explicit_width.unwrap_or(available_width - margin.horizontal());
3854            let h = explicit_height.unwrap_or(w * 0.75);
3855            (w, h)
3856        };
3857
3858        let total_height = img_height + margin.vertical();
3859
3860        if total_height > cursor.remaining_height() {
3861            pages.push(cursor.finalize());
3862            *cursor = cursor.new_page();
3863        }
3864
3865        cursor.y += margin.top;
3866
3867        let draw = if let Some(image_data) = loaded {
3868            DrawCommand::Image { image_data }
3869        } else {
3870            DrawCommand::ImagePlaceholder
3871        };
3872
3873        cursor.elements.push(LayoutElement {
3874            x: x + margin.left,
3875            y: cursor.content_y + cursor.y,
3876            width: img_width,
3877            height: img_height,
3878            draw,
3879            children: vec![],
3880            node_type: Some(node_kind_name(&node.kind).to_string()),
3881            resolved_style: Some(style.clone()),
3882            source_location: node.source_location.clone(),
3883            href: node.href.clone(),
3884            bookmark: node.bookmark.clone(),
3885            alt: node.alt.clone(),
3886            is_header_row: false,
3887            overflow: style.overflow,
3888            opacity: style.opacity,
3889        });
3890
3891        cursor.y += img_height + margin.bottom;
3892    }
3893
3894    /// Layout an SVG element as a fixed-size box.
3895    #[allow(clippy::too_many_arguments)]
3896    fn layout_svg(
3897        &self,
3898        node: &Node,
3899        style: &ResolvedStyle,
3900        cursor: &mut PageCursor,
3901        pages: &mut Vec<LayoutPage>,
3902        x: f64,
3903        _available_width: f64,
3904        svg_width: f64,
3905        svg_height: f64,
3906        view_box: Option<&str>,
3907        content: &str,
3908    ) {
3909        let margin = &style.margin.to_edges();
3910        let total_height = svg_height + margin.vertical();
3911
3912        if total_height > cursor.remaining_height() {
3913            pages.push(cursor.finalize());
3914            *cursor = cursor.new_page();
3915        }
3916
3917        cursor.y += margin.top;
3918
3919        let vb = view_box
3920            .and_then(crate::svg::parse_view_box)
3921            .unwrap_or(crate::svg::ViewBox {
3922                min_x: 0.0,
3923                min_y: 0.0,
3924                width: svg_width,
3925                height: svg_height,
3926            });
3927
3928        let commands = crate::svg::parse_svg(content, vb, svg_width, svg_height);
3929
3930        cursor.elements.push(LayoutElement {
3931            x: x + margin.left,
3932            y: cursor.content_y + cursor.y,
3933            width: svg_width,
3934            height: svg_height,
3935            draw: DrawCommand::Svg {
3936                commands,
3937                width: svg_width,
3938                height: svg_height,
3939                clip: false,
3940            },
3941            children: vec![],
3942            node_type: Some("Svg".to_string()),
3943            resolved_style: Some(style.clone()),
3944            source_location: node.source_location.clone(),
3945            href: node.href.clone(),
3946            bookmark: node.bookmark.clone(),
3947            alt: node.alt.clone(),
3948            is_header_row: false,
3949            overflow: style.overflow,
3950            opacity: style.opacity,
3951        });
3952
3953        cursor.y += svg_height + margin.bottom;
3954    }
3955
3956    /// Convert CanvasOps to SvgCommands, reusing the existing SVG rendering pipeline.
3957    fn canvas_ops_to_svg_commands(operations: &[CanvasOp]) -> Vec<crate::svg::SvgCommand> {
3958        use crate::svg::SvgCommand;
3959
3960        let mut commands = Vec::new();
3961        let mut cur_x = 0.0_f64;
3962        let mut cur_y = 0.0_f64;
3963
3964        for op in operations {
3965            match op {
3966                CanvasOp::MoveTo { x, y } => {
3967                    commands.push(SvgCommand::MoveTo(*x, *y));
3968                    cur_x = *x;
3969                    cur_y = *y;
3970                }
3971                CanvasOp::LineTo { x, y } => {
3972                    commands.push(SvgCommand::LineTo(*x, *y));
3973                    cur_x = *x;
3974                    cur_y = *y;
3975                }
3976                CanvasOp::BezierCurveTo {
3977                    cp1x,
3978                    cp1y,
3979                    cp2x,
3980                    cp2y,
3981                    x,
3982                    y,
3983                } => {
3984                    commands.push(SvgCommand::CurveTo(*cp1x, *cp1y, *cp2x, *cp2y, *x, *y));
3985                    cur_x = *x;
3986                    cur_y = *y;
3987                }
3988                CanvasOp::QuadraticCurveTo { cpx, cpy, x, y } => {
3989                    // Convert quadratic to cubic bezier
3990                    let cp1x = cur_x + 2.0 / 3.0 * (*cpx - cur_x);
3991                    let cp1y = cur_y + 2.0 / 3.0 * (*cpy - cur_y);
3992                    let cp2x = *x + 2.0 / 3.0 * (*cpx - *x);
3993                    let cp2y = *y + 2.0 / 3.0 * (*cpy - *y);
3994                    commands.push(SvgCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, *x, *y));
3995                    cur_x = *x;
3996                    cur_y = *y;
3997                }
3998                CanvasOp::ClosePath => {
3999                    commands.push(SvgCommand::ClosePath);
4000                }
4001                CanvasOp::Rect {
4002                    x,
4003                    y,
4004                    width,
4005                    height,
4006                } => {
4007                    commands.push(SvgCommand::MoveTo(*x, *y));
4008                    commands.push(SvgCommand::LineTo(*x + *width, *y));
4009                    commands.push(SvgCommand::LineTo(*x + *width, *y + *height));
4010                    commands.push(SvgCommand::LineTo(*x, *y + *height));
4011                    commands.push(SvgCommand::ClosePath);
4012                    cur_x = *x;
4013                    cur_y = *y;
4014                }
4015                CanvasOp::Circle { cx, cy, r } => {
4016                    commands.extend(crate::svg::ellipse_commands(*cx, *cy, *r, *r));
4017                }
4018                CanvasOp::Ellipse { cx, cy, rx, ry } => {
4019                    commands.extend(crate::svg::ellipse_commands(*cx, *cy, *rx, *ry));
4020                }
4021                CanvasOp::Arc {
4022                    cx,
4023                    cy,
4024                    r,
4025                    start_angle,
4026                    end_angle,
4027                    counterclockwise,
4028                } => {
4029                    // Approximate arc with line segments matching HTML Canvas arc() semantics.
4030                    // Canvas coords are Y-down (like HTML Canvas), and the PDF Y-flip
4031                    // preserves visual positions, so standard trig (cy + r*sin) is correct.
4032                    let steps = 32;
4033                    let mut sweep = end_angle - start_angle;
4034                    if !counterclockwise && sweep < 0.0 {
4035                        sweep += 2.0 * std::f64::consts::PI;
4036                    }
4037                    if *counterclockwise && sweep > 0.0 {
4038                        sweep -= 2.0 * std::f64::consts::PI;
4039                    }
4040                    for i in 0..=steps {
4041                        let t = *start_angle + sweep * (i as f64 / steps as f64);
4042                        let px = cx + r * t.cos();
4043                        let py = cy + r * t.sin();
4044                        if i == 0 {
4045                            commands.push(SvgCommand::MoveTo(px, py));
4046                        } else {
4047                            commands.push(SvgCommand::LineTo(px, py));
4048                        }
4049                    }
4050                }
4051                CanvasOp::Stroke => commands.push(SvgCommand::Stroke),
4052                CanvasOp::Fill => commands.push(SvgCommand::Fill),
4053                CanvasOp::FillAndStroke => commands.push(SvgCommand::FillAndStroke),
4054                CanvasOp::SetFillColor { r, g, b } => {
4055                    // Canvas API uses 0-255, PDF/SVG pipeline uses 0-1
4056                    commands.push(SvgCommand::SetFill(r / 255.0, g / 255.0, b / 255.0));
4057                }
4058                CanvasOp::SetStrokeColor { r, g, b } => {
4059                    commands.push(SvgCommand::SetStroke(r / 255.0, g / 255.0, b / 255.0));
4060                }
4061                CanvasOp::SetLineWidth { width } => {
4062                    commands.push(SvgCommand::SetStrokeWidth(*width));
4063                }
4064                CanvasOp::SetLineCap { cap } => {
4065                    commands.push(SvgCommand::SetLineCap(*cap));
4066                }
4067                CanvasOp::SetLineJoin { join } => {
4068                    commands.push(SvgCommand::SetLineJoin(*join));
4069                }
4070                CanvasOp::Save => commands.push(SvgCommand::SaveState),
4071                CanvasOp::Restore => commands.push(SvgCommand::RestoreState),
4072            }
4073        }
4074
4075        commands
4076    }
4077
4078    /// Layout a canvas element as a fixed-size box with vector graphics.
4079    #[allow(clippy::too_many_arguments)]
4080    fn layout_canvas(
4081        &self,
4082        node: &Node,
4083        style: &ResolvedStyle,
4084        cursor: &mut PageCursor,
4085        pages: &mut Vec<LayoutPage>,
4086        x: f64,
4087        _available_width: f64,
4088        canvas_width: f64,
4089        canvas_height: f64,
4090        operations: &[CanvasOp],
4091    ) {
4092        let margin = style.margin.to_edges();
4093        let total_height = canvas_height + margin.top + margin.bottom;
4094
4095        // Page break check
4096        if cursor.remaining_height() < total_height && cursor.y > 0.0 {
4097            pages.push(cursor.finalize());
4098            *cursor = cursor.new_page();
4099        }
4100
4101        cursor.y += margin.top;
4102
4103        let svg_commands = Self::canvas_ops_to_svg_commands(operations);
4104
4105        cursor.elements.push(LayoutElement {
4106            x: x + margin.left,
4107            y: cursor.content_y + cursor.y,
4108            width: canvas_width,
4109            height: canvas_height,
4110            draw: DrawCommand::Svg {
4111                commands: svg_commands,
4112                width: canvas_width,
4113                height: canvas_height,
4114                clip: true,
4115            },
4116            children: vec![],
4117            node_type: Some("Canvas".to_string()),
4118            resolved_style: Some(style.clone()),
4119            source_location: node.source_location.clone(),
4120            href: node.href.clone(),
4121            bookmark: node.bookmark.clone(),
4122            alt: node.alt.clone(),
4123            is_header_row: false,
4124            overflow: style.overflow,
4125            opacity: style.opacity,
4126        });
4127
4128        cursor.y += canvas_height + margin.bottom;
4129    }
4130
4131    /// Layout a 1D barcode as a row of vector rectangles.
4132    #[allow(clippy::too_many_arguments)]
4133    /// Layout a chart as a single unbreakable block of drawing primitives.
4134    #[allow(clippy::too_many_arguments)]
4135    fn layout_chart(
4136        &self,
4137        node: &Node,
4138        style: &ResolvedStyle,
4139        cursor: &mut PageCursor,
4140        pages: &mut Vec<LayoutPage>,
4141        x: f64,
4142        chart_width: f64,
4143        chart_height: f64,
4144        primitives: Vec<crate::chart::ChartPrimitive>,
4145        node_type_name: &str,
4146    ) {
4147        let margin = &style.margin.to_edges();
4148        let total_height = chart_height + margin.vertical();
4149
4150        if total_height > cursor.remaining_height() {
4151            pages.push(cursor.finalize());
4152            *cursor = cursor.new_page();
4153        }
4154
4155        cursor.y += margin.top;
4156
4157        let draw = DrawCommand::Chart { primitives };
4158
4159        cursor.elements.push(LayoutElement {
4160            x: x + margin.left,
4161            y: cursor.content_y + cursor.y,
4162            width: chart_width,
4163            height: chart_height,
4164            draw,
4165            children: vec![],
4166            node_type: Some(node_type_name.to_string()),
4167            resolved_style: Some(style.clone()),
4168            source_location: node.source_location.clone(),
4169            href: node.href.clone(),
4170            bookmark: node.bookmark.clone(),
4171            alt: node.alt.clone(),
4172            is_header_row: false,
4173            overflow: style.overflow,
4174            opacity: style.opacity,
4175        });
4176
4177        cursor.y += chart_height + margin.bottom;
4178    }
4179
4180    /// Layout a form field as a fixed-size leaf node.
4181    #[allow(clippy::too_many_arguments)]
4182    fn layout_form_field(
4183        &self,
4184        node: &Node,
4185        style: &ResolvedStyle,
4186        cursor: &mut PageCursor,
4187        pages: &mut Vec<LayoutPage>,
4188        x: f64,
4189        field_width: f64,
4190        field_height: f64,
4191        draw: DrawCommand,
4192        node_type_name: &str,
4193    ) {
4194        let margin = &style.margin.to_edges();
4195        let total_height = field_height + margin.vertical();
4196
4197        if total_height > cursor.remaining_height() {
4198            pages.push(cursor.finalize());
4199            *cursor = cursor.new_page();
4200        }
4201
4202        cursor.y += margin.top;
4203
4204        cursor.elements.push(LayoutElement {
4205            x: x + margin.left,
4206            y: cursor.content_y + cursor.y,
4207            width: field_width,
4208            height: field_height,
4209            draw,
4210            children: vec![],
4211            node_type: Some(node_type_name.to_string()),
4212            resolved_style: Some(style.clone()),
4213            source_location: node.source_location.clone(),
4214            href: node.href.clone(),
4215            bookmark: node.bookmark.clone(),
4216            alt: node.alt.clone(),
4217            is_header_row: false,
4218            overflow: style.overflow,
4219            opacity: style.opacity,
4220        });
4221
4222        cursor.y += field_height + margin.bottom;
4223    }
4224
4225    #[allow(clippy::too_many_arguments)]
4226    fn layout_barcode(
4227        &self,
4228        node: &Node,
4229        style: &ResolvedStyle,
4230        cursor: &mut PageCursor,
4231        pages: &mut Vec<LayoutPage>,
4232        x: f64,
4233        available_width: f64,
4234        data: &str,
4235        format: crate::barcode::BarcodeFormat,
4236        explicit_width: Option<f64>,
4237        bar_height: f64,
4238    ) {
4239        let margin = &style.margin.to_edges();
4240        let display_width = explicit_width.unwrap_or(available_width - margin.horizontal());
4241        let total_height = bar_height + margin.vertical();
4242
4243        if total_height > cursor.remaining_height() {
4244            pages.push(cursor.finalize());
4245            *cursor = cursor.new_page();
4246        }
4247
4248        cursor.y += margin.top;
4249
4250        let draw = match crate::barcode::generate_barcode(data, format) {
4251            Ok(barcode_data) => {
4252                let bar_width = if barcode_data.bars.is_empty() {
4253                    0.0
4254                } else {
4255                    display_width / barcode_data.bars.len() as f64
4256                };
4257                DrawCommand::Barcode {
4258                    bars: barcode_data.bars,
4259                    bar_width,
4260                    height: bar_height,
4261                    color: style.color,
4262                }
4263            }
4264            Err(_) => DrawCommand::None,
4265        };
4266
4267        cursor.elements.push(LayoutElement {
4268            x: x + margin.left,
4269            y: cursor.content_y + cursor.y,
4270            width: display_width,
4271            height: bar_height,
4272            draw,
4273            children: vec![],
4274            node_type: Some("Barcode".to_string()),
4275            resolved_style: Some(style.clone()),
4276            source_location: node.source_location.clone(),
4277            href: node.href.clone(),
4278            bookmark: node.bookmark.clone(),
4279            alt: node.alt.clone(),
4280            is_header_row: false,
4281            overflow: style.overflow,
4282            opacity: style.opacity,
4283        });
4284
4285        cursor.y += bar_height + margin.bottom;
4286    }
4287
4288    /// Layout a QR code as a square block of vector rectangles.
4289    #[allow(clippy::too_many_arguments)]
4290    fn layout_qrcode(
4291        &self,
4292        node: &Node,
4293        style: &ResolvedStyle,
4294        cursor: &mut PageCursor,
4295        pages: &mut Vec<LayoutPage>,
4296        x: f64,
4297        available_width: f64,
4298        data: &str,
4299        explicit_size: Option<f64>,
4300    ) {
4301        let margin = &style.margin.to_edges();
4302        let display_size = explicit_size.unwrap_or(available_width - margin.horizontal());
4303        let total_height = display_size + margin.vertical();
4304
4305        if total_height > cursor.remaining_height() {
4306            pages.push(cursor.finalize());
4307            *cursor = cursor.new_page();
4308        }
4309
4310        cursor.y += margin.top;
4311
4312        let draw = match crate::qrcode::generate_qr(data) {
4313            Ok(matrix) => {
4314                let module_size = display_size / matrix.size as f64;
4315                DrawCommand::QrCode {
4316                    modules: matrix.modules,
4317                    module_size,
4318                    color: style.color,
4319                }
4320            }
4321            Err(_) => DrawCommand::None,
4322        };
4323
4324        cursor.elements.push(LayoutElement {
4325            x: x + margin.left,
4326            y: cursor.content_y + cursor.y,
4327            width: display_size,
4328            height: display_size,
4329            draw,
4330            children: vec![],
4331            node_type: Some("QrCode".to_string()),
4332            resolved_style: Some(style.clone()),
4333            source_location: node.source_location.clone(),
4334            href: node.href.clone(),
4335            bookmark: node.bookmark.clone(),
4336            alt: node.alt.clone(),
4337            is_header_row: false,
4338            overflow: style.overflow,
4339            opacity: style.opacity,
4340        });
4341
4342        cursor.y += display_size + margin.bottom;
4343    }
4344
4345    // ── Measurement helpers ─────────────────────────────────────
4346
4347    fn measure_node_height(
4348        &self,
4349        node: &Node,
4350        available_width: f64,
4351        style: &ResolvedStyle,
4352        font_context: &FontContext,
4353    ) -> f64 {
4354        match &node.kind {
4355            NodeKind::Text { content, runs, .. } => {
4356                // Mirror layout_text: a fixed width drives line-breaking, so height
4357                // measurement must use the same width or it will under-count lines.
4358                let measure_width = match style.width {
4359                    SizeConstraint::Fixed(w) => (w - style.margin.horizontal()).max(0.0),
4360                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
4361                };
4362                if !runs.is_empty() {
4363                    // Measure runs
4364                    let mut styled_chars: Vec<StyledChar> = Vec::new();
4365                    for run in runs {
4366                        let run_style = run.style.resolve(Some(style), measure_width);
4367                        let run_content = substitute_page_placeholders(&run.content);
4368                        for ch in run_content.chars() {
4369                            styled_chars.push(StyledChar {
4370                                ch,
4371                                font_family: run_style.font_family.clone(),
4372                                font_size: run_style.font_size,
4373                                font_weight: run_style.font_weight,
4374                                font_style: run_style.font_style,
4375                                color: run_style.color,
4376                                href: None,
4377                                text_decoration: run_style.text_decoration,
4378                                letter_spacing: run_style.letter_spacing,
4379                            });
4380                        }
4381                    }
4382                    let broken_lines = self.text_layout.break_runs_into_lines(
4383                        font_context,
4384                        &styled_chars,
4385                        measure_width,
4386                        style.hyphens,
4387                        style.lang.as_deref(),
4388                    );
4389                    let line_height = style.font_size * style.line_height;
4390                    (broken_lines.len() as f64) * line_height + style.padding.vertical()
4391                } else {
4392                    let content = substitute_page_placeholders(content);
4393                    let lines = self.text_layout.break_into_lines(
4394                        font_context,
4395                        &content,
4396                        measure_width,
4397                        style.font_size,
4398                        &style.font_family,
4399                        style.font_weight,
4400                        style.font_style,
4401                        style.letter_spacing,
4402                        style.hyphens,
4403                        style.lang.as_deref(),
4404                    );
4405                    let line_height = style.font_size * style.line_height;
4406                    (lines.len() as f64) * line_height + style.padding.vertical()
4407                }
4408            }
4409            NodeKind::Image {
4410                src,
4411                width: explicit_w,
4412                height: explicit_h,
4413            } => {
4414                // 1. style.height takes precedence
4415                if let SizeConstraint::Fixed(h) = style.height {
4416                    return h + style.padding.vertical();
4417                }
4418                // 2. Explicit height prop
4419                if let Some(h) = explicit_h {
4420                    return *h + style.padding.vertical();
4421                }
4422                // 3. Compute from real image aspect ratio (header-only read, no pixel decode)
4423                let aspect = self
4424                    .get_image_dimensions(src)
4425                    .map(|(w, h)| if w > 0 { h as f64 / w as f64 } else { 0.75 })
4426                    .unwrap_or(0.75);
4427                let w = if let SizeConstraint::Fixed(w) = style.width {
4428                    w
4429                } else {
4430                    explicit_w.unwrap_or(available_width - style.margin.horizontal())
4431                };
4432                w * aspect + style.padding.vertical()
4433            }
4434            NodeKind::Svg { height, .. } => *height + style.margin.vertical(),
4435            NodeKind::Barcode { height, .. } => *height + style.margin.vertical(),
4436            NodeKind::QrCode { size, .. } => {
4437                let display_size = size.unwrap_or(available_width - style.margin.horizontal());
4438                display_size + style.margin.vertical()
4439            }
4440            NodeKind::Canvas { height, .. } => *height + style.margin.vertical(),
4441            NodeKind::BarChart { height, .. }
4442            | NodeKind::LineChart { height, .. }
4443            | NodeKind::PieChart { height, .. }
4444            | NodeKind::AreaChart { height, .. }
4445            | NodeKind::DotPlot { height, .. } => *height + style.margin.vertical(),
4446            NodeKind::TextField { height, .. }
4447            | NodeKind::Checkbox { height, .. }
4448            | NodeKind::Dropdown { height, .. }
4449            | NodeKind::RadioButton { height, .. } => *height + style.margin.vertical(),
4450            NodeKind::Watermark { .. } => 0.0, // Watermarks take zero layout height
4451            _ => {
4452                // If a fixed height is specified, use it directly
4453                if let SizeConstraint::Fixed(h) = style.height {
4454                    return h;
4455                }
4456                // Match layout_view: when width is Auto, margin reduces the outer width
4457                let outer_width = match style.width {
4458                    SizeConstraint::Fixed(w) => w,
4459                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
4460                };
4461                let inner_width =
4462                    outer_width - style.padding.horizontal() - style.border_width.horizontal();
4463                let children_height =
4464                    self.measure_children_height(&node.children, inner_width, style, font_context);
4465                children_height + style.padding.vertical() + style.border_width.vertical()
4466            }
4467        }
4468    }
4469
4470    fn measure_children_height(
4471        &self,
4472        children: &[Node],
4473        available_width: f64,
4474        parent_style: &ResolvedStyle,
4475        font_context: &FontContext,
4476    ) -> f64 {
4477        // Grid layout: measure using actual grid placement instead of stacking
4478        if matches!(parent_style.display, Display::Grid) {
4479            if let Some(template_cols) = &parent_style.grid_template_columns {
4480                let num_columns = template_cols.len();
4481                if num_columns > 0 && !children.is_empty() {
4482                    let col_gap = parent_style.column_gap;
4483                    let row_gap = parent_style.row_gap;
4484
4485                    let content_sizes: Vec<f64> = template_cols
4486                        .iter()
4487                        .map(|track| {
4488                            if matches!(track, GridTrackSize::Auto) {
4489                                available_width / num_columns as f64
4490                            } else {
4491                                0.0
4492                            }
4493                        })
4494                        .collect();
4495
4496                    let col_widths = grid::resolve_tracks(
4497                        template_cols,
4498                        available_width,
4499                        col_gap,
4500                        &content_sizes,
4501                    );
4502
4503                    let placements: Vec<Option<&GridPlacement>> = children
4504                        .iter()
4505                        .map(|child| child.style.grid_placement.as_ref())
4506                        .collect();
4507
4508                    let item_placements = grid::place_items(&placements, num_columns);
4509                    let num_rows = grid::compute_num_rows(&item_placements);
4510
4511                    if num_rows == 0 {
4512                        return 0.0;
4513                    }
4514
4515                    let mut row_heights = vec![0.0_f64; num_rows];
4516                    for placement in &item_placements {
4517                        let cell_width = grid::span_width(
4518                            placement.col_start,
4519                            placement.col_end,
4520                            &col_widths,
4521                            col_gap,
4522                        );
4523                        let child = &children[placement.child_index];
4524                        let child_style = child.style.resolve(Some(parent_style), cell_width);
4525                        let h =
4526                            self.measure_node_height(child, cell_width, &child_style, font_context);
4527                        let span = placement.row_end - placement.row_start;
4528                        let per_row = h / span as f64;
4529                        for rh in row_heights
4530                            .iter_mut()
4531                            .take(placement.row_end.min(num_rows))
4532                            .skip(placement.row_start)
4533                        {
4534                            if per_row > *rh {
4535                                *rh = per_row;
4536                            }
4537                        }
4538                    }
4539
4540                    let total_row_gap = row_gap * (num_rows as f64 - 1.0).max(0.0);
4541                    return row_heights.iter().sum::<f64>() + total_row_gap;
4542                }
4543            }
4544        }
4545
4546        let direction = parent_style.flex_direction;
4547        let row_gap = parent_style.row_gap;
4548        let column_gap = parent_style.column_gap;
4549
4550        match direction {
4551            FlexDirection::Row | FlexDirection::RowReverse => {
4552                // Measure base widths for all children
4553                // flex_basis takes precedence over width (matching layout_flex_row)
4554                let styles: Vec<ResolvedStyle> = children
4555                    .iter()
4556                    .map(|child| child.style.resolve(Some(parent_style), available_width))
4557                    .collect();
4558
4559                let base_widths: Vec<f64> = children
4560                    .iter()
4561                    .zip(&styles)
4562                    .map(|(child, style)| match style.flex_basis {
4563                        SizeConstraint::Fixed(w) => w,
4564                        SizeConstraint::Auto => match style.width {
4565                            SizeConstraint::Fixed(w) => w,
4566                            SizeConstraint::Auto => self
4567                                .measure_intrinsic_width(child, style, font_context)
4568                                .min(available_width),
4569                        },
4570                    })
4571                    .collect();
4572
4573                let lines = match parent_style.flex_wrap {
4574                    FlexWrap::NoWrap => {
4575                        vec![flex::WrapLine {
4576                            start: 0,
4577                            end: children.len(),
4578                        }]
4579                    }
4580                    FlexWrap::Wrap | FlexWrap::WrapReverse => {
4581                        flex::partition_into_lines(&base_widths, column_gap, available_width)
4582                    }
4583                };
4584
4585                // Apply flex grow/shrink to get final widths (matching layout_flex_row)
4586                let mut final_widths = base_widths.clone();
4587                for line in &lines {
4588                    let line_count = line.end - line.start;
4589                    let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
4590                    let distributable = available_width - line_gap;
4591                    let total_base: f64 = base_widths[line.start..line.end].iter().sum();
4592                    let remaining = distributable - total_base;
4593
4594                    if remaining > 0.0 {
4595                        let total_grow: f64 = styles[line.start..line.end]
4596                            .iter()
4597                            .map(|s| s.flex_grow)
4598                            .sum();
4599                        if total_grow > 0.0 {
4600                            for (j, s) in styles[line.start..line.end].iter().enumerate() {
4601                                final_widths[line.start + j] = base_widths[line.start + j]
4602                                    + remaining * (s.flex_grow / total_grow);
4603                            }
4604                        }
4605                    } else if remaining < 0.0 {
4606                        let total_shrink: f64 = styles[line.start..line.end]
4607                            .iter()
4608                            .enumerate()
4609                            .map(|(j, s)| s.flex_shrink * base_widths[line.start + j])
4610                            .sum();
4611                        if total_shrink > 0.0 {
4612                            for (j, s) in styles[line.start..line.end].iter().enumerate() {
4613                                let factor =
4614                                    (s.flex_shrink * base_widths[line.start + j]) / total_shrink;
4615                                let w = base_widths[line.start + j] + remaining * factor;
4616                                final_widths[line.start + j] = w.max(s.min_width);
4617                            }
4618                        }
4619                    }
4620                }
4621
4622                let mut total = 0.0;
4623                for (i, line) in lines.iter().enumerate() {
4624                    let line_height: f64 = children[line.start..line.end]
4625                        .iter()
4626                        .enumerate()
4627                        .map(|(j, child)| {
4628                            let fw = final_widths[line.start + j];
4629                            let child_style = child.style.resolve(Some(parent_style), fw);
4630                            self.measure_node_height(child, fw, &child_style, font_context)
4631                                + child_style.margin.vertical()
4632                        })
4633                        .fold(0.0f64, f64::max);
4634                    total += line_height;
4635                    if i > 0 {
4636                        total += row_gap;
4637                    }
4638                }
4639                total
4640            }
4641            FlexDirection::Column | FlexDirection::ColumnReverse => {
4642                let mut total = 0.0;
4643                for (i, child) in children.iter().enumerate() {
4644                    let child_style = child.style.resolve(Some(parent_style), available_width);
4645                    let child_height = self.measure_node_height(
4646                        child,
4647                        available_width,
4648                        &child_style,
4649                        font_context,
4650                    );
4651                    total += child_height + child_style.margin.vertical();
4652                    if i > 0 {
4653                        total += row_gap;
4654                    }
4655                }
4656                total
4657            }
4658        }
4659    }
4660
4661    /// Measure intrinsic width of a node (used for flex row sizing).
4662    fn measure_intrinsic_width(
4663        &self,
4664        node: &Node,
4665        style: &ResolvedStyle,
4666        font_context: &FontContext,
4667    ) -> f64 {
4668        match &node.kind {
4669            NodeKind::Svg { width, .. } => {
4670                *width + style.padding.horizontal() + style.margin.horizontal()
4671            }
4672            NodeKind::Text { content, .. } => {
4673                let content = substitute_page_placeholders(content);
4674                let transformed = apply_text_transform(&content, style.text_transform);
4675                let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
4676                let text_width = font_context.measure_string(
4677                    &transformed,
4678                    &style.font_family,
4679                    style.font_weight,
4680                    italic,
4681                    style.font_size,
4682                    style.letter_spacing,
4683                );
4684                // Add tiny epsilon to prevent exact-boundary line wrapping when
4685                // this width is later used as max_width for line breaking
4686                text_width + 0.01 + style.padding.horizontal() + style.margin.horizontal()
4687            }
4688            NodeKind::Image {
4689                src, width, height, ..
4690            } => {
4691                let w = if let SizeConstraint::Fixed(w) = style.width {
4692                    w
4693                } else if let Some(w) = width {
4694                    *w
4695                } else if let Some((iw, ih)) = self.get_image_dimensions(src) {
4696                    let pixel_w = iw as f64;
4697                    let pixel_h = ih as f64;
4698                    let aspect = if pixel_w > 0.0 {
4699                        pixel_h / pixel_w
4700                    } else {
4701                        0.75
4702                    };
4703                    // Check for height constraint (style or node prop)
4704                    let constrained_h = match style.height {
4705                        SizeConstraint::Fixed(h) => Some(h),
4706                        SizeConstraint::Auto => *height,
4707                    };
4708                    if let Some(h) = constrained_h {
4709                        h / aspect
4710                    } else {
4711                        pixel_w
4712                    }
4713                } else {
4714                    100.0
4715                };
4716                w + style.padding.horizontal() + style.margin.horizontal()
4717            }
4718            NodeKind::Barcode { width, .. } => {
4719                let w = width.unwrap_or(0.0);
4720                w + style.padding.horizontal() + style.margin.horizontal()
4721            }
4722            NodeKind::QrCode { size, .. } => {
4723                let display_size = size.unwrap_or(0.0);
4724                display_size + style.padding.horizontal() + style.margin.horizontal()
4725            }
4726            NodeKind::Canvas { width, .. } => {
4727                *width + style.padding.horizontal() + style.margin.horizontal()
4728            }
4729            NodeKind::BarChart { width, .. }
4730            | NodeKind::LineChart { width, .. }
4731            | NodeKind::PieChart { width, .. }
4732            | NodeKind::AreaChart { width, .. }
4733            | NodeKind::DotPlot { width, .. } => {
4734                *width + style.padding.horizontal() + style.margin.horizontal()
4735            }
4736            NodeKind::TextField { width, .. } | NodeKind::Dropdown { width, .. } => {
4737                *width + style.padding.horizontal() + style.margin.horizontal()
4738            }
4739            NodeKind::Checkbox { width, .. } | NodeKind::RadioButton { width, .. } => {
4740                *width + style.padding.horizontal() + style.margin.horizontal()
4741            }
4742            NodeKind::Watermark { .. } => 0.0, // Watermarks take zero width
4743            _ => {
4744                // Recursively measure children's intrinsic widths
4745                if node.children.is_empty() {
4746                    style.padding.horizontal() + style.margin.horizontal()
4747                } else {
4748                    let direction = style.flex_direction;
4749                    let gap = style.gap;
4750                    let mut total = 0.0f64;
4751                    for (i, child) in node.children.iter().enumerate() {
4752                        let child_style = child.style.resolve(Some(style), 0.0);
4753                        let child_width =
4754                            self.measure_intrinsic_width(child, &child_style, font_context);
4755                        match direction {
4756                            FlexDirection::Row | FlexDirection::RowReverse => {
4757                                total += child_width;
4758                                if i > 0 {
4759                                    total += gap;
4760                                }
4761                            }
4762                            _ => {
4763                                total = total.max(child_width);
4764                            }
4765                        }
4766                    }
4767                    total
4768                        + style.padding.horizontal()
4769                        + style.margin.horizontal()
4770                        + style.border_width.horizontal()
4771                }
4772            }
4773        }
4774    }
4775
4776    /// Measure the min-content width of a node — the minimum width needed
4777    /// to render without breaking unbreakable words. For Text nodes this is
4778    /// the widest single word; for containers it's the max of children.
4779    pub fn measure_min_content_width(
4780        &self,
4781        node: &Node,
4782        style: &ResolvedStyle,
4783        font_context: &FontContext,
4784    ) -> f64 {
4785        match &node.kind {
4786            NodeKind::Text { content, runs, .. } => {
4787                let word_width = if !runs.is_empty() {
4788                    // For styled runs, measure each run's widest word
4789                    runs.iter()
4790                        .map(|run| {
4791                            let run_style = run.style.resolve(Some(style), 0.0);
4792                            let run_content = substitute_page_placeholders(&run.content);
4793                            let transformed =
4794                                apply_text_transform(&run_content, run_style.text_transform);
4795                            self.text_layout.measure_widest_word(
4796                                font_context,
4797                                &transformed,
4798                                run_style.font_size,
4799                                &run_style.font_family,
4800                                run_style.font_weight,
4801                                run_style.font_style,
4802                                run_style.letter_spacing,
4803                                style.hyphens,
4804                                style.lang.as_deref(),
4805                            )
4806                        })
4807                        .fold(0.0f64, f64::max)
4808                } else {
4809                    let content = substitute_page_placeholders(content);
4810                    let transformed = apply_text_transform(&content, style.text_transform);
4811                    self.text_layout.measure_widest_word(
4812                        font_context,
4813                        &transformed,
4814                        style.font_size,
4815                        &style.font_family,
4816                        style.font_weight,
4817                        style.font_style,
4818                        style.letter_spacing,
4819                        style.hyphens,
4820                        style.lang.as_deref(),
4821                    )
4822                };
4823                word_width + style.padding.horizontal() + style.margin.horizontal()
4824            }
4825            NodeKind::Image { width, .. } => {
4826                width.unwrap_or(0.0) + style.padding.horizontal() + style.margin.horizontal()
4827            }
4828            NodeKind::Svg { width, .. } => {
4829                *width + style.padding.horizontal() + style.margin.horizontal()
4830            }
4831            _ => {
4832                if node.children.is_empty() {
4833                    style.padding.horizontal()
4834                        + style.margin.horizontal()
4835                        + style.border_width.horizontal()
4836                } else {
4837                    let mut max_child_min = 0.0f64;
4838                    for child in &node.children {
4839                        let child_style = child.style.resolve(Some(style), 0.0);
4840                        let child_min =
4841                            self.measure_min_content_width(child, &child_style, font_context);
4842                        max_child_min = max_child_min.max(child_min);
4843                    }
4844                    max_child_min
4845                        + style.padding.horizontal()
4846                        + style.margin.horizontal()
4847                        + style.border_width.horizontal()
4848                }
4849            }
4850        }
4851    }
4852
4853    fn measure_table_row_height(
4854        &self,
4855        row: &Node,
4856        col_widths: &[f64],
4857        parent_style: &ResolvedStyle,
4858        font_context: &FontContext,
4859    ) -> f64 {
4860        let row_style = row
4861            .style
4862            .resolve(Some(parent_style), col_widths.iter().sum());
4863        let mut max_height: f64 = 0.0;
4864
4865        for (i, cell) in row.children.iter().enumerate() {
4866            let col_width = col_widths.get(i).copied().unwrap_or(0.0);
4867            let cell_style = cell.style.resolve(Some(&row_style), col_width);
4868            let inner_width =
4869                col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
4870
4871            let mut cell_content_height = 0.0;
4872            for child in &cell.children {
4873                let child_style = child.style.resolve(Some(&cell_style), inner_width);
4874                cell_content_height +=
4875                    self.measure_node_height(child, inner_width, &child_style, font_context);
4876            }
4877
4878            let total = cell_content_height
4879                + cell_style.padding.vertical()
4880                + cell_style.border_width.vertical();
4881            max_height = max_height.max(total);
4882        }
4883
4884        max_height.max(row_style.min_height)
4885    }
4886
4887    fn resolve_column_widths(
4888        &self,
4889        defs: &[ColumnDef],
4890        available_width: f64,
4891        children: &[Node],
4892    ) -> Vec<f64> {
4893        if defs.is_empty() {
4894            let num_cols = children.first().map(|row| row.children.len()).unwrap_or(1);
4895            return vec![available_width / num_cols as f64; num_cols];
4896        }
4897
4898        let mut widths = Vec::new();
4899        let mut remaining = available_width;
4900        let mut auto_count = 0;
4901
4902        for def in defs {
4903            match def.width {
4904                ColumnWidth::Fixed(w) => {
4905                    widths.push(w);
4906                    remaining -= w;
4907                }
4908                ColumnWidth::Fraction(f) => {
4909                    let w = available_width * f;
4910                    widths.push(w);
4911                    remaining -= w;
4912                }
4913                ColumnWidth::Auto => {
4914                    widths.push(0.0);
4915                    auto_count += 1;
4916                }
4917            }
4918        }
4919
4920        if auto_count > 0 {
4921            let auto_width = remaining / auto_count as f64;
4922            for (i, def) in defs.iter().enumerate() {
4923                if matches!(def.width, ColumnWidth::Auto) {
4924                    widths[i] = auto_width;
4925                }
4926            }
4927        }
4928
4929        widths
4930    }
4931
4932    fn inject_fixed_elements(&self, pages: &mut [LayoutPage], font_context: &FontContext) {
4933        for page in pages.iter_mut() {
4934            // Inject watermarks behind all content
4935            if !page.watermarks.is_empty() {
4936                let (page_w, page_h) = page.config.size.dimensions();
4937                let cx = page_w / 2.0;
4938                let cy = page_h / 2.0;
4939
4940                let mut watermark_elements = Vec::new();
4941                for wm_node in &page.watermarks {
4942                    if let NodeKind::Watermark {
4943                        text,
4944                        font_size,
4945                        angle,
4946                    } = &wm_node.kind
4947                    {
4948                        let style = wm_node.style.resolve(None, page_w);
4949                        let color = style.color;
4950                        let opacity = style.opacity;
4951                        let angle_rad = angle.to_radians();
4952
4953                        // Build positioned glyphs for the watermark text
4954                        let italic =
4955                            matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
4956
4957                        // Try shaping, fall back to per-char measurement
4958                        let shaped = self.text_layout.shape_text(
4959                            font_context,
4960                            text,
4961                            &style.font_family,
4962                            style.font_weight,
4963                            style.font_style,
4964                        );
4965
4966                        let mut glyphs = Vec::new();
4967                        let mut x_pos = 0.0;
4968                        let text_chars: Vec<char> = text.chars().collect();
4969
4970                        if let Some(shaped_glyphs) = shaped {
4971                            // Use shaped glyphs (custom fonts)
4972                            let units_per_em = font_context.units_per_em(
4973                                &style.font_family,
4974                                style.font_weight,
4975                                italic,
4976                            ) as f64;
4977
4978                            for sg in &shaped_glyphs {
4979                                let advance = sg.x_advance as f64 / units_per_em * *font_size;
4980                                let cluster_idx = sg.cluster as usize;
4981                                let ch = text_chars.get(cluster_idx).copied().unwrap_or(' ');
4982                                glyphs.push(PositionedGlyph {
4983                                    glyph_id: sg.glyph_id,
4984                                    char_value: ch,
4985                                    x_offset: x_pos,
4986                                    y_offset: 0.0,
4987                                    x_advance: advance,
4988                                    font_size: *font_size,
4989                                    font_family: style.font_family.clone(),
4990                                    font_weight: style.font_weight,
4991                                    font_style: style.font_style,
4992                                    color: Some(color),
4993                                    href: None,
4994                                    text_decoration: TextDecoration::None,
4995                                    letter_spacing: style.letter_spacing,
4996                                    cluster_text: None,
4997                                });
4998                                x_pos += advance + style.letter_spacing;
4999                            }
5000                        } else {
5001                            // Per-char measurement (standard fonts)
5002                            for &ch in &text_chars {
5003                                let w = font_context.char_width(
5004                                    ch,
5005                                    &style.font_family,
5006                                    style.font_weight,
5007                                    italic,
5008                                    *font_size,
5009                                );
5010                                glyphs.push(PositionedGlyph {
5011                                    glyph_id: ch as u16,
5012                                    char_value: ch,
5013                                    x_offset: x_pos,
5014                                    y_offset: 0.0,
5015                                    x_advance: w,
5016                                    font_size: *font_size,
5017                                    font_family: style.font_family.clone(),
5018                                    font_weight: style.font_weight,
5019                                    font_style: style.font_style,
5020                                    color: Some(color),
5021                                    href: None,
5022                                    text_decoration: TextDecoration::None,
5023                                    letter_spacing: style.letter_spacing,
5024                                    cluster_text: None,
5025                                });
5026                                x_pos += w + style.letter_spacing;
5027                            }
5028                        }
5029
5030                        let text_width = x_pos;
5031
5032                        let line = TextLine {
5033                            x: 0.0,
5034                            y: 0.0,
5035                            glyphs,
5036                            width: text_width,
5037                            height: *font_size,
5038                            word_spacing: 0.0,
5039                        };
5040
5041                        watermark_elements.push(LayoutElement {
5042                            x: cx,
5043                            y: cy,
5044                            width: text_width,
5045                            height: *font_size,
5046                            draw: DrawCommand::Watermark {
5047                                lines: vec![line],
5048                                color,
5049                                opacity,
5050                                angle_rad,
5051                                font_family: style.font_family.clone(),
5052                            },
5053                            children: vec![],
5054                            node_type: Some("Watermark".to_string()),
5055                            resolved_style: None,
5056                            source_location: None,
5057                            href: None,
5058                            bookmark: None,
5059                            alt: None,
5060                            is_header_row: false,
5061                            overflow: Overflow::default(),
5062                            opacity: 1.0,
5063                        });
5064                    }
5065                }
5066
5067                // Prepend watermark elements so they render behind all content
5068                watermark_elements.append(&mut page.elements);
5069                page.elements = watermark_elements;
5070                page.watermarks.clear();
5071            }
5072
5073            if page.fixed_header.is_empty() && page.fixed_footer.is_empty() {
5074                continue;
5075            }
5076
5077            // Lay out headers at top of content area
5078            if !page.fixed_header.is_empty() {
5079                let mut hdr_cursor = PageCursor::new(&page.config);
5080                for (node, _h) in &page.fixed_header {
5081                    let cw = hdr_cursor.content_width;
5082                    let cx = hdr_cursor.content_x;
5083                    let style = node.style.resolve(None, cw);
5084                    self.layout_view(
5085                        node,
5086                        &style,
5087                        &mut hdr_cursor,
5088                        &mut Vec::new(),
5089                        cx,
5090                        cw,
5091                        font_context,
5092                    );
5093                }
5094                // Prepend header elements so they draw behind body content
5095                let mut combined = hdr_cursor.elements;
5096                combined.append(&mut page.elements);
5097                page.elements = combined;
5098            }
5099
5100            // Lay out footers at bottom of content area.
5101            // We lay out from y=0 (so there's plenty of room and no spurious
5102            // page breaks), then shift all resulting elements down to the
5103            // correct footer position.
5104            if !page.fixed_footer.is_empty() {
5105                let mut ftr_cursor = PageCursor::new(&page.config);
5106                let total_ftr: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
5107                let target_y = ftr_cursor.content_height - total_ftr;
5108                // Layout from y=0
5109                for (node, _h) in &page.fixed_footer {
5110                    let cw = ftr_cursor.content_width;
5111                    let cx = ftr_cursor.content_x;
5112                    let style = node.style.resolve(None, cw);
5113                    self.layout_view(
5114                        node,
5115                        &style,
5116                        &mut ftr_cursor,
5117                        &mut Vec::new(),
5118                        cx,
5119                        cw,
5120                        font_context,
5121                    );
5122                }
5123                // Shift all footer elements down to the target position.
5124                // Elements already have content_y baked in, so we just offset
5125                // by target_y (which is relative to content area top).
5126                for el in &mut ftr_cursor.elements {
5127                    offset_element_y(el, target_y);
5128                }
5129                page.elements.extend(ftr_cursor.elements);
5130            }
5131
5132            // Clean up internal fields
5133            page.fixed_header.clear();
5134            page.fixed_footer.clear();
5135        }
5136    }
5137
5138    /// Layout children as a CSS Grid.
5139    ///
5140    /// Uses the grid track definitions from the parent style to create a 2D grid,
5141    /// places children into cells, and lays out each child within its cell bounds.
5142    #[allow(clippy::too_many_arguments)]
5143    fn layout_grid_children(
5144        &self,
5145        children: &[Node],
5146        parent_style: &ResolvedStyle,
5147        cursor: &mut PageCursor,
5148        pages: &mut Vec<LayoutPage>,
5149        x: f64,
5150        available_width: f64,
5151        font_context: &FontContext,
5152    ) {
5153        let template_cols = match &parent_style.grid_template_columns {
5154            Some(cols) => cols,
5155            None => return, // No columns defined, nothing to do
5156        };
5157
5158        let num_columns = template_cols.len();
5159        if num_columns == 0 || children.is_empty() {
5160            return;
5161        }
5162
5163        let col_gap = parent_style.column_gap;
5164        let row_gap = parent_style.row_gap;
5165
5166        // Resolve column widths
5167        // For auto tracks, we need content sizes. Use a rough measure.
5168        let content_sizes: Vec<f64> = template_cols
5169            .iter()
5170            .map(|track| {
5171                if matches!(track, GridTrackSize::Auto) {
5172                    // Measure the widest child that falls in this column
5173                    // (approximation: use available_width / num_columns)
5174                    available_width / num_columns as f64
5175                } else {
5176                    0.0
5177                }
5178            })
5179            .collect();
5180
5181        let col_widths =
5182            grid::resolve_tracks(template_cols, available_width, col_gap, &content_sizes);
5183
5184        // Collect grid placements from children's styles
5185        let placements: Vec<Option<&GridPlacement>> = children
5186            .iter()
5187            .map(|child| child.style.grid_placement.as_ref())
5188            .collect();
5189
5190        // Place items in the grid
5191        let item_placements = grid::place_items(&placements, num_columns);
5192        let num_rows = grid::compute_num_rows(&item_placements);
5193
5194        if num_rows == 0 {
5195            return;
5196        }
5197
5198        // Measure each item's height at its resolved cell width
5199        let mut item_heights: Vec<f64> = vec![0.0; children.len()];
5200        for placement in &item_placements {
5201            let cell_width =
5202                grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
5203            let child = &children[placement.child_index];
5204            let child_style = child.style.resolve(Some(parent_style), cell_width);
5205            item_heights[placement.child_index] =
5206                self.measure_node_height(child, cell_width, &child_style, font_context);
5207        }
5208
5209        // Compute row heights: max height of all items in each row
5210        let template_rows = parent_style.grid_template_rows.as_deref();
5211        let mut row_heights = vec![0.0_f64; num_rows];
5212        for placement in &item_placements {
5213            let h = item_heights[placement.child_index];
5214            let span = placement.row_end - placement.row_start;
5215            let per_row = h / span as f64;
5216            for rh in row_heights
5217                .iter_mut()
5218                .take(placement.row_end.min(num_rows))
5219                .skip(placement.row_start)
5220            {
5221                if per_row > *rh {
5222                    *rh = per_row;
5223                }
5224            }
5225        }
5226
5227        // Apply template row sizes if provided
5228        if let Some(template) = template_rows {
5229            let auto_row = parent_style.grid_auto_rows.as_ref();
5230            for (r, rh) in row_heights.iter_mut().enumerate() {
5231                let track = template.get(r).or(auto_row);
5232                if let Some(track) = track {
5233                    match track {
5234                        GridTrackSize::Pt(pts) => *rh = *pts,
5235                        GridTrackSize::Auto => {} // keep computed
5236                        _ => {}                   // Fr for rows is complex, skip for now
5237                    }
5238                }
5239            }
5240        }
5241
5242        // Layout each row
5243        for (row, &row_height) in row_heights.iter().enumerate().take(num_rows) {
5244            // Check page break: treat each row as unbreakable. The whole row
5245            // moves to the next page so all columns share the same baseline
5246            // (otherwise each cell's layout_node would page-break individually
5247            // and scatter the columns across separate pages).
5248            if row_height > cursor.remaining_height() {
5249                pages.push(cursor.finalize());
5250                *cursor = cursor.new_page();
5251            }
5252
5253            let row_start_y = cursor.y;
5254
5255            // Layout items in this row
5256            for placement in &item_placements {
5257                if placement.row_start != row {
5258                    continue; // Only process items starting in this row
5259                }
5260
5261                let cell_x = x + grid::column_x_offset(placement.col_start, &col_widths, col_gap);
5262                let cell_width =
5263                    grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
5264
5265                let child = &children[placement.child_index];
5266
5267                self.layout_node(
5268                    child,
5269                    cursor,
5270                    pages,
5271                    cell_x,
5272                    cell_width,
5273                    Some(parent_style),
5274                    font_context,
5275                    None,
5276                    None,
5277                );
5278                // Restore y to row baseline (items don't affect each other's y)
5279                cursor.y = row_start_y;
5280            }
5281
5282            cursor.y = row_start_y + row_height + row_gap;
5283        }
5284
5285        // Remove trailing gap
5286        if num_rows > 0 {
5287            cursor.y -= row_gap;
5288        }
5289    }
5290}
5291
5292struct FlexItem<'a> {
5293    node: &'a Node,
5294    style: ResolvedStyle,
5295    base_width: f64,
5296    min_content_width: f64,
5297}
5298
5299#[cfg(test)]
5300mod tests {
5301    use super::*;
5302    use crate::font::FontContext;
5303
5304    fn make_text(content: &str, font_size: f64) -> Node {
5305        Node {
5306            kind: NodeKind::Text {
5307                content: content.to_string(),
5308                href: None,
5309                runs: vec![],
5310            },
5311            style: Style {
5312                font_size: Some(font_size),
5313                ..Default::default()
5314            },
5315            children: vec![],
5316            id: None,
5317            source_location: None,
5318            bookmark: None,
5319            href: None,
5320            alt: None,
5321        }
5322    }
5323
5324    fn make_styled_view(style: Style, children: Vec<Node>) -> Node {
5325        Node {
5326            kind: NodeKind::View,
5327            style,
5328            children,
5329            id: None,
5330            source_location: None,
5331            bookmark: None,
5332            href: None,
5333            alt: None,
5334        }
5335    }
5336
5337    #[test]
5338    fn intrinsic_width_flex_row_sums_children() {
5339        let engine = LayoutEngine::new();
5340        let font_context = FontContext::new();
5341
5342        let child1 = make_text("Hello", 14.0);
5343        let child2 = make_text("World", 14.0);
5344
5345        let child1_style = child1.style.resolve(None, 0.0);
5346        let child2_style = child2.style.resolve(None, 0.0);
5347        let child1_w = engine.measure_intrinsic_width(&child1, &child1_style, &font_context);
5348        let child2_w = engine.measure_intrinsic_width(&child2, &child2_style, &font_context);
5349
5350        let row = make_styled_view(
5351            Style {
5352                flex_direction: Some(FlexDirection::Row),
5353                ..Default::default()
5354            },
5355            vec![make_text("Hello", 14.0), make_text("World", 14.0)],
5356        );
5357        let row_style = row.style.resolve(None, 0.0);
5358        let row_w = engine.measure_intrinsic_width(&row, &row_style, &font_context);
5359
5360        assert!(
5361            (row_w - (child1_w + child2_w)).abs() < 0.01,
5362            "Row intrinsic width ({}) should equal sum of children ({} + {})",
5363            row_w,
5364            child1_w,
5365            child2_w
5366        );
5367    }
5368
5369    #[test]
5370    fn intrinsic_width_flex_column_takes_max() {
5371        let engine = LayoutEngine::new();
5372        let font_context = FontContext::new();
5373
5374        let short = make_text("Hi", 14.0);
5375        let long = make_text("Hello World", 14.0);
5376
5377        let short_style = short.style.resolve(None, 0.0);
5378        let long_style = long.style.resolve(None, 0.0);
5379        let short_w = engine.measure_intrinsic_width(&short, &short_style, &font_context);
5380        let long_w = engine.measure_intrinsic_width(&long, &long_style, &font_context);
5381
5382        let col = make_styled_view(
5383            Style {
5384                flex_direction: Some(FlexDirection::Column),
5385                ..Default::default()
5386            },
5387            vec![make_text("Hi", 14.0), make_text("Hello World", 14.0)],
5388        );
5389        let col_style = col.style.resolve(None, 0.0);
5390        let col_w = engine.measure_intrinsic_width(&col, &col_style, &font_context);
5391
5392        assert!(
5393            (col_w - long_w).abs() < 0.01,
5394            "Column intrinsic width ({}) should equal max child ({}, short was {})",
5395            col_w,
5396            long_w,
5397            short_w
5398        );
5399    }
5400
5401    #[test]
5402    fn intrinsic_width_nested_containers() {
5403        let engine = LayoutEngine::new();
5404        let font_context = FontContext::new();
5405
5406        let inner = make_styled_view(
5407            Style {
5408                flex_direction: Some(FlexDirection::Row),
5409                ..Default::default()
5410            },
5411            vec![make_text("A", 12.0), make_text("B", 12.0)],
5412        );
5413        let inner_style = inner.style.resolve(None, 0.0);
5414        let inner_w = engine.measure_intrinsic_width(&inner, &inner_style, &font_context);
5415
5416        let outer = make_styled_view(
5417            Style::default(),
5418            vec![make_styled_view(
5419                Style {
5420                    flex_direction: Some(FlexDirection::Row),
5421                    ..Default::default()
5422                },
5423                vec![make_text("A", 12.0), make_text("B", 12.0)],
5424            )],
5425        );
5426        let outer_style = outer.style.resolve(None, 0.0);
5427        let outer_w = engine.measure_intrinsic_width(&outer, &outer_style, &font_context);
5428
5429        assert!(
5430            (outer_w - inner_w).abs() < 0.01,
5431            "Nested container ({}) should match inner container ({})",
5432            outer_w,
5433            inner_w
5434        );
5435    }
5436
5437    #[test]
5438    fn intrinsic_width_row_with_gap() {
5439        let engine = LayoutEngine::new();
5440        let font_context = FontContext::new();
5441
5442        let no_gap = make_styled_view(
5443            Style {
5444                flex_direction: Some(FlexDirection::Row),
5445                ..Default::default()
5446            },
5447            vec![make_text("A", 12.0), make_text("B", 12.0)],
5448        );
5449        let with_gap = make_styled_view(
5450            Style {
5451                flex_direction: Some(FlexDirection::Row),
5452                gap: Some(10.0),
5453                ..Default::default()
5454            },
5455            vec![make_text("A", 12.0), make_text("B", 12.0)],
5456        );
5457
5458        let no_gap_style = no_gap.style.resolve(None, 0.0);
5459        let with_gap_style = with_gap.style.resolve(None, 0.0);
5460        let no_gap_w = engine.measure_intrinsic_width(&no_gap, &no_gap_style, &font_context);
5461        let with_gap_w = engine.measure_intrinsic_width(&with_gap, &with_gap_style, &font_context);
5462
5463        assert!(
5464            (with_gap_w - no_gap_w - 10.0).abs() < 0.01,
5465            "Gap should add 10pt: with_gap={}, no_gap={}",
5466            with_gap_w,
5467            no_gap_w
5468        );
5469    }
5470
5471    #[test]
5472    fn intrinsic_width_empty_container() {
5473        let engine = LayoutEngine::new();
5474        let font_context = FontContext::new();
5475
5476        let padding = 8.0;
5477        let empty = make_styled_view(
5478            Style {
5479                padding: Some(Edges::uniform(padding)),
5480                ..Default::default()
5481            },
5482            vec![],
5483        );
5484        let style = empty.style.resolve(None, 0.0);
5485        let w = engine.measure_intrinsic_width(&empty, &style, &font_context);
5486
5487        assert!(
5488            (w - padding * 2.0).abs() < 0.01,
5489            "Empty container width ({}) should equal horizontal padding ({})",
5490            w,
5491            padding * 2.0
5492        );
5493    }
5494
5495    // ── Fix 1: min-content width prevents text wrapping in flex shrink ──
5496
5497    #[test]
5498    fn flex_shrink_respects_min_content_width() {
5499        // A flex row with a short-text child ("SALE") and a large sibling.
5500        // The shrink algorithm should not compress the short-text child below
5501        // the width of the word "SALE".
5502        let engine = LayoutEngine::new();
5503        let font_context = FontContext::new();
5504
5505        let sale_text = make_text("SALE", 12.0);
5506        let sale_style = sale_text.style.resolve(None, 0.0);
5507        let sale_word_width =
5508            engine.measure_min_content_width(&sale_text, &sale_style, &font_context);
5509        assert!(
5510            sale_word_width > 0.0,
5511            "SALE should have non-zero min-content width"
5512        );
5513
5514        // Row with 100pt available; child1 wants 80pt, child2 (SALE) wants 60pt.
5515        // Total = 140pt, overflow = 40pt. Without floor, SALE would shrink below word width.
5516        let container = make_styled_view(
5517            Style {
5518                flex_direction: Some(FlexDirection::Row),
5519                width: Some(Dimension::Pt(100.0)),
5520                ..Default::default()
5521            },
5522            vec![
5523                make_styled_view(
5524                    Style {
5525                        width: Some(Dimension::Pt(80.0)),
5526                        flex_shrink: Some(1.0),
5527                        ..Default::default()
5528                    },
5529                    vec![],
5530                ),
5531                make_styled_view(
5532                    Style {
5533                        width: Some(Dimension::Pt(60.0)),
5534                        flex_shrink: Some(1.0),
5535                        ..Default::default()
5536                    },
5537                    vec![make_text("SALE", 12.0)],
5538                ),
5539            ],
5540        );
5541
5542        let doc = Document {
5543            children: vec![Node::page(
5544                PageConfig::default(),
5545                Style::default(),
5546                vec![container],
5547            )],
5548            metadata: Default::default(),
5549            default_page: PageConfig::default(),
5550            fonts: vec![],
5551            tagged: false,
5552            pdfa: None,
5553            default_style: None,
5554            embedded_data: None,
5555            flatten_forms: false,
5556            pdf_ua: false,
5557            certification: None,
5558        };
5559
5560        let pages = engine.layout(&doc, &font_context);
5561        assert!(!pages.is_empty());
5562
5563        // The SALE child (second flex item) should not be narrower than its min-content width
5564        // Walk the layout tree: Page -> View (container) -> second child
5565        let page = &pages[0];
5566        // Find the container (the View with children)
5567        let container_el = page.elements.iter().find(|e| e.children.len() == 2);
5568        assert!(
5569            container_el.is_some(),
5570            "Should find container with 2 children"
5571        );
5572        let sale_child = &container_el.unwrap().children[1];
5573        assert!(
5574            sale_child.width >= sale_word_width - 0.01,
5575            "SALE child width ({}) should be >= min-content width ({})",
5576            sale_child.width,
5577            sale_word_width
5578        );
5579    }
5580
5581    // ── Fix 2: column justify-content and align-items ──
5582
5583    #[test]
5584    fn column_justify_content_center() {
5585        // A column container with fixed height 200pt and a single child of ~20pt.
5586        // With justify-content: center, the child should be roughly centered vertically.
5587        let engine = LayoutEngine::new();
5588        let font_context = FontContext::new();
5589
5590        let container = make_styled_view(
5591            Style {
5592                flex_direction: Some(FlexDirection::Column),
5593                height: Some(Dimension::Pt(200.0)),
5594                justify_content: Some(JustifyContent::Center),
5595                ..Default::default()
5596            },
5597            vec![make_text("Centered", 12.0)],
5598        );
5599
5600        let doc = Document {
5601            children: vec![Node::page(
5602                PageConfig::default(),
5603                Style::default(),
5604                vec![container],
5605            )],
5606            metadata: Default::default(),
5607            default_page: PageConfig::default(),
5608            fonts: vec![],
5609            tagged: false,
5610            pdfa: None,
5611            default_style: None,
5612            embedded_data: None,
5613            flatten_forms: false,
5614            pdf_ua: false,
5615            certification: None,
5616        };
5617
5618        let pages = engine.layout(&doc, &font_context);
5619        let page = &pages[0];
5620
5621        // The container should have one child, and that child should be
5622        // offset roughly to the vertical center
5623        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
5624        assert!(
5625            container_el.is_some(),
5626            "Should find container with children"
5627        );
5628        let container_el = container_el.unwrap();
5629        let child = &container_el.children[0];
5630
5631        // Child y should be container.y + roughly (200 - child_height) / 2
5632        let child_offset = child.y - container_el.y;
5633        let expected_offset = (200.0 - child.height) / 2.0;
5634        assert!(
5635            (child_offset - expected_offset).abs() < 2.0,
5636            "Child offset ({}) should be near center ({})",
5637            child_offset,
5638            expected_offset
5639        );
5640    }
5641
5642    #[test]
5643    fn column_align_items_center() {
5644        // A column container with a narrow text child.
5645        // With align-items: center, the child should be horizontally centered.
5646        let engine = LayoutEngine::new();
5647        let font_context = FontContext::new();
5648
5649        let container = make_styled_view(
5650            Style {
5651                flex_direction: Some(FlexDirection::Column),
5652                width: Some(Dimension::Pt(300.0)),
5653                align_items: Some(AlignItems::Center),
5654                ..Default::default()
5655            },
5656            vec![make_text("Hi", 12.0)],
5657        );
5658
5659        let doc = Document {
5660            children: vec![Node::page(
5661                PageConfig::default(),
5662                Style::default(),
5663                vec![container],
5664            )],
5665            metadata: Default::default(),
5666            default_page: PageConfig::default(),
5667            fonts: vec![],
5668            tagged: false,
5669            pdfa: None,
5670            default_style: None,
5671            embedded_data: None,
5672            flatten_forms: false,
5673            pdf_ua: false,
5674            certification: None,
5675        };
5676
5677        let pages = engine.layout(&doc, &font_context);
5678        let page = &pages[0];
5679
5680        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
5681        assert!(container_el.is_some());
5682        let container_el = container_el.unwrap();
5683        let child = &container_el.children[0];
5684
5685        // Child should be centered within the 300pt container
5686        let child_center = child.x + child.width / 2.0;
5687        let container_center = container_el.x + container_el.width / 2.0;
5688        assert!(
5689            (child_center - container_center).abs() < 2.0,
5690            "Child center ({}) should be near container center ({})",
5691            child_center,
5692            container_center
5693        );
5694    }
5695
5696    // ── Fix 3: absolute positioning relative to parent ──
5697
5698    #[test]
5699    fn absolute_child_positioned_relative_to_parent() {
5700        // A parent View at some offset with an absolute child using top: 10, left: 10.
5701        // The absolute child should be at parent + 10, not page + 10.
5702        let engine = LayoutEngine::new();
5703        let font_context = FontContext::new();
5704
5705        let parent = make_styled_view(
5706            Style {
5707                margin: Some(MarginEdges::from_edges(Edges {
5708                    top: 50.0,
5709                    left: 50.0,
5710                    ..Default::default()
5711                })),
5712                width: Some(Dimension::Pt(200.0)),
5713                height: Some(Dimension::Pt(200.0)),
5714                ..Default::default()
5715            },
5716            vec![make_styled_view(
5717                Style {
5718                    position: Some(crate::model::Position::Absolute),
5719                    top: Some(10.0),
5720                    left: Some(10.0),
5721                    width: Some(Dimension::Pt(50.0)),
5722                    height: Some(Dimension::Pt(50.0)),
5723                    ..Default::default()
5724                },
5725                vec![],
5726            )],
5727        );
5728
5729        let doc = Document {
5730            children: vec![Node::page(
5731                PageConfig::default(),
5732                Style::default(),
5733                vec![parent],
5734            )],
5735            metadata: Default::default(),
5736            default_page: PageConfig::default(),
5737            fonts: vec![],
5738            tagged: false,
5739            pdfa: None,
5740            default_style: None,
5741            embedded_data: None,
5742            flatten_forms: false,
5743            pdf_ua: false,
5744            certification: None,
5745        };
5746
5747        let pages = engine.layout(&doc, &font_context);
5748        let page = &pages[0];
5749
5750        // Find the parent container (has the absolute child inside it or as sibling)
5751        // Absolute children are added to cursor.elements, so they'll be inside the parent
5752        let parent_el = page
5753            .elements
5754            .iter()
5755            .find(|e| e.width > 190.0 && e.width < 210.0);
5756        assert!(parent_el.is_some(), "Should find the 200x200 parent");
5757        let parent_el = parent_el.unwrap();
5758
5759        // The absolute child should be at parent.x + 10, parent.y + 10
5760        let abs_child = parent_el
5761            .children
5762            .iter()
5763            .find(|e| e.width > 45.0 && e.width < 55.0);
5764        assert!(abs_child.is_some(), "Should find 50x50 absolute child");
5765        let abs_child = abs_child.unwrap();
5766
5767        let expected_x = parent_el.x + 10.0;
5768        let expected_y = parent_el.y + 10.0;
5769        assert!(
5770            (abs_child.x - expected_x).abs() < 1.0,
5771            "Absolute child x ({}) should be parent.x + 10 ({})",
5772            abs_child.x,
5773            expected_x
5774        );
5775        assert!(
5776            (abs_child.y - expected_y).abs() < 1.0,
5777            "Absolute child y ({}) should be parent.y + 10 ({})",
5778            abs_child.y,
5779            expected_y
5780        );
5781    }
5782
5783    #[test]
5784    fn text_transform_none_passthrough() {
5785        assert_eq!(
5786            apply_text_transform("Hello World", TextTransform::None),
5787            "Hello World"
5788        );
5789    }
5790
5791    #[test]
5792    fn text_transform_uppercase() {
5793        assert_eq!(
5794            apply_text_transform("hello world", TextTransform::Uppercase),
5795            "HELLO WORLD"
5796        );
5797    }
5798
5799    #[test]
5800    fn text_transform_lowercase() {
5801        assert_eq!(
5802            apply_text_transform("HELLO WORLD", TextTransform::Lowercase),
5803            "hello world"
5804        );
5805    }
5806
5807    #[test]
5808    fn text_transform_capitalize() {
5809        assert_eq!(
5810            apply_text_transform("hello world", TextTransform::Capitalize),
5811            "Hello World"
5812        );
5813        assert_eq!(
5814            apply_text_transform("  hello  world  ", TextTransform::Capitalize),
5815            "  Hello  World  "
5816        );
5817        assert_eq!(
5818            apply_text_transform("already Capitalized", TextTransform::Capitalize),
5819            "Already Capitalized"
5820        );
5821    }
5822
5823    #[test]
5824    fn text_transform_capitalize_empty() {
5825        assert_eq!(apply_text_transform("", TextTransform::Capitalize), "");
5826    }
5827
5828    #[test]
5829    fn apply_char_transform_uppercase() {
5830        assert_eq!(
5831            apply_char_transform('a', TextTransform::Uppercase, false),
5832            'A'
5833        );
5834        assert_eq!(
5835            apply_char_transform('A', TextTransform::Uppercase, false),
5836            'A'
5837        );
5838    }
5839
5840    #[test]
5841    fn apply_char_transform_capitalize_word_start() {
5842        assert_eq!(
5843            apply_char_transform('h', TextTransform::Capitalize, true),
5844            'H'
5845        );
5846        assert_eq!(
5847            apply_char_transform('h', TextTransform::Capitalize, false),
5848            'h'
5849        );
5850    }
5851
5852    // ── flex-grow in column direction ──
5853
5854    #[test]
5855    fn column_flex_grow_single_child_fills_container() {
5856        // A column container with fixed height 300pt and a single child with flex_grow: 1.
5857        // The child should expand to fill the entire 300pt.
5858        let engine = LayoutEngine::new();
5859        let font_context = FontContext::new();
5860
5861        let child = make_styled_view(
5862            Style {
5863                flex_grow: Some(1.0),
5864                ..Default::default()
5865            },
5866            vec![make_text("Short", 12.0)],
5867        );
5868
5869        let container = make_styled_view(
5870            Style {
5871                flex_direction: Some(FlexDirection::Column),
5872                height: Some(Dimension::Pt(300.0)),
5873                ..Default::default()
5874            },
5875            vec![child],
5876        );
5877
5878        let doc = Document {
5879            children: vec![Node::page(
5880                PageConfig::default(),
5881                Style::default(),
5882                vec![container],
5883            )],
5884            metadata: Default::default(),
5885            default_page: PageConfig::default(),
5886            fonts: vec![],
5887            tagged: false,
5888            pdfa: None,
5889            default_style: None,
5890            embedded_data: None,
5891            flatten_forms: false,
5892            pdf_ua: false,
5893            certification: None,
5894        };
5895
5896        let pages = engine.layout(&doc, &font_context);
5897        let page = &pages[0];
5898
5899        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
5900        assert!(container_el.is_some());
5901        let container_el = container_el.unwrap();
5902        assert!(
5903            (container_el.height - 300.0).abs() < 1.0,
5904            "Container should be 300pt, got {}",
5905            container_el.height
5906        );
5907
5908        let child_el = &container_el.children[0];
5909        assert!(
5910            (child_el.height - 300.0).abs() < 1.0,
5911            "flex-grow child should expand to 300pt, got {}",
5912            child_el.height
5913        );
5914    }
5915
5916    #[test]
5917    fn column_flex_grow_two_children_proportional() {
5918        // Two children: one with flex_grow: 1, one with flex_grow: 2.
5919        // They should share remaining space proportionally (1:2).
5920        let engine = LayoutEngine::new();
5921        let font_context = FontContext::new();
5922
5923        let child1 = make_styled_view(
5924            Style {
5925                flex_grow: Some(1.0),
5926                ..Default::default()
5927            },
5928            vec![make_text("A", 12.0)],
5929        );
5930        let child2 = make_styled_view(
5931            Style {
5932                flex_grow: Some(2.0),
5933                ..Default::default()
5934            },
5935            vec![make_text("B", 12.0)],
5936        );
5937
5938        let container = make_styled_view(
5939            Style {
5940                flex_direction: Some(FlexDirection::Column),
5941                height: Some(Dimension::Pt(300.0)),
5942                ..Default::default()
5943            },
5944            vec![child1, child2],
5945        );
5946
5947        let doc = Document {
5948            children: vec![Node::page(
5949                PageConfig::default(),
5950                Style::default(),
5951                vec![container],
5952            )],
5953            metadata: Default::default(),
5954            default_page: PageConfig::default(),
5955            fonts: vec![],
5956            tagged: false,
5957            pdfa: None,
5958            default_style: None,
5959            embedded_data: None,
5960            flatten_forms: false,
5961            pdf_ua: false,
5962            certification: None,
5963        };
5964
5965        let pages = engine.layout(&doc, &font_context);
5966        let page = &pages[0];
5967
5968        let container_el = page
5969            .elements
5970            .iter()
5971            .find(|e| e.children.len() == 2)
5972            .expect("Should find container with two children");
5973
5974        let c1 = &container_el.children[0];
5975        let c2 = &container_el.children[1];
5976
5977        // Both children have the same natural height (one line of text).
5978        // The slack is split 1:2 between them.
5979        // So child2 should be roughly twice as much taller than child1's growth.
5980        let total = c1.height + c2.height;
5981        assert!(
5982            (total - 300.0).abs() < 2.0,
5983            "Children should sum to ~300pt, got {}",
5984            total
5985        );
5986
5987        // child2.height should be roughly 2x child1.height
5988        // (not exact because natural heights are equal, but growth is 1:2)
5989        let ratio = c2.height / c1.height;
5990        assert!(
5991            ratio > 1.3 && ratio < 2.5,
5992            "child2/child1 ratio should be between 1.3 and 2.5, got {}",
5993            ratio
5994        );
5995    }
5996
5997    #[test]
5998    fn column_flex_grow_mixed_grow_and_fixed() {
5999        // One fixed child (no flex_grow) and one flex_grow child.
6000        // The flex_grow child takes all remaining space.
6001        let engine = LayoutEngine::new();
6002        let font_context = FontContext::new();
6003
6004        let fixed_child = make_styled_view(
6005            Style {
6006                height: Some(Dimension::Pt(50.0)),
6007                ..Default::default()
6008            },
6009            vec![make_text("Fixed", 12.0)],
6010        );
6011        let grow_child = make_styled_view(
6012            Style {
6013                flex_grow: Some(1.0),
6014                ..Default::default()
6015            },
6016            vec![make_text("Grow", 12.0)],
6017        );
6018
6019        let container = make_styled_view(
6020            Style {
6021                flex_direction: Some(FlexDirection::Column),
6022                height: Some(Dimension::Pt(300.0)),
6023                ..Default::default()
6024            },
6025            vec![fixed_child, grow_child],
6026        );
6027
6028        let doc = Document {
6029            children: vec![Node::page(
6030                PageConfig::default(),
6031                Style::default(),
6032                vec![container],
6033            )],
6034            metadata: Default::default(),
6035            default_page: PageConfig::default(),
6036            fonts: vec![],
6037            tagged: false,
6038            pdfa: None,
6039            default_style: None,
6040            embedded_data: None,
6041            flatten_forms: false,
6042            pdf_ua: false,
6043            certification: None,
6044        };
6045
6046        let pages = engine.layout(&doc, &font_context);
6047        let page = &pages[0];
6048
6049        let container_el = page
6050            .elements
6051            .iter()
6052            .find(|e| e.children.len() == 2)
6053            .expect("Should find container with two children");
6054
6055        let fixed_el = &container_el.children[0];
6056        let grow_el = &container_el.children[1];
6057
6058        // Fixed child stays at 50pt
6059        assert!(
6060            (fixed_el.height - 50.0).abs() < 1.0,
6061            "Fixed child should stay at 50pt, got {}",
6062            fixed_el.height
6063        );
6064
6065        // Grow child takes remaining ~250pt
6066        assert!(
6067            (grow_el.height - 250.0).abs() < 2.0,
6068            "Grow child should expand to ~250pt, got {}",
6069            grow_el.height
6070        );
6071    }
6072
6073    #[test]
6074    fn column_flex_grow_page_level() {
6075        // flex_grow: 1 on a direct Page child should fill the page content area.
6076        let engine = LayoutEngine::new();
6077        let font_context = FontContext::new();
6078
6079        let grow_child = make_styled_view(
6080            Style {
6081                flex_grow: Some(1.0),
6082                ..Default::default()
6083            },
6084            vec![make_text("Fill page", 12.0)],
6085        );
6086
6087        let doc = Document {
6088            children: vec![Node::page(
6089                PageConfig::default(),
6090                Style::default(),
6091                vec![grow_child],
6092            )],
6093            metadata: Default::default(),
6094            default_page: PageConfig::default(),
6095            fonts: vec![],
6096            tagged: false,
6097            pdfa: None,
6098            default_style: None,
6099            embedded_data: None,
6100            flatten_forms: false,
6101            pdf_ua: false,
6102            certification: None,
6103        };
6104
6105        let pages = engine.layout(&doc, &font_context);
6106        let page = &pages[0];
6107
6108        // The child should fill the page content height
6109        assert!(
6110            !page.elements.is_empty(),
6111            "Page should have at least one element"
6112        );
6113
6114        let content_height = page.height - page.config.margin.top - page.config.margin.bottom;
6115        let el = &page.elements[0];
6116        assert!(
6117            (el.height - content_height).abs() < 2.0,
6118            "Page-level flex-grow child should fill content height ({}), got {}",
6119            content_height,
6120            el.height
6121        );
6122    }
6123
6124    #[test]
6125    fn column_flex_grow_with_justify_content() {
6126        // flex-grow and justify-content: center should work together.
6127        // A fixed child + a grow child + justify-content: center.
6128        // After grow fills the space, there's no slack left for justify, so positions stay as-is.
6129        let engine = LayoutEngine::new();
6130        let font_context = FontContext::new();
6131
6132        let fixed_child = make_styled_view(
6133            Style {
6134                height: Some(Dimension::Pt(50.0)),
6135                ..Default::default()
6136            },
6137            vec![make_text("Top", 12.0)],
6138        );
6139        let grow_child = make_styled_view(
6140            Style {
6141                flex_grow: Some(1.0),
6142                ..Default::default()
6143            },
6144            vec![make_text("Fill", 12.0)],
6145        );
6146
6147        let container = make_styled_view(
6148            Style {
6149                flex_direction: Some(FlexDirection::Column),
6150                height: Some(Dimension::Pt(300.0)),
6151                justify_content: Some(JustifyContent::Center),
6152                ..Default::default()
6153            },
6154            vec![fixed_child, grow_child],
6155        );
6156
6157        let doc = Document {
6158            children: vec![Node::page(
6159                PageConfig::default(),
6160                Style::default(),
6161                vec![container],
6162            )],
6163            metadata: Default::default(),
6164            default_page: PageConfig::default(),
6165            fonts: vec![],
6166            tagged: false,
6167            pdfa: None,
6168            default_style: None,
6169            embedded_data: None,
6170            flatten_forms: false,
6171            pdf_ua: false,
6172            certification: None,
6173        };
6174
6175        let pages = engine.layout(&doc, &font_context);
6176        let page = &pages[0];
6177
6178        let container_el = page
6179            .elements
6180            .iter()
6181            .find(|e| e.children.len() == 2)
6182            .expect("Should find container");
6183
6184        // After flex-grow absorbs all slack, justify-content has nothing to distribute.
6185        // First child should be at the top of the container.
6186        let first_child = &container_el.children[0];
6187        assert!(
6188            (first_child.y - container_el.y).abs() < 1.0,
6189            "First child should be at top of container"
6190        );
6191
6192        // Children should still sum to container height
6193        let total = container_el.children[0].height + container_el.children[1].height;
6194        assert!(
6195            (total - 300.0).abs() < 2.0,
6196            "Children should fill container, got {}",
6197            total
6198        );
6199    }
6200
6201    #[test]
6202    fn column_flex_grow_child_justify_content_center() {
6203        // A flex-grow child with justify-content: center should vertically center its content.
6204        // This is the cover-page bug: the inner View grows via flex but its children stay at top.
6205        let engine = LayoutEngine::new();
6206        let font_context = FontContext::new();
6207
6208        // Inner content: a small fixed-height box
6209        let inner_box = make_styled_view(
6210            Style {
6211                height: Some(Dimension::Pt(40.0)),
6212                ..Default::default()
6213            },
6214            vec![make_text("Centered", 12.0)],
6215        );
6216
6217        // The grow child: flex: 1, justify-content: center
6218        let grow_child = make_styled_view(
6219            Style {
6220                flex_grow: Some(1.0),
6221                flex_direction: Some(FlexDirection::Column),
6222                justify_content: Some(JustifyContent::Center),
6223                ..Default::default()
6224            },
6225            vec![inner_box],
6226        );
6227
6228        // Outer column container with fixed height
6229        let container = make_styled_view(
6230            Style {
6231                flex_direction: Some(FlexDirection::Column),
6232                height: Some(Dimension::Pt(400.0)),
6233                ..Default::default()
6234            },
6235            vec![grow_child],
6236        );
6237
6238        let doc = Document {
6239            children: vec![Node::page(
6240                PageConfig::default(),
6241                Style::default(),
6242                vec![container],
6243            )],
6244            metadata: Default::default(),
6245            default_page: PageConfig::default(),
6246            fonts: vec![],
6247            tagged: false,
6248            pdfa: None,
6249            default_style: None,
6250            embedded_data: None,
6251            flatten_forms: false,
6252            pdf_ua: false,
6253            certification: None,
6254        };
6255
6256        let pages = engine.layout(&doc, &font_context);
6257        let page = &pages[0];
6258
6259        // Find the container (has 1 child = the grow child)
6260        let container_el = page
6261            .elements
6262            .iter()
6263            .find(|e| e.height > 350.0 && e.children.len() == 1)
6264            .expect("Should find outer container");
6265
6266        let grow_el = &container_el.children[0];
6267        assert!(
6268            (grow_el.height - 400.0).abs() < 2.0,
6269            "Grow child should expand to 400, got {}",
6270            grow_el.height
6271        );
6272
6273        // The inner box should be vertically centered within the grow child
6274        let inner_el = &grow_el.children[0];
6275        let expected_center = grow_el.y + grow_el.height / 2.0;
6276        let actual_center = inner_el.y + inner_el.height / 2.0;
6277        assert!(
6278            (actual_center - expected_center).abs() < 2.0,
6279            "Inner box should be vertically centered. Expected center ~{}, got ~{}",
6280            expected_center,
6281            actual_center
6282        );
6283    }
6284
6285    #[test]
6286    fn column_flex_grow_child_justify_content_flex_end() {
6287        // A flex-grow child with justify-content: flex-end should push content to the bottom.
6288        let engine = LayoutEngine::new();
6289        let font_context = FontContext::new();
6290
6291        let inner_box = make_styled_view(
6292            Style {
6293                height: Some(Dimension::Pt(30.0)),
6294                ..Default::default()
6295            },
6296            vec![make_text("Bottom", 12.0)],
6297        );
6298
6299        let grow_child = make_styled_view(
6300            Style {
6301                flex_grow: Some(1.0),
6302                flex_direction: Some(FlexDirection::Column),
6303                justify_content: Some(JustifyContent::FlexEnd),
6304                ..Default::default()
6305            },
6306            vec![inner_box],
6307        );
6308
6309        let container = make_styled_view(
6310            Style {
6311                flex_direction: Some(FlexDirection::Column),
6312                height: Some(Dimension::Pt(300.0)),
6313                ..Default::default()
6314            },
6315            vec![grow_child],
6316        );
6317
6318        let doc = Document {
6319            children: vec![Node::page(
6320                PageConfig::default(),
6321                Style::default(),
6322                vec![container],
6323            )],
6324            metadata: Default::default(),
6325            default_page: PageConfig::default(),
6326            fonts: vec![],
6327            tagged: false,
6328            pdfa: None,
6329            default_style: None,
6330            embedded_data: None,
6331            flatten_forms: false,
6332            pdf_ua: false,
6333            certification: None,
6334        };
6335
6336        let pages = engine.layout(&doc, &font_context);
6337        let page = &pages[0];
6338
6339        let container_el = page
6340            .elements
6341            .iter()
6342            .find(|e| e.height > 250.0 && e.children.len() == 1)
6343            .expect("Should find outer container");
6344
6345        let grow_el = &container_el.children[0];
6346        let inner_el = &grow_el.children[0];
6347
6348        // Inner box should be near the bottom of the grow child
6349        let inner_bottom = inner_el.y + inner_el.height;
6350        let grow_bottom = grow_el.y + grow_el.height;
6351        assert!(
6352            (inner_bottom - grow_bottom).abs() < 2.0,
6353            "Inner box bottom ({}) should align with grow child bottom ({})",
6354            inner_bottom,
6355            grow_bottom
6356        );
6357    }
6358
6359    #[test]
6360    fn column_flex_grow_child_no_justify_unchanged() {
6361        // Regression: flex-grow with default FlexStart should keep content at top.
6362        let engine = LayoutEngine::new();
6363        let font_context = FontContext::new();
6364
6365        let inner_box = make_styled_view(
6366            Style {
6367                height: Some(Dimension::Pt(50.0)),
6368                ..Default::default()
6369            },
6370            vec![make_text("Top", 12.0)],
6371        );
6372
6373        let grow_child = make_styled_view(
6374            Style {
6375                flex_grow: Some(1.0),
6376                flex_direction: Some(FlexDirection::Column),
6377                // No justify-content set — defaults to FlexStart
6378                ..Default::default()
6379            },
6380            vec![inner_box],
6381        );
6382
6383        let container = make_styled_view(
6384            Style {
6385                flex_direction: Some(FlexDirection::Column),
6386                height: Some(Dimension::Pt(300.0)),
6387                ..Default::default()
6388            },
6389            vec![grow_child],
6390        );
6391
6392        let doc = Document {
6393            children: vec![Node::page(
6394                PageConfig::default(),
6395                Style::default(),
6396                vec![container],
6397            )],
6398            metadata: Default::default(),
6399            default_page: PageConfig::default(),
6400            fonts: vec![],
6401            tagged: false,
6402            pdfa: None,
6403            default_style: None,
6404            embedded_data: None,
6405            flatten_forms: false,
6406            pdf_ua: false,
6407            certification: None,
6408        };
6409
6410        let pages = engine.layout(&doc, &font_context);
6411        let page = &pages[0];
6412
6413        let container_el = page
6414            .elements
6415            .iter()
6416            .find(|e| e.height > 250.0 && e.children.len() == 1)
6417            .expect("Should find outer container");
6418
6419        let grow_el = &container_el.children[0];
6420        let inner_el = &grow_el.children[0];
6421
6422        // Inner box should stay at the top of the grow child
6423        assert!(
6424            (inner_el.y - grow_el.y).abs() < 2.0,
6425            "Inner box ({}) should be at top of grow child ({})",
6426            inner_el.y,
6427            grow_el.y
6428        );
6429    }
6430
6431    #[test]
6432    fn column_flex_grow_child_align_items_center() {
6433        // A flex-grown View with align_items: Center should horizontally center its Text child.
6434        let engine = LayoutEngine::new();
6435        let font_context = FontContext::new();
6436
6437        let text = make_text("Hello", 12.0);
6438
6439        let grow_child = make_styled_view(
6440            Style {
6441                flex_grow: Some(1.0),
6442                flex_direction: Some(FlexDirection::Column),
6443                align_items: Some(AlignItems::Center),
6444                ..Default::default()
6445            },
6446            vec![text],
6447        );
6448
6449        let container = make_styled_view(
6450            Style {
6451                flex_direction: Some(FlexDirection::Column),
6452                height: Some(Dimension::Pt(300.0)),
6453                ..Default::default()
6454            },
6455            vec![grow_child],
6456        );
6457
6458        let doc = Document {
6459            children: vec![Node::page(
6460                PageConfig::default(),
6461                Style::default(),
6462                vec![container],
6463            )],
6464            metadata: Default::default(),
6465            default_page: PageConfig::default(),
6466            fonts: vec![],
6467            tagged: false,
6468            pdfa: None,
6469            default_style: None,
6470            embedded_data: None,
6471            flatten_forms: false,
6472            pdf_ua: false,
6473            certification: None,
6474        };
6475
6476        let pages = engine.layout(&doc, &font_context);
6477        let page = &pages[0];
6478
6479        let container_el = page
6480            .elements
6481            .iter()
6482            .find(|e| e.height > 250.0 && e.children.len() == 1)
6483            .expect("Should find outer container");
6484
6485        let grow_el = &container_el.children[0];
6486        assert!(
6487            !grow_el.children.is_empty(),
6488            "Grow child should have text child"
6489        );
6490
6491        let text_el = &grow_el.children[0];
6492        let text_center = text_el.x + text_el.width / 2.0;
6493        let grow_center = grow_el.x + grow_el.width / 2.0;
6494        assert!(
6495            (text_center - grow_center).abs() < 2.0,
6496            "Text center ({}) should be near grow child center ({})",
6497            text_center,
6498            grow_center
6499        );
6500    }
6501
6502    #[test]
6503    fn image_intrinsic_width_respects_height_constraint() {
6504        // An Image with only a height prop should compute intrinsic width from
6505        // aspect ratio, not return the raw pixel width. This ensures align-items:
6506        // center can correctly center images.
6507        let engine = LayoutEngine::new();
6508        let font_context = FontContext::new();
6509
6510        // Use a 1x1 PNG data URI (known dimensions: 1x1 pixels)
6511        let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
6512
6513        let image_node = Node {
6514            kind: NodeKind::Image {
6515                src: one_px_png.to_string(),
6516                width: None,
6517                height: Some(36.0),
6518            },
6519            style: Style::default(),
6520            children: vec![],
6521            id: None,
6522            source_location: None,
6523            bookmark: None,
6524            href: None,
6525            alt: None,
6526        };
6527
6528        let resolved = image_node.style.resolve(None, 0.0);
6529        let intrinsic = engine.measure_intrinsic_width(&image_node, &resolved, &font_context);
6530
6531        // 1x1 pixel image with height: 36 should give width = 36 / (1/1) = 36
6532        assert!(
6533            (intrinsic - 36.0).abs() < 1.0,
6534            "Intrinsic width should be ~36 for 1:1 aspect image with height 36, got {}",
6535            intrinsic
6536        );
6537    }
6538}