Skip to main content

forme/layout/
mod.rs

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