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