Skip to main content

forme/layout/
mod.rs

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