Skip to main content

forme/layout/
mod.rs

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