Skip to main content

forme/layout/
mod.rs

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