Skip to main content

forme/layout/
mod.rs

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