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