Skip to main content

forme/layout/
mod.rs

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