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