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