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