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