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 mut glyphs =
4953                self.build_positioned_glyphs_single_style(line, style, href, font_context);
4954
4955            // Use actual rendered width from glyphs for alignment (may differ from
4956            // line.width when per-char measurement is used for line breaking but
4957            // shaping is used for glyph placement).
4958            let rendered_width = if glyphs.is_empty() {
4959                line.width
4960            } else {
4961                let last = &glyphs[glyphs.len() - 1];
4962                (last.x_offset + last.x_advance).max(line.width * 0.5)
4963            };
4964
4965            let line_x = match style.text_align {
4966                TextAlign::Left => text_x,
4967                TextAlign::Right => text_x + text_width - rendered_width,
4968                TextAlign::Center => text_x + (text_width - rendered_width) / 2.0,
4969                TextAlign::Justify => text_x,
4970            };
4971
4972            // Justify: compute extra word spacing so the line fills the column width.
4973            // Use the sum of natural glyph advances (what PDF Tj actually renders)
4974            // rather than KP-adjusted positions, which bake justification into
4975            // char_positions and make slack ≈ 0.
4976            //
4977            // User-set `word_spacing` is the base; when text is justified, the
4978            // computed slack-per-space is added on top.
4979            // A line ending at a forced break is left alone, like the last
4980            // line, as CSS text-align-last does (#186).
4981            let is_last_line = line_idx == lines.len() - 1 || line.hard_break;
4982            let user_ws = style.word_spacing;
4983            let (justified_width, word_spacing) =
4984                if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
4985                    let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
4986                    let (natural_width, space_count) = if let Some(idx) = last_non_space {
4987                        let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
4988                        let s = glyphs[..=idx]
4989                            .iter()
4990                            .filter(|g| g.char_value == ' ')
4991                            .count();
4992                        (w, s)
4993                    } else {
4994                        (0.0, 0)
4995                    };
4996                    if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
4997                        Self::spread_justification(&mut glyphs, text_width);
4998                    }
4999                    let slack = text_width - natural_width;
5000                    let ws = if space_count > 0 && slack.abs() > 0.01 {
5001                        slack / space_count as f64
5002                    } else {
5003                        0.0
5004                    };
5005                    (text_width, user_ws + ws)
5006                } else {
5007                    (rendered_width, user_ws)
5008                };
5009
5010            let text_line = TextLine {
5011                x: line_x,
5012                // Half-leading: the line box's extra space over the glyph block
5013                // splits evenly above and below (CSS line box model). The
5014                // baseline therefore sits half the leading further down —
5015                // this is also what makes the pre-flexbox centering idiom
5016                // (line-height matched to a box height) actually center.
5017                y: cursor.content_y
5018                    + cursor.y
5019                    + baseline_in_line(
5020                        line_height,
5021                        style.font_size,
5022                        font_context.baseline_metrics(
5023                            &style.font_family,
5024                            style.font_weight,
5025                            matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique),
5026                        ),
5027                    ),
5028                glyphs,
5029                width: justified_width,
5030                height: line_height,
5031                word_spacing,
5032            };
5033
5034            cursor.elements.push(LayoutElement {
5035                x: line_x,
5036                y: cursor.content_y + cursor.y,
5037                width: justified_width,
5038                height: line_height,
5039                draw: DrawCommand::Text {
5040                    lines: vec![text_line],
5041                    color: style.color,
5042                    text_decoration: style.text_decoration,
5043                    opacity: 1.0,
5044                },
5045                children: vec![],
5046                node_type: Some("TextLine".to_string()),
5047                resolved_style: Some(style.clone()),
5048                source_location: None,
5049                href: href.map(|s| s.to_string()),
5050                bookmark: None,
5051                alt: None,
5052                is_header_row: false,
5053                actual_text: None,
5054                list_numbering: None,
5055                col_span: 1,
5056                overflow: Overflow::default(),
5057                opacity: 1.0,
5058            });
5059
5060            cursor.y += line_height;
5061        }
5062
5063        // Wrap remaining lines into a Text container
5064        let line_elements: Vec<LayoutElement> = drain_since(&mut cursor.elements, snapshot);
5065        if !line_elements.is_empty() {
5066            let container_height = cursor.content_y + cursor.y - container_start_y;
5067            cursor.elements.push(LayoutElement {
5068                x: text_x,
5069                y: container_start_y,
5070                width: text_width,
5071                height: container_height,
5072                draw: DrawCommand::None,
5073                children: line_elements,
5074                node_type: Some(node_type_override.unwrap_or("Text").to_string()),
5075                resolved_style: Some(style.clone()),
5076                source_location: source_location.cloned(),
5077                href: href.map(|s| s.to_string()),
5078                bookmark: if is_first_element {
5079                    bookmark.map(|s| s.to_string())
5080                } else {
5081                    None
5082                },
5083                alt: None,
5084                is_header_row: false,
5085                actual_text: None,
5086                list_numbering: None,
5087                col_span: 1,
5088                overflow: Overflow::default(),
5089                opacity: 1.0,
5090            });
5091        }
5092
5093        cursor.y += margin.bottom;
5094    }
5095
5096    /// Build a justified line's stretch into its glyph offsets: whatever gap
5097    /// is left between the last non-space glyph and `width` is shared out
5098    /// over the spaces before it, each glyph moving by the share of every
5099    /// space to its left. Knuth-Plass already builds justification into its
5100    /// positions, so there the gap is nil and nothing moves; when it falls
5101    /// back to the greedy breaker, the stretch existed only as `Tw`, which a
5102    /// registered (Type0) font never applies, and the line was drawn ragged.
5103    /// With the stretch in the offsets, a registered font is justified by its
5104    /// positioned `TJ`, and a styled group starts where the stretched group
5105    /// before it ends (#186). `Tw` is unchanged: standard fonts still draw
5106    /// their spaces with it, which now matches the offsets.
5107    fn spread_justification(glyphs: &mut [PositionedGlyph], width: f64) {
5108        let Some(last) = glyphs.iter().rposition(|g| g.char_value != ' ') else {
5109            return;
5110        };
5111        let spaces = glyphs[..last]
5112            .iter()
5113            .filter(|g| g.char_value == ' ')
5114            .count();
5115        let gap = width - (glyphs[last].x_offset + glyphs[last].x_advance);
5116        if spaces == 0 || gap.abs() <= 0.01 {
5117            return;
5118        }
5119        let share = gap / spaces as f64;
5120        let mut seen = 0usize;
5121        for g in glyphs.iter_mut() {
5122            g.x_offset += share * seen as f64;
5123            if g.char_value == ' ' {
5124                seen += 1;
5125            }
5126        }
5127    }
5128
5129    /// The styled chars a run sequence lays out as: each run's resolved
5130    /// style, placeholders substituted and text-transform applied. Intrinsic
5131    /// measurement builds the same chars, so it measures what layout breaks.
5132    fn runs_to_styled_chars(
5133        runs: &[TextRun],
5134        parent_href: Option<&str>,
5135        style: &ResolvedStyle,
5136        width: f64,
5137    ) -> Vec<StyledChar> {
5138        let mut styled_chars: Vec<StyledChar> = Vec::new();
5139        for run in runs {
5140            let run_style = run.style.resolve(Some(style), width);
5141            let run_href = run.href.as_deref().or(parent_href);
5142            let transform = run_style.text_transform;
5143            let run_content = substitute_page_placeholders(&run.content);
5144            let mut prev_is_whitespace = true;
5145            for ch in run_content.chars() {
5146                let transformed_ch = apply_char_transform(ch, transform, prev_is_whitespace);
5147                prev_is_whitespace = ch.is_whitespace();
5148                styled_chars.push(StyledChar {
5149                    ch: transformed_ch,
5150                    font_family: run_style.font_family.clone(),
5151                    font_size: run_style.font_size,
5152                    font_weight: run_style.font_weight,
5153                    font_style: run_style.font_style,
5154                    color: run_style.color,
5155                    href: run_href.map(|s| s.to_string()),
5156                    text_decoration: run_style.text_decoration,
5157                    letter_spacing: run_style.letter_spacing,
5158                    word_spacing: run_style.word_spacing,
5159                });
5160            }
5161        }
5162        styled_chars
5163    }
5164
5165    /// Layout text runs with per-run styling.
5166    #[allow(clippy::too_many_arguments)]
5167    #[allow(clippy::too_many_arguments)]
5168    fn layout_text_runs(
5169        &self,
5170        runs: &[TextRun],
5171        parent_href: Option<&str>,
5172        style: &ResolvedStyle,
5173        cursor: &mut PageCursor,
5174        pages: &mut Vec<LayoutPage>,
5175        text_x: f64,
5176        text_width: f64,
5177        font_context: &FontContext,
5178        source_location: Option<&SourceLocation>,
5179        bookmark: Option<&str>,
5180        // Same role as in layout_text — None defaults to "Text".
5181        node_type_override: Option<&str>,
5182    ) {
5183        let styled_chars = Self::runs_to_styled_chars(runs, parent_href, style, text_width);
5184
5185        // Break into lines
5186        let justify = matches!(style.text_align, TextAlign::Justify);
5187        let broken_lines = match style.line_breaking {
5188            LineBreaking::Optimal => self.text_layout.break_runs_into_lines_optimal(
5189                font_context,
5190                &styled_chars,
5191                text_width,
5192                style.hyphens,
5193                style.lang.as_deref(),
5194                justify,
5195            ),
5196            LineBreaking::Greedy => self.text_layout.break_runs_into_lines(
5197                font_context,
5198                &styled_chars,
5199                text_width,
5200                style.hyphens,
5201                style.lang.as_deref(),
5202            ),
5203        };
5204
5205        // Apply text overflow truncation (single-line modes)
5206        let broken_lines = match style.text_overflow {
5207            TextOverflow::Ellipsis => {
5208                self.text_layout
5209                    .truncate_runs_with_ellipsis(font_context, broken_lines, text_width)
5210            }
5211            TextOverflow::Clip => {
5212                self.text_layout
5213                    .truncate_runs_clip(font_context, broken_lines, text_width)
5214            }
5215            TextOverflow::Wrap => broken_lines,
5216        };
5217
5218        let line_height = style.font_size * style.line_height;
5219
5220        // Widow/orphan control for text runs
5221        let line_heights: Vec<f64> = vec![line_height; broken_lines.len()];
5222        let decision = page_break::decide_break(
5223            cursor.remaining_height(),
5224            &line_heights,
5225            true,
5226            style.min_orphan_lines as usize,
5227            style.min_widow_lines as usize,
5228        );
5229
5230        let mut snapshot = cursor.elements.len();
5231        let mut container_start_y = cursor.content_y + cursor.y;
5232        let mut is_first_element = true;
5233
5234        if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
5235            pages.push(cursor.finalize());
5236            *cursor = cursor.new_page();
5237            snapshot = cursor.elements.len();
5238            container_start_y = cursor.content_y + cursor.y;
5239        }
5240
5241        let forced_break_at = match decision {
5242            page_break::BreakDecision::Split {
5243                items_on_current_page,
5244            } => Some(items_on_current_page),
5245            _ => None,
5246        };
5247        let mut first_break_done = false;
5248
5249        for (line_idx, run_line) in broken_lines.iter().enumerate() {
5250            let needs_break = if let Some(break_at) = forced_break_at {
5251                if !first_break_done && line_idx == break_at {
5252                    true
5253                } else {
5254                    line_height > cursor.remaining_height()
5255                }
5256            } else {
5257                line_height > cursor.remaining_height()
5258            };
5259
5260            if needs_break {
5261                first_break_done = true;
5262                let line_elements: Vec<LayoutElement> = drain_since(&mut cursor.elements, snapshot);
5263                if !line_elements.is_empty() {
5264                    let container_height = cursor.content_y + cursor.y - container_start_y;
5265                    cursor.elements.push(LayoutElement {
5266                        x: text_x,
5267                        y: container_start_y,
5268                        width: text_width,
5269                        height: container_height,
5270                        draw: DrawCommand::None,
5271                        children: line_elements,
5272                        node_type: Some(node_type_override.unwrap_or("Text").to_string()),
5273                        resolved_style: Some(style.clone()),
5274                        source_location: source_location.cloned(),
5275                        href: parent_href.map(|s| s.to_string()),
5276                        bookmark: if is_first_element {
5277                            bookmark.map(|s| s.to_string())
5278                        } else {
5279                            None
5280                        },
5281                        alt: None,
5282                        is_header_row: false,
5283                        actual_text: None,
5284                        list_numbering: None,
5285                        col_span: 1,
5286                        overflow: Overflow::default(),
5287                        opacity: 1.0,
5288                    });
5289                    is_first_element = false;
5290                }
5291
5292                pages.push(cursor.finalize());
5293                *cursor = cursor.new_page();
5294
5295                snapshot = cursor.elements.len();
5296                container_start_y = cursor.content_y + cursor.y;
5297            }
5298
5299            let line_x = match style.text_align {
5300                TextAlign::Left => text_x,
5301                TextAlign::Right => text_x + text_width - run_line.width,
5302                TextAlign::Center => text_x + (text_width - run_line.width) / 2.0,
5303                TextAlign::Justify => text_x,
5304            };
5305
5306            let mut glyphs =
5307                self.build_positioned_glyphs_runs(run_line, font_context, style.direction);
5308
5309            // Justify: compute extra word spacing so the line fills the column width.
5310            // Use the sum of natural glyph advances (what PDF Tj actually renders)
5311            // rather than KP-adjusted line width.
5312            //
5313            // User-set `word_spacing` is the base; when text is justified, the
5314            // computed slack-per-space is added on top.
5315            // A line ending at a forced break is left alone, like the last
5316            // line, as CSS text-align-last does (#186).
5317            let is_last_line = line_idx == broken_lines.len() - 1 || run_line.hard_break;
5318            let user_ws = style.word_spacing;
5319            let (justified_width, word_spacing) =
5320                if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
5321                    let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
5322                    let (natural_width, space_count) = if let Some(idx) = last_non_space {
5323                        let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
5324                        let s = glyphs[..=idx]
5325                            .iter()
5326                            .filter(|g| g.char_value == ' ')
5327                            .count();
5328                        (w, s)
5329                    } else {
5330                        (0.0, 0)
5331                    };
5332                    if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
5333                        Self::spread_justification(&mut glyphs, text_width);
5334                    }
5335                    let slack = text_width - natural_width;
5336                    let ws = if space_count > 0 && slack.abs() > 0.01 {
5337                        slack / space_count as f64
5338                    } else {
5339                        0.0
5340                    };
5341                    (text_width, user_ws + ws)
5342                } else {
5343                    (run_line.width, user_ws)
5344                };
5345
5346            let text_line = TextLine {
5347                x: line_x,
5348                // Half-leading: the line box's extra space over the glyph block
5349                // splits evenly above and below (CSS line box model). The
5350                // baseline therefore sits half the leading further down —
5351                // this is also what makes the pre-flexbox centering idiom
5352                // (line-height matched to a box height) actually center.
5353                y: cursor.content_y
5354                    + cursor.y
5355                    + baseline_in_line(
5356                        line_height,
5357                        style.font_size,
5358                        font_context.baseline_metrics(
5359                            &style.font_family,
5360                            style.font_weight,
5361                            matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique),
5362                        ),
5363                    ),
5364                glyphs,
5365                width: justified_width,
5366                height: line_height,
5367                word_spacing,
5368            };
5369
5370            // Determine text decoration: use the run's decoration if any glyph has one
5371            let text_dec = run_line
5372                .chars
5373                .iter()
5374                .find(|sc| !matches!(sc.text_decoration, TextDecoration::None))
5375                .map(|sc| sc.text_decoration)
5376                .unwrap_or(style.text_decoration);
5377
5378            cursor.elements.push(LayoutElement {
5379                x: line_x,
5380                y: cursor.content_y + cursor.y,
5381                width: justified_width,
5382                height: line_height,
5383                draw: DrawCommand::Text {
5384                    lines: vec![text_line],
5385                    color: style.color,
5386                    text_decoration: text_dec,
5387                    opacity: 1.0,
5388                },
5389                children: vec![],
5390                node_type: Some("TextLine".to_string()),
5391                resolved_style: Some(style.clone()),
5392                source_location: None,
5393                href: parent_href.map(|s| s.to_string()),
5394                bookmark: None,
5395                alt: None,
5396                is_header_row: false,
5397                actual_text: None,
5398                list_numbering: None,
5399                col_span: 1,
5400                overflow: Overflow::default(),
5401                opacity: 1.0,
5402            });
5403
5404            cursor.y += line_height;
5405        }
5406
5407        let line_elements: Vec<LayoutElement> = drain_since(&mut cursor.elements, snapshot);
5408        if !line_elements.is_empty() {
5409            let container_height = cursor.content_y + cursor.y - container_start_y;
5410            cursor.elements.push(LayoutElement {
5411                x: text_x,
5412                y: container_start_y,
5413                width: text_width,
5414                height: container_height,
5415                draw: DrawCommand::None,
5416                children: line_elements,
5417                node_type: Some(node_type_override.unwrap_or("Text").to_string()),
5418                resolved_style: Some(style.clone()),
5419                source_location: source_location.cloned(),
5420                href: parent_href.map(|s| s.to_string()),
5421                bookmark: if is_first_element {
5422                    bookmark.map(|s| s.to_string())
5423                } else {
5424                    None
5425                },
5426                alt: None,
5427                is_header_row: false,
5428                actual_text: None,
5429                list_numbering: None,
5430                col_span: 1,
5431                overflow: Overflow::default(),
5432                opacity: 1.0,
5433            });
5434        }
5435    }
5436
5437    /// Build PositionedGlyphs for a single-style BrokenLine.
5438    /// For custom fonts, shapes the line text to get real glyph IDs.
5439    /// For standard fonts, uses char-as-u16 glyph IDs.
5440    fn build_positioned_glyphs_single_style(
5441        &self,
5442        line: &BrokenLine,
5443        style: &ResolvedStyle,
5444        href: Option<&str>,
5445        font_context: &FontContext,
5446    ) -> Vec<PositionedGlyph> {
5447        let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
5448        let line_text: String = line.chars.iter().collect();
5449        let direction = style.direction;
5450        // Check if BiDi processing is needed
5451        let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
5452
5453        // Segment by font — handles both explicit fallback chains and
5454        // automatic builtin font fallback (Noto Sans for non-Latin chars)
5455        let font_runs = crate::font::fallback::segment_by_font(
5456            &line.chars,
5457            &style.font_family,
5458            style.font_weight,
5459            italic,
5460            font_context.registry(),
5461        );
5462        let needs_per_char_fallback = font_runs.len() > 1
5463            || (font_runs.len() == 1 && font_runs[0].family != style.font_family);
5464
5465        // Per-char fallback path: segment by font within each BiDi run
5466        if needs_per_char_fallback {
5467            let bidi_runs = if has_bidi {
5468                bidi::analyze_bidi(&line_text, direction)
5469            } else {
5470                vec![crate::text::bidi::BidiRun {
5471                    char_start: 0,
5472                    char_end: line.chars.len(),
5473                    level: unicode_bidi::Level::ltr(),
5474                    is_rtl: false,
5475                }]
5476            };
5477
5478            let mut all_glyphs = Vec::new();
5479            let mut bidi_levels = Vec::new();
5480            let mut x = 0.0_f64;
5481
5482            // Process each BiDi run
5483            for bidi_run in &bidi_runs {
5484                // Within this BiDi run, sub-segment by font
5485                for font_run in &font_runs {
5486                    // Intersect font_run with bidi_run
5487                    let start = font_run.start.max(bidi_run.char_start);
5488                    let end = font_run.end.min(bidi_run.char_end);
5489                    if start >= end {
5490                        continue;
5491                    }
5492
5493                    let sub_chars: Vec<char> = line.chars[start..end].to_vec();
5494                    let sub_text: String = sub_chars.iter().collect();
5495                    let resolved_family = &font_run.family;
5496
5497                    if let Some(font_data) =
5498                        font_context.font_data(resolved_family, style.font_weight, italic)
5499                    {
5500                        if let Some(shaped) = shaping::shape_text_with_direction(
5501                            &sub_text,
5502                            font_data,
5503                            bidi_run.is_rtl,
5504                        ) {
5505                            let units_per_em = font_context.units_per_em(
5506                                resolved_family,
5507                                style.font_weight,
5508                                italic,
5509                            );
5510                            let scale = style.font_size / units_per_em as f64;
5511
5512                            let clusters = cluster_texts(&shaped, &sub_chars);
5513                            for (sg, (cluster_text, ligature, extraction_text)) in
5514                                shaped.iter().zip(clusters)
5515                            {
5516                                let cluster = sg.cluster as usize;
5517                                let char_value = sub_chars.get(cluster).copied().unwrap_or(' ');
5518
5519                                let glyph_x = x + sg.x_offset as f64 * scale;
5520                                let glyph_y = sg.y_offset as f64 * scale;
5521                                let advance = sg.x_advance as f64 * scale + style.letter_spacing;
5522
5523                                all_glyphs.push(PositionedGlyph {
5524                                    glyph_id: sg.glyph_id,
5525                                    x_offset: glyph_x,
5526                                    y_offset: glyph_y,
5527                                    x_advance: advance,
5528                                    font_size: style.font_size,
5529                                    font_family: Arc::from(resolved_family.as_str()),
5530                                    font_weight: style.font_weight,
5531                                    font_style: style.font_style,
5532                                    char_value,
5533                                    color: Some(style.color),
5534                                    href: href.map(|s| s.to_string()),
5535                                    text_decoration: style.text_decoration,
5536                                    letter_spacing: style.letter_spacing,
5537                                    cluster_text,
5538                                    extraction_text,
5539
5540                                    ligature,
5541                                });
5542                                bidi_levels.push(bidi_run.level);
5543                                x += advance;
5544                            }
5545                            continue;
5546                        }
5547                    }
5548
5549                    // Fallback: standard font or shaping failure for this sub-segment
5550                    for i in start..end {
5551                        let ch = line.chars[i];
5552                        let glyph_x = x;
5553                        let char_width = font_context.char_width(
5554                            ch,
5555                            resolved_family,
5556                            style.font_weight,
5557                            italic,
5558                            style.font_size,
5559                        );
5560                        let advance = char_width + style.letter_spacing;
5561                        all_glyphs.push(PositionedGlyph {
5562                            glyph_id: ch as u16,
5563                            x_offset: glyph_x,
5564                            y_offset: 0.0,
5565                            x_advance: advance,
5566                            font_size: style.font_size,
5567                            font_family: Arc::from(resolved_family.as_str()),
5568                            font_weight: style.font_weight,
5569                            font_style: style.font_style,
5570                            char_value: ch,
5571                            color: Some(style.color),
5572                            href: href.map(|s| s.to_string()),
5573                            text_decoration: style.text_decoration,
5574                            letter_spacing: style.letter_spacing,
5575                            cluster_text: None,
5576                            extraction_text: None,
5577                            ligature: false,
5578                        });
5579                        bidi_levels.push(bidi_run.level);
5580                        x += advance;
5581                    }
5582                }
5583            }
5584
5585            // Apply BiDi visual reordering if needed
5586            if has_bidi && !all_glyphs.is_empty() {
5587                all_glyphs = bidi::reorder_and_position(all_glyphs, &bidi_levels);
5588            }
5589            return all_glyphs;
5590        }
5591
5592        // Original single-font path (no comma in font_family)
5593        // Try shaping for custom fonts
5594        if let Some(font_data) =
5595            font_context.font_data(&style.font_family, style.font_weight, italic)
5596        {
5597            if has_bidi {
5598                // BiDi path: analyze runs, shape each with correct direction
5599                let bidi_runs = bidi::analyze_bidi(&line_text, direction);
5600                let units_per_em =
5601                    font_context.units_per_em(&style.font_family, style.font_weight, italic);
5602                let scale = style.font_size / units_per_em as f64;
5603
5604                let mut all_glyphs = Vec::new();
5605                let mut bidi_levels = Vec::new();
5606                let mut x = 0.0_f64;
5607
5608                for run in &bidi_runs {
5609                    let run_chars: Vec<char> = line.chars[run.char_start..run.char_end].to_vec();
5610                    let run_text: String = run_chars.iter().collect();
5611
5612                    if let Some(shaped) =
5613                        shaping::shape_text_with_direction(&run_text, font_data, run.is_rtl)
5614                    {
5615                        let clusters = cluster_texts(&shaped, &run_chars);
5616                        for (sg, (cluster_text, ligature, extraction_text)) in
5617                            shaped.iter().zip(clusters)
5618                        {
5619                            let cluster = sg.cluster as usize;
5620                            let char_value = run_chars.get(cluster).copied().unwrap_or(' ');
5621
5622                            let glyph_x = x + sg.x_offset as f64 * scale;
5623                            let glyph_y = sg.y_offset as f64 * scale;
5624                            let advance = sg.x_advance as f64 * scale + style.letter_spacing;
5625
5626                            all_glyphs.push(PositionedGlyph {
5627                                glyph_id: sg.glyph_id,
5628                                x_offset: glyph_x,
5629                                y_offset: glyph_y,
5630                                x_advance: advance,
5631                                font_size: style.font_size,
5632                                font_family: Arc::from(style.font_family.as_str()),
5633                                font_weight: style.font_weight,
5634                                font_style: style.font_style,
5635                                char_value,
5636                                color: Some(style.color),
5637                                href: href.map(|s| s.to_string()),
5638                                text_decoration: style.text_decoration,
5639                                letter_spacing: style.letter_spacing,
5640                                cluster_text,
5641                                extraction_text,
5642
5643                                ligature,
5644                            });
5645                            bidi_levels.push(run.level);
5646
5647                            x += advance;
5648                        }
5649                    }
5650                }
5651
5652                // Reorder glyphs visually and reposition
5653                let glyphs = bidi::reorder_and_position(all_glyphs, &bidi_levels);
5654                return glyphs;
5655            }
5656
5657            // Pure LTR path: shape normally
5658            if let Some(shaped) = shaping::shape_text(&line_text, font_data) {
5659                let units_per_em =
5660                    font_context.units_per_em(&style.font_family, style.font_weight, italic);
5661                let scale = style.font_size / units_per_em as f64;
5662
5663                return self.shaped_glyphs_to_positioned(
5664                    &shaped,
5665                    &line.chars,
5666                    &line.char_positions,
5667                    scale,
5668                    style.font_size,
5669                    &style.font_family,
5670                    style.font_weight,
5671                    style.font_style,
5672                    Some(style.color),
5673                    href,
5674                    style.text_decoration,
5675                    style.letter_spacing,
5676                );
5677            }
5678        }
5679
5680        // Fallback: standard fonts or shaping failure
5681        let mut glyphs: Vec<PositionedGlyph> = line
5682            .chars
5683            .iter()
5684            .enumerate()
5685            .map(|(j, ch)| {
5686                let glyph_x = line.char_positions.get(j).copied().unwrap_or(0.0);
5687                let char_width = font_context.char_width(
5688                    *ch,
5689                    &style.font_family,
5690                    style.font_weight,
5691                    italic,
5692                    style.font_size,
5693                );
5694                PositionedGlyph {
5695                    glyph_id: *ch as u16,
5696                    x_offset: glyph_x,
5697                    y_offset: 0.0,
5698                    x_advance: char_width,
5699                    font_size: style.font_size,
5700                    font_family: Arc::from(style.font_family.as_str()),
5701                    font_weight: style.font_weight,
5702                    font_style: style.font_style,
5703                    char_value: *ch,
5704                    color: Some(style.color),
5705                    href: href.map(|s| s.to_string()),
5706                    text_decoration: style.text_decoration,
5707                    letter_spacing: style.letter_spacing,
5708                    cluster_text: None,
5709                    extraction_text: None,
5710                    ligature: false,
5711                }
5712            })
5713            .collect();
5714
5715        // For standard fonts with BiDi text, still reorder visually
5716        if has_bidi && !glyphs.is_empty() {
5717            let bidi_runs = bidi::analyze_bidi(&line_text, direction);
5718            let mut levels = Vec::with_capacity(glyphs.len());
5719            let mut char_idx = 0;
5720            for run in &bidi_runs {
5721                for _ in run.char_start..run.char_end {
5722                    if char_idx < glyphs.len() {
5723                        levels.push(run.level);
5724                        char_idx += 1;
5725                    }
5726                }
5727            }
5728            // Pad if needed
5729            while levels.len() < glyphs.len() {
5730                levels.push(unicode_bidi::Level::ltr());
5731            }
5732            glyphs = bidi::reorder_and_position(glyphs, &levels);
5733        }
5734
5735        glyphs
5736    }
5737
5738    /// Build PositionedGlyphs for a multi-style RunBrokenLine.
5739    /// Shapes contiguous runs of the same custom font, with BiDi support.
5740    /// When a StyledChar has a comma-separated font_family, resolves each
5741    /// character to a single font before grouping for shaping.
5742    fn build_positioned_glyphs_runs(
5743        &self,
5744        run_line: &RunBrokenLine,
5745        font_context: &FontContext,
5746        direction: Direction,
5747    ) -> Vec<PositionedGlyph> {
5748        let chars = &run_line.chars;
5749        if chars.is_empty() {
5750            return vec![];
5751        }
5752
5753        // Fallback must match line measurement, including shaping controls.
5754        let resolved_families = crate::text::resolved_style_families(chars, font_context);
5755
5756        let line_text: String = chars.iter().map(|c| c.ch).collect();
5757        let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
5758        // Each char's real embedding level, as the single-style path uses.
5759        // Flattening these to 0/1 put a number inside an RTL paragraph
5760        // (level 2) below the line's level 1, so reordering left it at the
5761        // wrong end of the line (#175).
5762        let mut char_levels = vec![unicode_bidi::Level::ltr(); chars.len()];
5763        if has_bidi {
5764            for run in bidi::analyze_bidi(&line_text, direction) {
5765                for level in &mut char_levels[run.char_start..run.char_end.min(chars.len())] {
5766                    *level = run.level;
5767                }
5768            }
5769        }
5770
5771        let mut glyphs = Vec::new();
5772        let mut bidi_levels = Vec::new();
5773        let mut i = 0;
5774
5775        while i < chars.len() {
5776            let sc = &chars[i];
5777            let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
5778            let resolved_family = &resolved_families[i];
5779            let level = char_levels[i];
5780            let is_rtl = level.is_rtl();
5781
5782            // Check for custom font with shaping (using resolved single family)
5783            if let Some(font_data) = font_context.font_data(resolved_family, sc.font_weight, italic)
5784            {
5785                // Find contiguous run with same resolved font AND same BiDi level
5786                let run_start = i;
5787                let mut run_end = i + 1;
5788                while run_end < chars.len() {
5789                    let next = &chars[run_end];
5790                    let next_italic =
5791                        matches!(next.font_style, FontStyle::Italic | FontStyle::Oblique);
5792                    // Group by resolved family, not original comma chain
5793                    if resolved_families[run_end] == *resolved_family
5794                        && next.font_weight == sc.font_weight
5795                        && next_italic == italic
5796                        && (next.font_size - sc.font_size).abs() < 0.001
5797                        && char_levels[run_end] == level
5798                    {
5799                        run_end += 1;
5800                    } else {
5801                        break;
5802                    }
5803                }
5804
5805                let run_text: String = chars[run_start..run_end].iter().map(|c| c.ch).collect();
5806                if let Some(shaped) =
5807                    shaping::shape_text_with_direction(&run_text, font_data, is_rtl)
5808                {
5809                    let units_per_em =
5810                        font_context.units_per_em(resolved_family, sc.font_weight, italic);
5811                    let scale = sc.font_size / units_per_em as f64;
5812
5813                    // Build char positions for this run segment
5814                    let run_chars: Vec<char> =
5815                        chars[run_start..run_end].iter().map(|c| c.ch).collect();
5816                    let run_positions: Vec<f64> = (run_start..run_end)
5817                        .map(|j| run_line.char_positions.get(j).copied().unwrap_or(0.0))
5818                        .collect();
5819
5820                    // Build glyphs with resolved single family on each glyph
5821                    let mut run_glyphs = self.shaped_glyphs_to_positioned_runs(
5822                        &shaped,
5823                        &chars[run_start..run_end],
5824                        &run_chars,
5825                        &run_positions,
5826                        scale,
5827                    );
5828                    // Override font_family to the resolved single family
5829                    let resolved_family_arc: Arc<str> = Arc::from(resolved_family.as_str());
5830                    for g in &mut run_glyphs {
5831                        g.font_family = resolved_family_arc.clone();
5832                    }
5833                    // Track BiDi levels for each glyph
5834                    for _ in &run_glyphs {
5835                        bidi_levels.push(level);
5836                    }
5837                    glyphs.extend(run_glyphs);
5838                    i = run_end;
5839                    continue;
5840                }
5841            }
5842
5843            // Fallback: unshaped glyph (using resolved family)
5844            let glyph_x = run_line.char_positions.get(i).copied().unwrap_or(0.0);
5845            let char_width = font_context.char_width(
5846                sc.ch,
5847                resolved_family,
5848                sc.font_weight,
5849                italic,
5850                sc.font_size,
5851            );
5852            glyphs.push(PositionedGlyph {
5853                glyph_id: sc.ch as u16,
5854                x_offset: glyph_x,
5855                y_offset: 0.0,
5856                x_advance: char_width,
5857                font_size: sc.font_size,
5858                font_family: Arc::from(resolved_family.as_str()),
5859                font_weight: sc.font_weight,
5860                font_style: sc.font_style,
5861                char_value: sc.ch,
5862                color: Some(sc.color),
5863                href: sc.href.clone(),
5864                text_decoration: sc.text_decoration,
5865                letter_spacing: sc.letter_spacing,
5866                cluster_text: None,
5867                extraction_text: None,
5868                ligature: false,
5869            });
5870            bidi_levels.push(level);
5871            i += 1;
5872        }
5873
5874        // Apply BiDi visual reordering if needed
5875        if has_bidi && !glyphs.is_empty() {
5876            glyphs = bidi::reorder_and_position(glyphs, &bidi_levels);
5877        }
5878
5879        glyphs
5880    }
5881
5882    /// Convert shaped glyphs to PositionedGlyphs for single-style text.
5883    #[allow(clippy::too_many_arguments)]
5884    fn shaped_glyphs_to_positioned(
5885        &self,
5886        shaped: &[shaping::ShapedGlyph],
5887        chars: &[char],
5888        char_positions: &[f64],
5889        scale: f64,
5890        font_size: f64,
5891        font_family: &str,
5892        font_weight: u32,
5893        font_style: FontStyle,
5894        color: Option<Color>,
5895        href: Option<&str>,
5896        text_decoration: TextDecoration,
5897        letter_spacing: f64,
5898    ) -> Vec<PositionedGlyph> {
5899        let mut result = Vec::with_capacity(shaped.len());
5900        let mut x = 0.0_f64;
5901        let mut cluster_pen = 0.0_f64;
5902        let mut prev_cluster: Option<usize> = None;
5903
5904        let clusters = cluster_texts(shaped, chars);
5905        for (sg, (cluster_text, ligature, extraction_text)) in shaped.iter().zip(clusters) {
5906            let cluster = sg.cluster as usize;
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 = shaped_glyph_x(char_positions, cluster, x, cluster_pen, sg, scale);
5914            let glyph_y = sg.y_offset as f64 * scale;
5915            let advance = sg.x_advance as f64 * scale + letter_spacing;
5916
5917            result.push(PositionedGlyph {
5918                glyph_id: sg.glyph_id,
5919                x_offset: glyph_x,
5920                y_offset: glyph_y,
5921                x_advance: advance,
5922                font_size,
5923                font_family: Arc::from(font_family),
5924                font_weight,
5925                font_style,
5926                char_value,
5927                color,
5928                href: href.map(|s| s.to_string()),
5929                text_decoration,
5930                letter_spacing,
5931                cluster_text,
5932                extraction_text,
5933
5934                ligature,
5935            });
5936
5937            x += advance;
5938        }
5939
5940        result
5941    }
5942
5943    /// Convert shaped glyphs to PositionedGlyphs for multi-style runs.
5944    fn shaped_glyphs_to_positioned_runs(
5945        &self,
5946        shaped: &[shaping::ShapedGlyph],
5947        styled_chars: &[StyledChar],
5948        chars: &[char],
5949        char_positions: &[f64],
5950        scale: f64,
5951    ) -> Vec<PositionedGlyph> {
5952        let mut result = Vec::with_capacity(shaped.len());
5953        // Run-relative pen, used only for offsets inside a cluster and when a
5954        // cluster has no Knuth-Plass position; positions are line-absolute.
5955        let base_x = char_positions.first().copied().unwrap_or(0.0);
5956        let mut x = 0.0_f64;
5957        let mut cluster_pen = 0.0_f64;
5958        let mut prev_cluster: Option<usize> = None;
5959
5960        let clusters = cluster_texts(shaped, chars);
5961        for (sg, (cluster_text, ligature, extraction_text)) in shaped.iter().zip(clusters) {
5962            let cluster = sg.cluster as usize;
5963            let sc = styled_chars.get(cluster).unwrap_or(&styled_chars[0]);
5964            let char_value = chars.get(cluster).copied().unwrap_or(' ');
5965
5966            if prev_cluster != Some(cluster) {
5967                cluster_pen = x;
5968                prev_cluster = Some(cluster);
5969            }
5970            let glyph_x = if char_positions.get(cluster).is_some() {
5971                shaped_glyph_x(char_positions, cluster, x, cluster_pen, sg, scale)
5972            } else {
5973                base_x + x + sg.x_offset as f64 * scale
5974            };
5975            let glyph_y = sg.y_offset as f64 * scale;
5976            let advance = sg.x_advance as f64 * scale + sc.letter_spacing;
5977
5978            result.push(PositionedGlyph {
5979                glyph_id: sg.glyph_id,
5980                x_offset: glyph_x,
5981                y_offset: glyph_y,
5982                x_advance: advance,
5983                font_size: sc.font_size,
5984                font_family: Arc::from(sc.font_family.as_str()),
5985                font_weight: sc.font_weight,
5986                font_style: sc.font_style,
5987                char_value,
5988                color: Some(sc.color),
5989                href: sc.href.clone(),
5990                text_decoration: sc.text_decoration,
5991                letter_spacing: sc.letter_spacing,
5992                cluster_text,
5993                extraction_text,
5994
5995                ligature,
5996            });
5997
5998            x += advance;
5999        }
6000
6001        result
6002    }
6003
6004    /// The ONE image sizing ladder — used by both `layout_image` and
6005    /// `measure_node_height`, so measurement and layout agree by
6006    /// construction (the measure/layout agreement family: an earlier
6007    /// version measured small images at container width while layout
6008    /// drew them at intrinsic size, reserving container-sized phantom
6009    /// space — template-compat 01/05/07). Chrome semantics: style width
6010    /// (percents already resolved) > explicit prop > intrinsic;
6011    /// max/min-width clamp; height follows the real aspect ratio unless
6012    /// given.
6013    fn image_display_size(
6014        &self,
6015        src: &str,
6016        style: &ResolvedStyle,
6017        explicit_width: Option<f64>,
6018        explicit_height: Option<f64>,
6019        available_width: f64,
6020    ) -> (f64, f64) {
6021        let dims = if src.is_empty() {
6022            None
6023        } else {
6024            self.get_image_dimensions(src)
6025        };
6026        let aspect = dims
6027            .map(|(w, h)| {
6028                if w > 0 {
6029                    f64::from(h) / f64::from(w)
6030                } else {
6031                    0.75
6032                }
6033            })
6034            .unwrap_or(0.75);
6035
6036        let style_w = match style.width {
6037            SizeConstraint::Fixed(w) => Some(w),
6038            SizeConstraint::Auto => None,
6039        };
6040        let style_h = match style.height {
6041            SizeConstraint::Fixed(h) => Some(h),
6042            SizeConstraint::Auto => None,
6043        };
6044        let clamp = |w: f64| w.min(style.max_width).max(style.min_width);
6045
6046        let width_source = style_w.or(explicit_width);
6047        let height_source = style_h.or(explicit_height);
6048        match (width_source, height_source) {
6049            (Some(w), Some(h)) => (clamp(w), h),
6050            (Some(w), None) => {
6051                let w = clamp(w);
6052                (w, w * aspect)
6053            }
6054            (None, Some(h)) => (clamp(h / aspect), h),
6055            (None, None) => {
6056                // Intrinsic size, shrunk to fit the container. An
6057                // unloadable image keeps the container-width placeholder.
6058                let w = clamp(
6059                    dims.map(|(w, _)| f64::from(w))
6060                        .unwrap_or(available_width)
6061                        .min(available_width),
6062                );
6063                (w, w * aspect)
6064            }
6065        }
6066    }
6067
6068    #[allow(clippy::too_many_arguments)]
6069    fn layout_image(
6070        &self,
6071        node: &Node,
6072        style: &ResolvedStyle,
6073        cursor: &mut PageCursor,
6074        pages: &mut Vec<LayoutPage>,
6075        x: f64,
6076        available_width: f64,
6077        explicit_width: Option<f64>,
6078        explicit_height: Option<f64>,
6079    ) {
6080        let margin = &style.margin.to_edges();
6081
6082        // Try to load the image from the node's src field
6083        let src = match &node.kind {
6084            NodeKind::Image { src, .. } => src.as_str(),
6085            _ => "",
6086        };
6087
6088        let loaded = if !src.is_empty() {
6089            crate::image_loader::load_image(src).ok()
6090        } else {
6091            None
6092        };
6093
6094        let (img_width, img_height) = self.image_display_size(
6095            src,
6096            style,
6097            explicit_width,
6098            explicit_height,
6099            available_width - margin.horizontal(),
6100        );
6101
6102        let total_height = img_height + margin.vertical();
6103
6104        if total_height > cursor.remaining_height() {
6105            pages.push(cursor.finalize());
6106            *cursor = cursor.new_page();
6107        }
6108
6109        cursor.y += margin.top;
6110
6111        let draw = if let Some(image_data) = loaded {
6112            DrawCommand::Image { image_data }
6113        } else {
6114            DrawCommand::ImagePlaceholder
6115        };
6116
6117        cursor.elements.push(LayoutElement {
6118            x: x + margin.left,
6119            y: cursor.content_y + cursor.y,
6120            width: img_width,
6121            height: img_height,
6122            draw,
6123            children: vec![],
6124            node_type: Some(node_kind_name(&node.kind).to_string()),
6125            resolved_style: Some(style.clone()),
6126            source_location: node.source_location.clone(),
6127            href: node.href.clone(),
6128            bookmark: node.bookmark.clone(),
6129            alt: node.alt.clone(),
6130            is_header_row: false,
6131            actual_text: None,
6132            list_numbering: None,
6133            col_span: 1,
6134            overflow: style.overflow,
6135            opacity: style.opacity,
6136        });
6137
6138        cursor.y += img_height + margin.bottom;
6139    }
6140
6141    /// Layout an SVG element as a fixed-size box.
6142    #[allow(clippy::too_many_arguments)]
6143    fn layout_svg(
6144        &self,
6145        node: &Node,
6146        style: &ResolvedStyle,
6147        cursor: &mut PageCursor,
6148        pages: &mut Vec<LayoutPage>,
6149        x: f64,
6150        _available_width: f64,
6151        svg_width: f64,
6152        svg_height: f64,
6153        view_box: Option<&str>,
6154        content: &str,
6155    ) {
6156        let margin = &style.margin.to_edges();
6157        let total_height = svg_height + margin.vertical();
6158
6159        if total_height > cursor.remaining_height() {
6160            pages.push(cursor.finalize());
6161            *cursor = cursor.new_page();
6162        }
6163
6164        cursor.y += margin.top;
6165
6166        let vb = view_box
6167            .and_then(crate::svg::parse_view_box)
6168            .unwrap_or(crate::svg::ViewBox {
6169                min_x: 0.0,
6170                min_y: 0.0,
6171                width: svg_width,
6172                height: svg_height,
6173            });
6174
6175        let commands = crate::svg::parse_svg(content, vb, svg_width, svg_height);
6176
6177        cursor.elements.push(LayoutElement {
6178            x: x + margin.left,
6179            y: cursor.content_y + cursor.y,
6180            width: svg_width,
6181            height: svg_height,
6182            draw: DrawCommand::Svg {
6183                commands,
6184                width: svg_width,
6185                height: svg_height,
6186                viewbox_min_x: vb.min_x,
6187                viewbox_min_y: vb.min_y,
6188                viewbox_width: vb.width,
6189                viewbox_height: vb.height,
6190                clip: false,
6191            },
6192            children: vec![],
6193            node_type: Some("Svg".to_string()),
6194            resolved_style: Some(style.clone()),
6195            source_location: node.source_location.clone(),
6196            href: node.href.clone(),
6197            bookmark: node.bookmark.clone(),
6198            alt: node.alt.clone(),
6199            is_header_row: false,
6200            actual_text: None,
6201            list_numbering: None,
6202            col_span: 1,
6203            overflow: style.overflow,
6204            opacity: style.opacity,
6205        });
6206
6207        cursor.y += svg_height + margin.bottom;
6208    }
6209
6210    /// Convert CanvasOps to SvgCommands, reusing the existing SVG rendering pipeline.
6211    fn canvas_ops_to_svg_commands(operations: &[CanvasOp]) -> Vec<crate::svg::SvgCommand> {
6212        use crate::svg::SvgCommand;
6213
6214        let mut commands = Vec::new();
6215        let mut cur_x = 0.0_f64;
6216        let mut cur_y = 0.0_f64;
6217
6218        for op in operations {
6219            match op {
6220                CanvasOp::MoveTo { x, y } => {
6221                    commands.push(SvgCommand::MoveTo(*x, *y));
6222                    cur_x = *x;
6223                    cur_y = *y;
6224                }
6225                CanvasOp::LineTo { x, y } => {
6226                    commands.push(SvgCommand::LineTo(*x, *y));
6227                    cur_x = *x;
6228                    cur_y = *y;
6229                }
6230                CanvasOp::BezierCurveTo {
6231                    cp1x,
6232                    cp1y,
6233                    cp2x,
6234                    cp2y,
6235                    x,
6236                    y,
6237                } => {
6238                    commands.push(SvgCommand::CurveTo(*cp1x, *cp1y, *cp2x, *cp2y, *x, *y));
6239                    cur_x = *x;
6240                    cur_y = *y;
6241                }
6242                CanvasOp::QuadraticCurveTo { cpx, cpy, x, y } => {
6243                    // Convert quadratic to cubic bezier
6244                    let cp1x = cur_x + 2.0 / 3.0 * (*cpx - cur_x);
6245                    let cp1y = cur_y + 2.0 / 3.0 * (*cpy - cur_y);
6246                    let cp2x = *x + 2.0 / 3.0 * (*cpx - *x);
6247                    let cp2y = *y + 2.0 / 3.0 * (*cpy - *y);
6248                    commands.push(SvgCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, *x, *y));
6249                    cur_x = *x;
6250                    cur_y = *y;
6251                }
6252                CanvasOp::ClosePath => {
6253                    commands.push(SvgCommand::ClosePath);
6254                }
6255                CanvasOp::Rect {
6256                    x,
6257                    y,
6258                    width,
6259                    height,
6260                } => {
6261                    commands.push(SvgCommand::MoveTo(*x, *y));
6262                    commands.push(SvgCommand::LineTo(*x + *width, *y));
6263                    commands.push(SvgCommand::LineTo(*x + *width, *y + *height));
6264                    commands.push(SvgCommand::LineTo(*x, *y + *height));
6265                    commands.push(SvgCommand::ClosePath);
6266                    cur_x = *x;
6267                    cur_y = *y;
6268                }
6269                CanvasOp::Circle { cx, cy, r } => {
6270                    commands.extend(crate::svg::ellipse_commands(*cx, *cy, *r, *r));
6271                }
6272                CanvasOp::Ellipse { cx, cy, rx, ry } => {
6273                    commands.extend(crate::svg::ellipse_commands(*cx, *cy, *rx, *ry));
6274                }
6275                CanvasOp::Arc {
6276                    cx,
6277                    cy,
6278                    r,
6279                    start_angle,
6280                    end_angle,
6281                    counterclockwise,
6282                } => {
6283                    // Approximate arc with line segments matching HTML Canvas arc() semantics.
6284                    // Canvas coords are Y-down (like HTML Canvas), and the PDF Y-flip
6285                    // preserves visual positions, so standard trig (cy + r*sin) is correct.
6286                    let steps = 32;
6287                    let mut sweep = end_angle - start_angle;
6288                    if !counterclockwise && sweep < 0.0 {
6289                        sweep += 2.0 * std::f64::consts::PI;
6290                    }
6291                    if *counterclockwise && sweep > 0.0 {
6292                        sweep -= 2.0 * std::f64::consts::PI;
6293                    }
6294                    for i in 0..=steps {
6295                        let t = *start_angle + sweep * (i as f64 / steps as f64);
6296                        let px = cx + r * t.cos();
6297                        let py = cy + r * t.sin();
6298                        if i == 0 {
6299                            commands.push(SvgCommand::MoveTo(px, py));
6300                        } else {
6301                            commands.push(SvgCommand::LineTo(px, py));
6302                        }
6303                    }
6304                }
6305                CanvasOp::Stroke => commands.push(SvgCommand::Stroke),
6306                CanvasOp::Fill => commands.push(SvgCommand::Fill),
6307                CanvasOp::FillAndStroke => commands.push(SvgCommand::FillAndStroke),
6308                CanvasOp::SetFillColor { r, g, b } => {
6309                    // Canvas API uses 0-255, PDF/SVG pipeline uses 0-1
6310                    commands.push(SvgCommand::SetFill(r / 255.0, g / 255.0, b / 255.0));
6311                }
6312                CanvasOp::SetStrokeColor { r, g, b } => {
6313                    commands.push(SvgCommand::SetStroke(r / 255.0, g / 255.0, b / 255.0));
6314                }
6315                CanvasOp::SetLineWidth { width } => {
6316                    commands.push(SvgCommand::SetStrokeWidth(*width));
6317                }
6318                CanvasOp::SetLineCap { cap } => {
6319                    commands.push(SvgCommand::SetLineCap(*cap));
6320                }
6321                CanvasOp::SetLineJoin { join } => {
6322                    commands.push(SvgCommand::SetLineJoin(*join));
6323                }
6324                CanvasOp::Save => commands.push(SvgCommand::SaveState),
6325                CanvasOp::Restore => commands.push(SvgCommand::RestoreState),
6326            }
6327        }
6328
6329        commands
6330    }
6331
6332    /// Layout a canvas element as a fixed-size box with vector graphics.
6333    #[allow(clippy::too_many_arguments)]
6334    fn layout_canvas(
6335        &self,
6336        node: &Node,
6337        style: &ResolvedStyle,
6338        cursor: &mut PageCursor,
6339        pages: &mut Vec<LayoutPage>,
6340        x: f64,
6341        _available_width: f64,
6342        canvas_width: f64,
6343        canvas_height: f64,
6344        operations: &[CanvasOp],
6345    ) {
6346        let margin = style.margin.to_edges();
6347        let total_height = canvas_height + margin.top + margin.bottom;
6348
6349        // Page break check
6350        if cursor.remaining_height() < total_height && cursor.y > 0.0 {
6351            pages.push(cursor.finalize());
6352            *cursor = cursor.new_page();
6353        }
6354
6355        cursor.y += margin.top;
6356
6357        let svg_commands = Self::canvas_ops_to_svg_commands(operations);
6358
6359        cursor.elements.push(LayoutElement {
6360            x: x + margin.left,
6361            y: cursor.content_y + cursor.y,
6362            width: canvas_width,
6363            height: canvas_height,
6364            draw: DrawCommand::Svg {
6365                commands: svg_commands,
6366                width: canvas_width,
6367                height: canvas_height,
6368                // Canvas constructs commands in display coordinates, so the
6369                // viewBox matches the display box 1:1 — scale comes out to 1.
6370                viewbox_min_x: 0.0,
6371                viewbox_min_y: 0.0,
6372                viewbox_width: canvas_width,
6373                viewbox_height: canvas_height,
6374                clip: true,
6375            },
6376            children: vec![],
6377            node_type: Some("Canvas".to_string()),
6378            resolved_style: Some(style.clone()),
6379            source_location: node.source_location.clone(),
6380            href: node.href.clone(),
6381            bookmark: node.bookmark.clone(),
6382            alt: node.alt.clone(),
6383            is_header_row: false,
6384            actual_text: None,
6385            list_numbering: None,
6386            col_span: 1,
6387            overflow: style.overflow,
6388            opacity: style.opacity,
6389        });
6390
6391        cursor.y += canvas_height + margin.bottom;
6392    }
6393
6394    /// Layout a 1D barcode as a row of vector rectangles.
6395    #[allow(clippy::too_many_arguments)]
6396    /// Layout a chart as a single unbreakable block of drawing primitives.
6397    #[allow(clippy::too_many_arguments)]
6398    fn layout_chart(
6399        &self,
6400        node: &Node,
6401        style: &ResolvedStyle,
6402        cursor: &mut PageCursor,
6403        pages: &mut Vec<LayoutPage>,
6404        x: f64,
6405        chart_width: f64,
6406        chart_height: f64,
6407        primitives: Vec<crate::chart::ChartPrimitive>,
6408        node_type_name: &str,
6409    ) {
6410        let margin = &style.margin.to_edges();
6411        let total_height = chart_height + margin.vertical();
6412
6413        if total_height > cursor.remaining_height() {
6414            pages.push(cursor.finalize());
6415            *cursor = cursor.new_page();
6416        }
6417
6418        cursor.y += margin.top;
6419
6420        let draw = DrawCommand::Chart { primitives };
6421
6422        cursor.elements.push(LayoutElement {
6423            x: x + margin.left,
6424            y: cursor.content_y + cursor.y,
6425            width: chart_width,
6426            height: chart_height,
6427            draw,
6428            children: vec![],
6429            node_type: Some(node_type_name.to_string()),
6430            resolved_style: Some(style.clone()),
6431            source_location: node.source_location.clone(),
6432            href: node.href.clone(),
6433            bookmark: node.bookmark.clone(),
6434            alt: node.alt.clone(),
6435            is_header_row: false,
6436            actual_text: None,
6437            list_numbering: None,
6438            col_span: 1,
6439            overflow: style.overflow,
6440            opacity: style.opacity,
6441        });
6442
6443        cursor.y += chart_height + margin.bottom;
6444    }
6445
6446    /// Layout a form field as a fixed-size leaf node.
6447    #[allow(clippy::too_many_arguments)]
6448    fn layout_form_field(
6449        &self,
6450        node: &Node,
6451        style: &ResolvedStyle,
6452        cursor: &mut PageCursor,
6453        pages: &mut Vec<LayoutPage>,
6454        x: f64,
6455        field_width: f64,
6456        field_height: f64,
6457        draw: DrawCommand,
6458        node_type_name: &str,
6459    ) {
6460        let margin = &style.margin.to_edges();
6461        let total_height = field_height + margin.vertical();
6462
6463        if total_height > cursor.remaining_height() {
6464            pages.push(cursor.finalize());
6465            *cursor = cursor.new_page();
6466        }
6467
6468        cursor.y += margin.top;
6469
6470        cursor.elements.push(LayoutElement {
6471            x: x + margin.left,
6472            y: cursor.content_y + cursor.y,
6473            width: field_width,
6474            height: field_height,
6475            draw,
6476            children: vec![],
6477            node_type: Some(node_type_name.to_string()),
6478            resolved_style: Some(style.clone()),
6479            source_location: node.source_location.clone(),
6480            href: node.href.clone(),
6481            bookmark: node.bookmark.clone(),
6482            alt: node.alt.clone(),
6483            is_header_row: false,
6484            actual_text: None,
6485            list_numbering: None,
6486            col_span: 1,
6487            overflow: style.overflow,
6488            opacity: style.opacity,
6489        });
6490
6491        cursor.y += field_height + margin.bottom;
6492    }
6493
6494    #[allow(clippy::too_many_arguments)]
6495    fn layout_barcode(
6496        &self,
6497        node: &Node,
6498        style: &ResolvedStyle,
6499        cursor: &mut PageCursor,
6500        pages: &mut Vec<LayoutPage>,
6501        x: f64,
6502        available_width: f64,
6503        data: &str,
6504        format: crate::barcode::BarcodeFormat,
6505        explicit_width: Option<f64>,
6506        bar_height: f64,
6507    ) {
6508        let margin = &style.margin.to_edges();
6509        let display_width = explicit_width.unwrap_or(available_width - margin.horizontal());
6510        let total_height = bar_height + margin.vertical();
6511
6512        if total_height > cursor.remaining_height() {
6513            pages.push(cursor.finalize());
6514            *cursor = cursor.new_page();
6515        }
6516
6517        cursor.y += margin.top;
6518
6519        let draw = match crate::barcode::generate_barcode(data, format) {
6520            Ok(barcode_data) => {
6521                let bar_width = if barcode_data.bars.is_empty() {
6522                    0.0
6523                } else {
6524                    display_width / barcode_data.bars.len() as f64
6525                };
6526                DrawCommand::Barcode {
6527                    bars: barcode_data.bars,
6528                    bar_width,
6529                    height: bar_height,
6530                    color: style.color,
6531                }
6532            }
6533            Err(_) => DrawCommand::None,
6534        };
6535
6536        cursor.elements.push(LayoutElement {
6537            x: x + margin.left,
6538            y: cursor.content_y + cursor.y,
6539            width: display_width,
6540            height: bar_height,
6541            draw,
6542            children: vec![],
6543            node_type: Some("Barcode".to_string()),
6544            resolved_style: Some(style.clone()),
6545            source_location: node.source_location.clone(),
6546            href: node.href.clone(),
6547            bookmark: node.bookmark.clone(),
6548            alt: node.alt.clone(),
6549            is_header_row: false,
6550            actual_text: Some(data.to_string()),
6551            list_numbering: None,
6552            col_span: 1,
6553            overflow: style.overflow,
6554            opacity: style.opacity,
6555        });
6556
6557        cursor.y += bar_height + margin.bottom;
6558    }
6559
6560    /// Layout a QR code as a square block of vector rectangles.
6561    #[allow(clippy::too_many_arguments)]
6562    fn layout_qrcode(
6563        &self,
6564        node: &Node,
6565        style: &ResolvedStyle,
6566        cursor: &mut PageCursor,
6567        pages: &mut Vec<LayoutPage>,
6568        x: f64,
6569        available_width: f64,
6570        data: &str,
6571        explicit_size: Option<f64>,
6572    ) {
6573        let margin = &style.margin.to_edges();
6574        let display_size = explicit_size.unwrap_or(available_width - margin.horizontal());
6575        let total_height = display_size + margin.vertical();
6576
6577        if total_height > cursor.remaining_height() {
6578            pages.push(cursor.finalize());
6579            *cursor = cursor.new_page();
6580        }
6581
6582        cursor.y += margin.top;
6583
6584        let draw = match crate::qrcode::generate_qr(data) {
6585            Ok(matrix) => {
6586                let module_size = display_size / matrix.size as f64;
6587                DrawCommand::QrCode {
6588                    modules: matrix.modules,
6589                    module_size,
6590                    color: style.color,
6591                }
6592            }
6593            Err(_) => DrawCommand::None,
6594        };
6595
6596        cursor.elements.push(LayoutElement {
6597            x: x + margin.left,
6598            y: cursor.content_y + cursor.y,
6599            width: display_size,
6600            height: display_size,
6601            draw,
6602            children: vec![],
6603            node_type: Some("QrCode".to_string()),
6604            resolved_style: Some(style.clone()),
6605            source_location: node.source_location.clone(),
6606            href: node.href.clone(),
6607            bookmark: node.bookmark.clone(),
6608            alt: node.alt.clone(),
6609            is_header_row: false,
6610            actual_text: Some(data.to_string()),
6611            list_numbering: None,
6612            col_span: 1,
6613            overflow: style.overflow,
6614            opacity: style.opacity,
6615        });
6616
6617        cursor.y += display_size + margin.bottom;
6618    }
6619
6620    // ── Measurement helpers ─────────────────────────────────────
6621
6622    fn measure_node_height(
6623        &self,
6624        node: &Node,
6625        available_width: f64,
6626        style: &ResolvedStyle,
6627        font_context: &FontContext,
6628    ) -> f64 {
6629        match &node.kind {
6630            // Headings lay out exactly like Text (see the layout arm), so they
6631            // must measure the same way — otherwise a heading falls through to
6632            // the container `_` arm, measures ~0 (it has no children), and a
6633            // parent's auto-height omits it.
6634            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
6635                // Mirror layout_text: a fixed width drives line-breaking, so height
6636                // measurement must use the same width or it will under-count lines.
6637                let measure_width = match style.width {
6638                    SizeConstraint::Fixed(w) => (w - style.margin.horizontal()).max(0.0),
6639                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
6640                };
6641                // Measurement must reach the same line count layout will:
6642                // same text transform, same breaker (greedy vs Knuth-Plass —
6643                // the two can disagree at boundary widths, where optimal
6644                // accepts a slightly-overfull line greedy would wrap).
6645                // Divergence here is exactly what FORME_MEASURE_CHECK exists
6646                // to catch.
6647                if !runs.is_empty() {
6648                    let mut styled_chars: Vec<StyledChar> = Vec::new();
6649                    for run in runs {
6650                        let run_style = run.style.resolve(Some(style), measure_width);
6651                        let transform = run_style.text_transform;
6652                        let run_content = substitute_page_placeholders(&run.content);
6653                        let mut prev_is_whitespace = true;
6654                        for ch in run_content.chars() {
6655                            let transformed_ch =
6656                                apply_char_transform(ch, transform, prev_is_whitespace);
6657                            prev_is_whitespace = ch.is_whitespace();
6658                            styled_chars.push(StyledChar {
6659                                ch: transformed_ch,
6660                                font_family: run_style.font_family.clone(),
6661                                font_size: run_style.font_size,
6662                                font_weight: run_style.font_weight,
6663                                font_style: run_style.font_style,
6664                                color: run_style.color,
6665                                href: None,
6666                                text_decoration: run_style.text_decoration,
6667                                letter_spacing: run_style.letter_spacing,
6668                                word_spacing: run_style.word_spacing,
6669                            });
6670                        }
6671                    }
6672                    let justify = matches!(style.text_align, TextAlign::Justify);
6673                    let broken_lines = match style.line_breaking {
6674                        LineBreaking::Optimal => self.text_layout.break_runs_into_lines_optimal(
6675                            font_context,
6676                            &styled_chars,
6677                            measure_width,
6678                            style.hyphens,
6679                            style.lang.as_deref(),
6680                            justify,
6681                        ),
6682                        LineBreaking::Greedy => self.text_layout.break_runs_into_lines(
6683                            font_context,
6684                            &styled_chars,
6685                            measure_width,
6686                            style.hyphens,
6687                            style.lang.as_deref(),
6688                        ),
6689                    };
6690                    let line_height = style.font_size * style.line_height;
6691                    (broken_lines.len() as f64) * line_height + style.padding.vertical()
6692                } else {
6693                    let content = substitute_page_placeholders(content);
6694                    let transformed = apply_text_transform(&content, style.text_transform);
6695                    let justify = matches!(style.text_align, TextAlign::Justify);
6696                    let lines = match style.line_breaking {
6697                        LineBreaking::Optimal => self.text_layout.break_into_lines_optimal(
6698                            font_context,
6699                            &transformed,
6700                            measure_width,
6701                            style.font_size,
6702                            &style.font_family,
6703                            style.font_weight,
6704                            style.font_style,
6705                            style.letter_spacing,
6706                            style.word_spacing,
6707                            style.hyphens,
6708                            style.lang.as_deref(),
6709                            justify,
6710                        ),
6711                        LineBreaking::Greedy => self.text_layout.break_into_lines(
6712                            font_context,
6713                            &transformed,
6714                            measure_width,
6715                            style.font_size,
6716                            &style.font_family,
6717                            style.font_weight,
6718                            style.font_style,
6719                            style.letter_spacing,
6720                            style.word_spacing,
6721                            style.hyphens,
6722                            style.lang.as_deref(),
6723                        ),
6724                    };
6725                    let line_height = style.font_size * style.line_height;
6726                    (lines.len() as f64) * line_height + style.padding.vertical()
6727                }
6728            }
6729            NodeKind::Image {
6730                src,
6731                width: explicit_w,
6732                height: explicit_h,
6733            } => {
6734                // Same ladder layout_image uses — agreement by construction.
6735                let (_, h) = self.image_display_size(
6736                    src,
6737                    style,
6738                    *explicit_w,
6739                    *explicit_h,
6740                    available_width - style.margin.horizontal(),
6741                );
6742                h + style.padding.vertical()
6743            }
6744            NodeKind::Svg { height, .. } => *height + style.margin.vertical(),
6745            NodeKind::Barcode { height, .. } => *height + style.margin.vertical(),
6746            NodeKind::QrCode { size, .. } => {
6747                let display_size = size.unwrap_or(available_width - style.margin.horizontal());
6748                display_size + style.margin.vertical()
6749            }
6750            NodeKind::Canvas { height, .. } => *height + style.margin.vertical(),
6751            NodeKind::BarChart { height, .. }
6752            | NodeKind::LineChart { height, .. }
6753            | NodeKind::PieChart { height, .. }
6754            | NodeKind::AreaChart { height, .. }
6755            | NodeKind::DotPlot { height, .. } => *height + style.margin.vertical(),
6756            NodeKind::TextField { height, .. }
6757            | NodeKind::Checkbox { height, .. }
6758            | NodeKind::Dropdown { height, .. }
6759            | NodeKind::RadioButton { height, .. } => *height + style.margin.vertical(),
6760            NodeKind::Watermark { .. } => 0.0, // Watermarks take zero layout height
6761            NodeKind::Table { columns } => {
6762                // Use the same column-resolution + per-row max-of-cells helpers
6763                // that `layout_table` uses, so measurement matches what the
6764                // engine actually renders. Without this arm, Table fell into the
6765                // generic `_` branch which column-summed each row's children,
6766                // and (since TableRow also lacked an arm) over-counted row
6767                // heights by a factor of (cell count).
6768                if let SizeConstraint::Fixed(h) = style.height {
6769                    return h;
6770                }
6771                let outer_width = match style.width {
6772                    SizeConstraint::Fixed(w) => w,
6773                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
6774                };
6775                let inner_width =
6776                    outer_width - style.padding.horizontal() - style.border_width.horizontal();
6777                let col_widths = self.resolve_column_widths(
6778                    columns,
6779                    inner_width,
6780                    &node.children,
6781                    style,
6782                    font_context,
6783                );
6784                let row_gap = style.row_gap;
6785                let offsets = Self::table_column_offsets(&node.children);
6786                let mut total = 0.0;
6787                for (i, row) in node.children.iter().enumerate() {
6788                    if i > 0 {
6789                        total += row_gap;
6790                    }
6791                    total += self.measure_table_row_height(
6792                        row,
6793                        &col_widths,
6794                        &offsets[i],
6795                        style,
6796                        font_context,
6797                    );
6798                }
6799                total + style.padding.vertical() + style.border_width.vertical()
6800            }
6801            NodeKind::TableRow { .. } => {
6802                // Standalone-row fallback (rare): a TableRow measured outside
6803                // a Table context has no ColumnDef source, so split
6804                // available_width evenly across cells — matches what
6805                // resolve_column_widths does when its defs vec is empty.
6806                let n = node.children.len().max(1);
6807                let usable = (available_width - style.margin.horizontal()).max(0.0);
6808                let col_w = usable / n as f64;
6809                let col_widths = vec![col_w; n];
6810                let offsets = Self::table_column_offsets(std::slice::from_ref(node));
6811                self.measure_table_row_height(node, &col_widths, &offsets[0], style, font_context)
6812            }
6813            _ => {
6814                // If a fixed height is specified, use it directly
6815                if let SizeConstraint::Fixed(h) = style.height {
6816                    return h;
6817                }
6818                // Match layout_view: when width is Auto, margin reduces the
6819                // outer width; min/max clamp identically or measured heights
6820                // disagree with laid-out widths.
6821                let outer_width = match style.width {
6822                    SizeConstraint::Fixed(w) => w,
6823                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
6824                }
6825                .min(style.max_width)
6826                .max(style.min_width);
6827                let inner_width =
6828                    outer_width - style.padding.horizontal() - style.border_width.horizontal();
6829                let children_height =
6830                    self.measure_children_height(&node.children, inner_width, style, font_context);
6831                (children_height + style.padding.vertical() + style.border_width.vertical())
6832                    .max(style.min_height)
6833            }
6834        }
6835    }
6836
6837    fn measure_children_height(
6838        &self,
6839        children: &[Node],
6840        available_width: f64,
6841        parent_style: &ResolvedStyle,
6842        font_context: &FontContext,
6843    ) -> f64 {
6844        // Absolutely-positioned children are out of flow: layout_children
6845        // partitions them off and they never advance the cursor, so counting
6846        // them here reserves phantom space equal to their height in every
6847        // auto-height ancestor. Caught by FORME_MEASURE_CHECK. Clone-filter
6848        // only in the rare case one is present.
6849        if children
6850            .iter()
6851            .any(|c| matches!(c.style.position, Some(Position::Absolute)))
6852        {
6853            let flow: Vec<Node> = children
6854                .iter()
6855                .filter(|c| !matches!(c.style.position, Some(Position::Absolute)))
6856                .cloned()
6857                .collect();
6858            return self.measure_children_height(
6859                &flow,
6860                available_width,
6861                parent_style,
6862                font_context,
6863            );
6864        }
6865        // Grid layout: measure using actual grid placement instead of stacking
6866        if matches!(parent_style.display, Display::Grid) {
6867            if let Some(template_cols) = &parent_style.grid_template_columns {
6868                let num_columns = template_cols.len();
6869                if num_columns > 0 && !children.is_empty() {
6870                    let col_gap = parent_style.column_gap;
6871                    let row_gap = parent_style.row_gap;
6872
6873                    let content_sizes: Vec<f64> = template_cols
6874                        .iter()
6875                        .map(|track| {
6876                            if matches!(track, GridTrackSize::Auto) {
6877                                available_width / num_columns as f64
6878                            } else {
6879                                0.0
6880                            }
6881                        })
6882                        .collect();
6883
6884                    let col_widths = grid::resolve_tracks(
6885                        template_cols,
6886                        available_width,
6887                        col_gap,
6888                        &content_sizes,
6889                    );
6890
6891                    let placements: Vec<Option<&GridPlacement>> = children
6892                        .iter()
6893                        .map(|child| child.style.grid_placement.as_ref())
6894                        .collect();
6895
6896                    let item_placements = grid::place_items(&placements, num_columns);
6897                    let num_rows = grid::compute_num_rows(&item_placements);
6898
6899                    if num_rows == 0 {
6900                        return 0.0;
6901                    }
6902
6903                    let mut row_heights = vec![0.0_f64; num_rows];
6904                    for placement in &item_placements {
6905                        let cell_width = grid::span_width(
6906                            placement.col_start,
6907                            placement.col_end,
6908                            &col_widths,
6909                            col_gap,
6910                        );
6911                        let child = &children[placement.child_index];
6912                        let child_style = child.style.resolve(Some(parent_style), cell_width);
6913                        let h =
6914                            self.measure_node_height(child, cell_width, &child_style, font_context);
6915                        let span = placement.row_end - placement.row_start;
6916                        let per_row = h / span as f64;
6917                        for rh in row_heights
6918                            .iter_mut()
6919                            .take(placement.row_end.min(num_rows))
6920                            .skip(placement.row_start)
6921                        {
6922                            if per_row > *rh {
6923                                *rh = per_row;
6924                            }
6925                        }
6926                    }
6927
6928                    let total_row_gap = row_gap * (num_rows as f64 - 1.0).max(0.0);
6929                    return row_heights.iter().sum::<f64>() + total_row_gap;
6930                }
6931            }
6932        }
6933
6934        let direction = parent_style.flex_direction;
6935        let row_gap = parent_style.row_gap;
6936        let column_gap = parent_style.column_gap;
6937
6938        match direction {
6939            FlexDirection::Row | FlexDirection::RowReverse => {
6940                // Measure base widths for all children
6941                // flex_basis takes precedence over width (matching layout_flex_row)
6942                let styles: Vec<ResolvedStyle> = children
6943                    .iter()
6944                    .map(|child| child.style.resolve(Some(parent_style), available_width))
6945                    .collect();
6946
6947                let base_widths: Vec<f64> = children
6948                    .iter()
6949                    .zip(&styles)
6950                    .map(|(child, style)| match style.flex_basis {
6951                        SizeConstraint::Fixed(w) => w,
6952                        SizeConstraint::Auto => match style.width {
6953                            SizeConstraint::Fixed(w) => w,
6954                            SizeConstraint::Auto => self
6955                                .measure_intrinsic_width(child, style, font_context)
6956                                .min(available_width),
6957                        },
6958                    })
6959                    .collect();
6960
6961                let lines = match parent_style.flex_wrap {
6962                    FlexWrap::NoWrap => {
6963                        vec![flex::WrapLine {
6964                            start: 0,
6965                            end: children.len(),
6966                        }]
6967                    }
6968                    FlexWrap::Wrap | FlexWrap::WrapReverse => {
6969                        flex::partition_into_lines(&base_widths, column_gap, available_width)
6970                    }
6971                };
6972
6973                // Apply flex grow/shrink to get final widths (matching layout_flex_row)
6974                let mut final_widths = base_widths.clone();
6975                for line in &lines {
6976                    let line_count = line.end - line.start;
6977                    let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
6978                    let distributable = available_width - line_gap;
6979                    let total_base: f64 = base_widths[line.start..line.end].iter().sum();
6980                    let remaining = distributable - total_base;
6981
6982                    if remaining > 0.0 {
6983                        let total_grow: f64 = styles[line.start..line.end]
6984                            .iter()
6985                            .map(|s| s.flex_grow)
6986                            .sum();
6987                        if total_grow > 0.0 {
6988                            for (j, s) in styles[line.start..line.end].iter().enumerate() {
6989                                final_widths[line.start + j] = base_widths[line.start + j]
6990                                    + remaining * (s.flex_grow / total_grow);
6991                            }
6992                        }
6993                    } else if remaining < 0.0 {
6994                        let total_shrink: f64 = styles[line.start..line.end]
6995                            .iter()
6996                            .enumerate()
6997                            .map(|(j, s)| s.flex_shrink * base_widths[line.start + j])
6998                            .sum();
6999                        if total_shrink > 0.0 {
7000                            for (j, s) in styles[line.start..line.end].iter().enumerate() {
7001                                let factor =
7002                                    (s.flex_shrink * base_widths[line.start + j]) / total_shrink;
7003                                let w = base_widths[line.start + j] + remaining * factor;
7004                                final_widths[line.start + j] = w.max(s.min_width);
7005                            }
7006                        }
7007                    }
7008                }
7009
7010                let mut total = 0.0;
7011                for (i, line) in lines.iter().enumerate() {
7012                    let line_height: f64 = children[line.start..line.end]
7013                        .iter()
7014                        .enumerate()
7015                        .map(|(j, child)| {
7016                            let fw = final_widths[line.start + j];
7017                            // Resolve against the CONTAINER's width, not the
7018                            // child's own final width: a child's percent width
7019                            // (and percent margins/padding — CSS resolves them
7020                            // against the containing block) must not resolve
7021                            // against itself. Resolving `width: 27%` against
7022                            // fw made it 27% of 27%, so text measured at a
7023                            // quarter width — one word per line — and rows
7024                            // measured 2.5-4x taller than layout produced.
7025                            let child_style =
7026                                child.style.resolve(Some(parent_style), available_width);
7027                            self.measure_node_height(child, fw, &child_style, font_context)
7028                                + child_style.margin.vertical()
7029                        })
7030                        .fold(0.0f64, f64::max);
7031                    total += line_height;
7032                    if i > 0 {
7033                        total += row_gap;
7034                    }
7035                }
7036                total
7037            }
7038            FlexDirection::Column | FlexDirection::ColumnReverse => {
7039                let mut total = 0.0;
7040                for (i, child) in children.iter().enumerate() {
7041                    let child_style = child.style.resolve(Some(parent_style), available_width);
7042                    let child_height = self.measure_node_height(
7043                        child,
7044                        available_width,
7045                        &child_style,
7046                        font_context,
7047                    );
7048                    total += child_height + child_style.margin.vertical();
7049                    if i > 0 {
7050                        total += row_gap;
7051                    }
7052                }
7053                total
7054            }
7055        }
7056    }
7057
7058    /// Measure intrinsic width of a node (used for flex row sizing).
7059    fn measure_intrinsic_width(
7060        &self,
7061        node: &Node,
7062        style: &ResolvedStyle,
7063        font_context: &FontContext,
7064    ) -> f64 {
7065        match &node.kind {
7066            NodeKind::Svg { width, .. } => {
7067                *width + style.padding.horizontal() + style.margin.horizontal()
7068            }
7069            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
7070                // Measured the way the line breaker measures (shaped, with
7071                // kerning, fallback and spacing), so a box sized by this
7072                // width holds the line it was sized for. An unshaped advance
7073                // sum came out narrower than the shaped line whenever kerning
7074                // widened a word, and the breaker then split the word (#181).
7075                // A hard break ('\n') restarts the line: the intrinsic width
7076                // of multi-line text is the widest line.
7077                let text_width = if !runs.is_empty() {
7078                    // `content` is empty (or a shadow copy) when runs are
7079                    // present, so the runs' own chars are measured.
7080                    let chars = Self::runs_to_styled_chars(runs, None, style, 0.0);
7081                    chars
7082                        .split(|c| c.ch == '\n')
7083                        .map(|line| self.text_layout.measure_runs_width(font_context, line))
7084                        .fold(0.0f64, f64::max)
7085                } else {
7086                    let content = substitute_page_placeholders(content);
7087                    let transformed = apply_text_transform(&content, style.text_transform);
7088                    transformed
7089                        .split('\n')
7090                        .map(|segment| {
7091                            self.text_layout.measure_width(
7092                                font_context,
7093                                segment,
7094                                style.font_size,
7095                                &style.font_family,
7096                                style.font_weight,
7097                                style.font_style,
7098                                style.letter_spacing,
7099                                style.word_spacing,
7100                            )
7101                        })
7102                        .fold(0.0f64, f64::max)
7103                };
7104                // Add tiny epsilon to prevent exact-boundary line wrapping when
7105                // this width is later used as max_width for line breaking
7106                text_width + 0.01 + style.padding.horizontal() + style.margin.horizontal()
7107            }
7108            NodeKind::Image {
7109                src, width, height, ..
7110            } => {
7111                let w = if let SizeConstraint::Fixed(w) = style.width {
7112                    w
7113                } else if let Some(w) = width {
7114                    *w
7115                } else if let Some((iw, ih)) = self.get_image_dimensions(src) {
7116                    let pixel_w = iw as f64;
7117                    let pixel_h = ih as f64;
7118                    let aspect = if pixel_w > 0.0 {
7119                        pixel_h / pixel_w
7120                    } else {
7121                        0.75
7122                    };
7123                    // Check for height constraint (style or node prop)
7124                    let constrained_h = match style.height {
7125                        SizeConstraint::Fixed(h) => Some(h),
7126                        SizeConstraint::Auto => *height,
7127                    };
7128                    if let Some(h) = constrained_h {
7129                        h / aspect
7130                    } else {
7131                        pixel_w
7132                    }
7133                } else {
7134                    100.0
7135                };
7136                w + style.padding.horizontal() + style.margin.horizontal()
7137            }
7138            NodeKind::Barcode { width, .. } => {
7139                let w = width.unwrap_or(0.0);
7140                w + style.padding.horizontal() + style.margin.horizontal()
7141            }
7142            NodeKind::QrCode { size, .. } => {
7143                let display_size = size.unwrap_or(0.0);
7144                display_size + style.padding.horizontal() + style.margin.horizontal()
7145            }
7146            NodeKind::Canvas { width, .. } => {
7147                *width + style.padding.horizontal() + style.margin.horizontal()
7148            }
7149            NodeKind::BarChart { width, .. }
7150            | NodeKind::LineChart { width, .. }
7151            | NodeKind::PieChart { width, .. }
7152            | NodeKind::AreaChart { width, .. }
7153            | NodeKind::DotPlot { width, .. } => {
7154                *width + style.padding.horizontal() + style.margin.horizontal()
7155            }
7156            NodeKind::TextField { width, .. } | NodeKind::Dropdown { width, .. } => {
7157                *width + style.padding.horizontal() + style.margin.horizontal()
7158            }
7159            NodeKind::Table { columns } => {
7160                // A table's max-content width is the SUM of its columns'
7161                // max-content (the default max-of-children arm below
7162                // reports only the widest cell, which made shrink-to-fit
7163                // containers crush tables to one column's width).
7164                let num_cols = node
7165                    .children
7166                    .iter()
7167                    .map(|row| row.children.iter().map(Self::cell_col_span).sum::<usize>())
7168                    .max()
7169                    .unwrap_or(1)
7170                    .max(columns.len().max(1));
7171                let (_, col_max) =
7172                    self.measure_column_content(&node.children, num_cols, 0.0, style, font_context);
7173                col_max.iter().sum::<f64>()
7174                    + style.padding.horizontal()
7175                    + style.margin.horizontal()
7176                    + style.border_width.horizontal()
7177            }
7178            NodeKind::Checkbox { width, .. } | NodeKind::RadioButton { width, .. } => {
7179                *width + style.padding.horizontal() + style.margin.horizontal()
7180            }
7181            NodeKind::Watermark { .. } => 0.0, // Watermarks take zero width
7182            _ => {
7183                // An explicit width IS the intrinsic width (content-box:
7184                // padding and border sit on top, margins outside). The
7185                // children-based measure below ignored it, so an empty
7186                // width:33pt div measured 0 and the masthead mark measured
7187                // as the width of its letter "N" (~10.8pt) — its flex row
7188                // then went over-full by exactly the difference and shrank
7189                // the mark to 25.9pt.
7190                if let SizeConstraint::Fixed(w) = style.width {
7191                    return w
7192                        + style.padding.horizontal()
7193                        + style.border_width.horizontal()
7194                        + style.margin.horizontal();
7195                }
7196                // Recursively measure children's intrinsic widths
7197                if node.children.is_empty() {
7198                    style.padding.horizontal() + style.margin.horizontal()
7199                } else {
7200                    let direction = style.flex_direction;
7201                    // The resolved authority for a row's inter-item gap is
7202                    // column_gap — resolve() folds the `gap` shorthand into
7203                    // it, and layout_flex_row reads column_gap. Reading the
7204                    // raw `gap` field measured every CSS `gap:`/`column-gap:`
7205                    // flex row as gapless: a nested row under-reported its
7206                    // intrinsic width by (n-1)*gap, was handed exactly that
7207                    // width, went over-full, and shrank its own fixed-width
7208                    // children (the masthead square that rendered 25.9pt
7209                    // wide with width: 33pt declared). JSX callers are
7210                    // unaffected: their `gap` folds into column_gap at
7211                    // resolve time, so the two fields agree there.
7212                    let gap = style.column_gap;
7213                    let mut total = 0.0f64;
7214                    for (i, child) in node.children.iter().enumerate() {
7215                        let child_style = child.style.resolve(Some(style), 0.0);
7216                        let child_width =
7217                            self.measure_intrinsic_width(child, &child_style, font_context);
7218                        match direction {
7219                            FlexDirection::Row | FlexDirection::RowReverse => {
7220                                total += child_width;
7221                                if i > 0 {
7222                                    total += gap;
7223                                }
7224                            }
7225                            _ => {
7226                                total = total.max(child_width);
7227                            }
7228                        }
7229                    }
7230                    total
7231                        + style.padding.horizontal()
7232                        + style.margin.horizontal()
7233                        + style.border_width.horizontal()
7234                }
7235            }
7236        }
7237    }
7238
7239    /// Measure the min-content width of a node — the minimum width needed
7240    /// to render without breaking unbreakable words. For Text nodes this is
7241    /// the widest single word; for containers it's the max of children.
7242    pub fn measure_min_content_width(
7243        &self,
7244        node: &Node,
7245        style: &ResolvedStyle,
7246        font_context: &FontContext,
7247    ) -> f64 {
7248        match &node.kind {
7249            NodeKind::Table { columns } => {
7250                // Min-content of a table = sum of per-column min-content
7251                // (mirrors the intrinsic-width Table arm).
7252                let num_cols = node
7253                    .children
7254                    .iter()
7255                    .map(|row| row.children.iter().map(Self::cell_col_span).sum::<usize>())
7256                    .max()
7257                    .unwrap_or(1)
7258                    .max(columns.len().max(1));
7259                let (col_min, _) =
7260                    self.measure_column_content(&node.children, num_cols, 0.0, style, font_context);
7261                col_min.iter().sum::<f64>()
7262                    + style.padding.horizontal()
7263                    + style.margin.horizontal()
7264                    + style.border_width.horizontal()
7265            }
7266            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
7267                let word_width = if !runs.is_empty() {
7268                    // For styled runs, measure each run's widest word
7269                    runs.iter()
7270                        .map(|run| {
7271                            let run_style = run.style.resolve(Some(style), 0.0);
7272                            let run_content = substitute_page_placeholders(&run.content);
7273                            let transformed =
7274                                apply_text_transform(&run_content, run_style.text_transform);
7275                            self.text_layout.measure_widest_word(
7276                                font_context,
7277                                &transformed,
7278                                run_style.font_size,
7279                                &run_style.font_family,
7280                                run_style.font_weight,
7281                                run_style.font_style,
7282                                run_style.letter_spacing,
7283                                run_style.word_spacing,
7284                                style.hyphens,
7285                                style.lang.as_deref(),
7286                            )
7287                        })
7288                        .fold(0.0f64, f64::max)
7289                } else {
7290                    let content = substitute_page_placeholders(content);
7291                    let transformed = apply_text_transform(&content, style.text_transform);
7292                    self.text_layout.measure_widest_word(
7293                        font_context,
7294                        &transformed,
7295                        style.font_size,
7296                        &style.font_family,
7297                        style.font_weight,
7298                        style.font_style,
7299                        style.letter_spacing,
7300                        style.word_spacing,
7301                        style.hyphens,
7302                        style.lang.as_deref(),
7303                    )
7304                };
7305                word_width + style.padding.horizontal() + style.margin.horizontal()
7306            }
7307            NodeKind::Image { width, .. } => {
7308                width.unwrap_or(0.0) + style.padding.horizontal() + style.margin.horizontal()
7309            }
7310            NodeKind::Svg { width, .. } => {
7311                *width + style.padding.horizontal() + style.margin.horizontal()
7312            }
7313            _ => {
7314                if node.children.is_empty() {
7315                    style.padding.horizontal()
7316                        + style.margin.horizontal()
7317                        + style.border_width.horizontal()
7318                } else {
7319                    let mut max_child_min = 0.0f64;
7320                    for child in &node.children {
7321                        let child_style = child.style.resolve(Some(style), 0.0);
7322                        let child_min =
7323                            self.measure_min_content_width(child, &child_style, font_context);
7324                        max_child_min = max_child_min.max(child_min);
7325                    }
7326                    max_child_min
7327                        + style.padding.horizontal()
7328                        + style.margin.horizontal()
7329                        + style.border_width.horizontal()
7330                }
7331            }
7332        }
7333    }
7334
7335    /// Distance from a flex ITEM's margin-box top to its first text baseline,
7336    /// in the engine's baseline model (half-leading + font_size — exactly
7337    /// where layout_text places glyphs; see `cell_first_baseline_in_line`).
7338    /// A Text/Heading item uses its own style; a container walks to its
7339    /// first text-producing descendant; an item with no text at all
7340    /// synthesizes from its own font style.
7341    fn flex_item_baseline_distance(
7342        &self,
7343        item: &Node,
7344        style: &ResolvedStyle,
7345        w: f64,
7346        font_context: &FontContext,
7347    ) -> f64 {
7348        let first_line = match &item.kind {
7349            NodeKind::Text { .. } | NodeKind::Heading { .. } => {
7350                let metrics = font_context.baseline_metrics(
7351                    &style.font_family,
7352                    style.font_weight,
7353                    matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique),
7354                );
7355                baseline_in_line(
7356                    style.font_size * style.line_height,
7357                    style.font_size,
7358                    metrics,
7359                )
7360            }
7361            _ => self.cell_first_baseline_in_line(item, style, w, font_context),
7362        };
7363        style.margin.to_edges().top + style.padding.top + style.border_width.top + first_line
7364    }
7365
7366    /// The first-baseline offset of a cell's first text line from its line-box
7367    /// top, in the same real-metric model as glyph placement (see
7368    /// `baseline_in_line`) — the two must agree or baseline alignment
7369    /// shoves drift from where the ink actually sits. Walks to the first
7370    /// text-producing descendant; falls back to the cell's own style when
7371    /// there is none.
7372    fn cell_first_baseline_in_line(
7373        &self,
7374        cell: &Node,
7375        cell_style: &ResolvedStyle,
7376        w: f64,
7377        font_context: &FontContext,
7378    ) -> f64 {
7379        fn first(node: &Node, parent: &ResolvedStyle, w: f64) -> Option<ResolvedStyle> {
7380            for ch in &node.children {
7381                let s = ch.style.resolve(Some(parent), w);
7382                match &ch.kind {
7383                    NodeKind::Text { .. } | NodeKind::Heading { .. } => return Some(s),
7384                    _ => {
7385                        if let Some(f) = first(ch, &s, w) {
7386                            return Some(f);
7387                        }
7388                    }
7389                }
7390            }
7391            None
7392        }
7393        let s = first(cell, cell_style, w).unwrap_or_else(|| cell_style.clone());
7394        let metrics = font_context.baseline_metrics(
7395            &s.font_family,
7396            s.font_weight,
7397            matches!(s.font_style, FontStyle::Italic | FontStyle::Oblique),
7398        );
7399        baseline_in_line(s.font_size * s.line_height, s.font_size, metrics)
7400    }
7401
7402    /// Distance from a cell's border-box top to its first text baseline:
7403    /// `padding.top + border.top + half-leading + first-line font_size` —
7404    /// matching exactly where layout_text places the glyphs, or baseline
7405    /// alignment drifts by half the leading.
7406    fn cell_baseline_distance(
7407        &self,
7408        cell: &Node,
7409        cell_style: &ResolvedStyle,
7410        inner_width: f64,
7411        font_context: &FontContext,
7412    ) -> f64 {
7413        cell_style.padding.top
7414            + cell_style.border_width.top
7415            + self.cell_first_baseline_in_line(cell, cell_style, inner_width, font_context)
7416    }
7417
7418    /// The row baseline: the max first-baseline distance across the row's
7419    /// `vertical-align: baseline` cells. `None` when no cell asks for baseline.
7420    fn row_baseline(
7421        &self,
7422        row: &Node,
7423        row_style: &ResolvedStyle,
7424        col_widths: &[f64],
7425        col_offsets: &[usize],
7426        font_context: &FontContext,
7427    ) -> Option<f64> {
7428        let mut b: Option<f64> = None;
7429        for (cell_i, cell) in row.children.iter().enumerate() {
7430            let span = match &cell.kind {
7431                NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
7432                _ => 1,
7433            };
7434            let start_col = col_offsets.get(cell_i).copied().unwrap_or(0);
7435            let col_width: f64 = col_widths.iter().skip(start_col).take(span).copied().sum();
7436            let cell_style = cell.style.resolve(Some(row_style), col_width);
7437            if matches!(cell_style.vertical_align, VerticalAlign::Baseline) {
7438                let iw = col_width
7439                    - cell_style.padding.horizontal()
7440                    - cell_style.border_width.horizontal();
7441                let d = self.cell_baseline_distance(cell, &cell_style, iw, font_context);
7442                b = Some(b.map_or(d, |m: f64| m.max(d)));
7443            }
7444        }
7445        b
7446    }
7447
7448    fn measure_table_row_height(
7449        &self,
7450        row: &Node,
7451        col_widths: &[f64],
7452        col_offsets: &[usize],
7453        parent_style: &ResolvedStyle,
7454        font_context: &FontContext,
7455    ) -> f64 {
7456        let row_style = row
7457            .style
7458            .resolve(Some(parent_style), col_widths.iter().sum());
7459        let mut max_height: f64 = 0.0;
7460        // Precompute the row baseline so a baseline-shoved cell can grow the row
7461        // rather than clip (the risk site).
7462        let row_bl = self.row_baseline(row, &row_style, col_widths, col_offsets, font_context);
7463
7464        for (cell_i, cell) in row.children.iter().enumerate() {
7465            let span = match &cell.kind {
7466                NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
7467                _ => 1,
7468            };
7469            let start_col = col_offsets.get(cell_i).copied().unwrap_or(0);
7470            let col_width: f64 = col_widths.iter().skip(start_col).take(span).copied().sum();
7471            let cell_style = cell.style.resolve(Some(&row_style), col_width);
7472            let inner_width =
7473                col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
7474
7475            let mut cell_content_height = 0.0;
7476            for child in &cell.children {
7477                let child_style = child.style.resolve(Some(&cell_style), inner_width);
7478                cell_content_height +=
7479                    self.measure_node_height(child, inner_width, &child_style, font_context);
7480            }
7481
7482            let mut total = cell_content_height
7483                + cell_style.padding.vertical()
7484                + cell_style.border_width.vertical();
7485            // A baseline cell is shoved down by `row_baseline - its own baseline
7486            // distance`; the row must be tall enough to fit that shove, or the
7487            // cell content clips.
7488            if matches!(cell_style.vertical_align, VerticalAlign::Baseline) {
7489                if let Some(b) = row_bl {
7490                    let d =
7491                        self.cell_baseline_distance(cell, &cell_style, inner_width, font_context);
7492                    total += (b - d).max(0.0);
7493                }
7494            }
7495            // CSS 2.1 §17.5.3: `height` on a table cell is a MINIMUM — the cell
7496            // grows to fit its content but never shrinks below the specified
7497            // height. This is the slack `vertical-align: middle/bottom` needs to
7498            // be visible. Auto-height cells are unaffected; content taller than
7499            // the height still wins. No clipping, and rows stay atomic (an
7500            // over-tall row overflows whole, it is not sliced).
7501            if let SizeConstraint::Fixed(h) = cell_style.height {
7502                total = total.max(h);
7503            }
7504            max_height = max_height.max(total);
7505        }
7506
7507        max_height.max(row_style.min_height)
7508    }
7509
7510    /// How many columns a cell spans (colspan, min 1).
7511    fn cell_col_span(cell: &Node) -> usize {
7512        match &cell.kind {
7513            NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
7514            _ => 1,
7515        }
7516    }
7517
7518    /// Per-column min-content / max-content, gathered across ALL rows.
7519    /// Spanning cells contribute an even share per column — the standard
7520    /// simplification. An explicit cell width pins the column's preferred
7521    /// size (still never below min-content).
7522    fn measure_column_content(
7523        &self,
7524        children: &[Node],
7525        num_cols: usize,
7526        available_width: f64,
7527        table_style: &ResolvedStyle,
7528        font_context: &FontContext,
7529    ) -> (Vec<f64>, Vec<f64>) {
7530        let mut col_min = vec![0.0f64; num_cols];
7531        let mut col_max = vec![0.0f64; num_cols];
7532        let offsets = Self::table_column_offsets(children);
7533        for (row_i, row_node) in children.iter().enumerate() {
7534            for (cell_i, cell) in row_node.children.iter().enumerate() {
7535                let col = offsets[row_i].get(cell_i).copied().unwrap_or(0);
7536                let span = Self::cell_col_span(cell);
7537                let cell_style = cell.style.resolve(Some(table_style), available_width);
7538                let chrome = cell_style.padding.horizontal() + cell_style.border_width.horizontal();
7539                let mut cmin = 0.0f64;
7540                let mut cmax = 0.0f64;
7541                for child in &cell.children {
7542                    let child_style = child.style.resolve(Some(&cell_style), 0.0);
7543                    cmin =
7544                        cmin.max(self.measure_min_content_width(child, &child_style, font_context));
7545                    cmax =
7546                        cmax.max(self.measure_intrinsic_width(child, &child_style, font_context));
7547                }
7548                cmin += chrome;
7549                let mut cmax = cmax.max(cmin) + chrome;
7550                if let SizeConstraint::Fixed(w) = cell_style.width {
7551                    cmax = w.max(cmin);
7552                }
7553                let per_min = cmin / span as f64;
7554                let per_max = cmax / span as f64;
7555                for k in col..(col + span).min(num_cols) {
7556                    col_min[k] = col_min[k].max(per_min);
7557                    col_max[k] = col_max[k].max(per_max);
7558                }
7559            }
7560        }
7561        (col_min, col_max)
7562    }
7563
7564    /// Resolve table column widths.
7565    ///
7566    /// With explicit defs: fixed/fraction as given, Auto shares the rest
7567    /// (clamped — overflowing fixed widths are a render defect, never a
7568    /// negative share). With NO defs: CSS-style automatic table layout —
7569    /// column count is the widest row's colspan sum (the old first-row
7570    /// cell count turned every banner-row invoice into a one-column table
7571    /// and shredded the rest, per template-compat/REPORT.md), and widths
7572    /// distribute by min/max content like a browser.
7573    fn resolve_column_widths(
7574        &self,
7575        defs: &[ColumnDef],
7576        available_width: f64,
7577        children: &[Node],
7578        table_style: &ResolvedStyle,
7579        font_context: &FontContext,
7580    ) -> Vec<f64> {
7581        if defs.is_empty() {
7582            let num_cols = Self::occupancy_column_count(children);
7583
7584            let (col_min, col_max) = self.measure_column_content(
7585                children,
7586                num_cols,
7587                available_width,
7588                table_style,
7589                font_context,
7590            );
7591
7592            let sum_min: f64 = col_min.iter().sum();
7593            let sum_max: f64 = col_max.iter().sum();
7594            let w = available_width;
7595            return if sum_max <= w {
7596                // Everything fits at preferred size: surplus distributes
7597                // proportionally to max-content (browser behavior for
7598                // width:100% tables).
7599                if sum_max <= f64::EPSILON {
7600                    vec![w / num_cols as f64; num_cols]
7601                } else {
7602                    col_max
7603                        .iter()
7604                        .map(|m| m + (w - sum_max) * (m / sum_max))
7605                        .collect()
7606                }
7607            } else if sum_min <= w {
7608                // Squeeze between min and max, proportional to each
7609                // column's flexibility.
7610                let denom = (sum_max - sum_min).max(f64::EPSILON);
7611                col_min
7612                    .iter()
7613                    .zip(&col_max)
7614                    .map(|(mn, mx)| mn + (w - sum_min) * ((mx - mn) / denom))
7615                    .collect()
7616            } else {
7617                // The content genuinely cannot fit. Scale mins down and
7618                // SAY SO — this used to be the silent shred.
7619                self.defect(format!(
7620                    "render defect: table columns need {:.0}pt at min-content but only {:.0}pt is available — text will wrap tighter than intended",
7621                    sum_min, w
7622                ));
7623                let scale = w / sum_min.max(f64::EPSILON);
7624                col_min.iter().map(|m| m * scale).collect()
7625            };
7626        }
7627
7628        // Defs can under-specify the table: a rowspan-spacer or short
7629        // first row yields fewer defs than the widest row has cells (the
7630        // InvoicePlane date block, template-compat/REPORT.md). Cells
7631        // beyond the defs used to get NO width at all — extend with Auto
7632        // columns to the true column count instead.
7633        let num_cols = Self::occupancy_column_count(children).max(defs.len());
7634        let mut defs_vec: Vec<ColumnDef> = defs.to_vec();
7635        while defs_vec.len() < num_cols {
7636            defs_vec.push(ColumnDef {
7637                width: ColumnWidth::Auto,
7638            });
7639        }
7640        let defs = &defs_vec[..];
7641
7642        let mut widths = Vec::new();
7643        let mut remaining = available_width;
7644        let mut auto_count = 0;
7645
7646        for def in defs {
7647            match def.width {
7648                ColumnWidth::Fixed(w) => {
7649                    widths.push(w);
7650                    remaining -= w;
7651                }
7652                ColumnWidth::Fraction(f) => {
7653                    let w = available_width * f;
7654                    widths.push(w);
7655                    remaining -= w;
7656                }
7657                ColumnWidth::Auto => {
7658                    widths.push(0.0);
7659                    auto_count += 1;
7660                }
7661            }
7662        }
7663
7664        // Sub-point slack is float noise, not an over-full table: columns
7665        // declared as fractions summing to exactly 1.0 leave a remainder of
7666        // ±3e-14 depending on the available width (487.25 lands positive,
7667        // 486.75 negative), and the bare `< 0.0` reported half of those as
7668        // a clamped table — "widths total 487pt but only 487pt is
7669        // available". A warning that cries wolf is worse than none, so the
7670        // threshold is a hundredth of a point: far below anything visible,
7671        // far above the noise.
7672        const OVERFULL_EPS: f64 = 0.01;
7673        if remaining < -OVERFULL_EPS {
7674            // Fixed/fraction widths exceed the table: Auto columns would
7675            // have gone NEGATIVE. Clamp, and report the defect.
7676            self.defect(format!(
7677                "render defect: table column widths total {:.0}pt but only {:.0}pt is available — remaining columns were clamped to their minimum",
7678                available_width - remaining,
7679                available_width
7680            ));
7681            remaining = 0.0;
7682        }
7683        // Floor the noise too: a -3e-14 remainder must not reach the Auto
7684        // distribution below, warning or no warning.
7685        remaining = remaining.max(0.0);
7686
7687        if auto_count > 0 {
7688            let auto_width = remaining / auto_count as f64;
7689            for (i, def) in defs.iter().enumerate() {
7690                if matches!(def.width, ColumnWidth::Auto) {
7691                    widths[i] = auto_width;
7692                }
7693            }
7694        }
7695
7696        // Specified widths are suggestions, not laws (browser auto table
7697        // layout): a column squeezed below its min-content — the classic
7698        // over-specified-width template — is floored at min-content, and
7699        // the deficit comes out of columns with surplus, proportionally.
7700        // A table where every column already fits is returned EXACTLY as
7701        // specified (byte-stable for the shipped templates).
7702        let (col_min, _) = self.measure_column_content(
7703            children,
7704            widths.len(),
7705            available_width,
7706            table_style,
7707            font_context,
7708        );
7709        let needs_floor = widths.iter().zip(&col_min).any(|(w, m)| *w + 0.01 < *m);
7710        if needs_floor {
7711            let sum_min: f64 = col_min.iter().sum();
7712            if sum_min > available_width {
7713                self.defect(format!(
7714                    "render defect: table columns need {:.0}pt at min-content but only {:.0}pt is available — text will wrap tighter than intended",
7715                    sum_min, available_width
7716                ));
7717                let scale = available_width / sum_min.max(f64::EPSILON);
7718                return col_min.iter().map(|m| m * scale).collect();
7719            }
7720            let deficit: f64 = widths
7721                .iter()
7722                .zip(&col_min)
7723                .map(|(w, m)| (m - w).max(0.0))
7724                .sum();
7725            let surplus: f64 = widths
7726                .iter()
7727                .zip(&col_min)
7728                .map(|(w, m)| (w - m).max(0.0))
7729                .sum();
7730            let take = if surplus > 0.0 {
7731                deficit / surplus
7732            } else {
7733                0.0
7734            };
7735            widths = widths
7736                .iter()
7737                .zip(&col_min)
7738                .map(|(w, m)| if *w < *m { *m } else { w - (w - m) * take })
7739                .collect();
7740        }
7741
7742        widths
7743    }
7744
7745    fn inject_fixed_elements(&self, pages: &mut [LayoutPage], font_context: &FontContext) {
7746        for (page_index, page) in pages.iter_mut().enumerate() {
7747            // Inject watermarks behind all content
7748            if !page.watermarks.is_empty() {
7749                let (page_w, page_h) = page.config.size.dimensions();
7750                let cx = page_w / 2.0;
7751                let cy = page_h / 2.0;
7752
7753                let mut watermark_elements = Vec::new();
7754                for (wm_node, wm_parent) in &page.watermarks {
7755                    if let NodeKind::Watermark {
7756                        text,
7757                        font_size,
7758                        angle,
7759                    } = &wm_node.kind
7760                    {
7761                        let style = wm_node.style.resolve(wm_parent.as_ref(), page_w);
7762                        let color = style.color;
7763                        let opacity = style.opacity;
7764                        let angle_rad = angle.to_radians();
7765
7766                        // Build positioned glyphs for the watermark text
7767                        let italic =
7768                            matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
7769
7770                        // Try shaping, fall back to per-char measurement
7771                        let shaped = self.text_layout.shape_text(
7772                            font_context,
7773                            text,
7774                            &style.font_family,
7775                            style.font_weight,
7776                            style.font_style,
7777                        );
7778
7779                        let mut glyphs = Vec::new();
7780                        let mut x_pos = 0.0;
7781                        let text_chars: Vec<char> = text.chars().collect();
7782
7783                        if let Some(shaped_glyphs) = shaped {
7784                            // Use shaped glyphs (custom fonts)
7785                            let units_per_em = font_context.units_per_em(
7786                                &style.font_family,
7787                                style.font_weight,
7788                                italic,
7789                            ) as f64;
7790
7791                            let clusters = cluster_texts(&shaped_glyphs, &text_chars);
7792                            for (sg, (cluster_text, ligature, extraction_text)) in
7793                                shaped_glyphs.iter().zip(clusters)
7794                            {
7795                                let advance = sg.x_advance as f64 / units_per_em * *font_size;
7796                                let cluster_idx = sg.cluster as usize;
7797                                let ch = text_chars.get(cluster_idx).copied().unwrap_or(' ');
7798                                glyphs.push(PositionedGlyph {
7799                                    glyph_id: sg.glyph_id,
7800                                    char_value: ch,
7801                                    x_offset: x_pos,
7802                                    y_offset: 0.0,
7803                                    x_advance: advance,
7804                                    font_size: *font_size,
7805                                    font_family: Arc::from(style.font_family.as_str()),
7806                                    font_weight: style.font_weight,
7807                                    font_style: style.font_style,
7808                                    color: Some(color),
7809                                    href: None,
7810                                    text_decoration: TextDecoration::None,
7811                                    letter_spacing: style.letter_spacing,
7812                                    cluster_text,
7813                                    extraction_text,
7814
7815                                    ligature,
7816                                });
7817                                x_pos += advance + style.letter_spacing;
7818                            }
7819                        } else {
7820                            // Per-char measurement (standard fonts)
7821                            for &ch in &text_chars {
7822                                let w = font_context.char_width(
7823                                    ch,
7824                                    &style.font_family,
7825                                    style.font_weight,
7826                                    italic,
7827                                    *font_size,
7828                                );
7829                                glyphs.push(PositionedGlyph {
7830                                    glyph_id: ch as u16,
7831                                    char_value: ch,
7832                                    x_offset: x_pos,
7833                                    y_offset: 0.0,
7834                                    x_advance: w,
7835                                    font_size: *font_size,
7836                                    font_family: Arc::from(style.font_family.as_str()),
7837                                    font_weight: style.font_weight,
7838                                    font_style: style.font_style,
7839                                    color: Some(color),
7840                                    href: None,
7841                                    text_decoration: TextDecoration::None,
7842                                    letter_spacing: style.letter_spacing,
7843                                    cluster_text: None,
7844                                    extraction_text: None,
7845                                    ligature: false,
7846                                });
7847                                x_pos += w + style.letter_spacing;
7848                            }
7849                        }
7850
7851                        let text_width = x_pos;
7852
7853                        let line = TextLine {
7854                            x: 0.0,
7855                            y: 0.0,
7856                            glyphs,
7857                            width: text_width,
7858                            height: *font_size,
7859                            word_spacing: 0.0,
7860                        };
7861
7862                        watermark_elements.push(LayoutElement {
7863                            x: cx,
7864                            y: cy,
7865                            width: text_width,
7866                            height: *font_size,
7867                            draw: DrawCommand::Watermark {
7868                                lines: vec![line],
7869                                color,
7870                                opacity,
7871                                angle_rad,
7872                                font_family: style.font_family.clone(),
7873                            },
7874                            children: vec![],
7875                            node_type: Some("Watermark".to_string()),
7876                            resolved_style: None,
7877                            source_location: None,
7878                            href: None,
7879                            bookmark: None,
7880                            alt: None,
7881                            is_header_row: false,
7882                            actual_text: None,
7883                            list_numbering: None,
7884                            col_span: 1,
7885                            overflow: Overflow::default(),
7886                            opacity: 1.0,
7887                        });
7888                    }
7889                }
7890
7891                // Prepend watermark elements so they render behind all content
7892                watermark_elements.append(&mut page.elements);
7893                page.elements = watermark_elements;
7894                page.watermarks.clear();
7895            }
7896
7897            if page.fixed_header.is_empty() && page.fixed_footer.is_empty() {
7898                continue;
7899            }
7900
7901            // Lay out headers at top of content area
7902            if !page.fixed_header.is_empty() {
7903                let mut hdr_cursor = PageCursor::new(&page.config);
7904                for (node, _h, parent) in &page.fixed_header {
7905                    // The enumerate index is the authoritative page number
7906                    // for First/NotFirst filtering.
7907                    if !fixed_applies_on(node, page_index, page.page_name.as_deref()) {
7908                        continue;
7909                    }
7910                    let cw = hdr_cursor.content_width;
7911                    let cx = hdr_cursor.content_x;
7912                    let style = node.style.resolve(parent.as_ref(), cw);
7913                    self.layout_view(
7914                        node,
7915                        &style,
7916                        &mut hdr_cursor,
7917                        &mut Vec::new(),
7918                        cx,
7919                        cw,
7920                        font_context,
7921                    );
7922                }
7923                // Prepend header elements so they draw behind body content
7924                let mut combined = hdr_cursor.elements;
7925                combined.append(&mut page.elements);
7926                page.elements = combined;
7927            }
7928
7929            // Lay out footers at bottom of content area.
7930            // We lay out from y=0 (so there's plenty of room and no spurious
7931            // page breaks), then shift all resulting elements down to the
7932            // correct footer position.
7933            if !page.fixed_footer.is_empty() {
7934                let mut ftr_cursor = PageCursor::new(&page.config);
7935                let total_ftr: f64 = page
7936                    .fixed_footer
7937                    .iter()
7938                    .filter(|(n, _, _)| fixed_applies_on(n, page_index, page.page_name.as_deref()))
7939                    .map(|(_, h, _)| *h)
7940                    .sum();
7941                let target_y = ftr_cursor.content_height - total_ftr;
7942                // Layout from y=0
7943                for (node, _h, parent) in &page.fixed_footer {
7944                    if !fixed_applies_on(node, page_index, page.page_name.as_deref()) {
7945                        continue;
7946                    }
7947                    let cw = ftr_cursor.content_width;
7948                    let cx = ftr_cursor.content_x;
7949                    let style = node.style.resolve(parent.as_ref(), cw);
7950                    self.layout_view(
7951                        node,
7952                        &style,
7953                        &mut ftr_cursor,
7954                        &mut Vec::new(),
7955                        cx,
7956                        cw,
7957                        font_context,
7958                    );
7959                }
7960                // Shift all footer elements down to the target position.
7961                // Elements already have content_y baked in, so we just offset
7962                // by target_y (which is relative to content area top).
7963                for el in &mut ftr_cursor.elements {
7964                    offset_element_y(el, target_y);
7965                }
7966                page.elements.extend(ftr_cursor.elements);
7967            }
7968
7969            // Clean up internal fields
7970            page.fixed_header.clear();
7971            page.fixed_footer.clear();
7972        }
7973    }
7974
7975    /// Layout children as a CSS Grid.
7976    ///
7977    /// Uses the grid track definitions from the parent style to create a 2D grid,
7978    /// places children into cells, and lays out each child within its cell bounds.
7979    #[allow(clippy::too_many_arguments)]
7980    fn layout_grid_children(
7981        &self,
7982        children: &[Node],
7983        parent_style: &ResolvedStyle,
7984        cursor: &mut PageCursor,
7985        pages: &mut Vec<LayoutPage>,
7986        x: f64,
7987        available_width: f64,
7988        font_context: &FontContext,
7989    ) {
7990        let template_cols = match &parent_style.grid_template_columns {
7991            Some(cols) => cols,
7992            None => return, // No columns defined, nothing to do
7993        };
7994
7995        let num_columns = template_cols.len();
7996        if num_columns == 0 || children.is_empty() {
7997            return;
7998        }
7999
8000        let col_gap = parent_style.column_gap;
8001        let row_gap = parent_style.row_gap;
8002
8003        // Resolve column widths
8004        // For auto tracks, we need content sizes. Use a rough measure.
8005        let content_sizes: Vec<f64> = template_cols
8006            .iter()
8007            .map(|track| {
8008                if matches!(track, GridTrackSize::Auto) {
8009                    // Measure the widest child that falls in this column
8010                    // (approximation: use available_width / num_columns)
8011                    available_width / num_columns as f64
8012                } else {
8013                    0.0
8014                }
8015            })
8016            .collect();
8017
8018        let col_widths =
8019            grid::resolve_tracks(template_cols, available_width, col_gap, &content_sizes);
8020
8021        // Collect grid placements from children's styles
8022        let placements: Vec<Option<&GridPlacement>> = children
8023            .iter()
8024            .map(|child| child.style.grid_placement.as_ref())
8025            .collect();
8026
8027        // Place items in the grid
8028        let item_placements = grid::place_items(&placements, num_columns);
8029        let num_rows = grid::compute_num_rows(&item_placements);
8030
8031        if num_rows == 0 {
8032            return;
8033        }
8034
8035        // Measure each item's height at its resolved cell width
8036        let mut item_heights: Vec<f64> = vec![0.0; children.len()];
8037        for placement in &item_placements {
8038            let cell_width =
8039                grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
8040            let child = &children[placement.child_index];
8041            let child_style = child.style.resolve(Some(parent_style), cell_width);
8042            item_heights[placement.child_index] =
8043                self.measure_node_height(child, cell_width, &child_style, font_context);
8044        }
8045
8046        // Compute row heights: max height of all items in each row
8047        let template_rows = parent_style.grid_template_rows.as_deref();
8048        let mut row_heights = vec![0.0_f64; num_rows];
8049        for placement in &item_placements {
8050            let h = item_heights[placement.child_index];
8051            let span = placement.row_end - placement.row_start;
8052            let per_row = h / span as f64;
8053            for rh in row_heights
8054                .iter_mut()
8055                .take(placement.row_end.min(num_rows))
8056                .skip(placement.row_start)
8057            {
8058                if per_row > *rh {
8059                    *rh = per_row;
8060                }
8061            }
8062        }
8063
8064        // Apply template row sizes if provided
8065        if let Some(template) = template_rows {
8066            let auto_row = parent_style.grid_auto_rows.as_ref();
8067            for (r, rh) in row_heights.iter_mut().enumerate() {
8068                let track = template.get(r).or(auto_row);
8069                if let Some(track) = track {
8070                    match track {
8071                        GridTrackSize::Pt(pts) => *rh = *pts,
8072                        GridTrackSize::Auto => {} // keep computed
8073                        _ => {}                   // Fr for rows is complex, skip for now
8074                    }
8075                }
8076            }
8077        }
8078
8079        // Layout each row
8080        for (row, &row_height) in row_heights.iter().enumerate().take(num_rows) {
8081            // Check page break: treat each row as unbreakable. The whole row
8082            // moves to the next page so all columns share the same baseline
8083            // (otherwise each cell's layout_node would page-break individually
8084            // and scatter the columns across separate pages).
8085            if row_height > cursor.remaining_height() {
8086                pages.push(cursor.finalize());
8087                *cursor = cursor.new_page();
8088            }
8089
8090            let row_start_y = cursor.y;
8091
8092            // Layout items in this row
8093            for placement in &item_placements {
8094                if placement.row_start != row {
8095                    continue; // Only process items starting in this row
8096                }
8097
8098                let cell_x = x + grid::column_x_offset(placement.col_start, &col_widths, col_gap);
8099                let cell_width =
8100                    grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
8101
8102                let child = &children[placement.child_index];
8103
8104                self.layout_node(
8105                    child,
8106                    cursor,
8107                    pages,
8108                    cell_x,
8109                    cell_width,
8110                    Some(parent_style),
8111                    font_context,
8112                    None,
8113                    None,
8114                );
8115                // Restore y to row baseline (items don't affect each other's y)
8116                cursor.y = row_start_y;
8117            }
8118
8119            cursor.y = row_start_y + row_height + row_gap;
8120        }
8121
8122        // Remove trailing gap
8123        if num_rows > 0 {
8124            cursor.y -= row_gap;
8125        }
8126    }
8127}
8128
8129/// The row's bottom on its last fragment: the deepest y reached by any
8130/// column that actually ends there. Columns that finished on an earlier
8131/// page do not hold it down.
8132fn end_y_of(frags: &[ItemFragments], last: usize) -> f64 {
8133    frags
8134        .iter()
8135        .filter(|f| f.pages.len() == last)
8136        .map(|f| f.final_y)
8137        .fold(f64::MIN, f64::max)
8138}
8139
8140/// Grow a stretched column's box on one fragment down to `bottom`. Only the
8141/// item's own top-level boxes move; their children keep their positions,
8142/// exactly as a taller container would have held them.
8143/// Does this element paint a box of its own (a fill or a visible border)?
8144///
8145/// Used to find the element that represents a stretched column's band, so an
8146/// empty fragment can clone it. A column with nothing to paint needs no band.
8147fn paints_a_box(el: &LayoutElement) -> bool {
8148    match &el.draw {
8149        DrawCommand::Rect {
8150            background,
8151            border_width,
8152            ..
8153        } => {
8154            background.is_some()
8155                || border_width.top > 0.0
8156                || border_width.right > 0.0
8157                || border_width.bottom > 0.0
8158                || border_width.left > 0.0
8159        }
8160        _ => false,
8161    }
8162}
8163
8164/// Collect everything pushed onto the cursor since `snapshot`, tolerating a
8165/// page break that happened in between.
8166///
8167/// The snapshot-and-collect pattern takes `cursor.elements.len()` before
8168/// laying out children and drains from it afterwards. If a child finished a
8169/// page, `finalize()` took those elements and `new_page()` installed a fresh,
8170/// EMPTY vector, so the saved index can point past the end and
8171/// `drain(snapshot..)` panics. A plain `<ul>` long enough to cross a page did
8172/// exactly that, in every release up to and including 0.23.0:
8173///
8174///   range start index 15 out of range for slice of length 1
8175///
8176/// Clamping is the correct collection, not a papering-over: after a break the
8177/// cursor starts empty and fixed headers and footers are injected separately
8178/// at the end, so everything present is this container's own content on the
8179/// current page.
8180fn drain_since(elements: &mut Vec<LayoutElement>, snapshot: usize) -> Vec<LayoutElement> {
8181    let start = snapshot.min(elements.len());
8182    elements.drain(start..).collect()
8183}
8184
8185fn stretch_fragment(elements: &mut [LayoutElement], bottom: f64) {
8186    for el in elements.iter_mut() {
8187        let grown = bottom - el.y;
8188        if grown > el.height {
8189            el.height = grown;
8190        }
8191    }
8192}
8193
8194/// One flex item's contribution to a row that may span pages: the
8195/// elements it placed on each page it crossed (`pages`, finished), the
8196/// elements still on its unfinished page (`tail`), the cursor it ended
8197/// with, and the y it reached there. Phase 2 composes fragment k of
8198/// every item onto page k of the row.
8199struct ItemFragments {
8200    pages: Vec<LayoutPage>,
8201    tail: Vec<LayoutElement>,
8202    cursor: PageCursor,
8203    final_y: f64,
8204    /// True when this item is stretching to the row's cross size
8205    /// (`align-items: stretch` with an auto height and no auto margins).
8206    /// A stretched column's box is grown to the bottom of each fragment it
8207    /// appears on, which is what a browser paints.
8208    stretches: bool,
8209}
8210
8211struct FlexItem<'a> {
8212    node: &'a Node,
8213    style: ResolvedStyle,
8214    base_width: f64,
8215    min_content_width: f64,
8216}
8217
8218/// First bit of text content under a node, for naming elements in
8219/// render-defect messages (the engine's Node has no id/class).
8220fn first_text_snippet(node: &Node) -> Option<String> {
8221    fn walk(n: &Node) -> Option<&str> {
8222        match &n.kind {
8223            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
8224                if !content.trim().is_empty() {
8225                    return Some(content.trim());
8226                }
8227                if let Some(r) = runs.iter().find(|r| !r.content.trim().is_empty()) {
8228                    return Some(r.content.trim());
8229                }
8230                None
8231            }
8232            _ => n.children.iter().find_map(walk),
8233        }
8234    }
8235    walk(node).map(|t| {
8236        let mut s: String = t.chars().take(32).collect();
8237        if t.chars().count() > 32 {
8238            s.push('…');
8239        }
8240        s
8241    })
8242}
8243
8244/// The baseline's offset from a line box's top, in the CSS line box
8245/// model with real font metrics: the glyph block is (ascent + descent)
8246/// times font_size tall, the remaining leading splits evenly above and
8247/// below (half-leading), and the baseline sits ascent below the block
8248/// top. The previous model used font_size as a stand-in for the whole
8249/// block with the baseline at its bottom — every baseline sat
8250/// fs(1 - ascent + descent)/2 lower than a browser puts it (~0.15em
8251/// for Arial-class metrics), which is why single glyphs centered in
8252/// boxes by the line-height idiom rode visibly low.
8253fn baseline_in_line(line_height: f64, font_size: f64, (ascent, descent): (f64, f64)) -> f64 {
8254    (line_height - (ascent + descent) * font_size) / 2.0 + ascent * font_size
8255}
8256
8257#[cfg(test)]
8258mod tests {
8259    use super::*;
8260    use crate::font::FontContext;
8261
8262    fn sg(glyph_id: u16, cluster: u32) -> shaping::ShapedGlyph {
8263        shaping::ShapedGlyph {
8264            glyph_id,
8265            cluster,
8266            x_advance: 500,
8267            y_advance: 0,
8268            x_offset: 0,
8269            y_offset: 0,
8270        }
8271    }
8272
8273    #[test]
8274    fn test_cluster_texts_ltr_ligature() {
8275        // "office": o, ffi-ligature (cluster 1..4), c, e.
8276        let chars: Vec<char> = "office".chars().collect();
8277        let shaped = [sg(1, 0), sg(2, 1), sg(3, 4), sg(4, 5)];
8278        let got = cluster_texts(&shaped, &chars);
8279        assert_eq!(
8280            got,
8281            vec![
8282                (None, false, None),
8283                (Some("ffi".to_string()), true, Some("ffi".to_string())),
8284                (None, false, None),
8285                (None, false, None)
8286            ]
8287        );
8288    }
8289
8290    /// RTL output is in visual order, so clusters DESCEND. A lam-alef style
8291    /// ligature at clusters 1..3 of "abcd" must get "bc", not "bcd" (the old
8292    /// code took the first glyph with a larger cluster, which in descending
8293    /// order is the LAST cluster of the run).
8294    #[test]
8295    fn test_cluster_texts_rtl_ligature_uses_next_larger_cluster() {
8296        let chars: Vec<char> = "abcd".chars().collect();
8297        let shaped = [sg(1, 3), sg(2, 1), sg(3, 0)];
8298        let got = cluster_texts(&shaped, &chars);
8299        assert_eq!(
8300            got,
8301            vec![
8302                (None, false, None),
8303                (Some("bc".to_string()), true, Some("bc".to_string())),
8304                (None, false, None)
8305            ]
8306        );
8307    }
8308
8309    /// A ligature is recognised even when another cluster decomposes into two
8310    /// glyphs and the line's glyph count equals its char count, which the old
8311    /// `glyphs < chars` guard read as "no ligatures here".
8312    #[test]
8313    fn test_cluster_texts_ligature_found_when_counts_balance() {
8314        // chars a b c d: "ab" ligates (1 glyph), "d" decomposes (2 glyphs).
8315        let chars: Vec<char> = "abcd".chars().collect();
8316        let shaped = [sg(1, 0), sg(2, 2), sg(3, 3), sg(4, 3)];
8317        let got = cluster_texts(&shaped, &chars);
8318        assert_eq!(
8319            got[0],
8320            (Some("ab".to_string()), true, Some("ab".to_string()))
8321        );
8322        assert_eq!(got[1], (None, false, None));
8323        assert_eq!(got[2], (None, false, Some("d".to_string())));
8324        assert_eq!(got[3], (None, false, Some("\u{200B}".to_string())));
8325    }
8326
8327    /// Several glyphs sharing a multi-char cluster: none is a ligature; each
8328    /// carries the cluster text when the run has fewer glyphs than chars,
8329    /// as before.
8330    #[test]
8331    fn test_cluster_texts_multi_glyph_cluster_is_not_a_ligature() {
8332        // chars k i x y: cluster 0 = "kix" drawn as 2 glyphs, then y.
8333        let chars: Vec<char> = "kixy".chars().collect();
8334        let shaped = [sg(1, 0), sg(2, 0), sg(3, 3)];
8335        let got = cluster_texts(&shaped, &chars);
8336        assert_eq!(
8337            got[0],
8338            (Some("kix".to_string()), false, Some("k".to_string()))
8339        );
8340        assert_eq!(
8341            got[1],
8342            (Some("kix".to_string()), false, Some("ix".to_string()))
8343        );
8344        assert_eq!(got[2], (None, false, None));
8345    }
8346
8347    fn make_text(content: &str, font_size: f64) -> Node {
8348        Node {
8349            kind: NodeKind::Text {
8350                content: content.to_string(),
8351                href: None,
8352                runs: vec![],
8353            },
8354            style: Style {
8355                font_size: Some(font_size),
8356                ..Default::default()
8357            },
8358            children: vec![],
8359            id: None,
8360            source_location: None,
8361            bookmark: None,
8362            href: None,
8363            alt: None,
8364        }
8365    }
8366
8367    fn make_styled_view(style: Style, children: Vec<Node>) -> Node {
8368        Node {
8369            kind: NodeKind::View,
8370            style,
8371            children,
8372            id: None,
8373            source_location: None,
8374            bookmark: None,
8375            href: None,
8376            alt: None,
8377        }
8378    }
8379
8380    fn make_runs_text(runs: Vec<crate::model::TextRun>) -> Node {
8381        Node {
8382            kind: NodeKind::Text {
8383                content: String::new(),
8384                href: None,
8385                runs,
8386            },
8387            style: Style::default(),
8388            children: vec![],
8389            id: None,
8390            source_location: None,
8391            bookmark: None,
8392            href: None,
8393            alt: None,
8394        }
8395    }
8396
8397    #[test]
8398    fn intrinsic_width_measures_runs_not_just_content() {
8399        // Found by the HTML input path: a runs-based Text node (empty
8400        // `content`) used to measure ~0 intrinsic width, so flex rows
8401        // collapsed it to one character per line.
8402        let engine = LayoutEngine::new();
8403        let font_context = FontContext::new();
8404
8405        let runs_node = make_runs_text(vec![
8406            crate::model::TextRun {
8407                content: "Hello ".to_string(),
8408                style: Style::default(),
8409                href: None,
8410            },
8411            crate::model::TextRun {
8412                content: "World".to_string(),
8413                style: Style {
8414                    font_weight: Some(700),
8415                    ..Default::default()
8416                },
8417                href: None,
8418            },
8419        ]);
8420        let plain_node = make_text("Hello World", 12.0);
8421
8422        let runs_style = runs_node.style.resolve(None, 0.0);
8423        let plain_style = plain_node.style.resolve(None, 0.0);
8424        let runs_w = engine.measure_intrinsic_width(&runs_node, &runs_style, &font_context);
8425        let plain_w = engine.measure_intrinsic_width(&plain_node, &plain_style, &font_context);
8426
8427        // Must be in the same ballpark as the plain-content equivalent
8428        // (slightly wider: the second run is bold).
8429        assert!(
8430            runs_w >= plain_w,
8431            "runs width ({runs_w}) must not undershoot plain width ({plain_w})"
8432        );
8433        assert!(
8434            runs_w < plain_w * 1.5,
8435            "runs width ({runs_w}) should be close to plain width ({plain_w})"
8436        );
8437    }
8438
8439    #[test]
8440    fn intrinsic_width_of_multiline_text_is_widest_line() {
8441        let engine = LayoutEngine::new();
8442        let font_context = FontContext::new();
8443
8444        let multiline = make_text("123 Main St\nSpringfield, IL 62704", 12.0);
8445        let widest = make_text("Springfield, IL 62704", 12.0);
8446
8447        let m_style = multiline.style.resolve(None, 0.0);
8448        let w_style = widest.style.resolve(None, 0.0);
8449        let m_w = engine.measure_intrinsic_width(&multiline, &m_style, &font_context);
8450        let w_w = engine.measure_intrinsic_width(&widest, &w_style, &font_context);
8451
8452        assert!(
8453            (m_w - w_w).abs() < 0.01,
8454            "multiline intrinsic width ({m_w}) must equal its widest line ({w_w})"
8455        );
8456    }
8457
8458    #[test]
8459    fn intrinsic_width_of_heading_measures_its_text() {
8460        // Heading used to fall through to the children-recursion arm and
8461        // measure zero (headings are leaves).
8462        let engine = LayoutEngine::new();
8463        let font_context = FontContext::new();
8464
8465        let heading = Node {
8466            kind: NodeKind::Heading {
8467                level: 1,
8468                content: "Invoice #2024-001".to_string(),
8469                href: None,
8470                runs: vec![],
8471            },
8472            style: Style {
8473                font_size: Some(24.0),
8474                ..Default::default()
8475            },
8476            children: vec![],
8477            id: None,
8478            source_location: None,
8479            bookmark: None,
8480            href: None,
8481            alt: None,
8482        };
8483        let style = heading.style.resolve(None, 0.0);
8484        let w = engine.measure_intrinsic_width(&heading, &style, &font_context);
8485        assert!(w > 100.0, "24pt heading text must measure wide, got {w}");
8486    }
8487
8488    #[test]
8489    fn measure_node_height_of_wrapping_heading_matches_text() {
8490        // A heading that wraps to multiple lines must contribute its full
8491        // height to a parent's auto-height, exactly like Text. Previously
8492        // Heading had no arm in `measure_node_height` and fell through to the
8493        // container `_` arm (children-recursion), measuring ~0 — so an
8494        // auto-height View wrapping a multi-line heading collapsed, shifting
8495        // every sibling below it.
8496        let engine = LayoutEngine::new();
8497        let font_context = FontContext::new();
8498
8499        let content = "Annual Performance Review";
8500        let heading = Node {
8501            kind: NodeKind::Heading {
8502                level: 1,
8503                content: content.to_string(),
8504                href: None,
8505                runs: vec![],
8506            },
8507            style: Style {
8508                font_size: Some(32.0),
8509                ..Default::default()
8510            },
8511            children: vec![],
8512            id: None,
8513            source_location: None,
8514            bookmark: None,
8515            href: None,
8516            alt: None,
8517        };
8518        let text = make_text(content, 32.0);
8519
8520        // A width narrow enough to force the 32pt title onto more than one line.
8521        let width = 200.0;
8522        let h_style = heading.style.resolve(None, width);
8523        let t_style = text.style.resolve(None, width);
8524        let h_height = engine.measure_node_height(&heading, width, &h_style, &font_context);
8525        let t_height = engine.measure_node_height(&text, width, &t_style, &font_context);
8526
8527        assert!(
8528            h_height > 32.0,
8529            "a wrapping 32pt heading must measure more than one line, got {h_height}"
8530        );
8531        assert!(
8532            (h_height - t_height).abs() < 0.01,
8533            "heading height ({h_height}) must equal the same text's height ({t_height})"
8534        );
8535    }
8536
8537    #[test]
8538    fn intrinsic_width_flex_row_sums_children() {
8539        let engine = LayoutEngine::new();
8540        let font_context = FontContext::new();
8541
8542        let child1 = make_text("Hello", 14.0);
8543        let child2 = make_text("World", 14.0);
8544
8545        let child1_style = child1.style.resolve(None, 0.0);
8546        let child2_style = child2.style.resolve(None, 0.0);
8547        let child1_w = engine.measure_intrinsic_width(&child1, &child1_style, &font_context);
8548        let child2_w = engine.measure_intrinsic_width(&child2, &child2_style, &font_context);
8549
8550        let row = make_styled_view(
8551            Style {
8552                flex_direction: Some(FlexDirection::Row),
8553                ..Default::default()
8554            },
8555            vec![make_text("Hello", 14.0), make_text("World", 14.0)],
8556        );
8557        let row_style = row.style.resolve(None, 0.0);
8558        let row_w = engine.measure_intrinsic_width(&row, &row_style, &font_context);
8559
8560        assert!(
8561            (row_w - (child1_w + child2_w)).abs() < 0.01,
8562            "Row intrinsic width ({}) should equal sum of children ({} + {})",
8563            row_w,
8564            child1_w,
8565            child2_w
8566        );
8567    }
8568
8569    #[test]
8570    fn intrinsic_width_flex_column_takes_max() {
8571        let engine = LayoutEngine::new();
8572        let font_context = FontContext::new();
8573
8574        let short = make_text("Hi", 14.0);
8575        let long = make_text("Hello World", 14.0);
8576
8577        let short_style = short.style.resolve(None, 0.0);
8578        let long_style = long.style.resolve(None, 0.0);
8579        let short_w = engine.measure_intrinsic_width(&short, &short_style, &font_context);
8580        let long_w = engine.measure_intrinsic_width(&long, &long_style, &font_context);
8581
8582        let col = make_styled_view(
8583            Style {
8584                flex_direction: Some(FlexDirection::Column),
8585                ..Default::default()
8586            },
8587            vec![make_text("Hi", 14.0), make_text("Hello World", 14.0)],
8588        );
8589        let col_style = col.style.resolve(None, 0.0);
8590        let col_w = engine.measure_intrinsic_width(&col, &col_style, &font_context);
8591
8592        assert!(
8593            (col_w - long_w).abs() < 0.01,
8594            "Column intrinsic width ({}) should equal max child ({}, short was {})",
8595            col_w,
8596            long_w,
8597            short_w
8598        );
8599    }
8600
8601    #[test]
8602    fn intrinsic_width_nested_containers() {
8603        let engine = LayoutEngine::new();
8604        let font_context = FontContext::new();
8605
8606        let inner = make_styled_view(
8607            Style {
8608                flex_direction: Some(FlexDirection::Row),
8609                ..Default::default()
8610            },
8611            vec![make_text("A", 12.0), make_text("B", 12.0)],
8612        );
8613        let inner_style = inner.style.resolve(None, 0.0);
8614        let inner_w = engine.measure_intrinsic_width(&inner, &inner_style, &font_context);
8615
8616        let outer = make_styled_view(
8617            Style::default(),
8618            vec![make_styled_view(
8619                Style {
8620                    flex_direction: Some(FlexDirection::Row),
8621                    ..Default::default()
8622                },
8623                vec![make_text("A", 12.0), make_text("B", 12.0)],
8624            )],
8625        );
8626        let outer_style = outer.style.resolve(None, 0.0);
8627        let outer_w = engine.measure_intrinsic_width(&outer, &outer_style, &font_context);
8628
8629        assert!(
8630            (outer_w - inner_w).abs() < 0.01,
8631            "Nested container ({}) should match inner container ({})",
8632            outer_w,
8633            inner_w
8634        );
8635    }
8636
8637    #[test]
8638    fn intrinsic_width_row_with_gap() {
8639        let engine = LayoutEngine::new();
8640        let font_context = FontContext::new();
8641
8642        let no_gap = make_styled_view(
8643            Style {
8644                flex_direction: Some(FlexDirection::Row),
8645                ..Default::default()
8646            },
8647            vec![make_text("A", 12.0), make_text("B", 12.0)],
8648        );
8649        let with_gap = make_styled_view(
8650            Style {
8651                flex_direction: Some(FlexDirection::Row),
8652                gap: Some(10.0),
8653                ..Default::default()
8654            },
8655            vec![make_text("A", 12.0), make_text("B", 12.0)],
8656        );
8657
8658        let no_gap_style = no_gap.style.resolve(None, 0.0);
8659        let with_gap_style = with_gap.style.resolve(None, 0.0);
8660        let no_gap_w = engine.measure_intrinsic_width(&no_gap, &no_gap_style, &font_context);
8661        let with_gap_w = engine.measure_intrinsic_width(&with_gap, &with_gap_style, &font_context);
8662
8663        assert!(
8664            (with_gap_w - no_gap_w - 10.0).abs() < 0.01,
8665            "Gap should add 10pt: with_gap={}, no_gap={}",
8666            with_gap_w,
8667            no_gap_w
8668        );
8669    }
8670
8671    #[test]
8672    fn intrinsic_width_empty_container() {
8673        let engine = LayoutEngine::new();
8674        let font_context = FontContext::new();
8675
8676        let padding = 8.0;
8677        let empty = make_styled_view(
8678            Style {
8679                padding: Some(Edges::uniform(padding)),
8680                ..Default::default()
8681            },
8682            vec![],
8683        );
8684        let style = empty.style.resolve(None, 0.0);
8685        let w = engine.measure_intrinsic_width(&empty, &style, &font_context);
8686
8687        assert!(
8688            (w - padding * 2.0).abs() < 0.01,
8689            "Empty container width ({}) should equal horizontal padding ({})",
8690            w,
8691            padding * 2.0
8692        );
8693    }
8694
8695    // ── Fix 1: min-content width prevents text wrapping in flex shrink ──
8696
8697    #[test]
8698    fn flex_shrink_respects_min_content_width() {
8699        // A flex row with a short-text child ("SALE") and a large sibling.
8700        // The shrink algorithm should not compress the short-text child below
8701        // the width of the word "SALE".
8702        let engine = LayoutEngine::new();
8703        let font_context = FontContext::new();
8704
8705        let sale_text = make_text("SALE", 12.0);
8706        let sale_style = sale_text.style.resolve(None, 0.0);
8707        let sale_word_width =
8708            engine.measure_min_content_width(&sale_text, &sale_style, &font_context);
8709        assert!(
8710            sale_word_width > 0.0,
8711            "SALE should have non-zero min-content width"
8712        );
8713
8714        // Row with 100pt available; child1 wants 80pt, child2 (SALE) wants 60pt.
8715        // Total = 140pt, overflow = 40pt. Without floor, SALE would shrink below word width.
8716        let container = make_styled_view(
8717            Style {
8718                flex_direction: Some(FlexDirection::Row),
8719                width: Some(Dimension::Pt(100.0)),
8720                ..Default::default()
8721            },
8722            vec![
8723                make_styled_view(
8724                    Style {
8725                        width: Some(Dimension::Pt(80.0)),
8726                        flex_shrink: Some(1.0),
8727                        ..Default::default()
8728                    },
8729                    vec![],
8730                ),
8731                make_styled_view(
8732                    Style {
8733                        width: Some(Dimension::Pt(60.0)),
8734                        flex_shrink: Some(1.0),
8735                        ..Default::default()
8736                    },
8737                    vec![make_text("SALE", 12.0)],
8738                ),
8739            ],
8740        );
8741
8742        let doc = Document {
8743            children: vec![Node::page(
8744                PageConfig::default(),
8745                Style::default(),
8746                vec![container],
8747            )],
8748            metadata: Default::default(),
8749            default_page: PageConfig::default(),
8750            first_page: None,
8751            left_page: None,
8752            right_page: None,
8753            named_pages: Default::default(),
8754            attachments: vec![],
8755            zugferd: None,
8756            fonts: vec![],
8757            tagged: false,
8758            pdfa: None,
8759            default_style: None,
8760            embedded_data: None,
8761            flatten_forms: false,
8762            pdf_ua: false,
8763            certification: None,
8764            pdf_version: Default::default(),
8765            pdf_ua2: false,
8766        };
8767
8768        let pages = engine.layout(&doc, &font_context);
8769        assert!(!pages.is_empty());
8770
8771        // The SALE child (second flex item) should not be narrower than its min-content width
8772        // Walk the layout tree: Page -> View (container) -> second child
8773        let page = &pages[0];
8774        // Find the container (the View with children)
8775        let container_el = page.elements.iter().find(|e| e.children.len() == 2);
8776        assert!(
8777            container_el.is_some(),
8778            "Should find container with 2 children"
8779        );
8780        let sale_child = &container_el.unwrap().children[1];
8781        assert!(
8782            sale_child.width >= sale_word_width - 0.01,
8783            "SALE child width ({}) should be >= min-content width ({})",
8784            sale_child.width,
8785            sale_word_width
8786        );
8787    }
8788
8789    // ── Fix 2: column justify-content and align-items ──
8790
8791    #[test]
8792    fn column_justify_content_center() {
8793        // A column container with fixed height 200pt and a single child of ~20pt.
8794        // With justify-content: center, the child should be roughly centered vertically.
8795        let engine = LayoutEngine::new();
8796        let font_context = FontContext::new();
8797
8798        let container = make_styled_view(
8799            Style {
8800                flex_direction: Some(FlexDirection::Column),
8801                height: Some(Dimension::Pt(200.0)),
8802                justify_content: Some(JustifyContent::Center),
8803                ..Default::default()
8804            },
8805            vec![make_text("Centered", 12.0)],
8806        );
8807
8808        let doc = Document {
8809            children: vec![Node::page(
8810                PageConfig::default(),
8811                Style::default(),
8812                vec![container],
8813            )],
8814            metadata: Default::default(),
8815            default_page: PageConfig::default(),
8816            first_page: None,
8817            left_page: None,
8818            right_page: None,
8819            named_pages: Default::default(),
8820            attachments: vec![],
8821            zugferd: None,
8822            fonts: vec![],
8823            tagged: false,
8824            pdfa: None,
8825            default_style: None,
8826            embedded_data: None,
8827            flatten_forms: false,
8828            pdf_ua: false,
8829            certification: None,
8830            pdf_version: Default::default(),
8831            pdf_ua2: false,
8832        };
8833
8834        let pages = engine.layout(&doc, &font_context);
8835        let page = &pages[0];
8836
8837        // The container should have one child, and that child should be
8838        // offset roughly to the vertical center
8839        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
8840        assert!(
8841            container_el.is_some(),
8842            "Should find container with children"
8843        );
8844        let container_el = container_el.unwrap();
8845        let child = &container_el.children[0];
8846
8847        // Child y should be container.y + roughly (200 - child_height) / 2
8848        let child_offset = child.y - container_el.y;
8849        let expected_offset = (200.0 - child.height) / 2.0;
8850        assert!(
8851            (child_offset - expected_offset).abs() < 2.0,
8852            "Child offset ({}) should be near center ({})",
8853            child_offset,
8854            expected_offset
8855        );
8856    }
8857
8858    #[test]
8859    fn column_align_items_center() {
8860        // A column container with a narrow text child.
8861        // With align-items: center, the child should be horizontally centered.
8862        let engine = LayoutEngine::new();
8863        let font_context = FontContext::new();
8864
8865        let container = make_styled_view(
8866            Style {
8867                flex_direction: Some(FlexDirection::Column),
8868                width: Some(Dimension::Pt(300.0)),
8869                align_items: Some(AlignItems::Center),
8870                ..Default::default()
8871            },
8872            vec![make_text("Hi", 12.0)],
8873        );
8874
8875        let doc = Document {
8876            children: vec![Node::page(
8877                PageConfig::default(),
8878                Style::default(),
8879                vec![container],
8880            )],
8881            metadata: Default::default(),
8882            default_page: PageConfig::default(),
8883            first_page: None,
8884            left_page: None,
8885            right_page: None,
8886            named_pages: Default::default(),
8887            attachments: vec![],
8888            zugferd: None,
8889            fonts: vec![],
8890            tagged: false,
8891            pdfa: None,
8892            default_style: None,
8893            embedded_data: None,
8894            flatten_forms: false,
8895            pdf_ua: false,
8896            certification: None,
8897            pdf_version: Default::default(),
8898            pdf_ua2: false,
8899        };
8900
8901        let pages = engine.layout(&doc, &font_context);
8902        let page = &pages[0];
8903
8904        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
8905        assert!(container_el.is_some());
8906        let container_el = container_el.unwrap();
8907        let child = &container_el.children[0];
8908
8909        // Child should be centered within the 300pt container
8910        let child_center = child.x + child.width / 2.0;
8911        let container_center = container_el.x + container_el.width / 2.0;
8912        assert!(
8913            (child_center - container_center).abs() < 2.0,
8914            "Child center ({}) should be near container center ({})",
8915            child_center,
8916            container_center
8917        );
8918    }
8919
8920    // ── Fix 3: absolute positioning relative to parent ──
8921
8922    #[test]
8923    fn absolute_child_positioned_relative_to_parent() {
8924        // A POSITIONED parent (position: relative) with an absolute child using
8925        // top: 10, left: 10. The child resolves against the parent — now the
8926        // correct CSS behavior, since the parent is a positioned ancestor.
8927        let engine = LayoutEngine::new();
8928        let font_context = FontContext::new();
8929
8930        let parent = make_styled_view(
8931            Style {
8932                position: Some(crate::model::Position::Relative),
8933                margin: Some(MarginEdges::from_edges(Edges {
8934                    top: 50.0,
8935                    left: 50.0,
8936                    ..Default::default()
8937                })),
8938                width: Some(Dimension::Pt(200.0)),
8939                height: Some(Dimension::Pt(200.0)),
8940                ..Default::default()
8941            },
8942            vec![make_styled_view(
8943                Style {
8944                    position: Some(crate::model::Position::Absolute),
8945                    top: Some(10.0),
8946                    left: Some(10.0),
8947                    width: Some(Dimension::Pt(50.0)),
8948                    height: Some(Dimension::Pt(50.0)),
8949                    ..Default::default()
8950                },
8951                vec![],
8952            )],
8953        );
8954
8955        let doc = Document {
8956            children: vec![Node::page(
8957                PageConfig::default(),
8958                Style::default(),
8959                vec![parent],
8960            )],
8961            metadata: Default::default(),
8962            default_page: PageConfig::default(),
8963            first_page: None,
8964            left_page: None,
8965            right_page: None,
8966            named_pages: Default::default(),
8967            attachments: vec![],
8968            zugferd: None,
8969            fonts: vec![],
8970            tagged: false,
8971            pdfa: None,
8972            default_style: None,
8973            embedded_data: None,
8974            flatten_forms: false,
8975            pdf_ua: false,
8976            certification: None,
8977            pdf_version: Default::default(),
8978            pdf_ua2: false,
8979        };
8980
8981        let pages = engine.layout(&doc, &font_context);
8982        let page = &pages[0];
8983
8984        // Find the parent container (has the absolute child inside it or as sibling)
8985        // Absolute children are added to cursor.elements, so they'll be inside the parent
8986        let parent_el = page
8987            .elements
8988            .iter()
8989            .find(|e| e.width > 190.0 && e.width < 210.0);
8990        assert!(parent_el.is_some(), "Should find the 200x200 parent");
8991        let parent_el = parent_el.unwrap();
8992
8993        // The absolute child should be at parent.x + 10, parent.y + 10
8994        let abs_child = parent_el
8995            .children
8996            .iter()
8997            .find(|e| e.width > 45.0 && e.width < 55.0);
8998        assert!(abs_child.is_some(), "Should find 50x50 absolute child");
8999        let abs_child = abs_child.unwrap();
9000
9001        let expected_x = parent_el.x + 10.0;
9002        let expected_y = parent_el.y + 10.0;
9003        assert!(
9004            (abs_child.x - expected_x).abs() < 1.0,
9005            "Absolute child x ({}) should be parent.x + 10 ({})",
9006            abs_child.x,
9007            expected_x
9008        );
9009        assert!(
9010            (abs_child.y - expected_y).abs() < 1.0,
9011            "Absolute child y ({}) should be parent.y + 10 ({})",
9012            abs_child.y,
9013            expected_y
9014        );
9015    }
9016
9017    #[test]
9018    fn absolute_escapes_unpositioned_parent_to_page() {
9019        // Same shape, but the parent is UNpositioned. Under browser semantics
9020        // the absolute child resolves against the nearest positioned ancestor —
9021        // here none exists, so the page content box, NOT the parent. This is
9022        // the retired v0 divergence.
9023        let engine = LayoutEngine::new();
9024        let font_context = FontContext::new();
9025        let parent = make_styled_view(
9026            Style {
9027                margin: Some(MarginEdges::from_edges(Edges {
9028                    top: 50.0,
9029                    left: 50.0,
9030                    ..Default::default()
9031                })),
9032                width: Some(Dimension::Pt(200.0)),
9033                height: Some(Dimension::Pt(200.0)),
9034                ..Default::default()
9035            },
9036            vec![make_styled_view(
9037                Style {
9038                    position: Some(crate::model::Position::Absolute),
9039                    top: Some(10.0),
9040                    left: Some(10.0),
9041                    width: Some(Dimension::Pt(50.0)),
9042                    height: Some(Dimension::Pt(50.0)),
9043                    ..Default::default()
9044                },
9045                vec![],
9046            )],
9047        );
9048        let doc = Document {
9049            children: vec![Node::page(
9050                PageConfig::default(),
9051                Style::default(),
9052                vec![parent],
9053            )],
9054            metadata: Default::default(),
9055            default_page: PageConfig::default(),
9056            first_page: None,
9057            left_page: None,
9058            right_page: None,
9059            named_pages: Default::default(),
9060            attachments: vec![],
9061            zugferd: None,
9062            fonts: vec![],
9063            tagged: false,
9064            pdfa: None,
9065            default_style: None,
9066            embedded_data: None,
9067            flatten_forms: false,
9068            pdf_ua: false,
9069            certification: None,
9070            pdf_version: Default::default(),
9071            pdf_ua2: false,
9072        };
9073        let pages = engine.layout(&doc, &font_context);
9074        let page = &pages[0];
9075        let parent_el = page
9076            .elements
9077            .iter()
9078            .find(|e| e.width > 190.0 && e.width < 210.0)
9079            .expect("parent");
9080        let abs_child = parent_el
9081            .children
9082            .iter()
9083            .find(|e| e.width > 45.0 && e.width < 55.0)
9084            .expect("abs child");
9085        let page_left = PageConfig::default().margin.left;
9086        let page_top = PageConfig::default().margin.top;
9087        assert!(
9088            (abs_child.x - (page_left + 10.0)).abs() < 1.0,
9089            "absolute escapes to the page: x {} should be page_left + 10 ({})",
9090            abs_child.x,
9091            page_left + 10.0
9092        );
9093        assert!(
9094            (abs_child.y - (page_top + 10.0)).abs() < 1.0,
9095            "absolute escapes to the page: y {} should be page_top + 10 ({})",
9096            abs_child.y,
9097            page_top + 10.0
9098        );
9099        assert!(
9100            abs_child.x < parent_el.x,
9101            "child must no longer be parent-relative (parent is 50pt further in)"
9102        );
9103    }
9104
9105    #[test]
9106    fn text_transform_none_passthrough() {
9107        assert_eq!(
9108            apply_text_transform("Hello World", TextTransform::None),
9109            "Hello World"
9110        );
9111    }
9112
9113    #[test]
9114    fn text_transform_uppercase() {
9115        assert_eq!(
9116            apply_text_transform("hello world", TextTransform::Uppercase),
9117            "HELLO WORLD"
9118        );
9119    }
9120
9121    #[test]
9122    fn text_transform_lowercase() {
9123        assert_eq!(
9124            apply_text_transform("HELLO WORLD", TextTransform::Lowercase),
9125            "hello world"
9126        );
9127    }
9128
9129    #[test]
9130    fn text_transform_capitalize() {
9131        assert_eq!(
9132            apply_text_transform("hello world", TextTransform::Capitalize),
9133            "Hello World"
9134        );
9135        assert_eq!(
9136            apply_text_transform("  hello  world  ", TextTransform::Capitalize),
9137            "  Hello  World  "
9138        );
9139        assert_eq!(
9140            apply_text_transform("already Capitalized", TextTransform::Capitalize),
9141            "Already Capitalized"
9142        );
9143    }
9144
9145    #[test]
9146    fn text_transform_capitalize_empty() {
9147        assert_eq!(apply_text_transform("", TextTransform::Capitalize), "");
9148    }
9149
9150    #[test]
9151    fn apply_char_transform_uppercase() {
9152        assert_eq!(
9153            apply_char_transform('a', TextTransform::Uppercase, false),
9154            'A'
9155        );
9156        assert_eq!(
9157            apply_char_transform('A', TextTransform::Uppercase, false),
9158            'A'
9159        );
9160    }
9161
9162    #[test]
9163    fn apply_char_transform_capitalize_word_start() {
9164        assert_eq!(
9165            apply_char_transform('h', TextTransform::Capitalize, true),
9166            'H'
9167        );
9168        assert_eq!(
9169            apply_char_transform('h', TextTransform::Capitalize, false),
9170            'h'
9171        );
9172    }
9173
9174    // ── flex-grow in column direction ──
9175
9176    #[test]
9177    fn column_flex_grow_single_child_fills_container() {
9178        // A column container with fixed height 300pt and a single child with flex_grow: 1.
9179        // The child should expand to fill the entire 300pt.
9180        let engine = LayoutEngine::new();
9181        let font_context = FontContext::new();
9182
9183        let child = make_styled_view(
9184            Style {
9185                flex_grow: Some(1.0),
9186                ..Default::default()
9187            },
9188            vec![make_text("Short", 12.0)],
9189        );
9190
9191        let container = make_styled_view(
9192            Style {
9193                flex_direction: Some(FlexDirection::Column),
9194                height: Some(Dimension::Pt(300.0)),
9195                ..Default::default()
9196            },
9197            vec![child],
9198        );
9199
9200        let doc = Document {
9201            children: vec![Node::page(
9202                PageConfig::default(),
9203                Style::default(),
9204                vec![container],
9205            )],
9206            metadata: Default::default(),
9207            default_page: PageConfig::default(),
9208            first_page: None,
9209            left_page: None,
9210            right_page: None,
9211            named_pages: Default::default(),
9212            attachments: vec![],
9213            zugferd: None,
9214            fonts: vec![],
9215            tagged: false,
9216            pdfa: None,
9217            default_style: None,
9218            embedded_data: None,
9219            flatten_forms: false,
9220            pdf_ua: false,
9221            certification: None,
9222            pdf_version: Default::default(),
9223            pdf_ua2: false,
9224        };
9225
9226        let pages = engine.layout(&doc, &font_context);
9227        let page = &pages[0];
9228
9229        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
9230        assert!(container_el.is_some());
9231        let container_el = container_el.unwrap();
9232        assert!(
9233            (container_el.height - 300.0).abs() < 1.0,
9234            "Container should be 300pt, got {}",
9235            container_el.height
9236        );
9237
9238        let child_el = &container_el.children[0];
9239        assert!(
9240            (child_el.height - 300.0).abs() < 1.0,
9241            "flex-grow child should expand to 300pt, got {}",
9242            child_el.height
9243        );
9244    }
9245
9246    #[test]
9247    fn column_flex_grow_two_children_proportional() {
9248        // Two children: one with flex_grow: 1, one with flex_grow: 2.
9249        // They should share remaining space proportionally (1:2).
9250        let engine = LayoutEngine::new();
9251        let font_context = FontContext::new();
9252
9253        let child1 = make_styled_view(
9254            Style {
9255                flex_grow: Some(1.0),
9256                ..Default::default()
9257            },
9258            vec![make_text("A", 12.0)],
9259        );
9260        let child2 = make_styled_view(
9261            Style {
9262                flex_grow: Some(2.0),
9263                ..Default::default()
9264            },
9265            vec![make_text("B", 12.0)],
9266        );
9267
9268        let container = make_styled_view(
9269            Style {
9270                flex_direction: Some(FlexDirection::Column),
9271                height: Some(Dimension::Pt(300.0)),
9272                ..Default::default()
9273            },
9274            vec![child1, child2],
9275        );
9276
9277        let doc = Document {
9278            children: vec![Node::page(
9279                PageConfig::default(),
9280                Style::default(),
9281                vec![container],
9282            )],
9283            metadata: Default::default(),
9284            default_page: PageConfig::default(),
9285            first_page: None,
9286            left_page: None,
9287            right_page: None,
9288            named_pages: Default::default(),
9289            attachments: vec![],
9290            zugferd: None,
9291            fonts: vec![],
9292            tagged: false,
9293            pdfa: None,
9294            default_style: None,
9295            embedded_data: None,
9296            flatten_forms: false,
9297            pdf_ua: false,
9298            certification: None,
9299            pdf_version: Default::default(),
9300            pdf_ua2: false,
9301        };
9302
9303        let pages = engine.layout(&doc, &font_context);
9304        let page = &pages[0];
9305
9306        let container_el = page
9307            .elements
9308            .iter()
9309            .find(|e| e.children.len() == 2)
9310            .expect("Should find container with two children");
9311
9312        let c1 = &container_el.children[0];
9313        let c2 = &container_el.children[1];
9314
9315        // Both children have the same natural height (one line of text).
9316        // The slack is split 1:2 between them.
9317        // So child2 should be roughly twice as much taller than child1's growth.
9318        let total = c1.height + c2.height;
9319        assert!(
9320            (total - 300.0).abs() < 2.0,
9321            "Children should sum to ~300pt, got {}",
9322            total
9323        );
9324
9325        // child2.height should be roughly 2x child1.height
9326        // (not exact because natural heights are equal, but growth is 1:2)
9327        let ratio = c2.height / c1.height;
9328        assert!(
9329            ratio > 1.3 && ratio < 2.5,
9330            "child2/child1 ratio should be between 1.3 and 2.5, got {}",
9331            ratio
9332        );
9333    }
9334
9335    #[test]
9336    fn column_flex_grow_mixed_grow_and_fixed() {
9337        // One fixed child (no flex_grow) and one flex_grow child.
9338        // The flex_grow child takes all remaining space.
9339        let engine = LayoutEngine::new();
9340        let font_context = FontContext::new();
9341
9342        let fixed_child = make_styled_view(
9343            Style {
9344                height: Some(Dimension::Pt(50.0)),
9345                ..Default::default()
9346            },
9347            vec![make_text("Fixed", 12.0)],
9348        );
9349        let grow_child = make_styled_view(
9350            Style {
9351                flex_grow: Some(1.0),
9352                ..Default::default()
9353            },
9354            vec![make_text("Grow", 12.0)],
9355        );
9356
9357        let container = make_styled_view(
9358            Style {
9359                flex_direction: Some(FlexDirection::Column),
9360                height: Some(Dimension::Pt(300.0)),
9361                ..Default::default()
9362            },
9363            vec![fixed_child, grow_child],
9364        );
9365
9366        let doc = Document {
9367            children: vec![Node::page(
9368                PageConfig::default(),
9369                Style::default(),
9370                vec![container],
9371            )],
9372            metadata: Default::default(),
9373            default_page: PageConfig::default(),
9374            first_page: None,
9375            left_page: None,
9376            right_page: None,
9377            named_pages: Default::default(),
9378            attachments: vec![],
9379            zugferd: None,
9380            fonts: vec![],
9381            tagged: false,
9382            pdfa: None,
9383            default_style: None,
9384            embedded_data: None,
9385            flatten_forms: false,
9386            pdf_ua: false,
9387            certification: None,
9388            pdf_version: Default::default(),
9389            pdf_ua2: false,
9390        };
9391
9392        let pages = engine.layout(&doc, &font_context);
9393        let page = &pages[0];
9394
9395        let container_el = page
9396            .elements
9397            .iter()
9398            .find(|e| e.children.len() == 2)
9399            .expect("Should find container with two children");
9400
9401        let fixed_el = &container_el.children[0];
9402        let grow_el = &container_el.children[1];
9403
9404        // Fixed child stays at 50pt
9405        assert!(
9406            (fixed_el.height - 50.0).abs() < 1.0,
9407            "Fixed child should stay at 50pt, got {}",
9408            fixed_el.height
9409        );
9410
9411        // Grow child takes remaining ~250pt
9412        assert!(
9413            (grow_el.height - 250.0).abs() < 2.0,
9414            "Grow child should expand to ~250pt, got {}",
9415            grow_el.height
9416        );
9417    }
9418
9419    #[test]
9420    fn column_flex_grow_page_level() {
9421        // flex_grow: 1 on a direct Page child should fill the page content area.
9422        let engine = LayoutEngine::new();
9423        let font_context = FontContext::new();
9424
9425        let grow_child = make_styled_view(
9426            Style {
9427                flex_grow: Some(1.0),
9428                ..Default::default()
9429            },
9430            vec![make_text("Fill page", 12.0)],
9431        );
9432
9433        let doc = Document {
9434            children: vec![Node::page(
9435                PageConfig::default(),
9436                Style::default(),
9437                vec![grow_child],
9438            )],
9439            metadata: Default::default(),
9440            default_page: PageConfig::default(),
9441            first_page: None,
9442            left_page: None,
9443            right_page: None,
9444            named_pages: Default::default(),
9445            attachments: vec![],
9446            zugferd: None,
9447            fonts: vec![],
9448            tagged: false,
9449            pdfa: None,
9450            default_style: None,
9451            embedded_data: None,
9452            flatten_forms: false,
9453            pdf_ua: false,
9454            certification: None,
9455            pdf_version: Default::default(),
9456            pdf_ua2: false,
9457        };
9458
9459        let pages = engine.layout(&doc, &font_context);
9460        let page = &pages[0];
9461
9462        // The child should fill the page content height
9463        assert!(
9464            !page.elements.is_empty(),
9465            "Page should have at least one element"
9466        );
9467
9468        let content_height = page.height - page.config.margin.top - page.config.margin.bottom;
9469        let el = &page.elements[0];
9470        assert!(
9471            (el.height - content_height).abs() < 2.0,
9472            "Page-level flex-grow child should fill content height ({}), got {}",
9473            content_height,
9474            el.height
9475        );
9476    }
9477
9478    #[test]
9479    fn column_flex_grow_with_justify_content() {
9480        // flex-grow and justify-content: center should work together.
9481        // A fixed child + a grow child + justify-content: center.
9482        // After grow fills the space, there's no slack left for justify, so positions stay as-is.
9483        let engine = LayoutEngine::new();
9484        let font_context = FontContext::new();
9485
9486        let fixed_child = make_styled_view(
9487            Style {
9488                height: Some(Dimension::Pt(50.0)),
9489                ..Default::default()
9490            },
9491            vec![make_text("Top", 12.0)],
9492        );
9493        let grow_child = make_styled_view(
9494            Style {
9495                flex_grow: Some(1.0),
9496                ..Default::default()
9497            },
9498            vec![make_text("Fill", 12.0)],
9499        );
9500
9501        let container = make_styled_view(
9502            Style {
9503                flex_direction: Some(FlexDirection::Column),
9504                height: Some(Dimension::Pt(300.0)),
9505                justify_content: Some(JustifyContent::Center),
9506                ..Default::default()
9507            },
9508            vec![fixed_child, grow_child],
9509        );
9510
9511        let doc = Document {
9512            children: vec![Node::page(
9513                PageConfig::default(),
9514                Style::default(),
9515                vec![container],
9516            )],
9517            metadata: Default::default(),
9518            default_page: PageConfig::default(),
9519            first_page: None,
9520            left_page: None,
9521            right_page: None,
9522            named_pages: Default::default(),
9523            attachments: vec![],
9524            zugferd: None,
9525            fonts: vec![],
9526            tagged: false,
9527            pdfa: None,
9528            default_style: None,
9529            embedded_data: None,
9530            flatten_forms: false,
9531            pdf_ua: false,
9532            certification: None,
9533            pdf_version: Default::default(),
9534            pdf_ua2: false,
9535        };
9536
9537        let pages = engine.layout(&doc, &font_context);
9538        let page = &pages[0];
9539
9540        let container_el = page
9541            .elements
9542            .iter()
9543            .find(|e| e.children.len() == 2)
9544            .expect("Should find container");
9545
9546        // After flex-grow absorbs all slack, justify-content has nothing to distribute.
9547        // First child should be at the top of the container.
9548        let first_child = &container_el.children[0];
9549        assert!(
9550            (first_child.y - container_el.y).abs() < 1.0,
9551            "First child should be at top of container"
9552        );
9553
9554        // Children should still sum to container height
9555        let total = container_el.children[0].height + container_el.children[1].height;
9556        assert!(
9557            (total - 300.0).abs() < 2.0,
9558            "Children should fill container, got {}",
9559            total
9560        );
9561    }
9562
9563    #[test]
9564    fn column_flex_grow_child_justify_content_center() {
9565        // A flex-grow child with justify-content: center should vertically center its content.
9566        // This is the cover-page bug: the inner View grows via flex but its children stay at top.
9567        let engine = LayoutEngine::new();
9568        let font_context = FontContext::new();
9569
9570        // Inner content: a small fixed-height box
9571        let inner_box = make_styled_view(
9572            Style {
9573                height: Some(Dimension::Pt(40.0)),
9574                ..Default::default()
9575            },
9576            vec![make_text("Centered", 12.0)],
9577        );
9578
9579        // The grow child: flex: 1, justify-content: center
9580        let grow_child = make_styled_view(
9581            Style {
9582                flex_grow: Some(1.0),
9583                flex_direction: Some(FlexDirection::Column),
9584                justify_content: Some(JustifyContent::Center),
9585                ..Default::default()
9586            },
9587            vec![inner_box],
9588        );
9589
9590        // Outer column container with fixed height
9591        let container = make_styled_view(
9592            Style {
9593                flex_direction: Some(FlexDirection::Column),
9594                height: Some(Dimension::Pt(400.0)),
9595                ..Default::default()
9596            },
9597            vec![grow_child],
9598        );
9599
9600        let doc = Document {
9601            children: vec![Node::page(
9602                PageConfig::default(),
9603                Style::default(),
9604                vec![container],
9605            )],
9606            metadata: Default::default(),
9607            default_page: PageConfig::default(),
9608            first_page: None,
9609            left_page: None,
9610            right_page: None,
9611            named_pages: Default::default(),
9612            attachments: vec![],
9613            zugferd: None,
9614            fonts: vec![],
9615            tagged: false,
9616            pdfa: None,
9617            default_style: None,
9618            embedded_data: None,
9619            flatten_forms: false,
9620            pdf_ua: false,
9621            certification: None,
9622            pdf_version: Default::default(),
9623            pdf_ua2: false,
9624        };
9625
9626        let pages = engine.layout(&doc, &font_context);
9627        let page = &pages[0];
9628
9629        // Find the container (has 1 child = the grow child)
9630        let container_el = page
9631            .elements
9632            .iter()
9633            .find(|e| e.height > 350.0 && e.children.len() == 1)
9634            .expect("Should find outer container");
9635
9636        let grow_el = &container_el.children[0];
9637        assert!(
9638            (grow_el.height - 400.0).abs() < 2.0,
9639            "Grow child should expand to 400, got {}",
9640            grow_el.height
9641        );
9642
9643        // The inner box should be vertically centered within the grow child
9644        let inner_el = &grow_el.children[0];
9645        let expected_center = grow_el.y + grow_el.height / 2.0;
9646        let actual_center = inner_el.y + inner_el.height / 2.0;
9647        assert!(
9648            (actual_center - expected_center).abs() < 2.0,
9649            "Inner box should be vertically centered. Expected center ~{}, got ~{}",
9650            expected_center,
9651            actual_center
9652        );
9653    }
9654
9655    #[test]
9656    fn column_flex_grow_child_justify_content_flex_end() {
9657        // A flex-grow child with justify-content: flex-end should push content to the bottom.
9658        let engine = LayoutEngine::new();
9659        let font_context = FontContext::new();
9660
9661        let inner_box = make_styled_view(
9662            Style {
9663                height: Some(Dimension::Pt(30.0)),
9664                ..Default::default()
9665            },
9666            vec![make_text("Bottom", 12.0)],
9667        );
9668
9669        let grow_child = make_styled_view(
9670            Style {
9671                flex_grow: Some(1.0),
9672                flex_direction: Some(FlexDirection::Column),
9673                justify_content: Some(JustifyContent::FlexEnd),
9674                ..Default::default()
9675            },
9676            vec![inner_box],
9677        );
9678
9679        let container = make_styled_view(
9680            Style {
9681                flex_direction: Some(FlexDirection::Column),
9682                height: Some(Dimension::Pt(300.0)),
9683                ..Default::default()
9684            },
9685            vec![grow_child],
9686        );
9687
9688        let doc = Document {
9689            children: vec![Node::page(
9690                PageConfig::default(),
9691                Style::default(),
9692                vec![container],
9693            )],
9694            metadata: Default::default(),
9695            default_page: PageConfig::default(),
9696            first_page: None,
9697            left_page: None,
9698            right_page: None,
9699            named_pages: Default::default(),
9700            attachments: vec![],
9701            zugferd: None,
9702            fonts: vec![],
9703            tagged: false,
9704            pdfa: None,
9705            default_style: None,
9706            embedded_data: None,
9707            flatten_forms: false,
9708            pdf_ua: false,
9709            certification: None,
9710            pdf_version: Default::default(),
9711            pdf_ua2: false,
9712        };
9713
9714        let pages = engine.layout(&doc, &font_context);
9715        let page = &pages[0];
9716
9717        let container_el = page
9718            .elements
9719            .iter()
9720            .find(|e| e.height > 250.0 && e.children.len() == 1)
9721            .expect("Should find outer container");
9722
9723        let grow_el = &container_el.children[0];
9724        let inner_el = &grow_el.children[0];
9725
9726        // Inner box should be near the bottom of the grow child
9727        let inner_bottom = inner_el.y + inner_el.height;
9728        let grow_bottom = grow_el.y + grow_el.height;
9729        assert!(
9730            (inner_bottom - grow_bottom).abs() < 2.0,
9731            "Inner box bottom ({}) should align with grow child bottom ({})",
9732            inner_bottom,
9733            grow_bottom
9734        );
9735    }
9736
9737    #[test]
9738    fn column_flex_grow_child_no_justify_unchanged() {
9739        // Regression: flex-grow with default FlexStart should keep content at top.
9740        let engine = LayoutEngine::new();
9741        let font_context = FontContext::new();
9742
9743        let inner_box = make_styled_view(
9744            Style {
9745                height: Some(Dimension::Pt(50.0)),
9746                ..Default::default()
9747            },
9748            vec![make_text("Top", 12.0)],
9749        );
9750
9751        let grow_child = make_styled_view(
9752            Style {
9753                flex_grow: Some(1.0),
9754                flex_direction: Some(FlexDirection::Column),
9755                // No justify-content set — defaults to FlexStart
9756                ..Default::default()
9757            },
9758            vec![inner_box],
9759        );
9760
9761        let container = make_styled_view(
9762            Style {
9763                flex_direction: Some(FlexDirection::Column),
9764                height: Some(Dimension::Pt(300.0)),
9765                ..Default::default()
9766            },
9767            vec![grow_child],
9768        );
9769
9770        let doc = Document {
9771            children: vec![Node::page(
9772                PageConfig::default(),
9773                Style::default(),
9774                vec![container],
9775            )],
9776            metadata: Default::default(),
9777            default_page: PageConfig::default(),
9778            first_page: None,
9779            left_page: None,
9780            right_page: None,
9781            named_pages: Default::default(),
9782            attachments: vec![],
9783            zugferd: None,
9784            fonts: vec![],
9785            tagged: false,
9786            pdfa: None,
9787            default_style: None,
9788            embedded_data: None,
9789            flatten_forms: false,
9790            pdf_ua: false,
9791            certification: None,
9792            pdf_version: Default::default(),
9793            pdf_ua2: false,
9794        };
9795
9796        let pages = engine.layout(&doc, &font_context);
9797        let page = &pages[0];
9798
9799        let container_el = page
9800            .elements
9801            .iter()
9802            .find(|e| e.height > 250.0 && e.children.len() == 1)
9803            .expect("Should find outer container");
9804
9805        let grow_el = &container_el.children[0];
9806        let inner_el = &grow_el.children[0];
9807
9808        // Inner box should stay at the top of the grow child
9809        assert!(
9810            (inner_el.y - grow_el.y).abs() < 2.0,
9811            "Inner box ({}) should be at top of grow child ({})",
9812            inner_el.y,
9813            grow_el.y
9814        );
9815    }
9816
9817    #[test]
9818    fn column_flex_grow_child_align_items_center() {
9819        // A flex-grown View with align_items: Center should horizontally center its Text child.
9820        let engine = LayoutEngine::new();
9821        let font_context = FontContext::new();
9822
9823        let text = make_text("Hello", 12.0);
9824
9825        let grow_child = make_styled_view(
9826            Style {
9827                flex_grow: Some(1.0),
9828                flex_direction: Some(FlexDirection::Column),
9829                align_items: Some(AlignItems::Center),
9830                ..Default::default()
9831            },
9832            vec![text],
9833        );
9834
9835        let container = make_styled_view(
9836            Style {
9837                flex_direction: Some(FlexDirection::Column),
9838                height: Some(Dimension::Pt(300.0)),
9839                ..Default::default()
9840            },
9841            vec![grow_child],
9842        );
9843
9844        let doc = Document {
9845            children: vec![Node::page(
9846                PageConfig::default(),
9847                Style::default(),
9848                vec![container],
9849            )],
9850            metadata: Default::default(),
9851            default_page: PageConfig::default(),
9852            first_page: None,
9853            left_page: None,
9854            right_page: None,
9855            named_pages: Default::default(),
9856            attachments: vec![],
9857            zugferd: None,
9858            fonts: vec![],
9859            tagged: false,
9860            pdfa: None,
9861            default_style: None,
9862            embedded_data: None,
9863            flatten_forms: false,
9864            pdf_ua: false,
9865            certification: None,
9866            pdf_version: Default::default(),
9867            pdf_ua2: false,
9868        };
9869
9870        let pages = engine.layout(&doc, &font_context);
9871        let page = &pages[0];
9872
9873        let container_el = page
9874            .elements
9875            .iter()
9876            .find(|e| e.height > 250.0 && e.children.len() == 1)
9877            .expect("Should find outer container");
9878
9879        let grow_el = &container_el.children[0];
9880        assert!(
9881            !grow_el.children.is_empty(),
9882            "Grow child should have text child"
9883        );
9884
9885        let text_el = &grow_el.children[0];
9886        let text_center = text_el.x + text_el.width / 2.0;
9887        let grow_center = grow_el.x + grow_el.width / 2.0;
9888        assert!(
9889            (text_center - grow_center).abs() < 2.0,
9890            "Text center ({}) should be near grow child center ({})",
9891            text_center,
9892            grow_center
9893        );
9894    }
9895
9896    #[test]
9897    fn image_intrinsic_width_respects_height_constraint() {
9898        // An Image with only a height prop should compute intrinsic width from
9899        // aspect ratio, not return the raw pixel width. This ensures align-items:
9900        // center can correctly center images.
9901        let engine = LayoutEngine::new();
9902        let font_context = FontContext::new();
9903
9904        // Use a 1x1 PNG data URI (known dimensions: 1x1 pixels)
9905        let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
9906
9907        let image_node = Node {
9908            kind: NodeKind::Image {
9909                src: one_px_png.to_string(),
9910                width: None,
9911                height: Some(36.0),
9912            },
9913            style: Style::default(),
9914            children: vec![],
9915            id: None,
9916            source_location: None,
9917            bookmark: None,
9918            href: None,
9919            alt: None,
9920        };
9921
9922        let resolved = image_node.style.resolve(None, 0.0);
9923        let intrinsic = engine.measure_intrinsic_width(&image_node, &resolved, &font_context);
9924
9925        // 1x1 pixel image with height: 36 should give width = 36 / (1/1) = 36
9926        assert!(
9927            (intrinsic - 36.0).abs() < 1.0,
9928            "Intrinsic width should be ~36 for 1:1 aspect image with height 36, got {}",
9929            intrinsic
9930        );
9931    }
9932}