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