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