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