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                        let w = match child_style.width {
2619                            SizeConstraint::Fixed(fw) => fw,
2620                            SizeConstraint::Auto => intrinsic,
2621                        };
2622                        let lw = if has_explicit_width {
2623                            available_width
2624                        } else {
2625                            w
2626                        };
2627                        match align {
2628                            AlignItems::Center => (content_x + (available_width - w) / 2.0, lw),
2629                            AlignItems::FlexEnd => (content_x + available_width - w, lw),
2630                            _ => (content_x, available_width),
2631                        }
2632                    } else {
2633                        (content_x, available_width)
2634                    };
2635
2636                    self.layout_node(
2637                        child,
2638                        cursor,
2639                        pages,
2640                        child_x,
2641                        layout_w,
2642                        parent_style,
2643                        font_context,
2644                        None,
2645                        None,
2646                    );
2647
2648                    child_ranges.push((child_start, cursor.elements.len()));
2649                }
2650
2651                // flex-grow: distribute extra vertical space proportionally
2652                // Compute container inner height from parent style or page content area
2653                let container_inner_h: Option<f64> = parent_style
2654                    .and_then(|ps| match ps.height {
2655                        SizeConstraint::Fixed(h) => {
2656                            Some(h - ps.padding.vertical() - ps.border_width.vertical())
2657                        }
2658                        SizeConstraint::Auto => None,
2659                    })
2660                    .or_else(|| {
2661                        // Page-level: use remaining content height from start
2662                        if parent_style.is_none() {
2663                            Some(cursor.content_height - start_y)
2664                        } else {
2665                            None
2666                        }
2667                    });
2668
2669                if let Some(inner_h) = container_inner_h {
2670                    if pages.len() == initial_pages {
2671                        let child_styles: Vec<ResolvedStyle> = items
2672                            .iter()
2673                            .map(|child| child.style.resolve(parent_style, available_width))
2674                            .collect();
2675                        let total_grow: f64 = child_styles.iter().map(|s| s.flex_grow).sum();
2676                        if total_grow > 0.0 {
2677                            let children_total = cursor.y - start_y;
2678                            let slack = (inner_h - children_total).max(0.0);
2679                            if slack > 0.0 {
2680                                let mut cumulative_shift = 0.0_f64;
2681                                for (i, cs) in child_styles.iter().enumerate() {
2682                                    let (start, end) = child_ranges[i];
2683                                    if cumulative_shift > 0.001 {
2684                                        for j in start..end {
2685                                            offset_element_y(
2686                                                &mut cursor.elements[j],
2687                                                cumulative_shift,
2688                                            );
2689                                        }
2690                                    }
2691                                    if cs.flex_grow > 0.0 {
2692                                        let extra = slack * (cs.flex_grow / total_grow);
2693                                        // Expand the container element's height
2694                                        if start < end {
2695                                            let elem = &mut cursor.elements[end - 1];
2696                                            elem.height += extra;
2697                                            reapply_justify_content(elem);
2698                                        }
2699                                        cumulative_shift += extra;
2700                                    }
2701                                }
2702                                cursor.y += cumulative_shift;
2703                            }
2704                        }
2705                    }
2706                }
2707
2708                // Auto vertical margin pass: distribute any remaining slack to
2709                // children with marginTop/marginBottom: Auto. Per CSS flex spec,
2710                // this runs AFTER flex-grow and BEFORE justify-content — auto
2711                // margins consume free space first, leaving nothing for
2712                // justify-content. Mirrors the cross-axis handling in
2713                // layout_flex_row (~2256-2267) but applied to the main axis here.
2714                if let Some(inner_h) = container_inner_h {
2715                    if pages.len() == initial_pages {
2716                        let auto_styles: Vec<ResolvedStyle> = items
2717                            .iter()
2718                            .map(|child| child.style.resolve(parent_style, available_width))
2719                            .collect();
2720                        let total_autos: usize = auto_styles
2721                            .iter()
2722                            .map(|s| {
2723                                s.margin.top.is_auto() as usize + s.margin.bottom.is_auto() as usize
2724                            })
2725                            .sum();
2726                        if total_autos > 0 {
2727                            let children_total = cursor.y - start_y;
2728                            let total_slack = (inner_h - children_total).max(0.0);
2729                            if total_slack > 0.0 {
2730                                let per_auto = total_slack / total_autos as f64;
2731                                let mut cumulative_shift = 0.0_f64;
2732                                for (i, cs) in auto_styles.iter().enumerate() {
2733                                    let (start, end) = child_ranges[i];
2734                                    let mt_auto = cs.margin.top.is_auto();
2735                                    let mb_auto = cs.margin.bottom.is_auto();
2736                                    // mt-auto pushes THIS child down by per_auto;
2737                                    // any cumulative_shift from earlier children
2738                                    // (including their mb-auto carryover) applies too.
2739                                    let this_child_shift =
2740                                        cumulative_shift + if mt_auto { per_auto } else { 0.0 };
2741                                    if this_child_shift > 0.001 {
2742                                        for j in start..end {
2743                                            offset_element_y(
2744                                                &mut cursor.elements[j],
2745                                                this_child_shift,
2746                                            );
2747                                        }
2748                                    }
2749                                    // mb-auto adds slack between this child and
2750                                    // any subsequent ones (carried forward).
2751                                    cumulative_shift =
2752                                        this_child_shift + if mb_auto { per_auto } else { 0.0 };
2753                                }
2754                                cursor.y += cumulative_shift;
2755                            }
2756                        }
2757                    }
2758                }
2759
2760                // justify-content: redistribute children vertically when parent has fixed height
2761                let needs_justify =
2762                    !matches!(justify, JustifyContent::FlexStart) && pages.len() == initial_pages;
2763                if needs_justify {
2764                    // Use container_inner_h if available, otherwise compute from parent style
2765                    let justify_inner_h = container_inner_h.or_else(|| {
2766                        parent_style.and_then(|ps| match ps.height {
2767                            SizeConstraint::Fixed(h) => {
2768                                Some(h - ps.padding.vertical() - ps.border_width.vertical())
2769                            }
2770                            SizeConstraint::Auto => None,
2771                        })
2772                    });
2773                    if let Some(inner_h) = justify_inner_h {
2774                        let children_total = cursor.y - start_y;
2775                        let slack = inner_h - children_total;
2776                        if slack > 0.0 {
2777                            let n = child_ranges.len();
2778                            let offsets: Vec<f64> = match justify {
2779                                JustifyContent::FlexEnd => vec![slack; n],
2780                                JustifyContent::Center => vec![slack / 2.0; n],
2781                                JustifyContent::SpaceBetween => {
2782                                    if n <= 1 {
2783                                        vec![0.0; n]
2784                                    } else {
2785                                        let per_gap = slack / (n - 1) as f64;
2786                                        (0..n).map(|i| i as f64 * per_gap).collect()
2787                                    }
2788                                }
2789                                JustifyContent::SpaceAround => {
2790                                    let space = slack / n as f64;
2791                                    (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
2792                                }
2793                                JustifyContent::SpaceEvenly => {
2794                                    let space = slack / (n + 1) as f64;
2795                                    (0..n).map(|i| (i + 1) as f64 * space).collect()
2796                                }
2797                                JustifyContent::FlexStart => vec![0.0; n],
2798                            };
2799                            for (i, &(start, end)) in child_ranges.iter().enumerate() {
2800                                let dy = offsets[i];
2801                                if dy.abs() > 0.001 {
2802                                    for j in start..end {
2803                                        offset_element_y(&mut cursor.elements[j], dy);
2804                                    }
2805                                }
2806                            }
2807                            cursor.y += *offsets.last().unwrap_or(&0.0);
2808                        }
2809                    }
2810                }
2811            }
2812
2813            FlexDirection::Row | FlexDirection::RowReverse => {
2814                let flow_owned: Vec<Node> = flow_children.into_iter().cloned().collect();
2815                self.layout_flex_row(
2816                    &flow_owned,
2817                    cursor,
2818                    pages,
2819                    content_x,
2820                    available_width,
2821                    parent_style,
2822                    column_gap,
2823                    row_gap,
2824                    font_context,
2825                );
2826            }
2827        }
2828
2829        // The containing block for these absolutes: the direct parent when it
2830        // is positioned (preserving the auto-height lazy computation), else the
2831        // nearest positioned ancestor / page carried on the cursor. This is the
2832        // v0-divergence retirement — an absolute inside an *unpositioned* parent
2833        // now escapes to its nearest positioned ancestor, matching browsers.
2834        // Did the parent fragment across pages during flow layout? Its
2835        // absolutes then anchor to the FIRST fragment (CSS): coordinates
2836        // are already first-page coordinates (parent_box_x/y), the auto
2837        // height is the first fragment's extent (down to that page's
2838        // content bottom — the fragment ran to the page end), and the
2839        // elements are emitted onto that page rather than the post-break
2840        // cursor. Emitting into the cursor was the last-fragment bug: the
2841        // badge drawn from first-page coordinates landed on the last page.
2842        let parent_fragmented = parent_positioned && pages.len() > entry_page_index;
2843        let (cb_x, cb_y, cb_w, cb_h) = if parent_positioned {
2844            let ph = parent_style
2845                .and_then(|ps| match ps.height {
2846                    SizeConstraint::Fixed(h) => {
2847                        Some(h - ps.padding.vertical() - ps.border_width.vertical())
2848                    }
2849                    SizeConstraint::Auto => None,
2850                })
2851                .unwrap_or_else(|| {
2852                    if parent_fragmented {
2853                        let first = &pages[entry_page_index];
2854                        (first.height - first.config.margin.bottom) - parent_box_y
2855                    } else {
2856                        cursor.content_y + cursor.y - parent_box_y
2857                    }
2858                });
2859            (parent_box_x, parent_box_y, available_width, ph)
2860        } else {
2861            cursor.containing_block
2862        };
2863
2864        // Second pass: absolute children
2865        for abs_child in &abs_children {
2866            let abs_style = abs_child.style.resolve(parent_style, cb_w);
2867
2868            // Measure intrinsic size
2869            let child_width = match abs_style.width {
2870                SizeConstraint::Fixed(w) => w,
2871                SizeConstraint::Auto => {
2872                    // If both left and right are set, stretch width
2873                    if let (Some(l), Some(r)) = (abs_style.left, abs_style.right) {
2874                        (cb_w - l - r).max(0.0)
2875                    } else {
2876                        self.measure_intrinsic_width(abs_child, &abs_style, font_context)
2877                    }
2878                }
2879            };
2880
2881            let child_height = match abs_style.height {
2882                SizeConstraint::Fixed(h) => h,
2883                SizeConstraint::Auto => {
2884                    self.measure_node_height(abs_child, child_width, &abs_style, font_context)
2885                }
2886            };
2887
2888            // Position relative to the containing block. Per CSS, the
2889            // offsets position the MARGIN edge: layout_node applies
2890            // margin.top/left inside the slot, so a top/left anchor needs
2891            // no adjustment — but bottom/right anchors must reserve the
2892            // margins, or a margin shoves the border box past the anchor
2893            // (template-compat 15: `bottom:0` + `margin-top:1rem` pushed a
2894            // footer off the page bottom, leaving only ascender tips).
2895            let abs_margin = abs_style.margin.to_edges();
2896            let abs_x = if let Some(l) = abs_style.left {
2897                cb_x + l
2898            } else if let Some(r) = abs_style.right {
2899                cb_x + cb_w - r - child_width - abs_margin.horizontal()
2900            } else {
2901                cb_x
2902            };
2903
2904            let abs_y = if let Some(t) = abs_style.top {
2905                cb_y + t
2906            } else if let Some(b) = abs_style.bottom {
2907                cb_y + cb_h - b - child_height - abs_margin.vertical()
2908            } else {
2909                cb_y
2910            };
2911
2912            // Lay out the absolute child into a temporary cursor
2913            let mut abs_cursor = PageCursor::new(&cursor.config);
2914            abs_cursor.y = 0.0;
2915            abs_cursor.content_x = abs_x;
2916            abs_cursor.content_y = abs_y;
2917
2918            self.layout_node(
2919                abs_child,
2920                &mut abs_cursor,
2921                &mut Vec::new(),
2922                abs_x,
2923                child_width,
2924                parent_style,
2925                font_context,
2926                None,
2927                None,
2928            );
2929
2930            // Add absolute elements to the page the containing block starts
2931            // on: the finalized first fragment when the parent broke across
2932            // pages, else the current cursor (renders on top either way).
2933            if parent_fragmented {
2934                pages[entry_page_index].elements.extend(abs_cursor.elements);
2935            } else {
2936                cursor.elements.extend(abs_cursor.elements);
2937            }
2938        }
2939
2940        // Restore the containing block for the caller's remaining siblings.
2941        cursor.containing_block = saved_cb;
2942    }
2943
2944    #[allow(clippy::too_many_arguments)]
2945    fn layout_flex_row(
2946        &self,
2947        children: &[Node],
2948        cursor: &mut PageCursor,
2949        pages: &mut Vec<LayoutPage>,
2950        content_x: f64,
2951        available_width: f64,
2952        parent_style: Option<&ResolvedStyle>,
2953        column_gap: f64,
2954        row_gap: f64,
2955        font_context: &FontContext,
2956    ) {
2957        if children.is_empty() {
2958            return;
2959        }
2960
2961        let flex_wrap = parent_style
2962            .map(|s| s.flex_wrap)
2963            .unwrap_or(FlexWrap::NoWrap);
2964
2965        // Phase 1: resolve styles and measure base widths for all items
2966        // flex_basis takes precedence over width for flex items (per CSS spec)
2967        let items: Vec<FlexItem> = children
2968            .iter()
2969            .map(|child| {
2970                let style = child.style.resolve(parent_style, available_width);
2971                let base_width = match style.flex_basis {
2972                    SizeConstraint::Fixed(w) => w,
2973                    SizeConstraint::Auto => match style.width {
2974                        SizeConstraint::Fixed(w) => w,
2975                        SizeConstraint::Auto => {
2976                            self.measure_intrinsic_width(child, &style, font_context)
2977                        }
2978                    },
2979                };
2980                let min_content_width = self.measure_min_content_width(child, &style, font_context);
2981                FlexItem {
2982                    node: child,
2983                    style,
2984                    base_width,
2985                    min_content_width,
2986                }
2987            })
2988            .collect();
2989
2990        // Phase 2: determine wrap lines
2991        let base_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
2992        let lines = match flex_wrap {
2993            FlexWrap::NoWrap => {
2994                vec![flex::WrapLine {
2995                    start: 0,
2996                    end: items.len(),
2997                }]
2998            }
2999            FlexWrap::Wrap => flex::partition_into_lines(&base_widths, column_gap, available_width),
3000            FlexWrap::WrapReverse => {
3001                let mut l = flex::partition_into_lines(&base_widths, column_gap, available_width);
3002                l.reverse();
3003                l
3004            }
3005        };
3006
3007        if lines.is_empty() {
3008            return;
3009        }
3010
3011        // Phase 3: lay out each line
3012        let justify = parent_style.map(|s| s.justify_content).unwrap_or_default();
3013
3014        // We need mutable final_widths per line, so collect into a vec
3015        let mut final_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
3016
3017        let initial_pages_count = pages.len();
3018        let flex_start_y = cursor.y;
3019        let mut line_infos: Vec<(usize, usize, f64)> = Vec::new();
3020
3021        for (line_idx, line) in lines.iter().enumerate() {
3022            let line_items = &items[line.start..line.end];
3023            let line_count = line.end - line.start;
3024            let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
3025            let distributable = available_width - line_gap;
3026
3027            // Flex distribution for this line
3028            let total_base: f64 = line_items.iter().map(|i| i.base_width).sum();
3029            let remaining = distributable - total_base;
3030
3031            if remaining > 0.0 {
3032                let total_grow: f64 = line_items.iter().map(|i| i.style.flex_grow).sum();
3033                if total_grow > 0.0 {
3034                    for (j, item) in line_items.iter().enumerate() {
3035                        final_widths[line.start + j] =
3036                            item.base_width + remaining * (item.style.flex_grow / total_grow);
3037                    }
3038                }
3039            } else if remaining < 0.0 {
3040                let total_shrink: f64 = line_items
3041                    .iter()
3042                    .map(|i| i.style.flex_shrink * i.base_width)
3043                    .sum();
3044                if total_shrink > 0.0 {
3045                    for (j, item) in line_items.iter().enumerate() {
3046                        let factor = (item.style.flex_shrink * item.base_width) / total_shrink;
3047                        let w = item.base_width + remaining * factor;
3048                        let floor = item.style.min_width.max(item.min_content_width);
3049                        final_widths[line.start + j] = w.max(floor);
3050                    }
3051                }
3052            }
3053
3054            // Measure line height
3055            let mut line_height: f64 = line_items
3056                .iter()
3057                .enumerate()
3058                .map(|(j, item)| {
3059                    let fw = final_widths[line.start + j];
3060                    self.measure_node_height(item.node, fw, &item.style, font_context)
3061                        + item.style.margin.vertical()
3062                })
3063                .fold(0.0f64, f64::max);
3064
3065            // align-items/align-self: baseline — the line's baseline is the
3066            // max first-baseline distance across its baseline items; each
3067            // baseline item is shoved down by (line_baseline − its own),
3068            // and a shoved item may extend past the tallest natural item,
3069            // so the line grows to hold it rather than overlap the next.
3070            let parent_align = parent_style.map(|s| s.align_items).unwrap_or_default();
3071            let baseline_ds: Vec<Option<f64>> = line_items
3072                .iter()
3073                .enumerate()
3074                .map(|(j, item)| {
3075                    let align = item.style.align_self.unwrap_or(parent_align);
3076                    if matches!(align, AlignItems::Baseline) {
3077                        let fw = final_widths[line.start + j];
3078                        Some(self.flex_item_baseline_distance(
3079                            item.node,
3080                            &item.style,
3081                            fw,
3082                            font_context,
3083                        ))
3084                    } else {
3085                        None
3086                    }
3087                })
3088                .collect();
3089            let line_baseline = baseline_ds
3090                .iter()
3091                .flatten()
3092                .fold(None, |m: Option<f64>, &d| Some(m.map_or(d, |x| x.max(d))));
3093            if let Some(bl) = line_baseline {
3094                for (j, item) in line_items.iter().enumerate() {
3095                    if let Some(d) = baseline_ds[j] {
3096                        let fw = final_widths[line.start + j];
3097                        let h = self.measure_node_height(item.node, fw, &item.style, font_context)
3098                            + item.style.margin.vertical();
3099                        line_height = line_height.max(bl - d + h);
3100                    }
3101                }
3102            }
3103
3104            // CSS 9.4.8: a single-line (nowrap) flex container with a
3105            // definite cross size gives its one flex line the CONTAINER'S
3106            // inner cross size, not the tallest item's. Without this,
3107            // align-items: center / flex-end on a fixed-height row were
3108            // no-ops — a 36pt logo box "centered" its 20pt text inside a
3109            // 20pt line (the launch-demo mark). `max` rather than replace:
3110            // when items overspill a too-small container the line keeps
3111            // content size (the spec would shrink and overflow; keeping
3112            // the larger value is the conservative reading for existing
3113            // documents).
3114            if let Some(ps) = parent_style {
3115                if matches!(ps.flex_wrap, FlexWrap::NoWrap) {
3116                    if let SizeConstraint::Fixed(h) = ps.height {
3117                        let inner = h - ps.padding.vertical() - ps.border_width.vertical();
3118                        line_height = line_height.max(inner);
3119                    }
3120                }
3121            }
3122
3123            // Page break check for this line. The `cursor.y > 0.0` guard
3124            // matches the other break sites: when the current page is
3125            // already empty, moving to a fresh page can't gain space — a
3126            // line taller than a full page would otherwise emit a blank
3127            // page and then overflow anyway (found by the HTML spike's
3128            // taller-than-page flex item).
3129            if line_height > cursor.remaining_height() && cursor.y > 0.0 {
3130                pages.push(cursor.finalize());
3131                *cursor = cursor.new_page();
3132            }
3133
3134            // Add row_gap between lines (not before first)
3135            if line_idx > 0 {
3136                cursor.y += row_gap;
3137            }
3138
3139            let row_start_y = cursor.y;
3140
3141            // Justify-content for this line
3142            let actual_total: f64 = (line.start..line.end).map(|i| final_widths[i]).sum();
3143            let slack = available_width - actual_total - line_gap;
3144
3145            let (start_offset, between_extra) = match justify {
3146                JustifyContent::FlexStart => (0.0, 0.0),
3147                JustifyContent::FlexEnd => (slack, 0.0),
3148                JustifyContent::Center => (slack / 2.0, 0.0),
3149                JustifyContent::SpaceBetween => {
3150                    if line_count > 1 {
3151                        (0.0, slack / (line_count as f64 - 1.0))
3152                    } else {
3153                        (0.0, 0.0)
3154                    }
3155                }
3156                JustifyContent::SpaceAround => {
3157                    let s = slack / line_count as f64;
3158                    (s / 2.0, s)
3159                }
3160                JustifyContent::SpaceEvenly => {
3161                    let s = slack / (line_count as f64 + 1.0);
3162                    (s, s)
3163                }
3164            };
3165
3166            let line_elem_start = cursor.elements.len();
3167            let mut x = content_x + start_offset;
3168
3169            // Sequential-split detection, precise form: the genuinely
3170            // sequential outcome is an ITEM's own layout breaking the
3171            // page while siblings share its line — the siblings don't
3172            // continue beside it on the next page, so columns serialize.
3173            // The signature is page growth DURING the item loop. A row
3174            // that merely relocated whole broke in the line-fit check
3175            // ABOVE, before this count is taken, and stays silent (the
3176            // old check fired on any page growth during the row's whole
3177            // layout and closed a correct PR — a warning that cries
3178            // wolf is worse than none).
3179            let line_start_pages = pages.len();
3180
3181            for (j, item) in line_items.iter().enumerate() {
3182                if j > 0 {
3183                    x += column_gap + between_extra;
3184                }
3185
3186                let fw = final_widths[line.start + j];
3187
3188                let align = item
3189                    .style
3190                    .align_self
3191                    .unwrap_or(parent_style.map(|s| s.align_items).unwrap_or_default());
3192
3193                let item_height =
3194                    self.measure_node_height(item.node, fw, &item.style, font_context);
3195
3196                // Auto margins on cross axis take priority over align-items
3197                let has_auto_v = item.style.margin.has_auto_vertical();
3198                let y_offset = if has_auto_v {
3199                    let fixed_v = item.style.margin.vertical();
3200                    let slack = (line_height - item_height - fixed_v).max(0.0);
3201                    let auto_top = item.style.margin.top.is_auto();
3202                    let auto_bottom = item.style.margin.bottom.is_auto();
3203                    match (auto_top, auto_bottom) {
3204                        (true, true) => slack / 2.0,
3205                        (true, false) => slack,
3206                        (false, true) => 0.0,
3207                        (false, false) => 0.0,
3208                    }
3209                } else {
3210                    match align {
3211                        AlignItems::FlexStart => 0.0,
3212                        AlignItems::FlexEnd => {
3213                            line_height - item_height - item.style.margin.vertical()
3214                        }
3215                        AlignItems::Center => {
3216                            (line_height - item_height - item.style.margin.vertical()) / 2.0
3217                        }
3218                        AlignItems::Stretch => 0.0,
3219                        AlignItems::Baseline => match (line_baseline, baseline_ds[j]) {
3220                            (Some(bl), Some(d)) => bl - d,
3221                            _ => 0.0,
3222                        },
3223                    }
3224                };
3225
3226                // When stretch applies and item has no explicit height, pass
3227                // the cross-axis height so inner layout sees a fixed container.
3228                // Auto margins prevent stretch.
3229                let cross_h = if matches!(align, AlignItems::Stretch)
3230                    && matches!(item.style.height, SizeConstraint::Auto)
3231                    && !has_auto_v
3232                {
3233                    let stretch_h = line_height - item.style.margin.vertical();
3234                    if stretch_h > item_height {
3235                        Some(stretch_h)
3236                    } else {
3237                        None
3238                    }
3239                } else {
3240                    None
3241                };
3242
3243                let saved_y = cursor.y;
3244                cursor.y = row_start_y + y_offset;
3245
3246                self.layout_node(
3247                    item.node,
3248                    cursor,
3249                    pages,
3250                    x,
3251                    available_width,
3252                    parent_style,
3253                    font_context,
3254                    cross_h,
3255                    Some(fw),
3256                );
3257
3258                cursor.y = saved_y;
3259                x += fw;
3260            }
3261
3262            if line_items.len() > 1 && pages.len() > line_start_pages {
3263                let near = line_items
3264                    .iter()
3265                    .find_map(|it| first_text_snippet(it.node))
3266                    .map(|t| format!(" (row beginning \"{t}\")"))
3267                    .unwrap_or_default();
3268                self.defect(format!(
3269                    "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}"
3270                ));
3271            }
3272            cursor.y = row_start_y + line_height;
3273            line_infos.push((line_elem_start, cursor.elements.len(), line_height));
3274        }
3275
3276        // Apply align-content redistribution for wrapped flex lines
3277        if pages.len() == initial_pages_count && !line_infos.is_empty() {
3278            let align_content = parent_style.map(|s| s.align_content).unwrap_or_default();
3279            if !matches!(align_content, AlignContent::FlexStart)
3280                && !matches!(flex_wrap, FlexWrap::NoWrap)
3281            {
3282                if let Some(parent) = parent_style {
3283                    if let SizeConstraint::Fixed(container_h) = parent.height {
3284                        let inner_h = container_h
3285                            - parent.padding.vertical()
3286                            - parent.border_width.vertical();
3287                        let total_used = cursor.y - flex_start_y;
3288                        let slack = inner_h - total_used;
3289                        if slack > 0.0 {
3290                            let n = line_infos.len();
3291                            let offsets: Vec<f64> = match align_content {
3292                                AlignContent::FlexEnd => vec![slack; n],
3293                                AlignContent::Center => vec![slack / 2.0; n],
3294                                AlignContent::SpaceBetween => {
3295                                    if n <= 1 {
3296                                        vec![0.0; n]
3297                                    } else {
3298                                        let per_gap = slack / (n - 1) as f64;
3299                                        (0..n).map(|i| i as f64 * per_gap).collect()
3300                                    }
3301                                }
3302                                AlignContent::SpaceAround => {
3303                                    let space = slack / n as f64;
3304                                    (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
3305                                }
3306                                AlignContent::SpaceEvenly => {
3307                                    let space = slack / (n + 1) as f64;
3308                                    (0..n).map(|i| (i + 1) as f64 * space).collect()
3309                                }
3310                                AlignContent::Stretch => {
3311                                    let extra = slack / n as f64;
3312                                    (0..n).map(|i| i as f64 * extra).collect()
3313                                }
3314                                AlignContent::FlexStart => vec![0.0; n],
3315                            };
3316                            for (i, &(start, end, _)) in line_infos.iter().enumerate() {
3317                                let dy = offsets[i];
3318                                if dy.abs() > 0.001 {
3319                                    for j in start..end {
3320                                        offset_element_y(&mut cursor.elements[j], dy);
3321                                    }
3322                                }
3323                            }
3324                            cursor.y += *offsets.last().unwrap_or(&0.0);
3325                        }
3326                    }
3327                }
3328            }
3329        }
3330    }
3331
3332    // ─── Lists ─────────────────────────────────────────────────────
3333
3334    #[allow(clippy::too_many_arguments)]
3335    fn layout_list(
3336        &self,
3337        node: &Node,
3338        ordered: bool,
3339        marker_type: ListMarkerType,
3340        start: u32,
3341        style: &ResolvedStyle,
3342        cursor: &mut PageCursor,
3343        pages: &mut Vec<LayoutPage>,
3344        x: f64,
3345        available_width: f64,
3346        font_context: &FontContext,
3347    ) {
3348        let margin = &style.margin.to_edges();
3349        let padding = &style.padding;
3350
3351        cursor.y += margin.top;
3352
3353        let list_x = x + margin.left;
3354        let outer_width = available_width - margin.horizontal();
3355        let inner_width = outer_width - padding.horizontal();
3356
3357        // Count items so we can size the marker gutter for the widest
3358        // marker the list will produce (e.g. "12." needs more space than "1.")
3359        let n_items = node
3360            .children
3361            .iter()
3362            .filter(|c| matches!(c.kind, NodeKind::ListItem))
3363            .count() as u32;
3364
3365        let marker_gutter =
3366            compute_marker_gutter_width(ordered, marker_type, start, n_items, style);
3367
3368        let list_inner_x = list_x + padding.left;
3369        let content_x = list_inner_x + marker_gutter;
3370        let content_width = (inner_width - marker_gutter).max(0.0);
3371
3372        // Snapshot for wrapping the items in a single List container
3373        // element (so tagged-PDF picks up the /L role on the whole list).
3374        let snapshot = cursor.elements.len();
3375        let list_start_y = cursor.content_y + cursor.y;
3376        cursor.y += padding.top;
3377
3378        let mut item_index: u32 = 0;
3379        for child in &node.children {
3380            if !matches!(child.kind, NodeKind::ListItem) {
3381                continue;
3382            }
3383            let marker_idx = start + item_index;
3384            self.layout_list_item(
3385                child,
3386                marker_idx,
3387                ordered,
3388                marker_type,
3389                marker_gutter,
3390                style,
3391                cursor,
3392                pages,
3393                list_inner_x,
3394                content_x,
3395                content_width,
3396                font_context,
3397            );
3398            item_index += 1;
3399        }
3400
3401        cursor.y += padding.bottom;
3402
3403        // Wrap collected item elements in a List container
3404        let item_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3405        let list_height = cursor.content_y + cursor.y - list_start_y;
3406        cursor.elements.push(LayoutElement {
3407            x: list_x,
3408            y: list_start_y,
3409            width: outer_width,
3410            height: list_height,
3411            draw: DrawCommand::None,
3412            children: item_elements,
3413            node_type: Some("List".to_string()),
3414            resolved_style: Some(style.clone()),
3415            source_location: node.source_location.clone(),
3416            href: None,
3417            bookmark: node.bookmark.clone(),
3418            alt: None,
3419            is_header_row: false,
3420            // ISO 32000-2 ListNumbering value matching the declared marker.
3421            // markerType "none" draws no marker (no Lbl child), so the
3422            // attribute isn't required — see format_marker.
3423            actual_text: None,
3424            list_numbering: match marker_type {
3425                ListMarkerType::None => None,
3426                ListMarkerType::Disc => Some("Disc"),
3427                ListMarkerType::Circle => Some("Circle"),
3428                ListMarkerType::Square => Some("Square"),
3429                ListMarkerType::Decimal => Some("Decimal"),
3430                ListMarkerType::LowerAlpha => Some("LowerAlpha"),
3431                ListMarkerType::UpperAlpha => Some("UpperAlpha"),
3432                ListMarkerType::LowerRoman => Some("LowerRoman"),
3433                ListMarkerType::UpperRoman => Some("UpperRoman"),
3434            },
3435            col_span: 1,
3436            overflow: style.overflow,
3437            opacity: style.opacity,
3438        });
3439
3440        cursor.y += margin.bottom;
3441    }
3442
3443    #[allow(clippy::too_many_arguments)]
3444    fn layout_list_item(
3445        &self,
3446        item: &Node,
3447        marker_idx: u32,
3448        ordered: bool,
3449        marker_type: ListMarkerType,
3450        marker_gutter: f64,
3451        parent_style: &ResolvedStyle,
3452        cursor: &mut PageCursor,
3453        pages: &mut Vec<LayoutPage>,
3454        list_inner_x: f64,
3455        content_x: f64,
3456        content_width: f64,
3457        font_context: &FontContext,
3458    ) {
3459        let item_style = item.style.resolve(Some(parent_style), content_width);
3460        let item_margin = item_style.margin.to_edges();
3461
3462        cursor.y += item_margin.top;
3463        let item_start_y = cursor.content_y + cursor.y;
3464        let item_snapshot = cursor.elements.len();
3465
3466        // 1. Render the marker. Save cursor.y, lay out marker as a tiny
3467        //    Text node at list_inner_x with width = marker_gutter, then
3468        //    restore cursor.y so the content lays out at the same line.
3469        let marker_str = format_marker(marker_idx, ordered, marker_type);
3470        if !marker_str.is_empty() {
3471            let saved_y = cursor.y;
3472            self.layout_text(
3473                &marker_str,
3474                None,
3475                &[],
3476                &item_style,
3477                cursor,
3478                pages,
3479                list_inner_x,
3480                marker_gutter,
3481                font_context,
3482                None,
3483                None,
3484                Some("Lbl"),
3485            );
3486            cursor.y = saved_y;
3487        }
3488
3489        // 2. Lay out item children at content_x using the standard
3490        //    layout_children path. Wrapping inside a long item naturally
3491        //    indents to content_x for every line because that's the x
3492        //    we hand to layout_children — no special hanging-indent
3493        //    logic required, since the marker is a separate element.
3494        self.layout_children(
3495            &item.children,
3496            &item.style,
3497            cursor,
3498            pages,
3499            content_x,
3500            content_width,
3501            Some(&item_style),
3502            font_context,
3503        );
3504
3505        // 3. Wrap marker + content in a ListItem container element
3506        //    (tagged PDF picks up /LI from the node_type).
3507        let item_children: Vec<LayoutElement> = cursor.elements.drain(item_snapshot..).collect();
3508        let item_height = cursor.content_y + cursor.y - item_start_y;
3509        let item_width = content_x + content_width - list_inner_x;
3510        cursor.elements.push(LayoutElement {
3511            x: list_inner_x,
3512            y: item_start_y,
3513            width: item_width,
3514            height: item_height,
3515            draw: DrawCommand::None,
3516            children: item_children,
3517            node_type: Some("ListItem".to_string()),
3518            resolved_style: Some(item_style.clone()),
3519            source_location: item.source_location.clone(),
3520            href: None,
3521            bookmark: item.bookmark.clone(),
3522            alt: None,
3523            is_header_row: false,
3524            actual_text: None,
3525            list_numbering: None,
3526            col_span: 1,
3527            overflow: item_style.overflow,
3528            opacity: item_style.opacity,
3529        });
3530
3531        cursor.y += item_margin.bottom;
3532    }
3533
3534    #[allow(clippy::too_many_arguments)]
3535    fn layout_table(
3536        &self,
3537        node: &Node,
3538        style: &ResolvedStyle,
3539        column_defs: &[ColumnDef],
3540        cursor: &mut PageCursor,
3541        pages: &mut Vec<LayoutPage>,
3542        x: f64,
3543        available_width: f64,
3544        font_context: &FontContext,
3545    ) {
3546        let padding = &style.padding;
3547        let margin = &style.margin.to_edges();
3548        let border = &style.border_width;
3549
3550        let table_x = x + margin.left;
3551        let table_width = match style.width {
3552            SizeConstraint::Fixed(w) => w,
3553            SizeConstraint::Auto => available_width - margin.horizontal(),
3554        };
3555        let inner_width = table_width - padding.horizontal() - border.horizontal();
3556
3557        let col_widths = self.resolve_column_widths(
3558            column_defs,
3559            inner_width,
3560            &node.children,
3561            style,
3562            font_context,
3563        );
3564
3565        // Column assignments for every row (colspan + rowspan occupancy),
3566        // computed once over the authored row order and carried alongside
3567        // each row through partitioning.
3568        let all_offsets = Self::table_column_offsets(&node.children);
3569        let mut header_rows: Vec<(&Node, &[usize])> = Vec::new();
3570        let mut body_rows: Vec<(&Node, &[usize])> = Vec::new();
3571
3572        for (child, offs) in node.children.iter().zip(&all_offsets) {
3573            match &child.kind {
3574                NodeKind::TableRow { is_header: true } => header_rows.push((child, offs)),
3575                _ => body_rows.push((child, offs)),
3576            }
3577        }
3578
3579        // break-inside: avoid (wrap: false). Row-by-row pagination below
3580        // ignores breakability, so an unbreakable table that doesn't fit
3581        // must move to a fresh page here — whole — before any row lands.
3582        // A table taller than a full page falls through to normal
3583        // pagination: breaking is unavoidable and splitting beats clipping.
3584        if !style.breakable {
3585            let total_height: f64 = node
3586                .children
3587                .iter()
3588                .zip(&all_offsets)
3589                .map(|(r, o)| self.measure_table_row_height(r, &col_widths, o, style, font_context))
3590                .sum::<f64>()
3591                + padding.vertical()
3592                + border.vertical();
3593            let fresh_page_available = cursor.content_height
3594                - cursor.fixed_header.iter().map(|(_, h)| *h).sum::<f64>()
3595                - cursor.fixed_footer.iter().map(|(_, h)| *h).sum::<f64>();
3596            if total_height > cursor.remaining_height()
3597                && total_height <= fresh_page_available
3598                && cursor.y > 0.0
3599            {
3600                pages.push(cursor.finalize());
3601                *cursor = cursor.new_page();
3602            }
3603        }
3604
3605        // Snapshot-and-collect state for the Table wrapper element (same
3606        // clone-semantics fragment wrapping as layout_breakable_view). Two
3607        // consumers need a real Table container: table-level border and
3608        // background have no paint target without one, and structural
3609        // consumers (tagged PDF /Table, pdf-testkit's extractor) otherwise
3610        // have to synthesize the table from loose rows.
3611        let initial_page_count = pages.len();
3612        let snapshot = cursor.elements.len();
3613        let rect_start_y = cursor.content_y + cursor.y + margin.top;
3614
3615        cursor.y += margin.top + padding.top + border.top;
3616
3617        let cell_x_start = table_x + padding.left + border.left;
3618
3619        // Initial-header pre-fit check. Covers three related symptoms:
3620        //
3621        //   * Original issue 4 ("doubled, sliding column"): table starts low
3622        //     enough that the header didn't fit. Each header cell's inner
3623        //     content triggered a widow/orphan page-break via layout_text,
3624        //     and layout_table_row's cell-overflow path committed those
3625        //     breaks as spurious "trial" pages.
3626        //   * Orphan header: header fits in remaining space but the first
3627        //     body row doesn't, so the header gets drawn at the bottom of
3628        //     the current page with no rows beneath it, then redrawn on
3629        //     the next page above the actual rows.
3630        //   * Long-token header (issue 2 reproduction): a single header
3631        //     cell wraps to many lines because of a no-break-opportunity
3632        //     token. Even though the pre-check would fire on header height
3633        //     alone, including the first body row makes the fit decision
3634        //     symmetric with body-row checks below and avoids edge cases
3635        //     where rounding leaves the header just barely fitting while
3636        //     no body row will ever land on the same page.
3637        //
3638        // Fold the first body row into the fit calculation so we never
3639        // leave an orphan header behind. Cap at fresh-page available
3640        // height: if the combined block is genuinely taller than a page,
3641        // page-breaking can't help — fall through and let the
3642        // `!is_header` cell-overflow guard in layout_table_row handle it.
3643        if !header_rows.is_empty() {
3644            let total_header_h: f64 = header_rows
3645                .iter()
3646                .map(|(r, o)| self.measure_table_row_height(r, &col_widths, o, style, font_context))
3647                .sum();
3648            let first_body_h = body_rows
3649                .first()
3650                .map(|(r, o)| self.measure_table_row_height(r, &col_widths, o, style, font_context))
3651                .unwrap_or(0.0);
3652
3653            let needed = total_header_h + first_body_h;
3654            let fresh_page_available = cursor.content_height
3655                - cursor.fixed_header.iter().map(|(_, h)| *h).sum::<f64>()
3656                - cursor.fixed_footer.iter().map(|(_, h)| *h).sum::<f64>();
3657
3658            if needed > cursor.remaining_height() && needed <= fresh_page_available {
3659                pages.push(cursor.finalize());
3660                *cursor = cursor.new_page();
3661                cursor.y += padding.top + border.top;
3662            }
3663        }
3664
3665        for (header_row, offs) in &header_rows {
3666            self.layout_table_row(
3667                header_row,
3668                &col_widths,
3669                offs,
3670                style,
3671                cursor,
3672                cell_x_start,
3673                font_context,
3674                pages,
3675            );
3676        }
3677
3678        for (body_row, offs) in &body_rows {
3679            let row_height =
3680                self.measure_table_row_height(body_row, &col_widths, offs, style, font_context);
3681
3682            // Break only when a fresh page actually buys room. A row taller
3683            // than any page (the email-template idiom: everything in one
3684            // <tr>) used to force a break even at the top of an empty page,
3685            // emitting blank pages before itself (template-compat 11). Row
3686            // atomicity stands — the row is placed whole and overflows — but
3687            // that's a render defect worth saying out loud, not a reason to
3688            // print empty pages.
3689            let fresh_page_available = cursor.content_height
3690                - cursor.fixed_header.iter().map(|(_, h)| *h).sum::<f64>()
3691                - cursor.fixed_footer.iter().map(|(_, h)| *h).sum::<f64>();
3692            if row_height > fresh_page_available {
3693                self.defect(format!(
3694                    "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",
3695                ));
3696            }
3697            if row_height > cursor.remaining_height()
3698                && cursor.remaining_height() < fresh_page_available - 0.5
3699            {
3700                pages.push(cursor.finalize());
3701                *cursor = cursor.new_page();
3702
3703                cursor.y += padding.top + border.top;
3704                for (header_row, h_offs) in &header_rows {
3705                    self.layout_table_row(
3706                        header_row,
3707                        &col_widths,
3708                        h_offs,
3709                        style,
3710                        cursor,
3711                        cell_x_start,
3712                        font_context,
3713                        pages,
3714                    );
3715                }
3716            }
3717
3718            self.layout_table_row(
3719                body_row,
3720                &col_widths,
3721                offs,
3722                style,
3723                cursor,
3724                cell_x_start,
3725                font_context,
3726                pages,
3727            );
3728        }
3729
3730        // Wrap the laid-out rows in a Table container element. Always
3731        // emitted (structural consumers need it even without visuals); the
3732        // draw command is a Rect only when there's something to paint.
3733        let has_visual = style.background_color.is_some()
3734            || style.background.is_some()
3735            || style.border_width.top > 0.0
3736            || style.border_width.right > 0.0
3737            || style.border_width.bottom > 0.0
3738            || style.border_width.left > 0.0;
3739        let draw_cmd = if has_visual {
3740            DrawCommand::Rect {
3741                background: style.background_color,
3742                border_width: style.border_width,
3743                border_color: style.border_color,
3744                border_style: style.border_style,
3745                border_radius: style.border_radius,
3746                opacity: 1.0,
3747                box_shadow: style.box_shadow.map(Box::new),
3748                background_gradient: style.background.clone().map(Box::new),
3749            }
3750        } else {
3751            DrawCommand::None
3752        };
3753        let make_wrapper =
3754            |y: f64, height: f64, children: Vec<LayoutElement>, draw| LayoutElement {
3755                x: table_x,
3756                y,
3757                width: table_width,
3758                height,
3759                draw,
3760                children,
3761                node_type: Some(node_kind_name(&node.kind).to_string()),
3762                resolved_style: Some(style.clone()),
3763                source_location: node.source_location.clone(),
3764                href: node.href.clone(),
3765                bookmark: None,
3766                alt: None,
3767                is_header_row: false,
3768                actual_text: None,
3769                list_numbering: None,
3770                col_span: 1,
3771                overflow: Overflow::default(),
3772                opacity: style.opacity,
3773            };
3774
3775        let table_bottom_y = cursor.content_y + cursor.y + padding.bottom + border.bottom;
3776
3777        if pages.len() == initial_page_count {
3778            // No page breaks: simple wrap.
3779            let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3780            cursor.elements.push(make_wrapper(
3781                rect_start_y,
3782                table_bottom_y - rect_start_y,
3783                child_elements,
3784                draw_cmd,
3785            ));
3786        } else {
3787            // Page breaks occurred: clone-semantics fragment per page,
3788            // mirroring layout_breakable_view.
3789            //
3790            // STREAMING-LAYOUT NOTE (investigated 2026-09, parked — see
3791            // scripts/parity/benchmarks.mjs trackedFixes "Streaming layout"):
3792            // this retroactive reach-back into `pages[initial_page_count..]` is
3793            // THE reason large-doc peak memory can't be streamed away. A
3794            // document-spanning table (e.g. ledger-500p: one table, 500 pages)
3795            // holds every page it covered resident until it closes here at
3796            // end-of-document, so streaming the producer/serializer saves
3797            // nothing. The fix is to emit each page's wrapper FORWARD at page
3798            // finalize (open-container stack on the cursor: record draw_cmd +
3799            // per-page start-y; wrap the finalizing page from start-y; reset
3800            // start-y to content_top on new_page). Byte-identical (each wrapper
3801            // below uses only its own page's geometry), but it must cover all
3802            // four container types (table, breakable_view, flex, paragraph).
3803
3804            // A. The page the table started on — wrap from the snapshot.
3805            let page = &mut pages[initial_page_count];
3806            let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
3807            let page_content_bottom =
3808                page.config.margin.top + (page.height - page.config.margin.vertical()) - footer_h;
3809            let our_elements: Vec<LayoutElement> = page.elements.drain(snapshot..).collect();
3810            if !our_elements.is_empty() {
3811                page.elements.push(make_wrapper(
3812                    rect_start_y,
3813                    page_content_bottom - rect_start_y,
3814                    our_elements,
3815                    draw_cmd.clone(),
3816                ));
3817            }
3818
3819            // B. Intermediate pages — entirely table content.
3820            for page in &mut pages[initial_page_count + 1..] {
3821                let header_h: f64 = page.fixed_header.iter().map(|(_, h)| *h).sum();
3822                let content_top = page.config.margin.top + header_h;
3823                let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
3824                let content_bottom = page.config.margin.top
3825                    + (page.height - page.config.margin.vertical())
3826                    - footer_h;
3827                let all_elements: Vec<LayoutElement> = std::mem::take(&mut page.elements);
3828                if !all_elements.is_empty() {
3829                    page.elements.push(make_wrapper(
3830                        content_top,
3831                        content_bottom - content_top,
3832                        all_elements,
3833                        draw_cmd.clone(),
3834                    ));
3835                }
3836            }
3837
3838            // C. Current page — everything on it is table content.
3839            let all_elements: Vec<LayoutElement> = std::mem::take(&mut cursor.elements);
3840            if !all_elements.is_empty() {
3841                let header_h: f64 = cursor.fixed_header.iter().map(|(_, h)| *h).sum();
3842                let content_top = cursor.content_y + header_h;
3843                cursor.elements.push(make_wrapper(
3844                    content_top,
3845                    table_bottom_y - content_top,
3846                    all_elements,
3847                    draw_cmd,
3848                ));
3849            }
3850        }
3851
3852        cursor.y += padding.bottom + border.bottom + margin.bottom;
3853    }
3854
3855    /// True if any node in this subtree can *force* a page break during flow
3856    /// layout: an explicit `PageBreak` or `PageName` marker, or a node with
3857    /// `break-before` set. Height-overflow breaks are NOT covered here — those
3858    /// are bounded separately by the caller's row-fits check. Used to decide
3859    /// whether a table row needs a per-cell rollback checkpoint (Fix 3-B): a
3860    /// row that fits and forces no break cannot break any cell, so its
3861    /// checkpoints are dead. Conservative — a new forced-break source not
3862    /// listed here would be missed, which the `row_may_break` invariant assert
3863    /// and the byte-identity corpus are positioned to catch.
3864    fn subtree_forces_break(node: &Node) -> bool {
3865        if matches!(node.kind, NodeKind::PageBreak | NodeKind::PageName { .. }) {
3866            return true;
3867        }
3868        if node.style.break_before == Some(true) {
3869            return true;
3870        }
3871        node.children.iter().any(Self::subtree_forces_break)
3872    }
3873
3874    /// Occupancy-aware column count: the widest row's last assigned column
3875    /// plus its span — includes columns carried by rowspans, so the DEFS
3876    /// path and the automatic path agree with layout's assignments (the
3877    /// defs path counting with a plain colspan sum starved template-compat
3878    /// 05's value column to zero width).
3879    fn occupancy_column_count(children: &[Node]) -> usize {
3880        let offsets = Self::table_column_offsets(children);
3881        children
3882            .iter()
3883            .zip(&offsets)
3884            .map(|(row, offs)| {
3885                row.children
3886                    .iter()
3887                    .zip(offs)
3888                    .map(|(cell, &start)| start + Self::cell_col_span(cell))
3889                    .max()
3890                    .unwrap_or(0)
3891            })
3892            .max()
3893            .unwrap_or(1)
3894            .max(1)
3895    }
3896
3897    /// Per-row, per-cell starting column for a table's rows, honoring BOTH
3898    /// colspan advancement and ROWSPAN OCCUPANCY: a cell with rowspan=N
3899    /// keeps its columns occupied for the following N-1 rows, so those
3900    /// rows' cells start past it. Without this, the Anvil idiom — a
3901    /// rowspan'd name cell beside per-row address lines — assigned the
3902    /// address lines to column 1 and right-aligned them mid-page
3903    /// (template-compat 02). Pure function of the node tree, so every
3904    /// consumer (layout, row measurement, column-content distribution,
3905    /// column counting) derives identical assignments.
3906    fn table_column_offsets(rows: &[Node]) -> Vec<Vec<usize>> {
3907        fn spans(cell: &Node) -> (usize, u32) {
3908            match &cell.kind {
3909                NodeKind::TableCell { col_span, row_span } => {
3910                    ((*col_span).max(1) as usize, (*row_span).max(1))
3911                }
3912                _ => (1, 1),
3913            }
3914        }
3915        let mut pending: Vec<u32> = Vec::new();
3916        let mut out = Vec::with_capacity(rows.len());
3917        for row in rows {
3918            let mut offsets = Vec::with_capacity(row.children.len());
3919            let mut col = 0usize;
3920            for cell in &row.children {
3921                let (span, rspan) = spans(cell);
3922                while pending.get(col).copied().unwrap_or(0) > 0 {
3923                    col += 1;
3924                }
3925                offsets.push(col);
3926                if rspan > 1 {
3927                    if pending.len() < col + span {
3928                        pending.resize(col + span, 0);
3929                    }
3930                    for slot in pending.iter_mut().take(col + span).skip(col) {
3931                        *slot = (*slot).max(rspan);
3932                    }
3933                }
3934                col += span;
3935            }
3936            out.push(offsets);
3937            for p in pending.iter_mut() {
3938                *p = p.saturating_sub(1);
3939            }
3940        }
3941        out
3942    }
3943
3944    #[allow(clippy::too_many_arguments)]
3945    fn layout_table_row(
3946        &self,
3947        row: &Node,
3948        col_widths: &[f64],
3949        col_offsets: &[usize],
3950        parent_style: &ResolvedStyle,
3951        cursor: &mut PageCursor,
3952        start_x: f64,
3953        font_context: &FontContext,
3954        pages: &mut Vec<LayoutPage>,
3955    ) {
3956        let row_style = row
3957            .style
3958            .resolve(Some(parent_style), col_widths.iter().sum());
3959
3960        let row_height =
3961            self.measure_table_row_height(row, col_widths, col_offsets, parent_style, font_context);
3962        let row_bl = self.row_baseline(row, &row_style, col_widths, col_offsets, font_context);
3963        let row_y = cursor.content_y + cursor.y;
3964        let total_width: f64 = col_widths.iter().sum();
3965
3966        let is_header = matches!(row.kind, NodeKind::TableRow { is_header: true });
3967
3968        // Snapshot before laying out cells — we'll collect them as row children
3969        let row_snapshot = cursor.elements.len();
3970
3971        // Rollback-checkpoint elision (Fix 3-B). Each cell below snapshots the
3972        // full cursor (`cursor.clone()`, which deep-copies every element on the
3973        // page so far) to restore if the cell's content triggers a page break.
3974        // dhat flagged that single clone as ~76% of all allocated bytes. A cell
3975        // can only break for two reasons: its content overflows the remaining
3976        // page height, or a forced break fires inside it. If the WHOLE row fits
3977        // in the remaining height (`row_height` bounds every cell's content) AND
3978        // the row subtree contains no forced break, no cell can break — so the
3979        // checkpoint is dead and we skip cloning it. Evaluated once here, at the
3980        // row's top `y`, before the loop advances the cursor; conservative by
3981        // construction (any doubt ⇒ clone), so output stays byte-identical.
3982        let row_may_break =
3983            row_height > cursor.remaining_height() || Self::subtree_forces_break(row);
3984
3985        let mut all_overflow_pages: Vec<LayoutPage> = Vec::new();
3986        // Column assignment comes from table_column_offsets (colspan
3987        // advancement + rowspan occupancy); x and width derive from the
3988        // assigned column, never from cell position.
3989        for (cell_i, cell) in row.children.iter().enumerate() {
3990            let span = match &cell.kind {
3991                NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
3992                _ => 1,
3993            };
3994            let start_col = col_offsets.get(cell_i).copied().unwrap_or(0);
3995            let col_width: f64 = col_widths.iter().skip(start_col).take(span).copied().sum();
3996            let cell_x = start_x + col_widths.iter().take(start_col).copied().sum::<f64>();
3997
3998            let cell_style = cell.style.resolve(Some(&row_style), col_width);
3999
4000            // Snapshot before cell content — we'll collect as cell children
4001            let cell_snapshot = cursor.elements.len();
4002
4003            let inner_width =
4004                col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
4005
4006            let content_x = cell_x + cell_style.padding.left + cell_style.border_width.left;
4007            let saved_y = cursor.y;
4008            cursor.y += cell_style.padding.top + cell_style.border_width.top;
4009
4010            // vertical-align: middle/bottom/baseline — the row box height is
4011            // already resolved (measured above the loop), so offset this cell's
4012            // content within it. Top is the default and costs nothing.
4013            if !matches!(cell_style.vertical_align, crate::style::VerticalAlign::Top) {
4014                let content_h: f64 = cell
4015                    .children
4016                    .iter()
4017                    .map(|ch| {
4018                        let ch_style = ch.style.resolve(Some(&cell_style), inner_width);
4019                        self.measure_node_height(ch, inner_width, &ch_style, font_context)
4020                    })
4021                    .sum();
4022                let inner_row =
4023                    row_height - cell_style.padding.vertical() - cell_style.border_width.vertical();
4024                let slack = (inner_row - content_h).max(0.0);
4025                cursor.y += match cell_style.vertical_align {
4026                    crate::style::VerticalAlign::Middle => slack / 2.0,
4027                    crate::style::VerticalAlign::Bottom => slack,
4028                    // Shove this cell down so its first baseline lands on the
4029                    // row baseline (the max first-baseline distance across the
4030                    // row's baseline cells). measure_table_row_height grew the
4031                    // row to fit this, so it never clips.
4032                    crate::style::VerticalAlign::Baseline => row_bl
4033                        .map(|b| {
4034                            let d = self.cell_baseline_distance(
4035                                cell,
4036                                &cell_style,
4037                                inner_width,
4038                                font_context,
4039                            );
4040                            (b - d).max(0.0)
4041                        })
4042                        .unwrap_or(0.0),
4043                    crate::style::VerticalAlign::Top => 0.0,
4044                };
4045            }
4046
4047            // Save cursor state in case cell content triggers page breaks — but
4048            // only when a break is actually possible (see `row_may_break`). When
4049            // the row provably fits with no forced break, this clone is dead, so
4050            // we skip the deep copy of the page's element vec.
4051            let cursor_before_cell = if row_may_break {
4052                Some(cursor.clone())
4053            } else {
4054                None
4055            };
4056            let mut cell_pages: Vec<LayoutPage> = Vec::new();
4057            for child in &cell.children {
4058                self.layout_node(
4059                    child,
4060                    cursor,
4061                    &mut cell_pages,
4062                    content_x,
4063                    inner_width,
4064                    Some(&cell_style),
4065                    font_context,
4066                    None,
4067                    None,
4068                );
4069            }
4070
4071            // If cell content triggered page breaks, collect overflow and restore cursor
4072            if !cell_pages.is_empty() {
4073                let post_break_elements = std::mem::take(&mut cursor.elements);
4074                if let Some(last_page) = cell_pages.last_mut() {
4075                    last_page.elements.extend(post_break_elements);
4076                }
4077                // Belt-and-suspenders for issue 4: header rows are designed to
4078                // be re-emitted on each continuation page and must never
4079                // legitimately produce mid-row page breaks. If they somehow do
4080                // (e.g. a future regression that puts headers in a tight spot
4081                // again), drop the trial pages rather than committing them.
4082                if !is_header {
4083                    all_overflow_pages.extend(cell_pages);
4084                }
4085                // A break occurred, so the checkpoint MUST exist: `row_may_break`
4086                // is a conservative over-approximation of "a cell can break", so
4087                // any real break implies we took the clone. If this ever fires,
4088                // the fits/forced-break guard missed a break source — a bug to
4089                // fix in the guard, not to paper over.
4090                *cursor = cursor_before_cell
4091                    .expect("table cell broke but no rollback checkpoint was taken (row_may_break under-approximated)");
4092            }
4093
4094            cursor.y = saved_y;
4095
4096            // Collect cell content elements
4097            let cell_children: Vec<LayoutElement> =
4098                cursor.elements.drain(cell_snapshot..).collect();
4099
4100            // Always push a cell element (with or without visual styling) to preserve hierarchy
4101            cursor.elements.push(LayoutElement {
4102                x: cell_x,
4103                y: row_y,
4104                width: col_width,
4105                height: row_height,
4106                draw: if cell_style.background_color.is_some()
4107                    || cell_style.border_width.horizontal() > 0.0
4108                    || cell_style.border_width.vertical() > 0.0
4109                {
4110                    DrawCommand::Rect {
4111                        background: cell_style.background_color,
4112                        border_width: cell_style.border_width,
4113                        border_color: cell_style.border_color,
4114                        border_style: cell_style.border_style,
4115                        border_radius: cell_style.border_radius,
4116                        opacity: 1.0,
4117                        box_shadow: cell_style.box_shadow.map(Box::new),
4118                        background_gradient: cell_style.background.clone().map(Box::new),
4119                    }
4120                } else {
4121                    DrawCommand::None
4122                },
4123                children: cell_children,
4124                node_type: Some("TableCell".to_string()),
4125                resolved_style: Some(cell_style.clone()),
4126                source_location: cell.source_location.clone(),
4127                href: None,
4128                bookmark: cell.bookmark.clone(),
4129                alt: None,
4130                is_header_row: is_header,
4131                actual_text: None,
4132                list_numbering: None,
4133                col_span: span as u32,
4134                overflow: Overflow::default(),
4135                opacity: 1.0,
4136            });
4137        }
4138
4139        // Collect all cell elements as row children
4140        let row_children: Vec<LayoutElement> = cursor.elements.drain(row_snapshot..).collect();
4141        let row_element = LayoutElement {
4142            x: start_x,
4143            y: row_y,
4144            width: total_width,
4145            height: row_height,
4146            draw: if let Some(bg) = row_style.background_color {
4147                DrawCommand::Rect {
4148                    background: Some(bg),
4149                    border_width: Edges::default(),
4150                    border_color: EdgeValues::uniform(Color::BLACK),
4151                    border_style: EdgeValues::uniform(crate::style::BorderStyle::Solid),
4152                    border_radius: CornerValues::uniform(0.0),
4153                    opacity: 1.0,
4154                    box_shadow: row_style.box_shadow.map(Box::new),
4155                    background_gradient: row_style.background.clone().map(Box::new),
4156                }
4157            } else {
4158                DrawCommand::None
4159            },
4160            children: row_children,
4161            node_type: Some("TableRow".to_string()),
4162            resolved_style: Some(row_style.clone()),
4163            source_location: row.source_location.clone(),
4164            href: None,
4165            bookmark: row.bookmark.clone(),
4166            alt: None,
4167            is_header_row: is_header,
4168            actual_text: None,
4169            list_numbering: None,
4170            col_span: 1,
4171            overflow: row_style.overflow,
4172            opacity: row_style.opacity,
4173        };
4174
4175        if let Some(first_overflow) = all_overflow_pages.first_mut() {
4176            // The row's content lives in the overflow pages (cell content
4177            // that exceeded the page split there and the cursor was rolled
4178            // back) — so the wrapper belongs on the FIRST of them, where the
4179            // row visually starts. Pushing it onto the restored cursor page
4180            // instead, and advancing the cursor by the full row height, used
4181            // to strand a container-only (visually blank) trailing page and
4182            // push everything after the table off-page (template-compat 11's
4183            // empty pages). The restored page stays at the row's start y for
4184            // whatever follows the table.
4185            first_overflow.elements.push(row_element);
4186        } else {
4187            cursor.elements.push(row_element);
4188            cursor.y += row_height;
4189        }
4190
4191        // Append any overflow pages from cells that exceeded page height
4192        pages.extend(all_overflow_pages);
4193    }
4194
4195    #[allow(clippy::too_many_arguments)]
4196    #[allow(clippy::too_many_arguments)]
4197    fn layout_text(
4198        &self,
4199        content: &str,
4200        href: Option<&str>,
4201        runs: &[TextRun],
4202        style: &ResolvedStyle,
4203        cursor: &mut PageCursor,
4204        pages: &mut Vec<LayoutPage>,
4205        x: f64,
4206        available_width: f64,
4207        font_context: &FontContext,
4208        source_location: Option<&SourceLocation>,
4209        bookmark: Option<&str>,
4210        // Optional node_type label for the wrapping Text element. Defaults
4211        // to "Text". Headings pass "H1".."H6" so tagged-PDF picks up the
4212        // semantic role; everything else passes None.
4213        node_type_override: Option<&str>,
4214    ) {
4215        // Text nodes paint glyphs and decorations only — borders and
4216        // backgrounds render exclusively on container views. A style that
4217        // asks for one here is silently unpaintable, which is exactly the
4218        // defect channel's question (found live: @page margin-box styles
4219        // landing on the band's text node dropped the running header's
4220        // rule without a word).
4221        if style.border_width.top > 0.0
4222            || style.border_width.right > 0.0
4223            || style.border_width.bottom > 0.0
4224            || style.border_width.left > 0.0
4225        {
4226            self.defect(
4227                "render defect: a border on a text node is not painted (wrap the text in a container element)".to_string(),
4228            );
4229        }
4230        if style.background_color.is_some() {
4231            self.defect(
4232                "render defect: a background on a text node is not painted (wrap the text in a container element)".to_string(),
4233            );
4234        }
4235        let margin = &style.margin.to_edges();
4236        let text_x = x + margin.left;
4237        // Honor an explicit/resolved fixed width for the text box; only fall back
4238        // to available_width when width is Auto. In a flex row, available_width is
4239        // the parent row's content width (used for percentage resolution) while the
4240        // child's own distributed width arrives via style.width — see layout_node's
4241        // forced_outer_width. layout_view already works this way; this keeps leaf
4242        // text consistent so textAlign/justify use the real box, not the row width.
4243        let text_width = match style.width {
4244            SizeConstraint::Fixed(w) => (w - margin.horizontal()).max(0.0),
4245            SizeConstraint::Auto => available_width - margin.horizontal(),
4246        };
4247
4248        cursor.y += margin.top;
4249
4250        // Runs path: if runs are provided, use multi-style line breaking
4251        if !runs.is_empty() {
4252            self.layout_text_runs(
4253                runs,
4254                href,
4255                style,
4256                cursor,
4257                pages,
4258                text_x,
4259                text_width,
4260                font_context,
4261                source_location,
4262                bookmark,
4263                node_type_override,
4264            );
4265            cursor.y += margin.bottom;
4266            return;
4267        }
4268
4269        let content = substitute_page_placeholders(content);
4270        let transformed = apply_text_transform(&content, style.text_transform);
4271        let justify = matches!(style.text_align, TextAlign::Justify);
4272        let lines = match style.line_breaking {
4273            LineBreaking::Optimal => self.text_layout.break_into_lines_optimal(
4274                font_context,
4275                &transformed,
4276                text_width,
4277                style.font_size,
4278                &style.font_family,
4279                style.font_weight,
4280                style.font_style,
4281                style.letter_spacing,
4282                style.hyphens,
4283                style.lang.as_deref(),
4284                justify,
4285            ),
4286            LineBreaking::Greedy => self.text_layout.break_into_lines(
4287                font_context,
4288                &transformed,
4289                text_width,
4290                style.font_size,
4291                &style.font_family,
4292                style.font_weight,
4293                style.font_style,
4294                style.letter_spacing,
4295                style.hyphens,
4296                style.lang.as_deref(),
4297            ),
4298        };
4299
4300        // Apply text overflow truncation (single-line modes)
4301        let lines = match style.text_overflow {
4302            TextOverflow::Ellipsis => self.text_layout.truncate_with_ellipsis(
4303                font_context,
4304                lines,
4305                text_width,
4306                style.font_size,
4307                &style.font_family,
4308                style.font_weight,
4309                style.font_style,
4310                style.letter_spacing,
4311            ),
4312            TextOverflow::Clip => self.text_layout.truncate_clip(
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::Wrap => lines,
4323        };
4324
4325        let line_height = style.font_size * style.line_height;
4326
4327        // Widow/orphan control: decide how to break before placing lines
4328        let line_heights: Vec<f64> = vec![line_height; lines.len()];
4329        let decision = page_break::decide_break(
4330            cursor.remaining_height(),
4331            &line_heights,
4332            true,
4333            style.min_orphan_lines as usize,
4334            style.min_widow_lines as usize,
4335        );
4336
4337        // Snapshot-and-collect: accumulate line elements, wrap in parent
4338        let mut snapshot = cursor.elements.len();
4339        let mut container_start_y = cursor.content_y + cursor.y;
4340        let mut is_first_element = true;
4341
4342        // Handle move-to-next-page decision (orphan control)
4343        if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
4344            pages.push(cursor.finalize());
4345            *cursor = cursor.new_page();
4346            snapshot = cursor.elements.len();
4347            container_start_y = cursor.content_y + cursor.y;
4348        }
4349
4350        // For split decisions, track the widow/orphan-adjusted first break point
4351        let forced_break_at = match decision {
4352            page_break::BreakDecision::Split {
4353                items_on_current_page,
4354            } => Some(items_on_current_page),
4355            _ => None,
4356        };
4357        let mut first_break_done = false;
4358
4359        for (line_idx, line) in lines.iter().enumerate() {
4360            // Widow/orphan-controlled first break, then normal overflow checks
4361            let needs_break = if let Some(break_at) = forced_break_at {
4362                if !first_break_done && line_idx == break_at {
4363                    true
4364                } else {
4365                    line_height > cursor.remaining_height()
4366                }
4367            } else {
4368                line_height > cursor.remaining_height()
4369            };
4370
4371            if needs_break {
4372                first_break_done = true;
4373                // Flush accumulated lines into a Text container on this page
4374                let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4375                if !line_elements.is_empty() {
4376                    let container_height = cursor.content_y + cursor.y - container_start_y;
4377                    cursor.elements.push(LayoutElement {
4378                        x: text_x,
4379                        y: container_start_y,
4380                        width: text_width,
4381                        height: container_height,
4382                        draw: DrawCommand::None,
4383                        children: line_elements,
4384                        node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4385                        resolved_style: Some(style.clone()),
4386                        source_location: source_location.cloned(),
4387                        href: href.map(|s| s.to_string()),
4388                        bookmark: if is_first_element {
4389                            bookmark.map(|s| s.to_string())
4390                        } else {
4391                            None
4392                        },
4393                        alt: None,
4394                        is_header_row: false,
4395                        actual_text: None,
4396                        list_numbering: None,
4397                        col_span: 1,
4398                        overflow: Overflow::default(),
4399                        opacity: 1.0,
4400                    });
4401                    is_first_element = false;
4402                }
4403
4404                pages.push(cursor.finalize());
4405                *cursor = cursor.new_page();
4406
4407                // Reset snapshot for new page
4408                snapshot = cursor.elements.len();
4409                container_start_y = cursor.content_y + cursor.y;
4410            }
4411
4412            let glyphs = self.build_positioned_glyphs_single_style(line, style, href, font_context);
4413
4414            // Use actual rendered width from glyphs for alignment (may differ from
4415            // line.width when per-char measurement is used for line breaking but
4416            // shaping is used for glyph placement).
4417            let rendered_width = if glyphs.is_empty() {
4418                line.width
4419            } else {
4420                let last = &glyphs[glyphs.len() - 1];
4421                (last.x_offset + last.x_advance).max(line.width * 0.5)
4422            };
4423
4424            let line_x = match style.text_align {
4425                TextAlign::Left => text_x,
4426                TextAlign::Right => text_x + text_width - rendered_width,
4427                TextAlign::Center => text_x + (text_width - rendered_width) / 2.0,
4428                TextAlign::Justify => text_x,
4429            };
4430
4431            // Justify: compute extra word spacing so the line fills the column width.
4432            // Use the sum of natural glyph advances (what PDF Tj actually renders)
4433            // rather than KP-adjusted positions, which bake justification into
4434            // char_positions and make slack ≈ 0.
4435            //
4436            // User-set `word_spacing` is the base; when text is justified, the
4437            // computed slack-per-space is added on top.
4438            let is_last_line = line_idx == lines.len() - 1;
4439            let user_ws = style.word_spacing;
4440            let (justified_width, word_spacing) =
4441                if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
4442                    let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
4443                    let (natural_width, space_count) = if let Some(idx) = last_non_space {
4444                        let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
4445                        let s = glyphs[..=idx]
4446                            .iter()
4447                            .filter(|g| g.char_value == ' ')
4448                            .count();
4449                        (w, s)
4450                    } else {
4451                        (0.0, 0)
4452                    };
4453                    let slack = text_width - natural_width;
4454                    let ws = if space_count > 0 && slack.abs() > 0.01 {
4455                        slack / space_count as f64
4456                    } else {
4457                        0.0
4458                    };
4459                    (text_width, user_ws + ws)
4460                } else {
4461                    (rendered_width, user_ws)
4462                };
4463
4464            let text_line = TextLine {
4465                x: line_x,
4466                // Half-leading: the line box's extra space over the glyph block
4467                // splits evenly above and below (CSS line box model). The
4468                // baseline therefore sits half the leading further down —
4469                // this is also what makes the pre-flexbox centering idiom
4470                // (line-height matched to a box height) actually center.
4471                y: cursor.content_y
4472                    + cursor.y
4473                    + baseline_in_line(
4474                        line_height,
4475                        style.font_size,
4476                        font_context.baseline_metrics(
4477                            &style.font_family,
4478                            style.font_weight,
4479                            matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique),
4480                        ),
4481                    ),
4482                glyphs,
4483                width: justified_width,
4484                height: line_height,
4485                word_spacing,
4486            };
4487
4488            cursor.elements.push(LayoutElement {
4489                x: line_x,
4490                y: cursor.content_y + cursor.y,
4491                width: justified_width,
4492                height: line_height,
4493                draw: DrawCommand::Text {
4494                    lines: vec![text_line],
4495                    color: style.color,
4496                    text_decoration: style.text_decoration,
4497                    opacity: 1.0,
4498                },
4499                children: vec![],
4500                node_type: Some("TextLine".to_string()),
4501                resolved_style: Some(style.clone()),
4502                source_location: None,
4503                href: href.map(|s| s.to_string()),
4504                bookmark: None,
4505                alt: None,
4506                is_header_row: false,
4507                actual_text: None,
4508                list_numbering: None,
4509                col_span: 1,
4510                overflow: Overflow::default(),
4511                opacity: 1.0,
4512            });
4513
4514            cursor.y += line_height;
4515        }
4516
4517        // Wrap remaining lines into a Text container
4518        let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4519        if !line_elements.is_empty() {
4520            let container_height = cursor.content_y + cursor.y - container_start_y;
4521            cursor.elements.push(LayoutElement {
4522                x: text_x,
4523                y: container_start_y,
4524                width: text_width,
4525                height: container_height,
4526                draw: DrawCommand::None,
4527                children: line_elements,
4528                node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4529                resolved_style: Some(style.clone()),
4530                source_location: source_location.cloned(),
4531                href: href.map(|s| s.to_string()),
4532                bookmark: if is_first_element {
4533                    bookmark.map(|s| s.to_string())
4534                } else {
4535                    None
4536                },
4537                alt: None,
4538                is_header_row: false,
4539                actual_text: None,
4540                list_numbering: None,
4541                col_span: 1,
4542                overflow: Overflow::default(),
4543                opacity: 1.0,
4544            });
4545        }
4546
4547        cursor.y += margin.bottom;
4548    }
4549
4550    /// Layout text runs with per-run styling.
4551    #[allow(clippy::too_many_arguments)]
4552    #[allow(clippy::too_many_arguments)]
4553    fn layout_text_runs(
4554        &self,
4555        runs: &[TextRun],
4556        parent_href: Option<&str>,
4557        style: &ResolvedStyle,
4558        cursor: &mut PageCursor,
4559        pages: &mut Vec<LayoutPage>,
4560        text_x: f64,
4561        text_width: f64,
4562        font_context: &FontContext,
4563        source_location: Option<&SourceLocation>,
4564        bookmark: Option<&str>,
4565        // Same role as in layout_text — None defaults to "Text".
4566        node_type_override: Option<&str>,
4567    ) {
4568        // Build StyledChar list from runs
4569        let mut styled_chars: Vec<StyledChar> = Vec::new();
4570        for run in runs {
4571            let run_style = run.style.resolve(Some(style), text_width);
4572            let run_href = run.href.as_deref().or(parent_href);
4573            let transform = run_style.text_transform;
4574            let run_content = substitute_page_placeholders(&run.content);
4575            let mut prev_is_whitespace = true;
4576            for ch in run_content.chars() {
4577                let transformed_ch = apply_char_transform(ch, transform, prev_is_whitespace);
4578                prev_is_whitespace = ch.is_whitespace();
4579                styled_chars.push(StyledChar {
4580                    ch: transformed_ch,
4581                    font_family: run_style.font_family.clone(),
4582                    font_size: run_style.font_size,
4583                    font_weight: run_style.font_weight,
4584                    font_style: run_style.font_style,
4585                    color: run_style.color,
4586                    href: run_href.map(|s| s.to_string()),
4587                    text_decoration: run_style.text_decoration,
4588                    letter_spacing: run_style.letter_spacing,
4589                });
4590            }
4591        }
4592
4593        // Break into lines
4594        let justify = matches!(style.text_align, TextAlign::Justify);
4595        let broken_lines = match style.line_breaking {
4596            LineBreaking::Optimal => self.text_layout.break_runs_into_lines_optimal(
4597                font_context,
4598                &styled_chars,
4599                text_width,
4600                style.hyphens,
4601                style.lang.as_deref(),
4602                justify,
4603            ),
4604            LineBreaking::Greedy => self.text_layout.break_runs_into_lines(
4605                font_context,
4606                &styled_chars,
4607                text_width,
4608                style.hyphens,
4609                style.lang.as_deref(),
4610            ),
4611        };
4612
4613        // Apply text overflow truncation (single-line modes)
4614        let broken_lines = match style.text_overflow {
4615            TextOverflow::Ellipsis => {
4616                self.text_layout
4617                    .truncate_runs_with_ellipsis(font_context, broken_lines, text_width)
4618            }
4619            TextOverflow::Clip => {
4620                self.text_layout
4621                    .truncate_runs_clip(font_context, broken_lines, text_width)
4622            }
4623            TextOverflow::Wrap => broken_lines,
4624        };
4625
4626        let line_height = style.font_size * style.line_height;
4627
4628        // Widow/orphan control for text runs
4629        let line_heights: Vec<f64> = vec![line_height; broken_lines.len()];
4630        let decision = page_break::decide_break(
4631            cursor.remaining_height(),
4632            &line_heights,
4633            true,
4634            style.min_orphan_lines as usize,
4635            style.min_widow_lines as usize,
4636        );
4637
4638        let mut snapshot = cursor.elements.len();
4639        let mut container_start_y = cursor.content_y + cursor.y;
4640        let mut is_first_element = true;
4641
4642        if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
4643            pages.push(cursor.finalize());
4644            *cursor = cursor.new_page();
4645            snapshot = cursor.elements.len();
4646            container_start_y = cursor.content_y + cursor.y;
4647        }
4648
4649        let forced_break_at = match decision {
4650            page_break::BreakDecision::Split {
4651                items_on_current_page,
4652            } => Some(items_on_current_page),
4653            _ => None,
4654        };
4655        let mut first_break_done = false;
4656
4657        for (line_idx, run_line) in broken_lines.iter().enumerate() {
4658            let needs_break = if let Some(break_at) = forced_break_at {
4659                if !first_break_done && line_idx == break_at {
4660                    true
4661                } else {
4662                    line_height > cursor.remaining_height()
4663                }
4664            } else {
4665                line_height > cursor.remaining_height()
4666            };
4667
4668            if needs_break {
4669                first_break_done = true;
4670                let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4671                if !line_elements.is_empty() {
4672                    let container_height = cursor.content_y + cursor.y - container_start_y;
4673                    cursor.elements.push(LayoutElement {
4674                        x: text_x,
4675                        y: container_start_y,
4676                        width: text_width,
4677                        height: container_height,
4678                        draw: DrawCommand::None,
4679                        children: line_elements,
4680                        node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4681                        resolved_style: Some(style.clone()),
4682                        source_location: source_location.cloned(),
4683                        href: parent_href.map(|s| s.to_string()),
4684                        bookmark: if is_first_element {
4685                            bookmark.map(|s| s.to_string())
4686                        } else {
4687                            None
4688                        },
4689                        alt: None,
4690                        is_header_row: false,
4691                        actual_text: None,
4692                        list_numbering: None,
4693                        col_span: 1,
4694                        overflow: Overflow::default(),
4695                        opacity: 1.0,
4696                    });
4697                    is_first_element = false;
4698                }
4699
4700                pages.push(cursor.finalize());
4701                *cursor = cursor.new_page();
4702
4703                snapshot = cursor.elements.len();
4704                container_start_y = cursor.content_y + cursor.y;
4705            }
4706
4707            let line_x = match style.text_align {
4708                TextAlign::Left => text_x,
4709                TextAlign::Right => text_x + text_width - run_line.width,
4710                TextAlign::Center => text_x + (text_width - run_line.width) / 2.0,
4711                TextAlign::Justify => text_x,
4712            };
4713
4714            let glyphs = self.build_positioned_glyphs_runs(run_line, font_context, style.direction);
4715
4716            // Justify: compute extra word spacing so the line fills the column width.
4717            // Use the sum of natural glyph advances (what PDF Tj actually renders)
4718            // rather than KP-adjusted line width.
4719            //
4720            // User-set `word_spacing` is the base; when text is justified, the
4721            // computed slack-per-space is added on top.
4722            let is_last_line = line_idx == broken_lines.len() - 1;
4723            let user_ws = style.word_spacing;
4724            let (justified_width, word_spacing) =
4725                if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
4726                    let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
4727                    let (natural_width, space_count) = if let Some(idx) = last_non_space {
4728                        let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
4729                        let s = glyphs[..=idx]
4730                            .iter()
4731                            .filter(|g| g.char_value == ' ')
4732                            .count();
4733                        (w, s)
4734                    } else {
4735                        (0.0, 0)
4736                    };
4737                    let slack = text_width - natural_width;
4738                    let ws = if space_count > 0 && slack.abs() > 0.01 {
4739                        slack / space_count as f64
4740                    } else {
4741                        0.0
4742                    };
4743                    (text_width, user_ws + ws)
4744                } else {
4745                    (run_line.width, user_ws)
4746                };
4747
4748            let text_line = TextLine {
4749                x: line_x,
4750                // Half-leading: the line box's extra space over the glyph block
4751                // splits evenly above and below (CSS line box model). The
4752                // baseline therefore sits half the leading further down —
4753                // this is also what makes the pre-flexbox centering idiom
4754                // (line-height matched to a box height) actually center.
4755                y: cursor.content_y
4756                    + cursor.y
4757                    + baseline_in_line(
4758                        line_height,
4759                        style.font_size,
4760                        font_context.baseline_metrics(
4761                            &style.font_family,
4762                            style.font_weight,
4763                            matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique),
4764                        ),
4765                    ),
4766                glyphs,
4767                width: justified_width,
4768                height: line_height,
4769                word_spacing,
4770            };
4771
4772            // Determine text decoration: use the run's decoration if any glyph has one
4773            let text_dec = run_line
4774                .chars
4775                .iter()
4776                .find(|sc| !matches!(sc.text_decoration, TextDecoration::None))
4777                .map(|sc| sc.text_decoration)
4778                .unwrap_or(style.text_decoration);
4779
4780            cursor.elements.push(LayoutElement {
4781                x: line_x,
4782                y: cursor.content_y + cursor.y,
4783                width: justified_width,
4784                height: line_height,
4785                draw: DrawCommand::Text {
4786                    lines: vec![text_line],
4787                    color: style.color,
4788                    text_decoration: text_dec,
4789                    opacity: 1.0,
4790                },
4791                children: vec![],
4792                node_type: Some("TextLine".to_string()),
4793                resolved_style: Some(style.clone()),
4794                source_location: None,
4795                href: parent_href.map(|s| s.to_string()),
4796                bookmark: None,
4797                alt: None,
4798                is_header_row: false,
4799                actual_text: None,
4800                list_numbering: None,
4801                col_span: 1,
4802                overflow: Overflow::default(),
4803                opacity: 1.0,
4804            });
4805
4806            cursor.y += line_height;
4807        }
4808
4809        let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4810        if !line_elements.is_empty() {
4811            let container_height = cursor.content_y + cursor.y - container_start_y;
4812            cursor.elements.push(LayoutElement {
4813                x: text_x,
4814                y: container_start_y,
4815                width: text_width,
4816                height: container_height,
4817                draw: DrawCommand::None,
4818                children: line_elements,
4819                node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4820                resolved_style: Some(style.clone()),
4821                source_location: source_location.cloned(),
4822                href: parent_href.map(|s| s.to_string()),
4823                bookmark: if is_first_element {
4824                    bookmark.map(|s| s.to_string())
4825                } else {
4826                    None
4827                },
4828                alt: None,
4829                is_header_row: false,
4830                actual_text: None,
4831                list_numbering: None,
4832                col_span: 1,
4833                overflow: Overflow::default(),
4834                opacity: 1.0,
4835            });
4836        }
4837    }
4838
4839    /// Build PositionedGlyphs for a single-style BrokenLine.
4840    /// For custom fonts, shapes the line text to get real glyph IDs.
4841    /// For standard fonts, uses char-as-u16 glyph IDs.
4842    fn build_positioned_glyphs_single_style(
4843        &self,
4844        line: &BrokenLine,
4845        style: &ResolvedStyle,
4846        href: Option<&str>,
4847        font_context: &FontContext,
4848    ) -> Vec<PositionedGlyph> {
4849        let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
4850        let line_text: String = line.chars.iter().collect();
4851        let direction = style.direction;
4852        // Check if BiDi processing is needed
4853        let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
4854
4855        // Segment by font — handles both explicit fallback chains and
4856        // automatic builtin font fallback (Noto Sans for non-Latin chars)
4857        let font_runs = crate::font::fallback::segment_by_font(
4858            &line.chars,
4859            &style.font_family,
4860            style.font_weight,
4861            italic,
4862            font_context.registry(),
4863        );
4864        let needs_per_char_fallback = font_runs.len() > 1
4865            || (font_runs.len() == 1 && font_runs[0].family != style.font_family);
4866
4867        // Per-char fallback path: segment by font within each BiDi run
4868        if needs_per_char_fallback {
4869            let bidi_runs = if has_bidi {
4870                bidi::analyze_bidi(&line_text, direction)
4871            } else {
4872                vec![crate::text::bidi::BidiRun {
4873                    char_start: 0,
4874                    char_end: line.chars.len(),
4875                    level: unicode_bidi::Level::ltr(),
4876                    is_rtl: false,
4877                }]
4878            };
4879
4880            let mut all_glyphs = Vec::new();
4881            let mut bidi_levels = Vec::new();
4882            let mut x = 0.0_f64;
4883
4884            // Process each BiDi run
4885            for bidi_run in &bidi_runs {
4886                // Within this BiDi run, sub-segment by font
4887                for font_run in &font_runs {
4888                    // Intersect font_run with bidi_run
4889                    let start = font_run.start.max(bidi_run.char_start);
4890                    let end = font_run.end.min(bidi_run.char_end);
4891                    if start >= end {
4892                        continue;
4893                    }
4894
4895                    let sub_chars: Vec<char> = line.chars[start..end].to_vec();
4896                    let sub_text: String = sub_chars.iter().collect();
4897                    let resolved_family = &font_run.family;
4898
4899                    if let Some(font_data) =
4900                        font_context.font_data(resolved_family, style.font_weight, italic)
4901                    {
4902                        if let Some(shaped) = shaping::shape_text_with_direction(
4903                            &sub_text,
4904                            font_data,
4905                            bidi_run.is_rtl,
4906                        ) {
4907                            let units_per_em = font_context.units_per_em(
4908                                resolved_family,
4909                                style.font_weight,
4910                                italic,
4911                            );
4912                            let scale = style.font_size / units_per_em as f64;
4913
4914                            for sg in &shaped {
4915                                let cluster = sg.cluster as usize;
4916                                let char_value = sub_chars.get(cluster).copied().unwrap_or(' ');
4917
4918                                let cluster_text = if shaped.len() < sub_chars.len() {
4919                                    let cluster_end =
4920                                        self.find_cluster_end(&shaped, sg, sub_chars.len());
4921                                    if cluster_end > cluster + 1 {
4922                                        Some(
4923                                            sub_chars[cluster..cluster_end]
4924                                                .iter()
4925                                                .collect::<String>(),
4926                                        )
4927                                    } else {
4928                                        None
4929                                    }
4930                                } else {
4931                                    None
4932                                };
4933
4934                                let glyph_x = x + sg.x_offset as f64 * scale;
4935                                let glyph_y = sg.y_offset as f64 * scale;
4936                                let advance = sg.x_advance as f64 * scale + style.letter_spacing;
4937
4938                                all_glyphs.push(PositionedGlyph {
4939                                    glyph_id: sg.glyph_id,
4940                                    x_offset: glyph_x,
4941                                    y_offset: glyph_y,
4942                                    x_advance: advance,
4943                                    font_size: style.font_size,
4944                                    font_family: Arc::from(resolved_family.as_str()),
4945                                    font_weight: style.font_weight,
4946                                    font_style: style.font_style,
4947                                    char_value,
4948                                    color: Some(style.color),
4949                                    href: href.map(|s| s.to_string()),
4950                                    text_decoration: style.text_decoration,
4951                                    letter_spacing: style.letter_spacing,
4952                                    cluster_text,
4953                                });
4954                                bidi_levels.push(bidi_run.level);
4955                                x += advance;
4956                            }
4957                            continue;
4958                        }
4959                    }
4960
4961                    // Fallback: standard font or shaping failure for this sub-segment
4962                    for i in start..end {
4963                        let ch = line.chars[i];
4964                        let glyph_x = x;
4965                        let char_width = font_context.char_width(
4966                            ch,
4967                            resolved_family,
4968                            style.font_weight,
4969                            italic,
4970                            style.font_size,
4971                        );
4972                        let advance = char_width + style.letter_spacing;
4973                        all_glyphs.push(PositionedGlyph {
4974                            glyph_id: ch as u16,
4975                            x_offset: glyph_x,
4976                            y_offset: 0.0,
4977                            x_advance: advance,
4978                            font_size: style.font_size,
4979                            font_family: Arc::from(resolved_family.as_str()),
4980                            font_weight: style.font_weight,
4981                            font_style: style.font_style,
4982                            char_value: ch,
4983                            color: Some(style.color),
4984                            href: href.map(|s| s.to_string()),
4985                            text_decoration: style.text_decoration,
4986                            letter_spacing: style.letter_spacing,
4987                            cluster_text: None,
4988                        });
4989                        bidi_levels.push(bidi_run.level);
4990                        x += advance;
4991                    }
4992                }
4993            }
4994
4995            // Apply BiDi visual reordering if needed
4996            if has_bidi && !all_glyphs.is_empty() {
4997                all_glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
4998                bidi::reposition_after_reorder(&mut all_glyphs, 0.0);
4999            }
5000            return all_glyphs;
5001        }
5002
5003        // Original single-font path (no comma in font_family)
5004        // Try shaping for custom fonts
5005        if let Some(font_data) =
5006            font_context.font_data(&style.font_family, style.font_weight, italic)
5007        {
5008            if has_bidi {
5009                // BiDi path: analyze runs, shape each with correct direction
5010                let bidi_runs = bidi::analyze_bidi(&line_text, direction);
5011                let units_per_em =
5012                    font_context.units_per_em(&style.font_family, style.font_weight, italic);
5013                let scale = style.font_size / units_per_em as f64;
5014
5015                let mut all_glyphs = Vec::new();
5016                let mut bidi_levels = Vec::new();
5017                let mut x = 0.0_f64;
5018
5019                for run in &bidi_runs {
5020                    let run_chars: Vec<char> = line.chars[run.char_start..run.char_end].to_vec();
5021                    let run_text: String = run_chars.iter().collect();
5022
5023                    if let Some(shaped) =
5024                        shaping::shape_text_with_direction(&run_text, font_data, run.is_rtl)
5025                    {
5026                        for sg in &shaped {
5027                            let cluster = sg.cluster as usize;
5028                            let char_value = run_chars.get(cluster).copied().unwrap_or(' ');
5029
5030                            let cluster_text = if shaped.len() < run_chars.len() {
5031                                let cluster_end =
5032                                    self.find_cluster_end(&shaped, sg, run_chars.len());
5033                                if cluster_end > cluster + 1 {
5034                                    Some(run_chars[cluster..cluster_end].iter().collect::<String>())
5035                                } else {
5036                                    None
5037                                }
5038                            } else {
5039                                None
5040                            };
5041
5042                            let glyph_x = x + sg.x_offset as f64 * scale;
5043                            let glyph_y = sg.y_offset as f64 * scale;
5044                            let advance = sg.x_advance as f64 * scale + style.letter_spacing;
5045
5046                            all_glyphs.push(PositionedGlyph {
5047                                glyph_id: sg.glyph_id,
5048                                x_offset: glyph_x,
5049                                y_offset: glyph_y,
5050                                x_advance: advance,
5051                                font_size: style.font_size,
5052                                font_family: Arc::from(style.font_family.as_str()),
5053                                font_weight: style.font_weight,
5054                                font_style: style.font_style,
5055                                char_value,
5056                                color: Some(style.color),
5057                                href: href.map(|s| s.to_string()),
5058                                text_decoration: style.text_decoration,
5059                                letter_spacing: style.letter_spacing,
5060                                cluster_text,
5061                            });
5062                            bidi_levels.push(run.level);
5063
5064                            x += advance;
5065                        }
5066                    }
5067                }
5068
5069                // Reorder glyphs visually and reposition
5070                let mut glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
5071                bidi::reposition_after_reorder(&mut glyphs, 0.0);
5072                return glyphs;
5073            }
5074
5075            // Pure LTR path: shape normally
5076            if let Some(shaped) = shaping::shape_text(&line_text, font_data) {
5077                let units_per_em =
5078                    font_context.units_per_em(&style.font_family, style.font_weight, italic);
5079                let scale = style.font_size / units_per_em as f64;
5080
5081                return self.shaped_glyphs_to_positioned(
5082                    &shaped,
5083                    &line.chars,
5084                    &line.char_positions,
5085                    scale,
5086                    style.font_size,
5087                    &style.font_family,
5088                    style.font_weight,
5089                    style.font_style,
5090                    Some(style.color),
5091                    href,
5092                    style.text_decoration,
5093                    style.letter_spacing,
5094                );
5095            }
5096        }
5097
5098        // Fallback: standard fonts or shaping failure
5099        let mut glyphs: Vec<PositionedGlyph> = line
5100            .chars
5101            .iter()
5102            .enumerate()
5103            .map(|(j, ch)| {
5104                let glyph_x = line.char_positions.get(j).copied().unwrap_or(0.0);
5105                let char_width = font_context.char_width(
5106                    *ch,
5107                    &style.font_family,
5108                    style.font_weight,
5109                    italic,
5110                    style.font_size,
5111                );
5112                PositionedGlyph {
5113                    glyph_id: *ch as u16,
5114                    x_offset: glyph_x,
5115                    y_offset: 0.0,
5116                    x_advance: char_width,
5117                    font_size: style.font_size,
5118                    font_family: Arc::from(style.font_family.as_str()),
5119                    font_weight: style.font_weight,
5120                    font_style: style.font_style,
5121                    char_value: *ch,
5122                    color: Some(style.color),
5123                    href: href.map(|s| s.to_string()),
5124                    text_decoration: style.text_decoration,
5125                    letter_spacing: style.letter_spacing,
5126                    cluster_text: None,
5127                }
5128            })
5129            .collect();
5130
5131        // For standard fonts with BiDi text, still reorder visually
5132        if has_bidi && !glyphs.is_empty() {
5133            let bidi_runs = bidi::analyze_bidi(&line_text, direction);
5134            let mut levels = Vec::with_capacity(glyphs.len());
5135            let mut char_idx = 0;
5136            for run in &bidi_runs {
5137                for _ in run.char_start..run.char_end {
5138                    if char_idx < glyphs.len() {
5139                        levels.push(run.level);
5140                        char_idx += 1;
5141                    }
5142                }
5143            }
5144            // Pad if needed
5145            while levels.len() < glyphs.len() {
5146                levels.push(unicode_bidi::Level::ltr());
5147            }
5148            glyphs = bidi::reorder_line_glyphs(glyphs, &levels);
5149            bidi::reposition_after_reorder(&mut glyphs, 0.0);
5150        }
5151
5152        glyphs
5153    }
5154
5155    /// Build PositionedGlyphs for a multi-style RunBrokenLine.
5156    /// Shapes contiguous runs of the same custom font, with BiDi support.
5157    /// When a StyledChar has a comma-separated font_family, resolves each
5158    /// character to a single font before grouping for shaping.
5159    fn build_positioned_glyphs_runs(
5160        &self,
5161        run_line: &RunBrokenLine,
5162        font_context: &FontContext,
5163        direction: Direction,
5164    ) -> Vec<PositionedGlyph> {
5165        let chars = &run_line.chars;
5166        if chars.is_empty() {
5167            return vec![];
5168        }
5169
5170        // Pre-resolve per-char font families — the same rule as
5171        // segment_by_font (the single-style path) and char_width
5172        // (measurement): the declared family when it covers the char,
5173        // per-char resolution otherwise. This path used to skip per-char
5174        // resolution entirely for comma-less families, so a non-WinAnsi
5175        // char in a TextRun rendered "?" on the base-14 path while the
5176        // identical char in single-style Text reached builtin Noto Sans —
5177        // measurement and rendering disagreeing about the char's font.
5178        let resolved_families: Vec<String> = chars
5179            .iter()
5180            .map(|sc| {
5181                let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
5182                if !sc.font_family.contains(',') {
5183                    let primary =
5184                        font_context
5185                            .registry()
5186                            .resolve(&sc.font_family, sc.font_weight, italic);
5187                    if sc.ch.is_whitespace()
5188                        || sc.ch == PAGE_NUMBER_SENTINEL
5189                        || sc.ch == TOTAL_PAGES_SENTINEL
5190                        || primary.has_char(sc.ch)
5191                    {
5192                        return sc.font_family.clone();
5193                    }
5194                }
5195                let (_, family) = font_context.registry().resolve_for_char(
5196                    &sc.font_family,
5197                    sc.ch,
5198                    sc.font_weight,
5199                    italic,
5200                );
5201                family
5202            })
5203            .collect();
5204
5205        let line_text: String = chars.iter().map(|c| c.ch).collect();
5206        let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
5207        let bidi_runs = if has_bidi {
5208            Some(bidi::analyze_bidi(&line_text, direction))
5209        } else {
5210            None
5211        };
5212
5213        let mut glyphs = Vec::new();
5214        let mut bidi_levels = Vec::new();
5215        let mut i = 0;
5216
5217        while i < chars.len() {
5218            let sc = &chars[i];
5219            let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
5220            let resolved_family = &resolved_families[i];
5221
5222            // Determine if this char is in an RTL BiDi run
5223            let is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
5224                runs.iter()
5225                    .any(|r| i >= r.char_start && i < r.char_end && r.is_rtl)
5226            });
5227
5228            // Check for custom font with shaping (using resolved single family)
5229            if let Some(font_data) = font_context.font_data(resolved_family, sc.font_weight, italic)
5230            {
5231                // Find contiguous run with same resolved font AND same BiDi direction
5232                let run_start = i;
5233                let mut run_end = i + 1;
5234                while run_end < chars.len() {
5235                    let next = &chars[run_end];
5236                    let next_italic =
5237                        matches!(next.font_style, FontStyle::Italic | FontStyle::Oblique);
5238                    let next_is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
5239                        runs.iter()
5240                            .any(|r| run_end >= r.char_start && run_end < r.char_end && r.is_rtl)
5241                    });
5242                    // Group by resolved family, not original comma chain
5243                    if resolved_families[run_end] == *resolved_family
5244                        && next.font_weight == sc.font_weight
5245                        && next_italic == italic
5246                        && (next.font_size - sc.font_size).abs() < 0.001
5247                        && next_is_rtl == is_rtl
5248                    {
5249                        run_end += 1;
5250                    } else {
5251                        break;
5252                    }
5253                }
5254
5255                let run_text: String = chars[run_start..run_end].iter().map(|c| c.ch).collect();
5256                if let Some(shaped) =
5257                    shaping::shape_text_with_direction(&run_text, font_data, is_rtl)
5258                {
5259                    let units_per_em =
5260                        font_context.units_per_em(resolved_family, sc.font_weight, italic);
5261                    let scale = sc.font_size / units_per_em as f64;
5262
5263                    // Build char positions for this run segment
5264                    let run_chars: Vec<char> =
5265                        chars[run_start..run_end].iter().map(|c| c.ch).collect();
5266                    let run_positions: Vec<f64> = (run_start..run_end)
5267                        .map(|j| run_line.char_positions.get(j).copied().unwrap_or(0.0))
5268                        .collect();
5269
5270                    // Build glyphs with resolved single family on each glyph
5271                    let mut run_glyphs = self.shaped_glyphs_to_positioned_runs(
5272                        &shaped,
5273                        &chars[run_start..run_end],
5274                        &run_chars,
5275                        &run_positions,
5276                        scale,
5277                    );
5278                    // Override font_family to the resolved single family
5279                    let resolved_family_arc: Arc<str> = Arc::from(resolved_family.as_str());
5280                    for g in &mut run_glyphs {
5281                        g.font_family = resolved_family_arc.clone();
5282                    }
5283                    // Track BiDi levels for each glyph
5284                    let run_level = if is_rtl {
5285                        unicode_bidi::Level::rtl()
5286                    } else {
5287                        unicode_bidi::Level::ltr()
5288                    };
5289                    for _ in &run_glyphs {
5290                        bidi_levels.push(run_level);
5291                    }
5292                    glyphs.extend(run_glyphs);
5293                    i = run_end;
5294                    continue;
5295                }
5296            }
5297
5298            // Fallback: unshaped glyph (using resolved family)
5299            let glyph_x = run_line.char_positions.get(i).copied().unwrap_or(0.0);
5300            let char_width = font_context.char_width(
5301                sc.ch,
5302                resolved_family,
5303                sc.font_weight,
5304                italic,
5305                sc.font_size,
5306            );
5307            glyphs.push(PositionedGlyph {
5308                glyph_id: sc.ch as u16,
5309                x_offset: glyph_x,
5310                y_offset: 0.0,
5311                x_advance: char_width,
5312                font_size: sc.font_size,
5313                font_family: Arc::from(resolved_family.as_str()),
5314                font_weight: sc.font_weight,
5315                font_style: sc.font_style,
5316                char_value: sc.ch,
5317                color: Some(sc.color),
5318                href: sc.href.clone(),
5319                text_decoration: sc.text_decoration,
5320                letter_spacing: sc.letter_spacing,
5321                cluster_text: None,
5322            });
5323            bidi_levels.push(if is_rtl {
5324                unicode_bidi::Level::rtl()
5325            } else {
5326                unicode_bidi::Level::ltr()
5327            });
5328            i += 1;
5329        }
5330
5331        // Apply BiDi visual reordering if needed
5332        if has_bidi && !glyphs.is_empty() {
5333            glyphs = bidi::reorder_line_glyphs(glyphs, &bidi_levels);
5334            bidi::reposition_after_reorder(&mut glyphs, 0.0);
5335        }
5336
5337        glyphs
5338    }
5339
5340    /// Convert shaped glyphs to PositionedGlyphs for single-style text.
5341    #[allow(clippy::too_many_arguments)]
5342    fn shaped_glyphs_to_positioned(
5343        &self,
5344        shaped: &[shaping::ShapedGlyph],
5345        chars: &[char],
5346        _char_positions: &[f64],
5347        scale: f64,
5348        font_size: f64,
5349        font_family: &str,
5350        font_weight: u32,
5351        font_style: FontStyle,
5352        color: Option<Color>,
5353        href: Option<&str>,
5354        text_decoration: TextDecoration,
5355        letter_spacing: f64,
5356    ) -> Vec<PositionedGlyph> {
5357        let mut result = Vec::with_capacity(shaped.len());
5358        let mut x = 0.0_f64;
5359
5360        for sg in shaped {
5361            let cluster = sg.cluster as usize;
5362            let char_value = chars.get(cluster).copied().unwrap_or(' ');
5363
5364            // Determine cluster text for ligatures
5365            let cluster_text = if shaped.len() < chars.len() {
5366                // There are fewer glyphs than chars: likely ligatures.
5367                // Find end of this cluster.
5368                let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
5369                if cluster_end > cluster + 1 {
5370                    Some(chars[cluster..cluster_end].iter().collect::<String>())
5371                } else {
5372                    None
5373                }
5374            } else {
5375                None
5376            };
5377
5378            // Use shaped position
5379            let glyph_x = x + sg.x_offset as f64 * scale;
5380            let glyph_y = sg.y_offset as f64 * scale;
5381            let advance = sg.x_advance as f64 * scale + letter_spacing;
5382
5383            result.push(PositionedGlyph {
5384                glyph_id: sg.glyph_id,
5385                x_offset: glyph_x,
5386                y_offset: glyph_y,
5387                x_advance: advance,
5388                font_size,
5389                font_family: Arc::from(font_family),
5390                font_weight,
5391                font_style,
5392                char_value,
5393                color,
5394                href: href.map(|s| s.to_string()),
5395                text_decoration,
5396                letter_spacing,
5397                cluster_text,
5398            });
5399
5400            x += advance;
5401        }
5402
5403        result
5404    }
5405
5406    /// Convert shaped glyphs to PositionedGlyphs for multi-style runs.
5407    fn shaped_glyphs_to_positioned_runs(
5408        &self,
5409        shaped: &[shaping::ShapedGlyph],
5410        styled_chars: &[StyledChar],
5411        chars: &[char],
5412        char_positions: &[f64],
5413        scale: f64,
5414    ) -> Vec<PositionedGlyph> {
5415        let mut result = Vec::with_capacity(shaped.len());
5416        // Use the first char position as the base offset for this run
5417        let base_x = char_positions.first().copied().unwrap_or(0.0);
5418        let mut x = 0.0_f64;
5419
5420        for sg in shaped {
5421            let cluster = sg.cluster as usize;
5422            let sc = styled_chars.get(cluster).unwrap_or(&styled_chars[0]);
5423            let char_value = chars.get(cluster).copied().unwrap_or(' ');
5424
5425            let cluster_text = if shaped.len() < chars.len() {
5426                let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
5427                if cluster_end > cluster + 1 {
5428                    Some(chars[cluster..cluster_end].iter().collect::<String>())
5429                } else {
5430                    None
5431                }
5432            } else {
5433                None
5434            };
5435
5436            let glyph_x = base_x + x + sg.x_offset as f64 * scale;
5437            let glyph_y = sg.y_offset as f64 * scale;
5438            let advance = sg.x_advance as f64 * scale + sc.letter_spacing;
5439
5440            result.push(PositionedGlyph {
5441                glyph_id: sg.glyph_id,
5442                x_offset: glyph_x,
5443                y_offset: glyph_y,
5444                x_advance: advance,
5445                font_size: sc.font_size,
5446                font_family: Arc::from(sc.font_family.as_str()),
5447                font_weight: sc.font_weight,
5448                font_style: sc.font_style,
5449                char_value,
5450                color: Some(sc.color),
5451                href: sc.href.clone(),
5452                text_decoration: sc.text_decoration,
5453                letter_spacing: sc.letter_spacing,
5454                cluster_text,
5455            });
5456
5457            x += advance;
5458        }
5459
5460        result
5461    }
5462
5463    /// Find the end index of a cluster in shaped glyphs.
5464    fn find_cluster_end(
5465        &self,
5466        shaped: &[shaping::ShapedGlyph],
5467        current: &shaping::ShapedGlyph,
5468        num_chars: usize,
5469    ) -> usize {
5470        // Find the next glyph's cluster value
5471        for sg in shaped {
5472            if sg.cluster > current.cluster {
5473                return sg.cluster as usize;
5474            }
5475        }
5476        // Last glyph: cluster extends to end of text
5477        num_chars
5478    }
5479
5480    /// The ONE image sizing ladder — used by both `layout_image` and
5481    /// `measure_node_height`, so measurement and layout agree by
5482    /// construction (the measure/layout agreement family: an earlier
5483    /// version measured small images at container width while layout
5484    /// drew them at intrinsic size, reserving container-sized phantom
5485    /// space — template-compat 01/05/07). Chrome semantics: style width
5486    /// (percents already resolved) > explicit prop > intrinsic;
5487    /// max/min-width clamp; height follows the real aspect ratio unless
5488    /// given.
5489    fn image_display_size(
5490        &self,
5491        src: &str,
5492        style: &ResolvedStyle,
5493        explicit_width: Option<f64>,
5494        explicit_height: Option<f64>,
5495        available_width: f64,
5496    ) -> (f64, f64) {
5497        let dims = if src.is_empty() {
5498            None
5499        } else {
5500            self.get_image_dimensions(src)
5501        };
5502        let aspect = dims
5503            .map(|(w, h)| {
5504                if w > 0 {
5505                    f64::from(h) / f64::from(w)
5506                } else {
5507                    0.75
5508                }
5509            })
5510            .unwrap_or(0.75);
5511
5512        let style_w = match style.width {
5513            SizeConstraint::Fixed(w) => Some(w),
5514            SizeConstraint::Auto => None,
5515        };
5516        let style_h = match style.height {
5517            SizeConstraint::Fixed(h) => Some(h),
5518            SizeConstraint::Auto => None,
5519        };
5520        let clamp = |w: f64| w.min(style.max_width).max(style.min_width);
5521
5522        let width_source = style_w.or(explicit_width);
5523        let height_source = style_h.or(explicit_height);
5524        match (width_source, height_source) {
5525            (Some(w), Some(h)) => (clamp(w), h),
5526            (Some(w), None) => {
5527                let w = clamp(w);
5528                (w, w * aspect)
5529            }
5530            (None, Some(h)) => (clamp(h / aspect), h),
5531            (None, None) => {
5532                // Intrinsic size, shrunk to fit the container. An
5533                // unloadable image keeps the container-width placeholder.
5534                let w = clamp(
5535                    dims.map(|(w, _)| f64::from(w))
5536                        .unwrap_or(available_width)
5537                        .min(available_width),
5538                );
5539                (w, w * aspect)
5540            }
5541        }
5542    }
5543
5544    #[allow(clippy::too_many_arguments)]
5545    fn layout_image(
5546        &self,
5547        node: &Node,
5548        style: &ResolvedStyle,
5549        cursor: &mut PageCursor,
5550        pages: &mut Vec<LayoutPage>,
5551        x: f64,
5552        available_width: f64,
5553        explicit_width: Option<f64>,
5554        explicit_height: Option<f64>,
5555    ) {
5556        let margin = &style.margin.to_edges();
5557
5558        // Try to load the image from the node's src field
5559        let src = match &node.kind {
5560            NodeKind::Image { src, .. } => src.as_str(),
5561            _ => "",
5562        };
5563
5564        let loaded = if !src.is_empty() {
5565            crate::image_loader::load_image(src).ok()
5566        } else {
5567            None
5568        };
5569
5570        let (img_width, img_height) = self.image_display_size(
5571            src,
5572            style,
5573            explicit_width,
5574            explicit_height,
5575            available_width - margin.horizontal(),
5576        );
5577
5578        let total_height = img_height + margin.vertical();
5579
5580        if total_height > cursor.remaining_height() {
5581            pages.push(cursor.finalize());
5582            *cursor = cursor.new_page();
5583        }
5584
5585        cursor.y += margin.top;
5586
5587        let draw = if let Some(image_data) = loaded {
5588            DrawCommand::Image { image_data }
5589        } else {
5590            DrawCommand::ImagePlaceholder
5591        };
5592
5593        cursor.elements.push(LayoutElement {
5594            x: x + margin.left,
5595            y: cursor.content_y + cursor.y,
5596            width: img_width,
5597            height: img_height,
5598            draw,
5599            children: vec![],
5600            node_type: Some(node_kind_name(&node.kind).to_string()),
5601            resolved_style: Some(style.clone()),
5602            source_location: node.source_location.clone(),
5603            href: node.href.clone(),
5604            bookmark: node.bookmark.clone(),
5605            alt: node.alt.clone(),
5606            is_header_row: false,
5607            actual_text: None,
5608            list_numbering: None,
5609            col_span: 1,
5610            overflow: style.overflow,
5611            opacity: style.opacity,
5612        });
5613
5614        cursor.y += img_height + margin.bottom;
5615    }
5616
5617    /// Layout an SVG element as a fixed-size box.
5618    #[allow(clippy::too_many_arguments)]
5619    fn layout_svg(
5620        &self,
5621        node: &Node,
5622        style: &ResolvedStyle,
5623        cursor: &mut PageCursor,
5624        pages: &mut Vec<LayoutPage>,
5625        x: f64,
5626        _available_width: f64,
5627        svg_width: f64,
5628        svg_height: f64,
5629        view_box: Option<&str>,
5630        content: &str,
5631    ) {
5632        let margin = &style.margin.to_edges();
5633        let total_height = svg_height + margin.vertical();
5634
5635        if total_height > cursor.remaining_height() {
5636            pages.push(cursor.finalize());
5637            *cursor = cursor.new_page();
5638        }
5639
5640        cursor.y += margin.top;
5641
5642        let vb = view_box
5643            .and_then(crate::svg::parse_view_box)
5644            .unwrap_or(crate::svg::ViewBox {
5645                min_x: 0.0,
5646                min_y: 0.0,
5647                width: svg_width,
5648                height: svg_height,
5649            });
5650
5651        let commands = crate::svg::parse_svg(content, vb, svg_width, svg_height);
5652
5653        cursor.elements.push(LayoutElement {
5654            x: x + margin.left,
5655            y: cursor.content_y + cursor.y,
5656            width: svg_width,
5657            height: svg_height,
5658            draw: DrawCommand::Svg {
5659                commands,
5660                width: svg_width,
5661                height: svg_height,
5662                viewbox_min_x: vb.min_x,
5663                viewbox_min_y: vb.min_y,
5664                viewbox_width: vb.width,
5665                viewbox_height: vb.height,
5666                clip: false,
5667            },
5668            children: vec![],
5669            node_type: Some("Svg".to_string()),
5670            resolved_style: Some(style.clone()),
5671            source_location: node.source_location.clone(),
5672            href: node.href.clone(),
5673            bookmark: node.bookmark.clone(),
5674            alt: node.alt.clone(),
5675            is_header_row: false,
5676            actual_text: None,
5677            list_numbering: None,
5678            col_span: 1,
5679            overflow: style.overflow,
5680            opacity: style.opacity,
5681        });
5682
5683        cursor.y += svg_height + margin.bottom;
5684    }
5685
5686    /// Convert CanvasOps to SvgCommands, reusing the existing SVG rendering pipeline.
5687    fn canvas_ops_to_svg_commands(operations: &[CanvasOp]) -> Vec<crate::svg::SvgCommand> {
5688        use crate::svg::SvgCommand;
5689
5690        let mut commands = Vec::new();
5691        let mut cur_x = 0.0_f64;
5692        let mut cur_y = 0.0_f64;
5693
5694        for op in operations {
5695            match op {
5696                CanvasOp::MoveTo { x, y } => {
5697                    commands.push(SvgCommand::MoveTo(*x, *y));
5698                    cur_x = *x;
5699                    cur_y = *y;
5700                }
5701                CanvasOp::LineTo { x, y } => {
5702                    commands.push(SvgCommand::LineTo(*x, *y));
5703                    cur_x = *x;
5704                    cur_y = *y;
5705                }
5706                CanvasOp::BezierCurveTo {
5707                    cp1x,
5708                    cp1y,
5709                    cp2x,
5710                    cp2y,
5711                    x,
5712                    y,
5713                } => {
5714                    commands.push(SvgCommand::CurveTo(*cp1x, *cp1y, *cp2x, *cp2y, *x, *y));
5715                    cur_x = *x;
5716                    cur_y = *y;
5717                }
5718                CanvasOp::QuadraticCurveTo { cpx, cpy, x, y } => {
5719                    // Convert quadratic to cubic bezier
5720                    let cp1x = cur_x + 2.0 / 3.0 * (*cpx - cur_x);
5721                    let cp1y = cur_y + 2.0 / 3.0 * (*cpy - cur_y);
5722                    let cp2x = *x + 2.0 / 3.0 * (*cpx - *x);
5723                    let cp2y = *y + 2.0 / 3.0 * (*cpy - *y);
5724                    commands.push(SvgCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, *x, *y));
5725                    cur_x = *x;
5726                    cur_y = *y;
5727                }
5728                CanvasOp::ClosePath => {
5729                    commands.push(SvgCommand::ClosePath);
5730                }
5731                CanvasOp::Rect {
5732                    x,
5733                    y,
5734                    width,
5735                    height,
5736                } => {
5737                    commands.push(SvgCommand::MoveTo(*x, *y));
5738                    commands.push(SvgCommand::LineTo(*x + *width, *y));
5739                    commands.push(SvgCommand::LineTo(*x + *width, *y + *height));
5740                    commands.push(SvgCommand::LineTo(*x, *y + *height));
5741                    commands.push(SvgCommand::ClosePath);
5742                    cur_x = *x;
5743                    cur_y = *y;
5744                }
5745                CanvasOp::Circle { cx, cy, r } => {
5746                    commands.extend(crate::svg::ellipse_commands(*cx, *cy, *r, *r));
5747                }
5748                CanvasOp::Ellipse { cx, cy, rx, ry } => {
5749                    commands.extend(crate::svg::ellipse_commands(*cx, *cy, *rx, *ry));
5750                }
5751                CanvasOp::Arc {
5752                    cx,
5753                    cy,
5754                    r,
5755                    start_angle,
5756                    end_angle,
5757                    counterclockwise,
5758                } => {
5759                    // Approximate arc with line segments matching HTML Canvas arc() semantics.
5760                    // Canvas coords are Y-down (like HTML Canvas), and the PDF Y-flip
5761                    // preserves visual positions, so standard trig (cy + r*sin) is correct.
5762                    let steps = 32;
5763                    let mut sweep = end_angle - start_angle;
5764                    if !counterclockwise && sweep < 0.0 {
5765                        sweep += 2.0 * std::f64::consts::PI;
5766                    }
5767                    if *counterclockwise && sweep > 0.0 {
5768                        sweep -= 2.0 * std::f64::consts::PI;
5769                    }
5770                    for i in 0..=steps {
5771                        let t = *start_angle + sweep * (i as f64 / steps as f64);
5772                        let px = cx + r * t.cos();
5773                        let py = cy + r * t.sin();
5774                        if i == 0 {
5775                            commands.push(SvgCommand::MoveTo(px, py));
5776                        } else {
5777                            commands.push(SvgCommand::LineTo(px, py));
5778                        }
5779                    }
5780                }
5781                CanvasOp::Stroke => commands.push(SvgCommand::Stroke),
5782                CanvasOp::Fill => commands.push(SvgCommand::Fill),
5783                CanvasOp::FillAndStroke => commands.push(SvgCommand::FillAndStroke),
5784                CanvasOp::SetFillColor { r, g, b } => {
5785                    // Canvas API uses 0-255, PDF/SVG pipeline uses 0-1
5786                    commands.push(SvgCommand::SetFill(r / 255.0, g / 255.0, b / 255.0));
5787                }
5788                CanvasOp::SetStrokeColor { r, g, b } => {
5789                    commands.push(SvgCommand::SetStroke(r / 255.0, g / 255.0, b / 255.0));
5790                }
5791                CanvasOp::SetLineWidth { width } => {
5792                    commands.push(SvgCommand::SetStrokeWidth(*width));
5793                }
5794                CanvasOp::SetLineCap { cap } => {
5795                    commands.push(SvgCommand::SetLineCap(*cap));
5796                }
5797                CanvasOp::SetLineJoin { join } => {
5798                    commands.push(SvgCommand::SetLineJoin(*join));
5799                }
5800                CanvasOp::Save => commands.push(SvgCommand::SaveState),
5801                CanvasOp::Restore => commands.push(SvgCommand::RestoreState),
5802            }
5803        }
5804
5805        commands
5806    }
5807
5808    /// Layout a canvas element as a fixed-size box with vector graphics.
5809    #[allow(clippy::too_many_arguments)]
5810    fn layout_canvas(
5811        &self,
5812        node: &Node,
5813        style: &ResolvedStyle,
5814        cursor: &mut PageCursor,
5815        pages: &mut Vec<LayoutPage>,
5816        x: f64,
5817        _available_width: f64,
5818        canvas_width: f64,
5819        canvas_height: f64,
5820        operations: &[CanvasOp],
5821    ) {
5822        let margin = style.margin.to_edges();
5823        let total_height = canvas_height + margin.top + margin.bottom;
5824
5825        // Page break check
5826        if cursor.remaining_height() < total_height && cursor.y > 0.0 {
5827            pages.push(cursor.finalize());
5828            *cursor = cursor.new_page();
5829        }
5830
5831        cursor.y += margin.top;
5832
5833        let svg_commands = Self::canvas_ops_to_svg_commands(operations);
5834
5835        cursor.elements.push(LayoutElement {
5836            x: x + margin.left,
5837            y: cursor.content_y + cursor.y,
5838            width: canvas_width,
5839            height: canvas_height,
5840            draw: DrawCommand::Svg {
5841                commands: svg_commands,
5842                width: canvas_width,
5843                height: canvas_height,
5844                // Canvas constructs commands in display coordinates, so the
5845                // viewBox matches the display box 1:1 — scale comes out to 1.
5846                viewbox_min_x: 0.0,
5847                viewbox_min_y: 0.0,
5848                viewbox_width: canvas_width,
5849                viewbox_height: canvas_height,
5850                clip: true,
5851            },
5852            children: vec![],
5853            node_type: Some("Canvas".to_string()),
5854            resolved_style: Some(style.clone()),
5855            source_location: node.source_location.clone(),
5856            href: node.href.clone(),
5857            bookmark: node.bookmark.clone(),
5858            alt: node.alt.clone(),
5859            is_header_row: false,
5860            actual_text: None,
5861            list_numbering: None,
5862            col_span: 1,
5863            overflow: style.overflow,
5864            opacity: style.opacity,
5865        });
5866
5867        cursor.y += canvas_height + margin.bottom;
5868    }
5869
5870    /// Layout a 1D barcode as a row of vector rectangles.
5871    #[allow(clippy::too_many_arguments)]
5872    /// Layout a chart as a single unbreakable block of drawing primitives.
5873    #[allow(clippy::too_many_arguments)]
5874    fn layout_chart(
5875        &self,
5876        node: &Node,
5877        style: &ResolvedStyle,
5878        cursor: &mut PageCursor,
5879        pages: &mut Vec<LayoutPage>,
5880        x: f64,
5881        chart_width: f64,
5882        chart_height: f64,
5883        primitives: Vec<crate::chart::ChartPrimitive>,
5884        node_type_name: &str,
5885    ) {
5886        let margin = &style.margin.to_edges();
5887        let total_height = chart_height + margin.vertical();
5888
5889        if total_height > cursor.remaining_height() {
5890            pages.push(cursor.finalize());
5891            *cursor = cursor.new_page();
5892        }
5893
5894        cursor.y += margin.top;
5895
5896        let draw = DrawCommand::Chart { primitives };
5897
5898        cursor.elements.push(LayoutElement {
5899            x: x + margin.left,
5900            y: cursor.content_y + cursor.y,
5901            width: chart_width,
5902            height: chart_height,
5903            draw,
5904            children: vec![],
5905            node_type: Some(node_type_name.to_string()),
5906            resolved_style: Some(style.clone()),
5907            source_location: node.source_location.clone(),
5908            href: node.href.clone(),
5909            bookmark: node.bookmark.clone(),
5910            alt: node.alt.clone(),
5911            is_header_row: false,
5912            actual_text: None,
5913            list_numbering: None,
5914            col_span: 1,
5915            overflow: style.overflow,
5916            opacity: style.opacity,
5917        });
5918
5919        cursor.y += chart_height + margin.bottom;
5920    }
5921
5922    /// Layout a form field as a fixed-size leaf node.
5923    #[allow(clippy::too_many_arguments)]
5924    fn layout_form_field(
5925        &self,
5926        node: &Node,
5927        style: &ResolvedStyle,
5928        cursor: &mut PageCursor,
5929        pages: &mut Vec<LayoutPage>,
5930        x: f64,
5931        field_width: f64,
5932        field_height: f64,
5933        draw: DrawCommand,
5934        node_type_name: &str,
5935    ) {
5936        let margin = &style.margin.to_edges();
5937        let total_height = field_height + margin.vertical();
5938
5939        if total_height > cursor.remaining_height() {
5940            pages.push(cursor.finalize());
5941            *cursor = cursor.new_page();
5942        }
5943
5944        cursor.y += margin.top;
5945
5946        cursor.elements.push(LayoutElement {
5947            x: x + margin.left,
5948            y: cursor.content_y + cursor.y,
5949            width: field_width,
5950            height: field_height,
5951            draw,
5952            children: vec![],
5953            node_type: Some(node_type_name.to_string()),
5954            resolved_style: Some(style.clone()),
5955            source_location: node.source_location.clone(),
5956            href: node.href.clone(),
5957            bookmark: node.bookmark.clone(),
5958            alt: node.alt.clone(),
5959            is_header_row: false,
5960            actual_text: None,
5961            list_numbering: None,
5962            col_span: 1,
5963            overflow: style.overflow,
5964            opacity: style.opacity,
5965        });
5966
5967        cursor.y += field_height + margin.bottom;
5968    }
5969
5970    #[allow(clippy::too_many_arguments)]
5971    fn layout_barcode(
5972        &self,
5973        node: &Node,
5974        style: &ResolvedStyle,
5975        cursor: &mut PageCursor,
5976        pages: &mut Vec<LayoutPage>,
5977        x: f64,
5978        available_width: f64,
5979        data: &str,
5980        format: crate::barcode::BarcodeFormat,
5981        explicit_width: Option<f64>,
5982        bar_height: f64,
5983    ) {
5984        let margin = &style.margin.to_edges();
5985        let display_width = explicit_width.unwrap_or(available_width - margin.horizontal());
5986        let total_height = bar_height + margin.vertical();
5987
5988        if total_height > cursor.remaining_height() {
5989            pages.push(cursor.finalize());
5990            *cursor = cursor.new_page();
5991        }
5992
5993        cursor.y += margin.top;
5994
5995        let draw = match crate::barcode::generate_barcode(data, format) {
5996            Ok(barcode_data) => {
5997                let bar_width = if barcode_data.bars.is_empty() {
5998                    0.0
5999                } else {
6000                    display_width / barcode_data.bars.len() as f64
6001                };
6002                DrawCommand::Barcode {
6003                    bars: barcode_data.bars,
6004                    bar_width,
6005                    height: bar_height,
6006                    color: style.color,
6007                }
6008            }
6009            Err(_) => DrawCommand::None,
6010        };
6011
6012        cursor.elements.push(LayoutElement {
6013            x: x + margin.left,
6014            y: cursor.content_y + cursor.y,
6015            width: display_width,
6016            height: bar_height,
6017            draw,
6018            children: vec![],
6019            node_type: Some("Barcode".to_string()),
6020            resolved_style: Some(style.clone()),
6021            source_location: node.source_location.clone(),
6022            href: node.href.clone(),
6023            bookmark: node.bookmark.clone(),
6024            alt: node.alt.clone(),
6025            is_header_row: false,
6026            actual_text: Some(data.to_string()),
6027            list_numbering: None,
6028            col_span: 1,
6029            overflow: style.overflow,
6030            opacity: style.opacity,
6031        });
6032
6033        cursor.y += bar_height + margin.bottom;
6034    }
6035
6036    /// Layout a QR code as a square block of vector rectangles.
6037    #[allow(clippy::too_many_arguments)]
6038    fn layout_qrcode(
6039        &self,
6040        node: &Node,
6041        style: &ResolvedStyle,
6042        cursor: &mut PageCursor,
6043        pages: &mut Vec<LayoutPage>,
6044        x: f64,
6045        available_width: f64,
6046        data: &str,
6047        explicit_size: Option<f64>,
6048    ) {
6049        let margin = &style.margin.to_edges();
6050        let display_size = explicit_size.unwrap_or(available_width - margin.horizontal());
6051        let total_height = display_size + margin.vertical();
6052
6053        if total_height > cursor.remaining_height() {
6054            pages.push(cursor.finalize());
6055            *cursor = cursor.new_page();
6056        }
6057
6058        cursor.y += margin.top;
6059
6060        let draw = match crate::qrcode::generate_qr(data) {
6061            Ok(matrix) => {
6062                let module_size = display_size / matrix.size as f64;
6063                DrawCommand::QrCode {
6064                    modules: matrix.modules,
6065                    module_size,
6066                    color: style.color,
6067                }
6068            }
6069            Err(_) => DrawCommand::None,
6070        };
6071
6072        cursor.elements.push(LayoutElement {
6073            x: x + margin.left,
6074            y: cursor.content_y + cursor.y,
6075            width: display_size,
6076            height: display_size,
6077            draw,
6078            children: vec![],
6079            node_type: Some("QrCode".to_string()),
6080            resolved_style: Some(style.clone()),
6081            source_location: node.source_location.clone(),
6082            href: node.href.clone(),
6083            bookmark: node.bookmark.clone(),
6084            alt: node.alt.clone(),
6085            is_header_row: false,
6086            actual_text: Some(data.to_string()),
6087            list_numbering: None,
6088            col_span: 1,
6089            overflow: style.overflow,
6090            opacity: style.opacity,
6091        });
6092
6093        cursor.y += display_size + margin.bottom;
6094    }
6095
6096    // ── Measurement helpers ─────────────────────────────────────
6097
6098    fn measure_node_height(
6099        &self,
6100        node: &Node,
6101        available_width: f64,
6102        style: &ResolvedStyle,
6103        font_context: &FontContext,
6104    ) -> f64 {
6105        match &node.kind {
6106            // Headings lay out exactly like Text (see the layout arm), so they
6107            // must measure the same way — otherwise a heading falls through to
6108            // the container `_` arm, measures ~0 (it has no children), and a
6109            // parent's auto-height omits it.
6110            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
6111                // Mirror layout_text: a fixed width drives line-breaking, so height
6112                // measurement must use the same width or it will under-count lines.
6113                let measure_width = match style.width {
6114                    SizeConstraint::Fixed(w) => (w - style.margin.horizontal()).max(0.0),
6115                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
6116                };
6117                // Measurement must reach the same line count layout will:
6118                // same text transform, same breaker (greedy vs Knuth-Plass —
6119                // the two can disagree at boundary widths, where optimal
6120                // accepts a slightly-overfull line greedy would wrap).
6121                // Divergence here is exactly what FORME_MEASURE_CHECK exists
6122                // to catch.
6123                if !runs.is_empty() {
6124                    let mut styled_chars: Vec<StyledChar> = Vec::new();
6125                    for run in runs {
6126                        let run_style = run.style.resolve(Some(style), measure_width);
6127                        let transform = run_style.text_transform;
6128                        let run_content = substitute_page_placeholders(&run.content);
6129                        let mut prev_is_whitespace = true;
6130                        for ch in run_content.chars() {
6131                            let transformed_ch =
6132                                apply_char_transform(ch, transform, prev_is_whitespace);
6133                            prev_is_whitespace = ch.is_whitespace();
6134                            styled_chars.push(StyledChar {
6135                                ch: transformed_ch,
6136                                font_family: run_style.font_family.clone(),
6137                                font_size: run_style.font_size,
6138                                font_weight: run_style.font_weight,
6139                                font_style: run_style.font_style,
6140                                color: run_style.color,
6141                                href: None,
6142                                text_decoration: run_style.text_decoration,
6143                                letter_spacing: run_style.letter_spacing,
6144                            });
6145                        }
6146                    }
6147                    let justify = matches!(style.text_align, TextAlign::Justify);
6148                    let broken_lines = match style.line_breaking {
6149                        LineBreaking::Optimal => self.text_layout.break_runs_into_lines_optimal(
6150                            font_context,
6151                            &styled_chars,
6152                            measure_width,
6153                            style.hyphens,
6154                            style.lang.as_deref(),
6155                            justify,
6156                        ),
6157                        LineBreaking::Greedy => self.text_layout.break_runs_into_lines(
6158                            font_context,
6159                            &styled_chars,
6160                            measure_width,
6161                            style.hyphens,
6162                            style.lang.as_deref(),
6163                        ),
6164                    };
6165                    let line_height = style.font_size * style.line_height;
6166                    (broken_lines.len() as f64) * line_height + style.padding.vertical()
6167                } else {
6168                    let content = substitute_page_placeholders(content);
6169                    let transformed = apply_text_transform(&content, style.text_transform);
6170                    let justify = matches!(style.text_align, TextAlign::Justify);
6171                    let lines = match style.line_breaking {
6172                        LineBreaking::Optimal => self.text_layout.break_into_lines_optimal(
6173                            font_context,
6174                            &transformed,
6175                            measure_width,
6176                            style.font_size,
6177                            &style.font_family,
6178                            style.font_weight,
6179                            style.font_style,
6180                            style.letter_spacing,
6181                            style.hyphens,
6182                            style.lang.as_deref(),
6183                            justify,
6184                        ),
6185                        LineBreaking::Greedy => self.text_layout.break_into_lines(
6186                            font_context,
6187                            &transformed,
6188                            measure_width,
6189                            style.font_size,
6190                            &style.font_family,
6191                            style.font_weight,
6192                            style.font_style,
6193                            style.letter_spacing,
6194                            style.hyphens,
6195                            style.lang.as_deref(),
6196                        ),
6197                    };
6198                    let line_height = style.font_size * style.line_height;
6199                    (lines.len() as f64) * line_height + style.padding.vertical()
6200                }
6201            }
6202            NodeKind::Image {
6203                src,
6204                width: explicit_w,
6205                height: explicit_h,
6206            } => {
6207                // Same ladder layout_image uses — agreement by construction.
6208                let (_, h) = self.image_display_size(
6209                    src,
6210                    style,
6211                    *explicit_w,
6212                    *explicit_h,
6213                    available_width - style.margin.horizontal(),
6214                );
6215                h + style.padding.vertical()
6216            }
6217            NodeKind::Svg { height, .. } => *height + style.margin.vertical(),
6218            NodeKind::Barcode { height, .. } => *height + style.margin.vertical(),
6219            NodeKind::QrCode { size, .. } => {
6220                let display_size = size.unwrap_or(available_width - style.margin.horizontal());
6221                display_size + style.margin.vertical()
6222            }
6223            NodeKind::Canvas { height, .. } => *height + style.margin.vertical(),
6224            NodeKind::BarChart { height, .. }
6225            | NodeKind::LineChart { height, .. }
6226            | NodeKind::PieChart { height, .. }
6227            | NodeKind::AreaChart { height, .. }
6228            | NodeKind::DotPlot { height, .. } => *height + style.margin.vertical(),
6229            NodeKind::TextField { height, .. }
6230            | NodeKind::Checkbox { height, .. }
6231            | NodeKind::Dropdown { height, .. }
6232            | NodeKind::RadioButton { height, .. } => *height + style.margin.vertical(),
6233            NodeKind::Watermark { .. } => 0.0, // Watermarks take zero layout height
6234            NodeKind::Table { columns } => {
6235                // Use the same column-resolution + per-row max-of-cells helpers
6236                // that `layout_table` uses, so measurement matches what the
6237                // engine actually renders. Without this arm, Table fell into the
6238                // generic `_` branch which column-summed each row's children,
6239                // and (since TableRow also lacked an arm) over-counted row
6240                // heights by a factor of (cell count).
6241                if let SizeConstraint::Fixed(h) = style.height {
6242                    return h;
6243                }
6244                let outer_width = match style.width {
6245                    SizeConstraint::Fixed(w) => w,
6246                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
6247                };
6248                let inner_width =
6249                    outer_width - style.padding.horizontal() - style.border_width.horizontal();
6250                let col_widths = self.resolve_column_widths(
6251                    columns,
6252                    inner_width,
6253                    &node.children,
6254                    style,
6255                    font_context,
6256                );
6257                let row_gap = style.row_gap;
6258                let offsets = Self::table_column_offsets(&node.children);
6259                let mut total = 0.0;
6260                for (i, row) in node.children.iter().enumerate() {
6261                    if i > 0 {
6262                        total += row_gap;
6263                    }
6264                    total += self.measure_table_row_height(
6265                        row,
6266                        &col_widths,
6267                        &offsets[i],
6268                        style,
6269                        font_context,
6270                    );
6271                }
6272                total + style.padding.vertical() + style.border_width.vertical()
6273            }
6274            NodeKind::TableRow { .. } => {
6275                // Standalone-row fallback (rare): a TableRow measured outside
6276                // a Table context has no ColumnDef source, so split
6277                // available_width evenly across cells — matches what
6278                // resolve_column_widths does when its defs vec is empty.
6279                let n = node.children.len().max(1);
6280                let usable = (available_width - style.margin.horizontal()).max(0.0);
6281                let col_w = usable / n as f64;
6282                let col_widths = vec![col_w; n];
6283                let offsets = Self::table_column_offsets(std::slice::from_ref(node));
6284                self.measure_table_row_height(node, &col_widths, &offsets[0], style, font_context)
6285            }
6286            _ => {
6287                // If a fixed height is specified, use it directly
6288                if let SizeConstraint::Fixed(h) = style.height {
6289                    return h;
6290                }
6291                // Match layout_view: when width is Auto, margin reduces the
6292                // outer width; min/max clamp identically or measured heights
6293                // disagree with laid-out widths.
6294                let outer_width = match style.width {
6295                    SizeConstraint::Fixed(w) => w,
6296                    SizeConstraint::Auto => available_width - style.margin.horizontal(),
6297                }
6298                .min(style.max_width)
6299                .max(style.min_width);
6300                let inner_width =
6301                    outer_width - style.padding.horizontal() - style.border_width.horizontal();
6302                let children_height =
6303                    self.measure_children_height(&node.children, inner_width, style, font_context);
6304                (children_height + style.padding.vertical() + style.border_width.vertical())
6305                    .max(style.min_height)
6306            }
6307        }
6308    }
6309
6310    fn measure_children_height(
6311        &self,
6312        children: &[Node],
6313        available_width: f64,
6314        parent_style: &ResolvedStyle,
6315        font_context: &FontContext,
6316    ) -> f64 {
6317        // Absolutely-positioned children are out of flow: layout_children
6318        // partitions them off and they never advance the cursor, so counting
6319        // them here reserves phantom space equal to their height in every
6320        // auto-height ancestor. Caught by FORME_MEASURE_CHECK. Clone-filter
6321        // only in the rare case one is present.
6322        if children
6323            .iter()
6324            .any(|c| matches!(c.style.position, Some(Position::Absolute)))
6325        {
6326            let flow: Vec<Node> = children
6327                .iter()
6328                .filter(|c| !matches!(c.style.position, Some(Position::Absolute)))
6329                .cloned()
6330                .collect();
6331            return self.measure_children_height(
6332                &flow,
6333                available_width,
6334                parent_style,
6335                font_context,
6336            );
6337        }
6338        // Grid layout: measure using actual grid placement instead of stacking
6339        if matches!(parent_style.display, Display::Grid) {
6340            if let Some(template_cols) = &parent_style.grid_template_columns {
6341                let num_columns = template_cols.len();
6342                if num_columns > 0 && !children.is_empty() {
6343                    let col_gap = parent_style.column_gap;
6344                    let row_gap = parent_style.row_gap;
6345
6346                    let content_sizes: Vec<f64> = template_cols
6347                        .iter()
6348                        .map(|track| {
6349                            if matches!(track, GridTrackSize::Auto) {
6350                                available_width / num_columns as f64
6351                            } else {
6352                                0.0
6353                            }
6354                        })
6355                        .collect();
6356
6357                    let col_widths = grid::resolve_tracks(
6358                        template_cols,
6359                        available_width,
6360                        col_gap,
6361                        &content_sizes,
6362                    );
6363
6364                    let placements: Vec<Option<&GridPlacement>> = children
6365                        .iter()
6366                        .map(|child| child.style.grid_placement.as_ref())
6367                        .collect();
6368
6369                    let item_placements = grid::place_items(&placements, num_columns);
6370                    let num_rows = grid::compute_num_rows(&item_placements);
6371
6372                    if num_rows == 0 {
6373                        return 0.0;
6374                    }
6375
6376                    let mut row_heights = vec![0.0_f64; num_rows];
6377                    for placement in &item_placements {
6378                        let cell_width = grid::span_width(
6379                            placement.col_start,
6380                            placement.col_end,
6381                            &col_widths,
6382                            col_gap,
6383                        );
6384                        let child = &children[placement.child_index];
6385                        let child_style = child.style.resolve(Some(parent_style), cell_width);
6386                        let h =
6387                            self.measure_node_height(child, cell_width, &child_style, font_context);
6388                        let span = placement.row_end - placement.row_start;
6389                        let per_row = h / span as f64;
6390                        for rh in row_heights
6391                            .iter_mut()
6392                            .take(placement.row_end.min(num_rows))
6393                            .skip(placement.row_start)
6394                        {
6395                            if per_row > *rh {
6396                                *rh = per_row;
6397                            }
6398                        }
6399                    }
6400
6401                    let total_row_gap = row_gap * (num_rows as f64 - 1.0).max(0.0);
6402                    return row_heights.iter().sum::<f64>() + total_row_gap;
6403                }
6404            }
6405        }
6406
6407        let direction = parent_style.flex_direction;
6408        let row_gap = parent_style.row_gap;
6409        let column_gap = parent_style.column_gap;
6410
6411        match direction {
6412            FlexDirection::Row | FlexDirection::RowReverse => {
6413                // Measure base widths for all children
6414                // flex_basis takes precedence over width (matching layout_flex_row)
6415                let styles: Vec<ResolvedStyle> = children
6416                    .iter()
6417                    .map(|child| child.style.resolve(Some(parent_style), available_width))
6418                    .collect();
6419
6420                let base_widths: Vec<f64> = children
6421                    .iter()
6422                    .zip(&styles)
6423                    .map(|(child, style)| match style.flex_basis {
6424                        SizeConstraint::Fixed(w) => w,
6425                        SizeConstraint::Auto => match style.width {
6426                            SizeConstraint::Fixed(w) => w,
6427                            SizeConstraint::Auto => self
6428                                .measure_intrinsic_width(child, style, font_context)
6429                                .min(available_width),
6430                        },
6431                    })
6432                    .collect();
6433
6434                let lines = match parent_style.flex_wrap {
6435                    FlexWrap::NoWrap => {
6436                        vec![flex::WrapLine {
6437                            start: 0,
6438                            end: children.len(),
6439                        }]
6440                    }
6441                    FlexWrap::Wrap | FlexWrap::WrapReverse => {
6442                        flex::partition_into_lines(&base_widths, column_gap, available_width)
6443                    }
6444                };
6445
6446                // Apply flex grow/shrink to get final widths (matching layout_flex_row)
6447                let mut final_widths = base_widths.clone();
6448                for line in &lines {
6449                    let line_count = line.end - line.start;
6450                    let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
6451                    let distributable = available_width - line_gap;
6452                    let total_base: f64 = base_widths[line.start..line.end].iter().sum();
6453                    let remaining = distributable - total_base;
6454
6455                    if remaining > 0.0 {
6456                        let total_grow: f64 = styles[line.start..line.end]
6457                            .iter()
6458                            .map(|s| s.flex_grow)
6459                            .sum();
6460                        if total_grow > 0.0 {
6461                            for (j, s) in styles[line.start..line.end].iter().enumerate() {
6462                                final_widths[line.start + j] = base_widths[line.start + j]
6463                                    + remaining * (s.flex_grow / total_grow);
6464                            }
6465                        }
6466                    } else if remaining < 0.0 {
6467                        let total_shrink: f64 = styles[line.start..line.end]
6468                            .iter()
6469                            .enumerate()
6470                            .map(|(j, s)| s.flex_shrink * base_widths[line.start + j])
6471                            .sum();
6472                        if total_shrink > 0.0 {
6473                            for (j, s) in styles[line.start..line.end].iter().enumerate() {
6474                                let factor =
6475                                    (s.flex_shrink * base_widths[line.start + j]) / total_shrink;
6476                                let w = base_widths[line.start + j] + remaining * factor;
6477                                final_widths[line.start + j] = w.max(s.min_width);
6478                            }
6479                        }
6480                    }
6481                }
6482
6483                let mut total = 0.0;
6484                for (i, line) in lines.iter().enumerate() {
6485                    let line_height: f64 = children[line.start..line.end]
6486                        .iter()
6487                        .enumerate()
6488                        .map(|(j, child)| {
6489                            let fw = final_widths[line.start + j];
6490                            // Resolve against the CONTAINER's width, not the
6491                            // child's own final width: a child's percent width
6492                            // (and percent margins/padding — CSS resolves them
6493                            // against the containing block) must not resolve
6494                            // against itself. Resolving `width: 27%` against
6495                            // fw made it 27% of 27%, so text measured at a
6496                            // quarter width — one word per line — and rows
6497                            // measured 2.5-4x taller than layout produced.
6498                            let child_style =
6499                                child.style.resolve(Some(parent_style), available_width);
6500                            self.measure_node_height(child, fw, &child_style, font_context)
6501                                + child_style.margin.vertical()
6502                        })
6503                        .fold(0.0f64, f64::max);
6504                    total += line_height;
6505                    if i > 0 {
6506                        total += row_gap;
6507                    }
6508                }
6509                total
6510            }
6511            FlexDirection::Column | FlexDirection::ColumnReverse => {
6512                let mut total = 0.0;
6513                for (i, child) in children.iter().enumerate() {
6514                    let child_style = child.style.resolve(Some(parent_style), available_width);
6515                    let child_height = self.measure_node_height(
6516                        child,
6517                        available_width,
6518                        &child_style,
6519                        font_context,
6520                    );
6521                    total += child_height + child_style.margin.vertical();
6522                    if i > 0 {
6523                        total += row_gap;
6524                    }
6525                }
6526                total
6527            }
6528        }
6529    }
6530
6531    /// Measure intrinsic width of a node (used for flex row sizing).
6532    fn measure_intrinsic_width(
6533        &self,
6534        node: &Node,
6535        style: &ResolvedStyle,
6536        font_context: &FontContext,
6537    ) -> f64 {
6538        match &node.kind {
6539            NodeKind::Svg { width, .. } => {
6540                *width + style.padding.horizontal() + style.margin.horizontal()
6541            }
6542            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
6543                // Runs-based text measures per run with each run's own
6544                // resolved style — `content` is empty (or a shadow copy)
6545                // when runs are present, so measuring it alone reports a
6546                // zero/approximate width and flex rows collapse the node
6547                // to one character per line.
6548                let text_width = if !runs.is_empty() {
6549                    runs.iter()
6550                        .map(|run| {
6551                            let run_style = run.style.resolve(Some(style), 0.0);
6552                            let run_content = substitute_page_placeholders(&run.content);
6553                            let transformed =
6554                                apply_text_transform(&run_content, run_style.text_transform);
6555                            let italic = matches!(
6556                                run_style.font_style,
6557                                FontStyle::Italic | FontStyle::Oblique
6558                            );
6559                            // A hard break ('\n') restarts the line: the
6560                            // intrinsic width of multi-line text is the
6561                            // widest line, so measure segments separately.
6562                            transformed
6563                                .split('\n')
6564                                .map(|segment| {
6565                                    font_context.measure_string(
6566                                        segment,
6567                                        &run_style.font_family,
6568                                        run_style.font_weight,
6569                                        italic,
6570                                        run_style.font_size,
6571                                        run_style.letter_spacing,
6572                                    )
6573                                })
6574                                .fold(0.0f64, f64::max)
6575                        })
6576                        .sum()
6577                } else {
6578                    let content = substitute_page_placeholders(content);
6579                    let transformed = apply_text_transform(&content, style.text_transform);
6580                    let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
6581                    transformed
6582                        .split('\n')
6583                        .map(|segment| {
6584                            font_context.measure_string(
6585                                segment,
6586                                &style.font_family,
6587                                style.font_weight,
6588                                italic,
6589                                style.font_size,
6590                                style.letter_spacing,
6591                            )
6592                        })
6593                        .fold(0.0f64, f64::max)
6594                };
6595                // Add tiny epsilon to prevent exact-boundary line wrapping when
6596                // this width is later used as max_width for line breaking
6597                text_width + 0.01 + style.padding.horizontal() + style.margin.horizontal()
6598            }
6599            NodeKind::Image {
6600                src, width, height, ..
6601            } => {
6602                let w = if let SizeConstraint::Fixed(w) = style.width {
6603                    w
6604                } else if let Some(w) = width {
6605                    *w
6606                } else if let Some((iw, ih)) = self.get_image_dimensions(src) {
6607                    let pixel_w = iw as f64;
6608                    let pixel_h = ih as f64;
6609                    let aspect = if pixel_w > 0.0 {
6610                        pixel_h / pixel_w
6611                    } else {
6612                        0.75
6613                    };
6614                    // Check for height constraint (style or node prop)
6615                    let constrained_h = match style.height {
6616                        SizeConstraint::Fixed(h) => Some(h),
6617                        SizeConstraint::Auto => *height,
6618                    };
6619                    if let Some(h) = constrained_h {
6620                        h / aspect
6621                    } else {
6622                        pixel_w
6623                    }
6624                } else {
6625                    100.0
6626                };
6627                w + style.padding.horizontal() + style.margin.horizontal()
6628            }
6629            NodeKind::Barcode { width, .. } => {
6630                let w = width.unwrap_or(0.0);
6631                w + style.padding.horizontal() + style.margin.horizontal()
6632            }
6633            NodeKind::QrCode { size, .. } => {
6634                let display_size = size.unwrap_or(0.0);
6635                display_size + style.padding.horizontal() + style.margin.horizontal()
6636            }
6637            NodeKind::Canvas { width, .. } => {
6638                *width + style.padding.horizontal() + style.margin.horizontal()
6639            }
6640            NodeKind::BarChart { width, .. }
6641            | NodeKind::LineChart { width, .. }
6642            | NodeKind::PieChart { width, .. }
6643            | NodeKind::AreaChart { width, .. }
6644            | NodeKind::DotPlot { width, .. } => {
6645                *width + style.padding.horizontal() + style.margin.horizontal()
6646            }
6647            NodeKind::TextField { width, .. } | NodeKind::Dropdown { width, .. } => {
6648                *width + style.padding.horizontal() + style.margin.horizontal()
6649            }
6650            NodeKind::Table { columns } => {
6651                // A table's max-content width is the SUM of its columns'
6652                // max-content (the default max-of-children arm below
6653                // reports only the widest cell, which made shrink-to-fit
6654                // containers crush tables to one column's width).
6655                let num_cols = node
6656                    .children
6657                    .iter()
6658                    .map(|row| row.children.iter().map(Self::cell_col_span).sum::<usize>())
6659                    .max()
6660                    .unwrap_or(1)
6661                    .max(columns.len().max(1));
6662                let (_, col_max) =
6663                    self.measure_column_content(&node.children, num_cols, 0.0, style, font_context);
6664                col_max.iter().sum::<f64>()
6665                    + style.padding.horizontal()
6666                    + style.margin.horizontal()
6667                    + style.border_width.horizontal()
6668            }
6669            NodeKind::Checkbox { width, .. } | NodeKind::RadioButton { width, .. } => {
6670                *width + style.padding.horizontal() + style.margin.horizontal()
6671            }
6672            NodeKind::Watermark { .. } => 0.0, // Watermarks take zero width
6673            _ => {
6674                // An explicit width IS the intrinsic width (content-box:
6675                // padding and border sit on top, margins outside). The
6676                // children-based measure below ignored it, so an empty
6677                // width:33pt div measured 0 and the masthead mark measured
6678                // as the width of its letter "N" (~10.8pt) — its flex row
6679                // then went over-full by exactly the difference and shrank
6680                // the mark to 25.9pt.
6681                if let SizeConstraint::Fixed(w) = style.width {
6682                    return w
6683                        + style.padding.horizontal()
6684                        + style.border_width.horizontal()
6685                        + style.margin.horizontal();
6686                }
6687                // Recursively measure children's intrinsic widths
6688                if node.children.is_empty() {
6689                    style.padding.horizontal() + style.margin.horizontal()
6690                } else {
6691                    let direction = style.flex_direction;
6692                    // The resolved authority for a row's inter-item gap is
6693                    // column_gap — resolve() folds the `gap` shorthand into
6694                    // it, and layout_flex_row reads column_gap. Reading the
6695                    // raw `gap` field measured every CSS `gap:`/`column-gap:`
6696                    // flex row as gapless: a nested row under-reported its
6697                    // intrinsic width by (n-1)*gap, was handed exactly that
6698                    // width, went over-full, and shrank its own fixed-width
6699                    // children (the masthead square that rendered 25.9pt
6700                    // wide with width: 33pt declared). JSX callers are
6701                    // unaffected: their `gap` folds into column_gap at
6702                    // resolve time, so the two fields agree there.
6703                    let gap = style.column_gap;
6704                    let mut total = 0.0f64;
6705                    for (i, child) in node.children.iter().enumerate() {
6706                        let child_style = child.style.resolve(Some(style), 0.0);
6707                        let child_width =
6708                            self.measure_intrinsic_width(child, &child_style, font_context);
6709                        match direction {
6710                            FlexDirection::Row | FlexDirection::RowReverse => {
6711                                total += child_width;
6712                                if i > 0 {
6713                                    total += gap;
6714                                }
6715                            }
6716                            _ => {
6717                                total = total.max(child_width);
6718                            }
6719                        }
6720                    }
6721                    total
6722                        + style.padding.horizontal()
6723                        + style.margin.horizontal()
6724                        + style.border_width.horizontal()
6725                }
6726            }
6727        }
6728    }
6729
6730    /// Measure the min-content width of a node — the minimum width needed
6731    /// to render without breaking unbreakable words. For Text nodes this is
6732    /// the widest single word; for containers it's the max of children.
6733    pub fn measure_min_content_width(
6734        &self,
6735        node: &Node,
6736        style: &ResolvedStyle,
6737        font_context: &FontContext,
6738    ) -> f64 {
6739        match &node.kind {
6740            NodeKind::Table { columns } => {
6741                // Min-content of a table = sum of per-column min-content
6742                // (mirrors the intrinsic-width Table arm).
6743                let num_cols = node
6744                    .children
6745                    .iter()
6746                    .map(|row| row.children.iter().map(Self::cell_col_span).sum::<usize>())
6747                    .max()
6748                    .unwrap_or(1)
6749                    .max(columns.len().max(1));
6750                let (col_min, _) =
6751                    self.measure_column_content(&node.children, num_cols, 0.0, style, font_context);
6752                col_min.iter().sum::<f64>()
6753                    + style.padding.horizontal()
6754                    + style.margin.horizontal()
6755                    + style.border_width.horizontal()
6756            }
6757            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
6758                let word_width = if !runs.is_empty() {
6759                    // For styled runs, measure each run's widest word
6760                    runs.iter()
6761                        .map(|run| {
6762                            let run_style = run.style.resolve(Some(style), 0.0);
6763                            let run_content = substitute_page_placeholders(&run.content);
6764                            let transformed =
6765                                apply_text_transform(&run_content, run_style.text_transform);
6766                            self.text_layout.measure_widest_word(
6767                                font_context,
6768                                &transformed,
6769                                run_style.font_size,
6770                                &run_style.font_family,
6771                                run_style.font_weight,
6772                                run_style.font_style,
6773                                run_style.letter_spacing,
6774                                style.hyphens,
6775                                style.lang.as_deref(),
6776                            )
6777                        })
6778                        .fold(0.0f64, f64::max)
6779                } else {
6780                    let content = substitute_page_placeholders(content);
6781                    let transformed = apply_text_transform(&content, style.text_transform);
6782                    self.text_layout.measure_widest_word(
6783                        font_context,
6784                        &transformed,
6785                        style.font_size,
6786                        &style.font_family,
6787                        style.font_weight,
6788                        style.font_style,
6789                        style.letter_spacing,
6790                        style.hyphens,
6791                        style.lang.as_deref(),
6792                    )
6793                };
6794                word_width + style.padding.horizontal() + style.margin.horizontal()
6795            }
6796            NodeKind::Image { width, .. } => {
6797                width.unwrap_or(0.0) + style.padding.horizontal() + style.margin.horizontal()
6798            }
6799            NodeKind::Svg { width, .. } => {
6800                *width + style.padding.horizontal() + style.margin.horizontal()
6801            }
6802            _ => {
6803                if node.children.is_empty() {
6804                    style.padding.horizontal()
6805                        + style.margin.horizontal()
6806                        + style.border_width.horizontal()
6807                } else {
6808                    let mut max_child_min = 0.0f64;
6809                    for child in &node.children {
6810                        let child_style = child.style.resolve(Some(style), 0.0);
6811                        let child_min =
6812                            self.measure_min_content_width(child, &child_style, font_context);
6813                        max_child_min = max_child_min.max(child_min);
6814                    }
6815                    max_child_min
6816                        + style.padding.horizontal()
6817                        + style.margin.horizontal()
6818                        + style.border_width.horizontal()
6819                }
6820            }
6821        }
6822    }
6823
6824    /// Distance from a flex ITEM's margin-box top to its first text baseline,
6825    /// in the engine's baseline model (half-leading + font_size — exactly
6826    /// where layout_text places glyphs; see `cell_first_baseline_in_line`).
6827    /// A Text/Heading item uses its own style; a container walks to its
6828    /// first text-producing descendant; an item with no text at all
6829    /// synthesizes from its own font style.
6830    fn flex_item_baseline_distance(
6831        &self,
6832        item: &Node,
6833        style: &ResolvedStyle,
6834        w: f64,
6835        font_context: &FontContext,
6836    ) -> f64 {
6837        let first_line = match &item.kind {
6838            NodeKind::Text { .. } | NodeKind::Heading { .. } => {
6839                let metrics = font_context.baseline_metrics(
6840                    &style.font_family,
6841                    style.font_weight,
6842                    matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique),
6843                );
6844                baseline_in_line(
6845                    style.font_size * style.line_height,
6846                    style.font_size,
6847                    metrics,
6848                )
6849            }
6850            _ => self.cell_first_baseline_in_line(item, style, w, font_context),
6851        };
6852        style.margin.to_edges().top + style.padding.top + style.border_width.top + first_line
6853    }
6854
6855    /// The first-baseline offset of a cell's first text line from its line-box
6856    /// top, in the same real-metric model as glyph placement (see
6857    /// `baseline_in_line`) — the two must agree or baseline alignment
6858    /// shoves drift from where the ink actually sits. Walks to the first
6859    /// text-producing descendant; falls back to the cell's own style when
6860    /// there is none.
6861    fn cell_first_baseline_in_line(
6862        &self,
6863        cell: &Node,
6864        cell_style: &ResolvedStyle,
6865        w: f64,
6866        font_context: &FontContext,
6867    ) -> f64 {
6868        fn first(node: &Node, parent: &ResolvedStyle, w: f64) -> Option<ResolvedStyle> {
6869            for ch in &node.children {
6870                let s = ch.style.resolve(Some(parent), w);
6871                match &ch.kind {
6872                    NodeKind::Text { .. } | NodeKind::Heading { .. } => return Some(s),
6873                    _ => {
6874                        if let Some(f) = first(ch, &s, w) {
6875                            return Some(f);
6876                        }
6877                    }
6878                }
6879            }
6880            None
6881        }
6882        let s = first(cell, cell_style, w).unwrap_or_else(|| cell_style.clone());
6883        let metrics = font_context.baseline_metrics(
6884            &s.font_family,
6885            s.font_weight,
6886            matches!(s.font_style, FontStyle::Italic | FontStyle::Oblique),
6887        );
6888        baseline_in_line(s.font_size * s.line_height, s.font_size, metrics)
6889    }
6890
6891    /// Distance from a cell's border-box top to its first text baseline:
6892    /// `padding.top + border.top + half-leading + first-line font_size` —
6893    /// matching exactly where layout_text places the glyphs, or baseline
6894    /// alignment drifts by half the leading.
6895    fn cell_baseline_distance(
6896        &self,
6897        cell: &Node,
6898        cell_style: &ResolvedStyle,
6899        inner_width: f64,
6900        font_context: &FontContext,
6901    ) -> f64 {
6902        cell_style.padding.top
6903            + cell_style.border_width.top
6904            + self.cell_first_baseline_in_line(cell, cell_style, inner_width, font_context)
6905    }
6906
6907    /// The row baseline: the max first-baseline distance across the row's
6908    /// `vertical-align: baseline` cells. `None` when no cell asks for baseline.
6909    fn row_baseline(
6910        &self,
6911        row: &Node,
6912        row_style: &ResolvedStyle,
6913        col_widths: &[f64],
6914        col_offsets: &[usize],
6915        font_context: &FontContext,
6916    ) -> Option<f64> {
6917        let mut b: Option<f64> = None;
6918        for (cell_i, cell) in row.children.iter().enumerate() {
6919            let span = match &cell.kind {
6920                NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
6921                _ => 1,
6922            };
6923            let start_col = col_offsets.get(cell_i).copied().unwrap_or(0);
6924            let col_width: f64 = col_widths.iter().skip(start_col).take(span).copied().sum();
6925            let cell_style = cell.style.resolve(Some(row_style), col_width);
6926            if matches!(cell_style.vertical_align, VerticalAlign::Baseline) {
6927                let iw = col_width
6928                    - cell_style.padding.horizontal()
6929                    - cell_style.border_width.horizontal();
6930                let d = self.cell_baseline_distance(cell, &cell_style, iw, font_context);
6931                b = Some(b.map_or(d, |m: f64| m.max(d)));
6932            }
6933        }
6934        b
6935    }
6936
6937    fn measure_table_row_height(
6938        &self,
6939        row: &Node,
6940        col_widths: &[f64],
6941        col_offsets: &[usize],
6942        parent_style: &ResolvedStyle,
6943        font_context: &FontContext,
6944    ) -> f64 {
6945        let row_style = row
6946            .style
6947            .resolve(Some(parent_style), col_widths.iter().sum());
6948        let mut max_height: f64 = 0.0;
6949        // Precompute the row baseline so a baseline-shoved cell can grow the row
6950        // rather than clip (the risk site).
6951        let row_bl = self.row_baseline(row, &row_style, col_widths, col_offsets, font_context);
6952
6953        for (cell_i, cell) in row.children.iter().enumerate() {
6954            let span = match &cell.kind {
6955                NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
6956                _ => 1,
6957            };
6958            let start_col = col_offsets.get(cell_i).copied().unwrap_or(0);
6959            let col_width: f64 = col_widths.iter().skip(start_col).take(span).copied().sum();
6960            let cell_style = cell.style.resolve(Some(&row_style), col_width);
6961            let inner_width =
6962                col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
6963
6964            let mut cell_content_height = 0.0;
6965            for child in &cell.children {
6966                let child_style = child.style.resolve(Some(&cell_style), inner_width);
6967                cell_content_height +=
6968                    self.measure_node_height(child, inner_width, &child_style, font_context);
6969            }
6970
6971            let mut total = cell_content_height
6972                + cell_style.padding.vertical()
6973                + cell_style.border_width.vertical();
6974            // A baseline cell is shoved down by `row_baseline - its own baseline
6975            // distance`; the row must be tall enough to fit that shove, or the
6976            // cell content clips.
6977            if matches!(cell_style.vertical_align, VerticalAlign::Baseline) {
6978                if let Some(b) = row_bl {
6979                    let d =
6980                        self.cell_baseline_distance(cell, &cell_style, inner_width, font_context);
6981                    total += (b - d).max(0.0);
6982                }
6983            }
6984            // CSS 2.1 §17.5.3: `height` on a table cell is a MINIMUM — the cell
6985            // grows to fit its content but never shrinks below the specified
6986            // height. This is the slack `vertical-align: middle/bottom` needs to
6987            // be visible. Auto-height cells are unaffected; content taller than
6988            // the height still wins. No clipping, and rows stay atomic (an
6989            // over-tall row overflows whole, it is not sliced).
6990            if let SizeConstraint::Fixed(h) = cell_style.height {
6991                total = total.max(h);
6992            }
6993            max_height = max_height.max(total);
6994        }
6995
6996        max_height.max(row_style.min_height)
6997    }
6998
6999    /// How many columns a cell spans (colspan, min 1).
7000    fn cell_col_span(cell: &Node) -> usize {
7001        match &cell.kind {
7002            NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
7003            _ => 1,
7004        }
7005    }
7006
7007    /// Per-column min-content / max-content, gathered across ALL rows.
7008    /// Spanning cells contribute an even share per column — the standard
7009    /// simplification. An explicit cell width pins the column's preferred
7010    /// size (still never below min-content).
7011    fn measure_column_content(
7012        &self,
7013        children: &[Node],
7014        num_cols: usize,
7015        available_width: f64,
7016        table_style: &ResolvedStyle,
7017        font_context: &FontContext,
7018    ) -> (Vec<f64>, Vec<f64>) {
7019        let mut col_min = vec![0.0f64; num_cols];
7020        let mut col_max = vec![0.0f64; num_cols];
7021        let offsets = Self::table_column_offsets(children);
7022        for (row_i, row_node) in children.iter().enumerate() {
7023            for (cell_i, cell) in row_node.children.iter().enumerate() {
7024                let col = offsets[row_i].get(cell_i).copied().unwrap_or(0);
7025                let span = Self::cell_col_span(cell);
7026                let cell_style = cell.style.resolve(Some(table_style), available_width);
7027                let chrome = cell_style.padding.horizontal() + cell_style.border_width.horizontal();
7028                let mut cmin = 0.0f64;
7029                let mut cmax = 0.0f64;
7030                for child in &cell.children {
7031                    let child_style = child.style.resolve(Some(&cell_style), 0.0);
7032                    cmin =
7033                        cmin.max(self.measure_min_content_width(child, &child_style, font_context));
7034                    cmax =
7035                        cmax.max(self.measure_intrinsic_width(child, &child_style, font_context));
7036                }
7037                cmin += chrome;
7038                let mut cmax = cmax.max(cmin) + chrome;
7039                if let SizeConstraint::Fixed(w) = cell_style.width {
7040                    cmax = w.max(cmin);
7041                }
7042                let per_min = cmin / span as f64;
7043                let per_max = cmax / span as f64;
7044                for k in col..(col + span).min(num_cols) {
7045                    col_min[k] = col_min[k].max(per_min);
7046                    col_max[k] = col_max[k].max(per_max);
7047                }
7048            }
7049        }
7050        (col_min, col_max)
7051    }
7052
7053    /// Resolve table column widths.
7054    ///
7055    /// With explicit defs: fixed/fraction as given, Auto shares the rest
7056    /// (clamped — overflowing fixed widths are a render defect, never a
7057    /// negative share). With NO defs: CSS-style automatic table layout —
7058    /// column count is the widest row's colspan sum (the old first-row
7059    /// cell count turned every banner-row invoice into a one-column table
7060    /// and shredded the rest, per template-compat/REPORT.md), and widths
7061    /// distribute by min/max content like a browser.
7062    fn resolve_column_widths(
7063        &self,
7064        defs: &[ColumnDef],
7065        available_width: f64,
7066        children: &[Node],
7067        table_style: &ResolvedStyle,
7068        font_context: &FontContext,
7069    ) -> Vec<f64> {
7070        if defs.is_empty() {
7071            let num_cols = Self::occupancy_column_count(children);
7072
7073            let (col_min, col_max) = self.measure_column_content(
7074                children,
7075                num_cols,
7076                available_width,
7077                table_style,
7078                font_context,
7079            );
7080
7081            let sum_min: f64 = col_min.iter().sum();
7082            let sum_max: f64 = col_max.iter().sum();
7083            let w = available_width;
7084            return if sum_max <= w {
7085                // Everything fits at preferred size: surplus distributes
7086                // proportionally to max-content (browser behavior for
7087                // width:100% tables).
7088                if sum_max <= f64::EPSILON {
7089                    vec![w / num_cols as f64; num_cols]
7090                } else {
7091                    col_max
7092                        .iter()
7093                        .map(|m| m + (w - sum_max) * (m / sum_max))
7094                        .collect()
7095                }
7096            } else if sum_min <= w {
7097                // Squeeze between min and max, proportional to each
7098                // column's flexibility.
7099                let denom = (sum_max - sum_min).max(f64::EPSILON);
7100                col_min
7101                    .iter()
7102                    .zip(&col_max)
7103                    .map(|(mn, mx)| mn + (w - sum_min) * ((mx - mn) / denom))
7104                    .collect()
7105            } else {
7106                // The content genuinely cannot fit. Scale mins down and
7107                // SAY SO — this used to be the silent shred.
7108                self.defect(format!(
7109                    "render defect: table columns need {:.0}pt at min-content but only {:.0}pt is available — text will wrap tighter than intended",
7110                    sum_min, w
7111                ));
7112                let scale = w / sum_min.max(f64::EPSILON);
7113                col_min.iter().map(|m| m * scale).collect()
7114            };
7115        }
7116
7117        // Defs can under-specify the table: a rowspan-spacer or short
7118        // first row yields fewer defs than the widest row has cells (the
7119        // InvoicePlane date block, template-compat/REPORT.md). Cells
7120        // beyond the defs used to get NO width at all — extend with Auto
7121        // columns to the true column count instead.
7122        let num_cols = Self::occupancy_column_count(children).max(defs.len());
7123        let mut defs_vec: Vec<ColumnDef> = defs.to_vec();
7124        while defs_vec.len() < num_cols {
7125            defs_vec.push(ColumnDef {
7126                width: ColumnWidth::Auto,
7127            });
7128        }
7129        let defs = &defs_vec[..];
7130
7131        let mut widths = Vec::new();
7132        let mut remaining = available_width;
7133        let mut auto_count = 0;
7134
7135        for def in defs {
7136            match def.width {
7137                ColumnWidth::Fixed(w) => {
7138                    widths.push(w);
7139                    remaining -= w;
7140                }
7141                ColumnWidth::Fraction(f) => {
7142                    let w = available_width * f;
7143                    widths.push(w);
7144                    remaining -= w;
7145                }
7146                ColumnWidth::Auto => {
7147                    widths.push(0.0);
7148                    auto_count += 1;
7149                }
7150            }
7151        }
7152
7153        if remaining < 0.0 {
7154            // Fixed/fraction widths exceed the table: Auto columns would
7155            // have gone NEGATIVE. Clamp, and report the defect.
7156            self.defect(format!(
7157                "render defect: table column widths total {:.0}pt but only {:.0}pt is available — remaining columns were clamped to their minimum",
7158                available_width - remaining,
7159                available_width
7160            ));
7161            remaining = 0.0;
7162        }
7163
7164        if auto_count > 0 {
7165            let auto_width = remaining / auto_count as f64;
7166            for (i, def) in defs.iter().enumerate() {
7167                if matches!(def.width, ColumnWidth::Auto) {
7168                    widths[i] = auto_width;
7169                }
7170            }
7171        }
7172
7173        // Specified widths are suggestions, not laws (browser auto table
7174        // layout): a column squeezed below its min-content — the classic
7175        // over-specified-width template — is floored at min-content, and
7176        // the deficit comes out of columns with surplus, proportionally.
7177        // A table where every column already fits is returned EXACTLY as
7178        // specified (byte-stable for the shipped templates).
7179        let (col_min, _) = self.measure_column_content(
7180            children,
7181            widths.len(),
7182            available_width,
7183            table_style,
7184            font_context,
7185        );
7186        let needs_floor = widths.iter().zip(&col_min).any(|(w, m)| *w + 0.01 < *m);
7187        if needs_floor {
7188            let sum_min: f64 = col_min.iter().sum();
7189            if sum_min > available_width {
7190                self.defect(format!(
7191                    "render defect: table columns need {:.0}pt at min-content but only {:.0}pt is available — text will wrap tighter than intended",
7192                    sum_min, available_width
7193                ));
7194                let scale = available_width / sum_min.max(f64::EPSILON);
7195                return col_min.iter().map(|m| m * scale).collect();
7196            }
7197            let deficit: f64 = widths
7198                .iter()
7199                .zip(&col_min)
7200                .map(|(w, m)| (m - w).max(0.0))
7201                .sum();
7202            let surplus: f64 = widths
7203                .iter()
7204                .zip(&col_min)
7205                .map(|(w, m)| (w - m).max(0.0))
7206                .sum();
7207            let take = if surplus > 0.0 {
7208                deficit / surplus
7209            } else {
7210                0.0
7211            };
7212            widths = widths
7213                .iter()
7214                .zip(&col_min)
7215                .map(|(w, m)| if *w < *m { *m } else { w - (w - m) * take })
7216                .collect();
7217        }
7218
7219        widths
7220    }
7221
7222    fn inject_fixed_elements(&self, pages: &mut [LayoutPage], font_context: &FontContext) {
7223        for (page_index, page) in pages.iter_mut().enumerate() {
7224            // Inject watermarks behind all content
7225            if !page.watermarks.is_empty() {
7226                let (page_w, page_h) = page.config.size.dimensions();
7227                let cx = page_w / 2.0;
7228                let cy = page_h / 2.0;
7229
7230                let mut watermark_elements = Vec::new();
7231                for wm_node in &page.watermarks {
7232                    if let NodeKind::Watermark {
7233                        text,
7234                        font_size,
7235                        angle,
7236                    } = &wm_node.kind
7237                    {
7238                        let style = wm_node.style.resolve(None, page_w);
7239                        let color = style.color;
7240                        let opacity = style.opacity;
7241                        let angle_rad = angle.to_radians();
7242
7243                        // Build positioned glyphs for the watermark text
7244                        let italic =
7245                            matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
7246
7247                        // Try shaping, fall back to per-char measurement
7248                        let shaped = self.text_layout.shape_text(
7249                            font_context,
7250                            text,
7251                            &style.font_family,
7252                            style.font_weight,
7253                            style.font_style,
7254                        );
7255
7256                        let mut glyphs = Vec::new();
7257                        let mut x_pos = 0.0;
7258                        let text_chars: Vec<char> = text.chars().collect();
7259
7260                        if let Some(shaped_glyphs) = shaped {
7261                            // Use shaped glyphs (custom fonts)
7262                            let units_per_em = font_context.units_per_em(
7263                                &style.font_family,
7264                                style.font_weight,
7265                                italic,
7266                            ) as f64;
7267
7268                            for sg in &shaped_glyphs {
7269                                let advance = sg.x_advance as f64 / units_per_em * *font_size;
7270                                let cluster_idx = sg.cluster as usize;
7271                                let ch = text_chars.get(cluster_idx).copied().unwrap_or(' ');
7272                                glyphs.push(PositionedGlyph {
7273                                    glyph_id: sg.glyph_id,
7274                                    char_value: ch,
7275                                    x_offset: x_pos,
7276                                    y_offset: 0.0,
7277                                    x_advance: advance,
7278                                    font_size: *font_size,
7279                                    font_family: Arc::from(style.font_family.as_str()),
7280                                    font_weight: style.font_weight,
7281                                    font_style: style.font_style,
7282                                    color: Some(color),
7283                                    href: None,
7284                                    text_decoration: TextDecoration::None,
7285                                    letter_spacing: style.letter_spacing,
7286                                    cluster_text: None,
7287                                });
7288                                x_pos += advance + style.letter_spacing;
7289                            }
7290                        } else {
7291                            // Per-char measurement (standard fonts)
7292                            for &ch in &text_chars {
7293                                let w = font_context.char_width(
7294                                    ch,
7295                                    &style.font_family,
7296                                    style.font_weight,
7297                                    italic,
7298                                    *font_size,
7299                                );
7300                                glyphs.push(PositionedGlyph {
7301                                    glyph_id: ch as u16,
7302                                    char_value: ch,
7303                                    x_offset: x_pos,
7304                                    y_offset: 0.0,
7305                                    x_advance: w,
7306                                    font_size: *font_size,
7307                                    font_family: Arc::from(style.font_family.as_str()),
7308                                    font_weight: style.font_weight,
7309                                    font_style: style.font_style,
7310                                    color: Some(color),
7311                                    href: None,
7312                                    text_decoration: TextDecoration::None,
7313                                    letter_spacing: style.letter_spacing,
7314                                    cluster_text: None,
7315                                });
7316                                x_pos += w + style.letter_spacing;
7317                            }
7318                        }
7319
7320                        let text_width = x_pos;
7321
7322                        let line = TextLine {
7323                            x: 0.0,
7324                            y: 0.0,
7325                            glyphs,
7326                            width: text_width,
7327                            height: *font_size,
7328                            word_spacing: 0.0,
7329                        };
7330
7331                        watermark_elements.push(LayoutElement {
7332                            x: cx,
7333                            y: cy,
7334                            width: text_width,
7335                            height: *font_size,
7336                            draw: DrawCommand::Watermark {
7337                                lines: vec![line],
7338                                color,
7339                                opacity,
7340                                angle_rad,
7341                                font_family: style.font_family.clone(),
7342                            },
7343                            children: vec![],
7344                            node_type: Some("Watermark".to_string()),
7345                            resolved_style: None,
7346                            source_location: None,
7347                            href: None,
7348                            bookmark: None,
7349                            alt: None,
7350                            is_header_row: false,
7351                            actual_text: None,
7352                            list_numbering: None,
7353                            col_span: 1,
7354                            overflow: Overflow::default(),
7355                            opacity: 1.0,
7356                        });
7357                    }
7358                }
7359
7360                // Prepend watermark elements so they render behind all content
7361                watermark_elements.append(&mut page.elements);
7362                page.elements = watermark_elements;
7363                page.watermarks.clear();
7364            }
7365
7366            if page.fixed_header.is_empty() && page.fixed_footer.is_empty() {
7367                continue;
7368            }
7369
7370            // Lay out headers at top of content area
7371            if !page.fixed_header.is_empty() {
7372                let mut hdr_cursor = PageCursor::new(&page.config);
7373                for (node, _h) in &page.fixed_header {
7374                    // The enumerate index is the authoritative page number
7375                    // for First/NotFirst filtering.
7376                    if !fixed_applies_on(node, page_index, page.page_name.as_deref()) {
7377                        continue;
7378                    }
7379                    let cw = hdr_cursor.content_width;
7380                    let cx = hdr_cursor.content_x;
7381                    let style = node.style.resolve(None, cw);
7382                    self.layout_view(
7383                        node,
7384                        &style,
7385                        &mut hdr_cursor,
7386                        &mut Vec::new(),
7387                        cx,
7388                        cw,
7389                        font_context,
7390                    );
7391                }
7392                // Prepend header elements so they draw behind body content
7393                let mut combined = hdr_cursor.elements;
7394                combined.append(&mut page.elements);
7395                page.elements = combined;
7396            }
7397
7398            // Lay out footers at bottom of content area.
7399            // We lay out from y=0 (so there's plenty of room and no spurious
7400            // page breaks), then shift all resulting elements down to the
7401            // correct footer position.
7402            if !page.fixed_footer.is_empty() {
7403                let mut ftr_cursor = PageCursor::new(&page.config);
7404                let total_ftr: f64 = page
7405                    .fixed_footer
7406                    .iter()
7407                    .filter(|(n, _)| fixed_applies_on(n, page_index, page.page_name.as_deref()))
7408                    .map(|(_, h)| *h)
7409                    .sum();
7410                let target_y = ftr_cursor.content_height - total_ftr;
7411                // Layout from y=0
7412                for (node, _h) in &page.fixed_footer {
7413                    if !fixed_applies_on(node, page_index, page.page_name.as_deref()) {
7414                        continue;
7415                    }
7416                    let cw = ftr_cursor.content_width;
7417                    let cx = ftr_cursor.content_x;
7418                    let style = node.style.resolve(None, cw);
7419                    self.layout_view(
7420                        node,
7421                        &style,
7422                        &mut ftr_cursor,
7423                        &mut Vec::new(),
7424                        cx,
7425                        cw,
7426                        font_context,
7427                    );
7428                }
7429                // Shift all footer elements down to the target position.
7430                // Elements already have content_y baked in, so we just offset
7431                // by target_y (which is relative to content area top).
7432                for el in &mut ftr_cursor.elements {
7433                    offset_element_y(el, target_y);
7434                }
7435                page.elements.extend(ftr_cursor.elements);
7436            }
7437
7438            // Clean up internal fields
7439            page.fixed_header.clear();
7440            page.fixed_footer.clear();
7441        }
7442    }
7443
7444    /// Layout children as a CSS Grid.
7445    ///
7446    /// Uses the grid track definitions from the parent style to create a 2D grid,
7447    /// places children into cells, and lays out each child within its cell bounds.
7448    #[allow(clippy::too_many_arguments)]
7449    fn layout_grid_children(
7450        &self,
7451        children: &[Node],
7452        parent_style: &ResolvedStyle,
7453        cursor: &mut PageCursor,
7454        pages: &mut Vec<LayoutPage>,
7455        x: f64,
7456        available_width: f64,
7457        font_context: &FontContext,
7458    ) {
7459        let template_cols = match &parent_style.grid_template_columns {
7460            Some(cols) => cols,
7461            None => return, // No columns defined, nothing to do
7462        };
7463
7464        let num_columns = template_cols.len();
7465        if num_columns == 0 || children.is_empty() {
7466            return;
7467        }
7468
7469        let col_gap = parent_style.column_gap;
7470        let row_gap = parent_style.row_gap;
7471
7472        // Resolve column widths
7473        // For auto tracks, we need content sizes. Use a rough measure.
7474        let content_sizes: Vec<f64> = template_cols
7475            .iter()
7476            .map(|track| {
7477                if matches!(track, GridTrackSize::Auto) {
7478                    // Measure the widest child that falls in this column
7479                    // (approximation: use available_width / num_columns)
7480                    available_width / num_columns as f64
7481                } else {
7482                    0.0
7483                }
7484            })
7485            .collect();
7486
7487        let col_widths =
7488            grid::resolve_tracks(template_cols, available_width, col_gap, &content_sizes);
7489
7490        // Collect grid placements from children's styles
7491        let placements: Vec<Option<&GridPlacement>> = children
7492            .iter()
7493            .map(|child| child.style.grid_placement.as_ref())
7494            .collect();
7495
7496        // Place items in the grid
7497        let item_placements = grid::place_items(&placements, num_columns);
7498        let num_rows = grid::compute_num_rows(&item_placements);
7499
7500        if num_rows == 0 {
7501            return;
7502        }
7503
7504        // Measure each item's height at its resolved cell width
7505        let mut item_heights: Vec<f64> = vec![0.0; children.len()];
7506        for placement in &item_placements {
7507            let cell_width =
7508                grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
7509            let child = &children[placement.child_index];
7510            let child_style = child.style.resolve(Some(parent_style), cell_width);
7511            item_heights[placement.child_index] =
7512                self.measure_node_height(child, cell_width, &child_style, font_context);
7513        }
7514
7515        // Compute row heights: max height of all items in each row
7516        let template_rows = parent_style.grid_template_rows.as_deref();
7517        let mut row_heights = vec![0.0_f64; num_rows];
7518        for placement in &item_placements {
7519            let h = item_heights[placement.child_index];
7520            let span = placement.row_end - placement.row_start;
7521            let per_row = h / span as f64;
7522            for rh in row_heights
7523                .iter_mut()
7524                .take(placement.row_end.min(num_rows))
7525                .skip(placement.row_start)
7526            {
7527                if per_row > *rh {
7528                    *rh = per_row;
7529                }
7530            }
7531        }
7532
7533        // Apply template row sizes if provided
7534        if let Some(template) = template_rows {
7535            let auto_row = parent_style.grid_auto_rows.as_ref();
7536            for (r, rh) in row_heights.iter_mut().enumerate() {
7537                let track = template.get(r).or(auto_row);
7538                if let Some(track) = track {
7539                    match track {
7540                        GridTrackSize::Pt(pts) => *rh = *pts,
7541                        GridTrackSize::Auto => {} // keep computed
7542                        _ => {}                   // Fr for rows is complex, skip for now
7543                    }
7544                }
7545            }
7546        }
7547
7548        // Layout each row
7549        for (row, &row_height) in row_heights.iter().enumerate().take(num_rows) {
7550            // Check page break: treat each row as unbreakable. The whole row
7551            // moves to the next page so all columns share the same baseline
7552            // (otherwise each cell's layout_node would page-break individually
7553            // and scatter the columns across separate pages).
7554            if row_height > cursor.remaining_height() {
7555                pages.push(cursor.finalize());
7556                *cursor = cursor.new_page();
7557            }
7558
7559            let row_start_y = cursor.y;
7560
7561            // Layout items in this row
7562            for placement in &item_placements {
7563                if placement.row_start != row {
7564                    continue; // Only process items starting in this row
7565                }
7566
7567                let cell_x = x + grid::column_x_offset(placement.col_start, &col_widths, col_gap);
7568                let cell_width =
7569                    grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
7570
7571                let child = &children[placement.child_index];
7572
7573                self.layout_node(
7574                    child,
7575                    cursor,
7576                    pages,
7577                    cell_x,
7578                    cell_width,
7579                    Some(parent_style),
7580                    font_context,
7581                    None,
7582                    None,
7583                );
7584                // Restore y to row baseline (items don't affect each other's y)
7585                cursor.y = row_start_y;
7586            }
7587
7588            cursor.y = row_start_y + row_height + row_gap;
7589        }
7590
7591        // Remove trailing gap
7592        if num_rows > 0 {
7593            cursor.y -= row_gap;
7594        }
7595    }
7596}
7597
7598struct FlexItem<'a> {
7599    node: &'a Node,
7600    style: ResolvedStyle,
7601    base_width: f64,
7602    min_content_width: f64,
7603}
7604
7605/// First bit of text content under a node, for naming elements in
7606/// render-defect messages (the engine's Node has no id/class).
7607fn first_text_snippet(node: &Node) -> Option<String> {
7608    fn walk(n: &Node) -> Option<&str> {
7609        match &n.kind {
7610            NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
7611                if !content.trim().is_empty() {
7612                    return Some(content.trim());
7613                }
7614                if let Some(r) = runs.iter().find(|r| !r.content.trim().is_empty()) {
7615                    return Some(r.content.trim());
7616                }
7617                None
7618            }
7619            _ => n.children.iter().find_map(walk),
7620        }
7621    }
7622    walk(node).map(|t| {
7623        let mut s: String = t.chars().take(32).collect();
7624        if t.chars().count() > 32 {
7625            s.push('…');
7626        }
7627        s
7628    })
7629}
7630
7631/// The baseline's offset from a line box's top, in the CSS line box
7632/// model with real font metrics: the glyph block is (ascent + descent)
7633/// times font_size tall, the remaining leading splits evenly above and
7634/// below (half-leading), and the baseline sits ascent below the block
7635/// top. The previous model used font_size as a stand-in for the whole
7636/// block with the baseline at its bottom — every baseline sat
7637/// fs(1 - ascent + descent)/2 lower than a browser puts it (~0.15em
7638/// for Arial-class metrics), which is why single glyphs centered in
7639/// boxes by the line-height idiom rode visibly low.
7640fn baseline_in_line(line_height: f64, font_size: f64, (ascent, descent): (f64, f64)) -> f64 {
7641    (line_height - (ascent + descent) * font_size) / 2.0 + ascent * font_size
7642}
7643
7644#[cfg(test)]
7645mod tests {
7646    use super::*;
7647    use crate::font::FontContext;
7648
7649    fn make_text(content: &str, font_size: f64) -> Node {
7650        Node {
7651            kind: NodeKind::Text {
7652                content: content.to_string(),
7653                href: None,
7654                runs: vec![],
7655            },
7656            style: Style {
7657                font_size: Some(font_size),
7658                ..Default::default()
7659            },
7660            children: vec![],
7661            id: None,
7662            source_location: None,
7663            bookmark: None,
7664            href: None,
7665            alt: None,
7666        }
7667    }
7668
7669    fn make_styled_view(style: Style, children: Vec<Node>) -> Node {
7670        Node {
7671            kind: NodeKind::View,
7672            style,
7673            children,
7674            id: None,
7675            source_location: None,
7676            bookmark: None,
7677            href: None,
7678            alt: None,
7679        }
7680    }
7681
7682    fn make_runs_text(runs: Vec<crate::model::TextRun>) -> Node {
7683        Node {
7684            kind: NodeKind::Text {
7685                content: String::new(),
7686                href: None,
7687                runs,
7688            },
7689            style: Style::default(),
7690            children: vec![],
7691            id: None,
7692            source_location: None,
7693            bookmark: None,
7694            href: None,
7695            alt: None,
7696        }
7697    }
7698
7699    #[test]
7700    fn intrinsic_width_measures_runs_not_just_content() {
7701        // Found by the HTML input path: a runs-based Text node (empty
7702        // `content`) used to measure ~0 intrinsic width, so flex rows
7703        // collapsed it to one character per line.
7704        let engine = LayoutEngine::new();
7705        let font_context = FontContext::new();
7706
7707        let runs_node = make_runs_text(vec![
7708            crate::model::TextRun {
7709                content: "Hello ".to_string(),
7710                style: Style::default(),
7711                href: None,
7712            },
7713            crate::model::TextRun {
7714                content: "World".to_string(),
7715                style: Style {
7716                    font_weight: Some(700),
7717                    ..Default::default()
7718                },
7719                href: None,
7720            },
7721        ]);
7722        let plain_node = make_text("Hello World", 12.0);
7723
7724        let runs_style = runs_node.style.resolve(None, 0.0);
7725        let plain_style = plain_node.style.resolve(None, 0.0);
7726        let runs_w = engine.measure_intrinsic_width(&runs_node, &runs_style, &font_context);
7727        let plain_w = engine.measure_intrinsic_width(&plain_node, &plain_style, &font_context);
7728
7729        // Must be in the same ballpark as the plain-content equivalent
7730        // (slightly wider: the second run is bold).
7731        assert!(
7732            runs_w >= plain_w,
7733            "runs width ({runs_w}) must not undershoot plain width ({plain_w})"
7734        );
7735        assert!(
7736            runs_w < plain_w * 1.5,
7737            "runs width ({runs_w}) should be close to plain width ({plain_w})"
7738        );
7739    }
7740
7741    #[test]
7742    fn intrinsic_width_of_multiline_text_is_widest_line() {
7743        let engine = LayoutEngine::new();
7744        let font_context = FontContext::new();
7745
7746        let multiline = make_text("123 Main St\nSpringfield, IL 62704", 12.0);
7747        let widest = make_text("Springfield, IL 62704", 12.0);
7748
7749        let m_style = multiline.style.resolve(None, 0.0);
7750        let w_style = widest.style.resolve(None, 0.0);
7751        let m_w = engine.measure_intrinsic_width(&multiline, &m_style, &font_context);
7752        let w_w = engine.measure_intrinsic_width(&widest, &w_style, &font_context);
7753
7754        assert!(
7755            (m_w - w_w).abs() < 0.01,
7756            "multiline intrinsic width ({m_w}) must equal its widest line ({w_w})"
7757        );
7758    }
7759
7760    #[test]
7761    fn intrinsic_width_of_heading_measures_its_text() {
7762        // Heading used to fall through to the children-recursion arm and
7763        // measure zero (headings are leaves).
7764        let engine = LayoutEngine::new();
7765        let font_context = FontContext::new();
7766
7767        let heading = Node {
7768            kind: NodeKind::Heading {
7769                level: 1,
7770                content: "Invoice #2024-001".to_string(),
7771                href: None,
7772                runs: vec![],
7773            },
7774            style: Style {
7775                font_size: Some(24.0),
7776                ..Default::default()
7777            },
7778            children: vec![],
7779            id: None,
7780            source_location: None,
7781            bookmark: None,
7782            href: None,
7783            alt: None,
7784        };
7785        let style = heading.style.resolve(None, 0.0);
7786        let w = engine.measure_intrinsic_width(&heading, &style, &font_context);
7787        assert!(w > 100.0, "24pt heading text must measure wide, got {w}");
7788    }
7789
7790    #[test]
7791    fn measure_node_height_of_wrapping_heading_matches_text() {
7792        // A heading that wraps to multiple lines must contribute its full
7793        // height to a parent's auto-height, exactly like Text. Previously
7794        // Heading had no arm in `measure_node_height` and fell through to the
7795        // container `_` arm (children-recursion), measuring ~0 — so an
7796        // auto-height View wrapping a multi-line heading collapsed, shifting
7797        // every sibling below it.
7798        let engine = LayoutEngine::new();
7799        let font_context = FontContext::new();
7800
7801        let content = "Annual Performance Review";
7802        let heading = Node {
7803            kind: NodeKind::Heading {
7804                level: 1,
7805                content: content.to_string(),
7806                href: None,
7807                runs: vec![],
7808            },
7809            style: Style {
7810                font_size: Some(32.0),
7811                ..Default::default()
7812            },
7813            children: vec![],
7814            id: None,
7815            source_location: None,
7816            bookmark: None,
7817            href: None,
7818            alt: None,
7819        };
7820        let text = make_text(content, 32.0);
7821
7822        // A width narrow enough to force the 32pt title onto more than one line.
7823        let width = 200.0;
7824        let h_style = heading.style.resolve(None, width);
7825        let t_style = text.style.resolve(None, width);
7826        let h_height = engine.measure_node_height(&heading, width, &h_style, &font_context);
7827        let t_height = engine.measure_node_height(&text, width, &t_style, &font_context);
7828
7829        assert!(
7830            h_height > 32.0,
7831            "a wrapping 32pt heading must measure more than one line, got {h_height}"
7832        );
7833        assert!(
7834            (h_height - t_height).abs() < 0.01,
7835            "heading height ({h_height}) must equal the same text's height ({t_height})"
7836        );
7837    }
7838
7839    #[test]
7840    fn intrinsic_width_flex_row_sums_children() {
7841        let engine = LayoutEngine::new();
7842        let font_context = FontContext::new();
7843
7844        let child1 = make_text("Hello", 14.0);
7845        let child2 = make_text("World", 14.0);
7846
7847        let child1_style = child1.style.resolve(None, 0.0);
7848        let child2_style = child2.style.resolve(None, 0.0);
7849        let child1_w = engine.measure_intrinsic_width(&child1, &child1_style, &font_context);
7850        let child2_w = engine.measure_intrinsic_width(&child2, &child2_style, &font_context);
7851
7852        let row = make_styled_view(
7853            Style {
7854                flex_direction: Some(FlexDirection::Row),
7855                ..Default::default()
7856            },
7857            vec![make_text("Hello", 14.0), make_text("World", 14.0)],
7858        );
7859        let row_style = row.style.resolve(None, 0.0);
7860        let row_w = engine.measure_intrinsic_width(&row, &row_style, &font_context);
7861
7862        assert!(
7863            (row_w - (child1_w + child2_w)).abs() < 0.01,
7864            "Row intrinsic width ({}) should equal sum of children ({} + {})",
7865            row_w,
7866            child1_w,
7867            child2_w
7868        );
7869    }
7870
7871    #[test]
7872    fn intrinsic_width_flex_column_takes_max() {
7873        let engine = LayoutEngine::new();
7874        let font_context = FontContext::new();
7875
7876        let short = make_text("Hi", 14.0);
7877        let long = make_text("Hello World", 14.0);
7878
7879        let short_style = short.style.resolve(None, 0.0);
7880        let long_style = long.style.resolve(None, 0.0);
7881        let short_w = engine.measure_intrinsic_width(&short, &short_style, &font_context);
7882        let long_w = engine.measure_intrinsic_width(&long, &long_style, &font_context);
7883
7884        let col = make_styled_view(
7885            Style {
7886                flex_direction: Some(FlexDirection::Column),
7887                ..Default::default()
7888            },
7889            vec![make_text("Hi", 14.0), make_text("Hello World", 14.0)],
7890        );
7891        let col_style = col.style.resolve(None, 0.0);
7892        let col_w = engine.measure_intrinsic_width(&col, &col_style, &font_context);
7893
7894        assert!(
7895            (col_w - long_w).abs() < 0.01,
7896            "Column intrinsic width ({}) should equal max child ({}, short was {})",
7897            col_w,
7898            long_w,
7899            short_w
7900        );
7901    }
7902
7903    #[test]
7904    fn intrinsic_width_nested_containers() {
7905        let engine = LayoutEngine::new();
7906        let font_context = FontContext::new();
7907
7908        let inner = make_styled_view(
7909            Style {
7910                flex_direction: Some(FlexDirection::Row),
7911                ..Default::default()
7912            },
7913            vec![make_text("A", 12.0), make_text("B", 12.0)],
7914        );
7915        let inner_style = inner.style.resolve(None, 0.0);
7916        let inner_w = engine.measure_intrinsic_width(&inner, &inner_style, &font_context);
7917
7918        let outer = make_styled_view(
7919            Style::default(),
7920            vec![make_styled_view(
7921                Style {
7922                    flex_direction: Some(FlexDirection::Row),
7923                    ..Default::default()
7924                },
7925                vec![make_text("A", 12.0), make_text("B", 12.0)],
7926            )],
7927        );
7928        let outer_style = outer.style.resolve(None, 0.0);
7929        let outer_w = engine.measure_intrinsic_width(&outer, &outer_style, &font_context);
7930
7931        assert!(
7932            (outer_w - inner_w).abs() < 0.01,
7933            "Nested container ({}) should match inner container ({})",
7934            outer_w,
7935            inner_w
7936        );
7937    }
7938
7939    #[test]
7940    fn intrinsic_width_row_with_gap() {
7941        let engine = LayoutEngine::new();
7942        let font_context = FontContext::new();
7943
7944        let no_gap = make_styled_view(
7945            Style {
7946                flex_direction: Some(FlexDirection::Row),
7947                ..Default::default()
7948            },
7949            vec![make_text("A", 12.0), make_text("B", 12.0)],
7950        );
7951        let with_gap = make_styled_view(
7952            Style {
7953                flex_direction: Some(FlexDirection::Row),
7954                gap: Some(10.0),
7955                ..Default::default()
7956            },
7957            vec![make_text("A", 12.0), make_text("B", 12.0)],
7958        );
7959
7960        let no_gap_style = no_gap.style.resolve(None, 0.0);
7961        let with_gap_style = with_gap.style.resolve(None, 0.0);
7962        let no_gap_w = engine.measure_intrinsic_width(&no_gap, &no_gap_style, &font_context);
7963        let with_gap_w = engine.measure_intrinsic_width(&with_gap, &with_gap_style, &font_context);
7964
7965        assert!(
7966            (with_gap_w - no_gap_w - 10.0).abs() < 0.01,
7967            "Gap should add 10pt: with_gap={}, no_gap={}",
7968            with_gap_w,
7969            no_gap_w
7970        );
7971    }
7972
7973    #[test]
7974    fn intrinsic_width_empty_container() {
7975        let engine = LayoutEngine::new();
7976        let font_context = FontContext::new();
7977
7978        let padding = 8.0;
7979        let empty = make_styled_view(
7980            Style {
7981                padding: Some(Edges::uniform(padding)),
7982                ..Default::default()
7983            },
7984            vec![],
7985        );
7986        let style = empty.style.resolve(None, 0.0);
7987        let w = engine.measure_intrinsic_width(&empty, &style, &font_context);
7988
7989        assert!(
7990            (w - padding * 2.0).abs() < 0.01,
7991            "Empty container width ({}) should equal horizontal padding ({})",
7992            w,
7993            padding * 2.0
7994        );
7995    }
7996
7997    // ── Fix 1: min-content width prevents text wrapping in flex shrink ──
7998
7999    #[test]
8000    fn flex_shrink_respects_min_content_width() {
8001        // A flex row with a short-text child ("SALE") and a large sibling.
8002        // The shrink algorithm should not compress the short-text child below
8003        // the width of the word "SALE".
8004        let engine = LayoutEngine::new();
8005        let font_context = FontContext::new();
8006
8007        let sale_text = make_text("SALE", 12.0);
8008        let sale_style = sale_text.style.resolve(None, 0.0);
8009        let sale_word_width =
8010            engine.measure_min_content_width(&sale_text, &sale_style, &font_context);
8011        assert!(
8012            sale_word_width > 0.0,
8013            "SALE should have non-zero min-content width"
8014        );
8015
8016        // Row with 100pt available; child1 wants 80pt, child2 (SALE) wants 60pt.
8017        // Total = 140pt, overflow = 40pt. Without floor, SALE would shrink below word width.
8018        let container = make_styled_view(
8019            Style {
8020                flex_direction: Some(FlexDirection::Row),
8021                width: Some(Dimension::Pt(100.0)),
8022                ..Default::default()
8023            },
8024            vec![
8025                make_styled_view(
8026                    Style {
8027                        width: Some(Dimension::Pt(80.0)),
8028                        flex_shrink: Some(1.0),
8029                        ..Default::default()
8030                    },
8031                    vec![],
8032                ),
8033                make_styled_view(
8034                    Style {
8035                        width: Some(Dimension::Pt(60.0)),
8036                        flex_shrink: Some(1.0),
8037                        ..Default::default()
8038                    },
8039                    vec![make_text("SALE", 12.0)],
8040                ),
8041            ],
8042        );
8043
8044        let doc = Document {
8045            children: vec![Node::page(
8046                PageConfig::default(),
8047                Style::default(),
8048                vec![container],
8049            )],
8050            metadata: Default::default(),
8051            default_page: PageConfig::default(),
8052            first_page: None,
8053            left_page: None,
8054            right_page: None,
8055            named_pages: Default::default(),
8056            attachments: vec![],
8057            zugferd: None,
8058            fonts: vec![],
8059            tagged: false,
8060            pdfa: None,
8061            default_style: None,
8062            embedded_data: None,
8063            flatten_forms: false,
8064            pdf_ua: false,
8065            certification: None,
8066            pdf_version: Default::default(),
8067            pdf_ua2: false,
8068        };
8069
8070        let pages = engine.layout(&doc, &font_context);
8071        assert!(!pages.is_empty());
8072
8073        // The SALE child (second flex item) should not be narrower than its min-content width
8074        // Walk the layout tree: Page -> View (container) -> second child
8075        let page = &pages[0];
8076        // Find the container (the View with children)
8077        let container_el = page.elements.iter().find(|e| e.children.len() == 2);
8078        assert!(
8079            container_el.is_some(),
8080            "Should find container with 2 children"
8081        );
8082        let sale_child = &container_el.unwrap().children[1];
8083        assert!(
8084            sale_child.width >= sale_word_width - 0.01,
8085            "SALE child width ({}) should be >= min-content width ({})",
8086            sale_child.width,
8087            sale_word_width
8088        );
8089    }
8090
8091    // ── Fix 2: column justify-content and align-items ──
8092
8093    #[test]
8094    fn column_justify_content_center() {
8095        // A column container with fixed height 200pt and a single child of ~20pt.
8096        // With justify-content: center, the child should be roughly centered vertically.
8097        let engine = LayoutEngine::new();
8098        let font_context = FontContext::new();
8099
8100        let container = make_styled_view(
8101            Style {
8102                flex_direction: Some(FlexDirection::Column),
8103                height: Some(Dimension::Pt(200.0)),
8104                justify_content: Some(JustifyContent::Center),
8105                ..Default::default()
8106            },
8107            vec![make_text("Centered", 12.0)],
8108        );
8109
8110        let doc = Document {
8111            children: vec![Node::page(
8112                PageConfig::default(),
8113                Style::default(),
8114                vec![container],
8115            )],
8116            metadata: Default::default(),
8117            default_page: PageConfig::default(),
8118            first_page: None,
8119            left_page: None,
8120            right_page: None,
8121            named_pages: Default::default(),
8122            attachments: vec![],
8123            zugferd: None,
8124            fonts: vec![],
8125            tagged: false,
8126            pdfa: None,
8127            default_style: None,
8128            embedded_data: None,
8129            flatten_forms: false,
8130            pdf_ua: false,
8131            certification: None,
8132            pdf_version: Default::default(),
8133            pdf_ua2: false,
8134        };
8135
8136        let pages = engine.layout(&doc, &font_context);
8137        let page = &pages[0];
8138
8139        // The container should have one child, and that child should be
8140        // offset roughly to the vertical center
8141        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
8142        assert!(
8143            container_el.is_some(),
8144            "Should find container with children"
8145        );
8146        let container_el = container_el.unwrap();
8147        let child = &container_el.children[0];
8148
8149        // Child y should be container.y + roughly (200 - child_height) / 2
8150        let child_offset = child.y - container_el.y;
8151        let expected_offset = (200.0 - child.height) / 2.0;
8152        assert!(
8153            (child_offset - expected_offset).abs() < 2.0,
8154            "Child offset ({}) should be near center ({})",
8155            child_offset,
8156            expected_offset
8157        );
8158    }
8159
8160    #[test]
8161    fn column_align_items_center() {
8162        // A column container with a narrow text child.
8163        // With align-items: center, the child should be horizontally centered.
8164        let engine = LayoutEngine::new();
8165        let font_context = FontContext::new();
8166
8167        let container = make_styled_view(
8168            Style {
8169                flex_direction: Some(FlexDirection::Column),
8170                width: Some(Dimension::Pt(300.0)),
8171                align_items: Some(AlignItems::Center),
8172                ..Default::default()
8173            },
8174            vec![make_text("Hi", 12.0)],
8175        );
8176
8177        let doc = Document {
8178            children: vec![Node::page(
8179                PageConfig::default(),
8180                Style::default(),
8181                vec![container],
8182            )],
8183            metadata: Default::default(),
8184            default_page: PageConfig::default(),
8185            first_page: None,
8186            left_page: None,
8187            right_page: None,
8188            named_pages: Default::default(),
8189            attachments: vec![],
8190            zugferd: None,
8191            fonts: vec![],
8192            tagged: false,
8193            pdfa: None,
8194            default_style: None,
8195            embedded_data: None,
8196            flatten_forms: false,
8197            pdf_ua: false,
8198            certification: None,
8199            pdf_version: Default::default(),
8200            pdf_ua2: false,
8201        };
8202
8203        let pages = engine.layout(&doc, &font_context);
8204        let page = &pages[0];
8205
8206        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
8207        assert!(container_el.is_some());
8208        let container_el = container_el.unwrap();
8209        let child = &container_el.children[0];
8210
8211        // Child should be centered within the 300pt container
8212        let child_center = child.x + child.width / 2.0;
8213        let container_center = container_el.x + container_el.width / 2.0;
8214        assert!(
8215            (child_center - container_center).abs() < 2.0,
8216            "Child center ({}) should be near container center ({})",
8217            child_center,
8218            container_center
8219        );
8220    }
8221
8222    // ── Fix 3: absolute positioning relative to parent ──
8223
8224    #[test]
8225    fn absolute_child_positioned_relative_to_parent() {
8226        // A POSITIONED parent (position: relative) with an absolute child using
8227        // top: 10, left: 10. The child resolves against the parent — now the
8228        // correct CSS behavior, since the parent is a positioned ancestor.
8229        let engine = LayoutEngine::new();
8230        let font_context = FontContext::new();
8231
8232        let parent = make_styled_view(
8233            Style {
8234                position: Some(crate::model::Position::Relative),
8235                margin: Some(MarginEdges::from_edges(Edges {
8236                    top: 50.0,
8237                    left: 50.0,
8238                    ..Default::default()
8239                })),
8240                width: Some(Dimension::Pt(200.0)),
8241                height: Some(Dimension::Pt(200.0)),
8242                ..Default::default()
8243            },
8244            vec![make_styled_view(
8245                Style {
8246                    position: Some(crate::model::Position::Absolute),
8247                    top: Some(10.0),
8248                    left: Some(10.0),
8249                    width: Some(Dimension::Pt(50.0)),
8250                    height: Some(Dimension::Pt(50.0)),
8251                    ..Default::default()
8252                },
8253                vec![],
8254            )],
8255        );
8256
8257        let doc = Document {
8258            children: vec![Node::page(
8259                PageConfig::default(),
8260                Style::default(),
8261                vec![parent],
8262            )],
8263            metadata: Default::default(),
8264            default_page: PageConfig::default(),
8265            first_page: None,
8266            left_page: None,
8267            right_page: None,
8268            named_pages: Default::default(),
8269            attachments: vec![],
8270            zugferd: None,
8271            fonts: vec![],
8272            tagged: false,
8273            pdfa: None,
8274            default_style: None,
8275            embedded_data: None,
8276            flatten_forms: false,
8277            pdf_ua: false,
8278            certification: None,
8279            pdf_version: Default::default(),
8280            pdf_ua2: false,
8281        };
8282
8283        let pages = engine.layout(&doc, &font_context);
8284        let page = &pages[0];
8285
8286        // Find the parent container (has the absolute child inside it or as sibling)
8287        // Absolute children are added to cursor.elements, so they'll be inside the parent
8288        let parent_el = page
8289            .elements
8290            .iter()
8291            .find(|e| e.width > 190.0 && e.width < 210.0);
8292        assert!(parent_el.is_some(), "Should find the 200x200 parent");
8293        let parent_el = parent_el.unwrap();
8294
8295        // The absolute child should be at parent.x + 10, parent.y + 10
8296        let abs_child = parent_el
8297            .children
8298            .iter()
8299            .find(|e| e.width > 45.0 && e.width < 55.0);
8300        assert!(abs_child.is_some(), "Should find 50x50 absolute child");
8301        let abs_child = abs_child.unwrap();
8302
8303        let expected_x = parent_el.x + 10.0;
8304        let expected_y = parent_el.y + 10.0;
8305        assert!(
8306            (abs_child.x - expected_x).abs() < 1.0,
8307            "Absolute child x ({}) should be parent.x + 10 ({})",
8308            abs_child.x,
8309            expected_x
8310        );
8311        assert!(
8312            (abs_child.y - expected_y).abs() < 1.0,
8313            "Absolute child y ({}) should be parent.y + 10 ({})",
8314            abs_child.y,
8315            expected_y
8316        );
8317    }
8318
8319    #[test]
8320    fn absolute_escapes_unpositioned_parent_to_page() {
8321        // Same shape, but the parent is UNpositioned. Under browser semantics
8322        // the absolute child resolves against the nearest positioned ancestor —
8323        // here none exists, so the page content box, NOT the parent. This is
8324        // the retired v0 divergence.
8325        let engine = LayoutEngine::new();
8326        let font_context = FontContext::new();
8327        let parent = make_styled_view(
8328            Style {
8329                margin: Some(MarginEdges::from_edges(Edges {
8330                    top: 50.0,
8331                    left: 50.0,
8332                    ..Default::default()
8333                })),
8334                width: Some(Dimension::Pt(200.0)),
8335                height: Some(Dimension::Pt(200.0)),
8336                ..Default::default()
8337            },
8338            vec![make_styled_view(
8339                Style {
8340                    position: Some(crate::model::Position::Absolute),
8341                    top: Some(10.0),
8342                    left: Some(10.0),
8343                    width: Some(Dimension::Pt(50.0)),
8344                    height: Some(Dimension::Pt(50.0)),
8345                    ..Default::default()
8346                },
8347                vec![],
8348            )],
8349        );
8350        let doc = Document {
8351            children: vec![Node::page(
8352                PageConfig::default(),
8353                Style::default(),
8354                vec![parent],
8355            )],
8356            metadata: Default::default(),
8357            default_page: PageConfig::default(),
8358            first_page: None,
8359            left_page: None,
8360            right_page: None,
8361            named_pages: Default::default(),
8362            attachments: vec![],
8363            zugferd: None,
8364            fonts: vec![],
8365            tagged: false,
8366            pdfa: None,
8367            default_style: None,
8368            embedded_data: None,
8369            flatten_forms: false,
8370            pdf_ua: false,
8371            certification: None,
8372            pdf_version: Default::default(),
8373            pdf_ua2: false,
8374        };
8375        let pages = engine.layout(&doc, &font_context);
8376        let page = &pages[0];
8377        let parent_el = page
8378            .elements
8379            .iter()
8380            .find(|e| e.width > 190.0 && e.width < 210.0)
8381            .expect("parent");
8382        let abs_child = parent_el
8383            .children
8384            .iter()
8385            .find(|e| e.width > 45.0 && e.width < 55.0)
8386            .expect("abs child");
8387        let page_left = PageConfig::default().margin.left;
8388        let page_top = PageConfig::default().margin.top;
8389        assert!(
8390            (abs_child.x - (page_left + 10.0)).abs() < 1.0,
8391            "absolute escapes to the page: x {} should be page_left + 10 ({})",
8392            abs_child.x,
8393            page_left + 10.0
8394        );
8395        assert!(
8396            (abs_child.y - (page_top + 10.0)).abs() < 1.0,
8397            "absolute escapes to the page: y {} should be page_top + 10 ({})",
8398            abs_child.y,
8399            page_top + 10.0
8400        );
8401        assert!(
8402            abs_child.x < parent_el.x,
8403            "child must no longer be parent-relative (parent is 50pt further in)"
8404        );
8405    }
8406
8407    #[test]
8408    fn text_transform_none_passthrough() {
8409        assert_eq!(
8410            apply_text_transform("Hello World", TextTransform::None),
8411            "Hello World"
8412        );
8413    }
8414
8415    #[test]
8416    fn text_transform_uppercase() {
8417        assert_eq!(
8418            apply_text_transform("hello world", TextTransform::Uppercase),
8419            "HELLO WORLD"
8420        );
8421    }
8422
8423    #[test]
8424    fn text_transform_lowercase() {
8425        assert_eq!(
8426            apply_text_transform("HELLO WORLD", TextTransform::Lowercase),
8427            "hello world"
8428        );
8429    }
8430
8431    #[test]
8432    fn text_transform_capitalize() {
8433        assert_eq!(
8434            apply_text_transform("hello world", TextTransform::Capitalize),
8435            "Hello World"
8436        );
8437        assert_eq!(
8438            apply_text_transform("  hello  world  ", TextTransform::Capitalize),
8439            "  Hello  World  "
8440        );
8441        assert_eq!(
8442            apply_text_transform("already Capitalized", TextTransform::Capitalize),
8443            "Already Capitalized"
8444        );
8445    }
8446
8447    #[test]
8448    fn text_transform_capitalize_empty() {
8449        assert_eq!(apply_text_transform("", TextTransform::Capitalize), "");
8450    }
8451
8452    #[test]
8453    fn apply_char_transform_uppercase() {
8454        assert_eq!(
8455            apply_char_transform('a', TextTransform::Uppercase, false),
8456            'A'
8457        );
8458        assert_eq!(
8459            apply_char_transform('A', TextTransform::Uppercase, false),
8460            'A'
8461        );
8462    }
8463
8464    #[test]
8465    fn apply_char_transform_capitalize_word_start() {
8466        assert_eq!(
8467            apply_char_transform('h', TextTransform::Capitalize, true),
8468            'H'
8469        );
8470        assert_eq!(
8471            apply_char_transform('h', TextTransform::Capitalize, false),
8472            'h'
8473        );
8474    }
8475
8476    // ── flex-grow in column direction ──
8477
8478    #[test]
8479    fn column_flex_grow_single_child_fills_container() {
8480        // A column container with fixed height 300pt and a single child with flex_grow: 1.
8481        // The child should expand to fill the entire 300pt.
8482        let engine = LayoutEngine::new();
8483        let font_context = FontContext::new();
8484
8485        let child = make_styled_view(
8486            Style {
8487                flex_grow: Some(1.0),
8488                ..Default::default()
8489            },
8490            vec![make_text("Short", 12.0)],
8491        );
8492
8493        let container = make_styled_view(
8494            Style {
8495                flex_direction: Some(FlexDirection::Column),
8496                height: Some(Dimension::Pt(300.0)),
8497                ..Default::default()
8498            },
8499            vec![child],
8500        );
8501
8502        let doc = Document {
8503            children: vec![Node::page(
8504                PageConfig::default(),
8505                Style::default(),
8506                vec![container],
8507            )],
8508            metadata: Default::default(),
8509            default_page: PageConfig::default(),
8510            first_page: None,
8511            left_page: None,
8512            right_page: None,
8513            named_pages: Default::default(),
8514            attachments: vec![],
8515            zugferd: None,
8516            fonts: vec![],
8517            tagged: false,
8518            pdfa: None,
8519            default_style: None,
8520            embedded_data: None,
8521            flatten_forms: false,
8522            pdf_ua: false,
8523            certification: None,
8524            pdf_version: Default::default(),
8525            pdf_ua2: false,
8526        };
8527
8528        let pages = engine.layout(&doc, &font_context);
8529        let page = &pages[0];
8530
8531        let container_el = page.elements.iter().find(|e| !e.children.is_empty());
8532        assert!(container_el.is_some());
8533        let container_el = container_el.unwrap();
8534        assert!(
8535            (container_el.height - 300.0).abs() < 1.0,
8536            "Container should be 300pt, got {}",
8537            container_el.height
8538        );
8539
8540        let child_el = &container_el.children[0];
8541        assert!(
8542            (child_el.height - 300.0).abs() < 1.0,
8543            "flex-grow child should expand to 300pt, got {}",
8544            child_el.height
8545        );
8546    }
8547
8548    #[test]
8549    fn column_flex_grow_two_children_proportional() {
8550        // Two children: one with flex_grow: 1, one with flex_grow: 2.
8551        // They should share remaining space proportionally (1:2).
8552        let engine = LayoutEngine::new();
8553        let font_context = FontContext::new();
8554
8555        let child1 = make_styled_view(
8556            Style {
8557                flex_grow: Some(1.0),
8558                ..Default::default()
8559            },
8560            vec![make_text("A", 12.0)],
8561        );
8562        let child2 = make_styled_view(
8563            Style {
8564                flex_grow: Some(2.0),
8565                ..Default::default()
8566            },
8567            vec![make_text("B", 12.0)],
8568        );
8569
8570        let container = make_styled_view(
8571            Style {
8572                flex_direction: Some(FlexDirection::Column),
8573                height: Some(Dimension::Pt(300.0)),
8574                ..Default::default()
8575            },
8576            vec![child1, child2],
8577        );
8578
8579        let doc = Document {
8580            children: vec![Node::page(
8581                PageConfig::default(),
8582                Style::default(),
8583                vec![container],
8584            )],
8585            metadata: Default::default(),
8586            default_page: PageConfig::default(),
8587            first_page: None,
8588            left_page: None,
8589            right_page: None,
8590            named_pages: Default::default(),
8591            attachments: vec![],
8592            zugferd: None,
8593            fonts: vec![],
8594            tagged: false,
8595            pdfa: None,
8596            default_style: None,
8597            embedded_data: None,
8598            flatten_forms: false,
8599            pdf_ua: false,
8600            certification: None,
8601            pdf_version: Default::default(),
8602            pdf_ua2: false,
8603        };
8604
8605        let pages = engine.layout(&doc, &font_context);
8606        let page = &pages[0];
8607
8608        let container_el = page
8609            .elements
8610            .iter()
8611            .find(|e| e.children.len() == 2)
8612            .expect("Should find container with two children");
8613
8614        let c1 = &container_el.children[0];
8615        let c2 = &container_el.children[1];
8616
8617        // Both children have the same natural height (one line of text).
8618        // The slack is split 1:2 between them.
8619        // So child2 should be roughly twice as much taller than child1's growth.
8620        let total = c1.height + c2.height;
8621        assert!(
8622            (total - 300.0).abs() < 2.0,
8623            "Children should sum to ~300pt, got {}",
8624            total
8625        );
8626
8627        // child2.height should be roughly 2x child1.height
8628        // (not exact because natural heights are equal, but growth is 1:2)
8629        let ratio = c2.height / c1.height;
8630        assert!(
8631            ratio > 1.3 && ratio < 2.5,
8632            "child2/child1 ratio should be between 1.3 and 2.5, got {}",
8633            ratio
8634        );
8635    }
8636
8637    #[test]
8638    fn column_flex_grow_mixed_grow_and_fixed() {
8639        // One fixed child (no flex_grow) and one flex_grow child.
8640        // The flex_grow child takes all remaining space.
8641        let engine = LayoutEngine::new();
8642        let font_context = FontContext::new();
8643
8644        let fixed_child = make_styled_view(
8645            Style {
8646                height: Some(Dimension::Pt(50.0)),
8647                ..Default::default()
8648            },
8649            vec![make_text("Fixed", 12.0)],
8650        );
8651        let grow_child = make_styled_view(
8652            Style {
8653                flex_grow: Some(1.0),
8654                ..Default::default()
8655            },
8656            vec![make_text("Grow", 12.0)],
8657        );
8658
8659        let container = make_styled_view(
8660            Style {
8661                flex_direction: Some(FlexDirection::Column),
8662                height: Some(Dimension::Pt(300.0)),
8663                ..Default::default()
8664            },
8665            vec![fixed_child, grow_child],
8666        );
8667
8668        let doc = Document {
8669            children: vec![Node::page(
8670                PageConfig::default(),
8671                Style::default(),
8672                vec![container],
8673            )],
8674            metadata: Default::default(),
8675            default_page: PageConfig::default(),
8676            first_page: None,
8677            left_page: None,
8678            right_page: None,
8679            named_pages: Default::default(),
8680            attachments: vec![],
8681            zugferd: None,
8682            fonts: vec![],
8683            tagged: false,
8684            pdfa: None,
8685            default_style: None,
8686            embedded_data: None,
8687            flatten_forms: false,
8688            pdf_ua: false,
8689            certification: None,
8690            pdf_version: Default::default(),
8691            pdf_ua2: false,
8692        };
8693
8694        let pages = engine.layout(&doc, &font_context);
8695        let page = &pages[0];
8696
8697        let container_el = page
8698            .elements
8699            .iter()
8700            .find(|e| e.children.len() == 2)
8701            .expect("Should find container with two children");
8702
8703        let fixed_el = &container_el.children[0];
8704        let grow_el = &container_el.children[1];
8705
8706        // Fixed child stays at 50pt
8707        assert!(
8708            (fixed_el.height - 50.0).abs() < 1.0,
8709            "Fixed child should stay at 50pt, got {}",
8710            fixed_el.height
8711        );
8712
8713        // Grow child takes remaining ~250pt
8714        assert!(
8715            (grow_el.height - 250.0).abs() < 2.0,
8716            "Grow child should expand to ~250pt, got {}",
8717            grow_el.height
8718        );
8719    }
8720
8721    #[test]
8722    fn column_flex_grow_page_level() {
8723        // flex_grow: 1 on a direct Page child should fill the page content area.
8724        let engine = LayoutEngine::new();
8725        let font_context = FontContext::new();
8726
8727        let grow_child = make_styled_view(
8728            Style {
8729                flex_grow: Some(1.0),
8730                ..Default::default()
8731            },
8732            vec![make_text("Fill page", 12.0)],
8733        );
8734
8735        let doc = Document {
8736            children: vec![Node::page(
8737                PageConfig::default(),
8738                Style::default(),
8739                vec![grow_child],
8740            )],
8741            metadata: Default::default(),
8742            default_page: PageConfig::default(),
8743            first_page: None,
8744            left_page: None,
8745            right_page: None,
8746            named_pages: Default::default(),
8747            attachments: vec![],
8748            zugferd: None,
8749            fonts: vec![],
8750            tagged: false,
8751            pdfa: None,
8752            default_style: None,
8753            embedded_data: None,
8754            flatten_forms: false,
8755            pdf_ua: false,
8756            certification: None,
8757            pdf_version: Default::default(),
8758            pdf_ua2: false,
8759        };
8760
8761        let pages = engine.layout(&doc, &font_context);
8762        let page = &pages[0];
8763
8764        // The child should fill the page content height
8765        assert!(
8766            !page.elements.is_empty(),
8767            "Page should have at least one element"
8768        );
8769
8770        let content_height = page.height - page.config.margin.top - page.config.margin.bottom;
8771        let el = &page.elements[0];
8772        assert!(
8773            (el.height - content_height).abs() < 2.0,
8774            "Page-level flex-grow child should fill content height ({}), got {}",
8775            content_height,
8776            el.height
8777        );
8778    }
8779
8780    #[test]
8781    fn column_flex_grow_with_justify_content() {
8782        // flex-grow and justify-content: center should work together.
8783        // A fixed child + a grow child + justify-content: center.
8784        // After grow fills the space, there's no slack left for justify, so positions stay as-is.
8785        let engine = LayoutEngine::new();
8786        let font_context = FontContext::new();
8787
8788        let fixed_child = make_styled_view(
8789            Style {
8790                height: Some(Dimension::Pt(50.0)),
8791                ..Default::default()
8792            },
8793            vec![make_text("Top", 12.0)],
8794        );
8795        let grow_child = make_styled_view(
8796            Style {
8797                flex_grow: Some(1.0),
8798                ..Default::default()
8799            },
8800            vec![make_text("Fill", 12.0)],
8801        );
8802
8803        let container = make_styled_view(
8804            Style {
8805                flex_direction: Some(FlexDirection::Column),
8806                height: Some(Dimension::Pt(300.0)),
8807                justify_content: Some(JustifyContent::Center),
8808                ..Default::default()
8809            },
8810            vec![fixed_child, grow_child],
8811        );
8812
8813        let doc = Document {
8814            children: vec![Node::page(
8815                PageConfig::default(),
8816                Style::default(),
8817                vec![container],
8818            )],
8819            metadata: Default::default(),
8820            default_page: PageConfig::default(),
8821            first_page: None,
8822            left_page: None,
8823            right_page: None,
8824            named_pages: Default::default(),
8825            attachments: vec![],
8826            zugferd: None,
8827            fonts: vec![],
8828            tagged: false,
8829            pdfa: None,
8830            default_style: None,
8831            embedded_data: None,
8832            flatten_forms: false,
8833            pdf_ua: false,
8834            certification: None,
8835            pdf_version: Default::default(),
8836            pdf_ua2: false,
8837        };
8838
8839        let pages = engine.layout(&doc, &font_context);
8840        let page = &pages[0];
8841
8842        let container_el = page
8843            .elements
8844            .iter()
8845            .find(|e| e.children.len() == 2)
8846            .expect("Should find container");
8847
8848        // After flex-grow absorbs all slack, justify-content has nothing to distribute.
8849        // First child should be at the top of the container.
8850        let first_child = &container_el.children[0];
8851        assert!(
8852            (first_child.y - container_el.y).abs() < 1.0,
8853            "First child should be at top of container"
8854        );
8855
8856        // Children should still sum to container height
8857        let total = container_el.children[0].height + container_el.children[1].height;
8858        assert!(
8859            (total - 300.0).abs() < 2.0,
8860            "Children should fill container, got {}",
8861            total
8862        );
8863    }
8864
8865    #[test]
8866    fn column_flex_grow_child_justify_content_center() {
8867        // A flex-grow child with justify-content: center should vertically center its content.
8868        // This is the cover-page bug: the inner View grows via flex but its children stay at top.
8869        let engine = LayoutEngine::new();
8870        let font_context = FontContext::new();
8871
8872        // Inner content: a small fixed-height box
8873        let inner_box = make_styled_view(
8874            Style {
8875                height: Some(Dimension::Pt(40.0)),
8876                ..Default::default()
8877            },
8878            vec![make_text("Centered", 12.0)],
8879        );
8880
8881        // The grow child: flex: 1, justify-content: center
8882        let grow_child = make_styled_view(
8883            Style {
8884                flex_grow: Some(1.0),
8885                flex_direction: Some(FlexDirection::Column),
8886                justify_content: Some(JustifyContent::Center),
8887                ..Default::default()
8888            },
8889            vec![inner_box],
8890        );
8891
8892        // Outer column container with fixed height
8893        let container = make_styled_view(
8894            Style {
8895                flex_direction: Some(FlexDirection::Column),
8896                height: Some(Dimension::Pt(400.0)),
8897                ..Default::default()
8898            },
8899            vec![grow_child],
8900        );
8901
8902        let doc = Document {
8903            children: vec![Node::page(
8904                PageConfig::default(),
8905                Style::default(),
8906                vec![container],
8907            )],
8908            metadata: Default::default(),
8909            default_page: PageConfig::default(),
8910            first_page: None,
8911            left_page: None,
8912            right_page: None,
8913            named_pages: Default::default(),
8914            attachments: vec![],
8915            zugferd: None,
8916            fonts: vec![],
8917            tagged: false,
8918            pdfa: None,
8919            default_style: None,
8920            embedded_data: None,
8921            flatten_forms: false,
8922            pdf_ua: false,
8923            certification: None,
8924            pdf_version: Default::default(),
8925            pdf_ua2: false,
8926        };
8927
8928        let pages = engine.layout(&doc, &font_context);
8929        let page = &pages[0];
8930
8931        // Find the container (has 1 child = the grow child)
8932        let container_el = page
8933            .elements
8934            .iter()
8935            .find(|e| e.height > 350.0 && e.children.len() == 1)
8936            .expect("Should find outer container");
8937
8938        let grow_el = &container_el.children[0];
8939        assert!(
8940            (grow_el.height - 400.0).abs() < 2.0,
8941            "Grow child should expand to 400, got {}",
8942            grow_el.height
8943        );
8944
8945        // The inner box should be vertically centered within the grow child
8946        let inner_el = &grow_el.children[0];
8947        let expected_center = grow_el.y + grow_el.height / 2.0;
8948        let actual_center = inner_el.y + inner_el.height / 2.0;
8949        assert!(
8950            (actual_center - expected_center).abs() < 2.0,
8951            "Inner box should be vertically centered. Expected center ~{}, got ~{}",
8952            expected_center,
8953            actual_center
8954        );
8955    }
8956
8957    #[test]
8958    fn column_flex_grow_child_justify_content_flex_end() {
8959        // A flex-grow child with justify-content: flex-end should push content to the bottom.
8960        let engine = LayoutEngine::new();
8961        let font_context = FontContext::new();
8962
8963        let inner_box = make_styled_view(
8964            Style {
8965                height: Some(Dimension::Pt(30.0)),
8966                ..Default::default()
8967            },
8968            vec![make_text("Bottom", 12.0)],
8969        );
8970
8971        let grow_child = make_styled_view(
8972            Style {
8973                flex_grow: Some(1.0),
8974                flex_direction: Some(FlexDirection::Column),
8975                justify_content: Some(JustifyContent::FlexEnd),
8976                ..Default::default()
8977            },
8978            vec![inner_box],
8979        );
8980
8981        let container = make_styled_view(
8982            Style {
8983                flex_direction: Some(FlexDirection::Column),
8984                height: Some(Dimension::Pt(300.0)),
8985                ..Default::default()
8986            },
8987            vec![grow_child],
8988        );
8989
8990        let doc = Document {
8991            children: vec![Node::page(
8992                PageConfig::default(),
8993                Style::default(),
8994                vec![container],
8995            )],
8996            metadata: Default::default(),
8997            default_page: PageConfig::default(),
8998            first_page: None,
8999            left_page: None,
9000            right_page: None,
9001            named_pages: Default::default(),
9002            attachments: vec![],
9003            zugferd: None,
9004            fonts: vec![],
9005            tagged: false,
9006            pdfa: None,
9007            default_style: None,
9008            embedded_data: None,
9009            flatten_forms: false,
9010            pdf_ua: false,
9011            certification: None,
9012            pdf_version: Default::default(),
9013            pdf_ua2: false,
9014        };
9015
9016        let pages = engine.layout(&doc, &font_context);
9017        let page = &pages[0];
9018
9019        let container_el = page
9020            .elements
9021            .iter()
9022            .find(|e| e.height > 250.0 && e.children.len() == 1)
9023            .expect("Should find outer container");
9024
9025        let grow_el = &container_el.children[0];
9026        let inner_el = &grow_el.children[0];
9027
9028        // Inner box should be near the bottom of the grow child
9029        let inner_bottom = inner_el.y + inner_el.height;
9030        let grow_bottom = grow_el.y + grow_el.height;
9031        assert!(
9032            (inner_bottom - grow_bottom).abs() < 2.0,
9033            "Inner box bottom ({}) should align with grow child bottom ({})",
9034            inner_bottom,
9035            grow_bottom
9036        );
9037    }
9038
9039    #[test]
9040    fn column_flex_grow_child_no_justify_unchanged() {
9041        // Regression: flex-grow with default FlexStart should keep content at top.
9042        let engine = LayoutEngine::new();
9043        let font_context = FontContext::new();
9044
9045        let inner_box = make_styled_view(
9046            Style {
9047                height: Some(Dimension::Pt(50.0)),
9048                ..Default::default()
9049            },
9050            vec![make_text("Top", 12.0)],
9051        );
9052
9053        let grow_child = make_styled_view(
9054            Style {
9055                flex_grow: Some(1.0),
9056                flex_direction: Some(FlexDirection::Column),
9057                // No justify-content set — defaults to FlexStart
9058                ..Default::default()
9059            },
9060            vec![inner_box],
9061        );
9062
9063        let container = make_styled_view(
9064            Style {
9065                flex_direction: Some(FlexDirection::Column),
9066                height: Some(Dimension::Pt(300.0)),
9067                ..Default::default()
9068            },
9069            vec![grow_child],
9070        );
9071
9072        let doc = Document {
9073            children: vec![Node::page(
9074                PageConfig::default(),
9075                Style::default(),
9076                vec![container],
9077            )],
9078            metadata: Default::default(),
9079            default_page: PageConfig::default(),
9080            first_page: None,
9081            left_page: None,
9082            right_page: None,
9083            named_pages: Default::default(),
9084            attachments: vec![],
9085            zugferd: None,
9086            fonts: vec![],
9087            tagged: false,
9088            pdfa: None,
9089            default_style: None,
9090            embedded_data: None,
9091            flatten_forms: false,
9092            pdf_ua: false,
9093            certification: None,
9094            pdf_version: Default::default(),
9095            pdf_ua2: false,
9096        };
9097
9098        let pages = engine.layout(&doc, &font_context);
9099        let page = &pages[0];
9100
9101        let container_el = page
9102            .elements
9103            .iter()
9104            .find(|e| e.height > 250.0 && e.children.len() == 1)
9105            .expect("Should find outer container");
9106
9107        let grow_el = &container_el.children[0];
9108        let inner_el = &grow_el.children[0];
9109
9110        // Inner box should stay at the top of the grow child
9111        assert!(
9112            (inner_el.y - grow_el.y).abs() < 2.0,
9113            "Inner box ({}) should be at top of grow child ({})",
9114            inner_el.y,
9115            grow_el.y
9116        );
9117    }
9118
9119    #[test]
9120    fn column_flex_grow_child_align_items_center() {
9121        // A flex-grown View with align_items: Center should horizontally center its Text child.
9122        let engine = LayoutEngine::new();
9123        let font_context = FontContext::new();
9124
9125        let text = make_text("Hello", 12.0);
9126
9127        let grow_child = make_styled_view(
9128            Style {
9129                flex_grow: Some(1.0),
9130                flex_direction: Some(FlexDirection::Column),
9131                align_items: Some(AlignItems::Center),
9132                ..Default::default()
9133            },
9134            vec![text],
9135        );
9136
9137        let container = make_styled_view(
9138            Style {
9139                flex_direction: Some(FlexDirection::Column),
9140                height: Some(Dimension::Pt(300.0)),
9141                ..Default::default()
9142            },
9143            vec![grow_child],
9144        );
9145
9146        let doc = Document {
9147            children: vec![Node::page(
9148                PageConfig::default(),
9149                Style::default(),
9150                vec![container],
9151            )],
9152            metadata: Default::default(),
9153            default_page: PageConfig::default(),
9154            first_page: None,
9155            left_page: None,
9156            right_page: None,
9157            named_pages: Default::default(),
9158            attachments: vec![],
9159            zugferd: None,
9160            fonts: vec![],
9161            tagged: false,
9162            pdfa: None,
9163            default_style: None,
9164            embedded_data: None,
9165            flatten_forms: false,
9166            pdf_ua: false,
9167            certification: None,
9168            pdf_version: Default::default(),
9169            pdf_ua2: false,
9170        };
9171
9172        let pages = engine.layout(&doc, &font_context);
9173        let page = &pages[0];
9174
9175        let container_el = page
9176            .elements
9177            .iter()
9178            .find(|e| e.height > 250.0 && e.children.len() == 1)
9179            .expect("Should find outer container");
9180
9181        let grow_el = &container_el.children[0];
9182        assert!(
9183            !grow_el.children.is_empty(),
9184            "Grow child should have text child"
9185        );
9186
9187        let text_el = &grow_el.children[0];
9188        let text_center = text_el.x + text_el.width / 2.0;
9189        let grow_center = grow_el.x + grow_el.width / 2.0;
9190        assert!(
9191            (text_center - grow_center).abs() < 2.0,
9192            "Text center ({}) should be near grow child center ({})",
9193            text_center,
9194            grow_center
9195        );
9196    }
9197
9198    #[test]
9199    fn image_intrinsic_width_respects_height_constraint() {
9200        // An Image with only a height prop should compute intrinsic width from
9201        // aspect ratio, not return the raw pixel width. This ensures align-items:
9202        // center can correctly center images.
9203        let engine = LayoutEngine::new();
9204        let font_context = FontContext::new();
9205
9206        // Use a 1x1 PNG data URI (known dimensions: 1x1 pixels)
9207        let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
9208
9209        let image_node = Node {
9210            kind: NodeKind::Image {
9211                src: one_px_png.to_string(),
9212                width: None,
9213                height: Some(36.0),
9214            },
9215            style: Style::default(),
9216            children: vec![],
9217            id: None,
9218            source_location: None,
9219            bookmark: None,
9220            href: None,
9221            alt: None,
9222        };
9223
9224        let resolved = image_node.style.resolve(None, 0.0);
9225        let intrinsic = engine.measure_intrinsic_width(&image_node, &resolved, &font_context);
9226
9227        // 1x1 pixel image with height: 36 should give width = 36 / (1/1) = 36
9228        assert!(
9229            (intrinsic - 36.0).abs() < 1.0,
9230            "Intrinsic width should be ~36 for 1:1 aspect image with height 36, got {}",
9231            intrinsic
9232        );
9233    }
9234}