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