Skip to main content

azul_layout/solver3/
fc.rs

1//! Formatting context layout (block, inline, table, and flex/grid via Taffy)
2
3use std::{
4    collections::{BTreeMap, HashMap},
5    sync::Arc,
6};
7
8use azul_core::{
9    dom::{FormattingContext, NodeId, NodeType},
10    geom::{LogicalPosition, LogicalRect, LogicalSize},
11    resources::RendererResources,
12    styled_dom::{StyledDom, StyledNodeState},
13};
14use azul_css::{
15    css::CssPropertyValue,
16    props::{
17        basic::{
18            font::{StyleFontStyle, StyleFontWeight},
19            pixel::{DEFAULT_FONT_SIZE, PT_TO_PX},
20            ColorU, PhysicalSize, PropertyContext, ResolutionContext, SizeMetric,
21        },
22        layout::{
23            ColumnCount, ColumnWidth, LayoutBorderSpacing, LayoutClear, LayoutDisplay, LayoutFloat,
24            LayoutHeight, LayoutJustifyContent, LayoutOverflow, LayoutPosition, LayoutTableLayout,
25            LayoutTextJustify, LayoutWidth, LayoutWritingMode, ShapeInside, ShapeOutside,
26            StyleBorderCollapse, StyleCaptionSide, StyleEmptyCells,
27        },
28        property::CssProperty,
29        style::{
30            BorderStyle, StyleDirection, StyleHyphens, StyleLineBreak, StyleListStylePosition,
31            StyleListStyleType, StyleOverflowWrap, StyleTextAlign, StyleTextAlignLast,
32            StyleTextBoxTrim, StyleTextCombineUpright, StyleTextOrientation, StyleUnicodeBidi,
33            StyleVerticalAlign, StyleVisibility, StyleWhiteSpace, StyleWordBreak,
34        },
35    },
36};
37use rust_fontconfig::FcWeight;
38use taffy::{AvailableSpace, LayoutInput, Line, Size as TaffySize};
39
40#[cfg(feature = "text_layout")]
41use crate::text3;
42use crate::{
43    debug_ifc_layout, debug_info, debug_log, debug_table_layout, debug_warning,
44    font_traits::{
45        ContentIndex, FontLoaderTrait, ImageSource, InlineContent, InlineImage, InlineShape,
46        LayoutFragment, ObjectFit, ParsedFontTrait, SegmentAlignment, ShapeBoundary,
47        ShapeDefinition, ShapedItem, Size, StyleProperties, StyledRun, TextLayoutCache,
48        UnifiedConstraints,
49    },
50    solver3::{
51        geometry::{BoxProps, EdgeSizes, IntrinsicSizes},
52        getters::{
53            get_css_border_bottom_width, get_css_border_top_width, get_css_box_sizing,
54            get_css_height, get_css_padding_bottom, get_css_padding_top,
55            get_css_width, get_direction_property, get_unicode_bidi_property,
56            get_display_property, get_element_font_size, get_float, get_clear,
57            get_list_style_position, get_list_style_type, get_overflow_x, get_overflow_y,
58            get_parent_font_size, get_root_font_size, get_style_properties,
59            get_text_align, get_text_box_trim_property, get_text_orientation_property,
60            get_vertical_align_property, get_visibility, get_white_space_property,
61            get_writing_mode, MultiValue,
62        },
63        layout_tree::{
64            AnonymousBoxType, CachedInlineLayout, LayoutNode, LayoutNodeHot, LayoutNodeWarm, LayoutNodeCold, LayoutTree, PseudoElement,
65        },
66        positioning::get_position_type,
67        scrollbar::ScrollbarRequirements,
68        sizing::extract_text_from_node,
69        taffy_bridge, LayoutContext, LayoutDebugMessage, LayoutError, Result,
70    },
71    text3::cache::{
72        AvailableSpace as Text3AvailableSpace, BreakType, ClearType, InlineBreak,
73        TextAlign as Text3TextAlign,
74    },
75};
76
77/// Default scrollbar width in pixels (CSS `scrollbar-width: auto`).
78///
79/// This is only used as a fallback when per-node CSS cannot be queried.
80/// Prefer `getters::get_layout_scrollbar_width_px()` for per-node resolution.
81pub const DEFAULT_SCROLLBAR_WIDTH_PX: f32 = 16.0;
82
83// Note: DEFAULT_FONT_SIZE and PT_TO_PX are imported from pixel
84
85/// Result of BFC layout with margin escape information
86#[derive(Debug, Clone)]
87pub(crate) struct BfcLayoutResult {
88    /// Standard layout output (positions, overflow size, baseline)
89    pub output: LayoutOutput,
90    /// Top margin that escaped the BFC (for parent-child collapse)
91    /// If Some, this margin should be used by parent instead of positioning this BFC
92    pub escaped_top_margin: Option<f32>,
93    /// Bottom margin that escaped the BFC (for parent-child collapse)
94    /// If Some, this margin should collapse with next sibling
95    pub escaped_bottom_margin: Option<f32>,
96}
97
98impl BfcLayoutResult {
99    pub(crate) const fn from_output(output: LayoutOutput) -> Self {
100        Self {
101            output,
102            escaped_top_margin: None,
103            escaped_bottom_margin: None,
104        }
105    }
106}
107
108/// The CSS `overflow` property behavior.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum OverflowBehavior {
111    Visible,
112    Hidden,
113    Clip,
114    Scroll,
115    Auto,
116}
117
118impl OverflowBehavior {
119    #[must_use] pub const fn is_clipped(&self) -> bool {
120        matches!(self, Self::Hidden | Self::Clip | Self::Scroll | Self::Auto)
121    }
122
123    #[must_use] pub const fn is_scroll(&self) -> bool {
124        matches!(self, Self::Scroll | Self::Auto)
125    }
126}
127
128/// Input constraints for a layout function.
129#[derive(Debug)]
130pub struct LayoutConstraints<'a> {
131    /// The available space for the content, excluding padding and borders.
132    pub available_size: LogicalSize,
133    /// The CSS writing-mode of the context.
134    pub writing_mode: LayoutWritingMode,
135    /// Full writing mode context (writing-mode + direction + text-orientation).
136    /// Used by writing-mode-aware layout code to correctly map inline/block
137    /// dimensions to physical x/y coordinates.
138    pub writing_mode_ctx: super::geometry::WritingModeContext,
139    /// The state of the parent Block Formatting Context, if applicable.
140    /// This is how state (like floats) is passed down.
141    pub bfc_state: Option<&'a mut BfcState>,
142    // Other properties like text-align would go here.
143    pub text_align: TextAlign,
144    /// The size of the containing block (parent's content box).
145    /// This is used for resolving percentage-based sizes and as `parent_size` for Taffy.
146    pub containing_block_size: LogicalSize,
147    /// The semantic type of the available width constraint.
148    ///
149    /// This field is crucial for correct inline layout caching:
150    /// - `Definite(w)`: Normal layout with a specific available width
151    /// - `MinContent`: Intrinsic minimum width measurement (maximum wrapping)
152    /// - `MaxContent`: Intrinsic maximum width measurement (no wrapping)
153    ///
154    /// When caching inline layouts, we must track which constraint type was used
155    /// to compute the cached result. A layout computed with `MinContent` (width=0)
156    /// must not be reused when the actual available width is known.
157    pub available_width_type: Text3AvailableSpace,
158}
159
160/// Manages all layout state for a single Block Formatting Context.
161/// This struct is created by the BFC root and lives for the duration of its layout.
162#[derive(Debug, Clone)]
163pub struct BfcState {
164    /// The current position for the next in-flow block element.
165    pub pen: LogicalPosition,
166    /// The state of all floated elements within this BFC.
167    pub floats: FloatingContext,
168    /// The state of margin collapsing within this BFC.
169    pub margins: MarginCollapseContext,
170}
171
172impl Default for BfcState {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178impl BfcState {
179    #[must_use] pub fn new() -> Self {
180        Self {
181            pen: LogicalPosition::zero(),
182            floats: FloatingContext::default(),
183            margins: MarginCollapseContext::default(),
184        }
185    }
186}
187
188/// Manages vertical margin collapsing within a BFC.
189#[derive(Copy, Debug, Default, Clone)]
190pub struct MarginCollapseContext {
191    /// The bottom margin of the last in-flow, block-level element.
192    /// Can be positive or negative.
193    pub last_in_flow_margin_bottom: f32,
194}
195
196/// The result of laying out a formatting context.
197#[derive(Debug, Default, Clone)]
198pub struct LayoutOutput {
199    /// The final positions of child nodes, relative to the container's content-box origin.
200    pub positions: BTreeMap<usize, LogicalPosition>,
201    /// The total size occupied by the content, which may exceed `available_size`.
202    pub overflow_size: LogicalSize,
203    // +spec:inline-formatting-context:f7eebb - baseline along inline axis for glyph alignment
204    /// The baseline of the context, if applicable, measured from the top of its content box.
205    pub baseline: Option<f32>,
206}
207
208/// Text alignment options
209#[derive(Debug, Clone, Copy, Default)]
210pub enum TextAlign {
211    #[default]
212    Start,
213    End,
214    Center,
215    Justify,
216}
217
218/// Represents a single floated element within a BFC.
219#[derive(Debug, Clone, Copy)]
220struct FloatBox {
221    /// The type of float (Left or Right).
222    kind: LayoutFloat,
223    /// The rectangle of the float's content box (origin includes top/left margin offset).
224    rect: LogicalRect,
225    /// The margin sizes (needed to calculate true margin-box bounds).
226    margin: EdgeSizes,
227}
228
229/// Manages the state of all floated elements within a Block Formatting Context.
230// +spec:block-formatting-context:a4e6f9 - float rules reference only elements in the same BFC (scoped via BfcState)
231// +spec:floats:2fa329 - Float positioning (left/right shift), content flow along sides, and clear property
232/// +spec:floats:970b4c - Implements CSS2§9.5 float positioning and flow interaction
233#[derive(Debug, Default, Clone)]
234pub struct FloatingContext {
235    /// All currently positioned floats within the BFC.
236    pub floats: Vec<FloatBox>,
237}
238
239impl FloatingContext {
240    /// Add a newly positioned float to the context
241    pub fn add_float(&mut self, kind: LayoutFloat, rect: LogicalRect, margin: EdgeSizes) {
242        self.floats.push(FloatBox { kind, rect, margin });
243    }
244
245    // +spec:box-model:0c9b13 - line boxes next to floats are shortened to make room
246    // +spec:floats:148fcd - floating boxes reduce available line box width between containing block edges
247    // +spec:floats:49a491 - Line boxes stacked with no separation except float clearance, never overlap
248    // +spec:floats:8974e6 - text flows into vacated space by narrowing line boxes around floats
249    // +spec:floats:af94f2 - content displaced by float: line boxes shrink to avoid float margin boxes
250    // +spec:floats:e5961b - remaining text flows into vacated space via available_line_box_space
251    // +spec:inline-formatting-context:7cbe58 - shortened line boxes due to floats; shift down if too small
252    /// Finds the available space on the cross-axis for a line box at a given main-axis range.
253    // +spec:containing-block:4b0c44 - line boxes shortened by floats resume containing block width after float
254    ///
255    /// Returns a tuple of (`cross_start_offset`, `cross_end_offset`) relative to the
256    /// BFC content box, defining the available space for an in-flow element.
257    // +spec:inline-formatting-context:e70328 - line box width reduced by floats between containing block edges
258    #[must_use] pub fn available_line_box_space(
259        &self,
260        main_start: f32,
261        main_end: f32,
262        bfc_cross_size: f32,
263        wm: LayoutWritingMode,
264    ) -> (f32, f32) {
265        let mut available_cross_start = 0.0_f32;
266        let mut available_cross_end = bfc_cross_size;
267
268        for float in &self.floats {
269            // Get the logical main-axis span of the existing float's MARGIN BOX.
270            let float_main_start = float.rect.origin.main(wm) - float.margin.main_start(wm);
271            let float_main_end = float_main_start + float.rect.size.main(wm)
272                + float.margin.main_start(wm) + float.margin.main_end(wm);
273
274            // Check for overlap on the main axis.
275            if main_end > float_main_start && main_start < float_main_end {
276                // CSS 2.2 § 9.5: border box must not overlap MARGIN BOX of floats,
277                // so we include the float's margins in the cross-axis bounds.
278                let float_cross_start = float.rect.origin.cross(wm) - float.margin.cross_start(wm);
279                let float_cross_end = float_cross_start + float.rect.size.cross(wm)
280                    + float.margin.cross_start(wm) + float.margin.cross_end(wm);
281
282                // +spec:floats:17a63f - float left/right map to line-left/line-right via logical coords
283                // +spec:writing-modes:e55820 - line-relative mappings: left/right interpreted as line-left/line-right per writing mode
284                if float.kind == LayoutFloat::Left {
285                    // "line-left", i.e., cross-start
286                    available_cross_start = available_cross_start.max(float_cross_end);
287                } else {
288                    // Float::Right, i.e., cross-end
289                    available_cross_end = available_cross_end.min(float_cross_start);
290                }
291            }
292        }
293        (available_cross_start, available_cross_end)
294    }
295
296    // +spec:block-formatting-context:d06e6e - clearance computation for clear property on blocks and floats (CSS 2.2 § 9.5.2)
297    // +spec:floats:31a3d5 - Clearance computation: places border edge even with bottom outer edge of lowest float to be cleared
298    // +spec:floats:f9bef1 - clear property moves element below preceding floats
299    /// Returns the main-axis offset needed to be clear of floats of the given type.
300    // +spec:block-formatting-context:7f6bde - CSS 2.2 § 9.5.2 clear property: clearance places border edge below bottom outer edge of cleared floats
301    // +spec:block-formatting-context:ef493f - clearance computation: places border edge even with bottom outer edge of lowest float to be cleared; inhibits margin collapsing
302    // +spec:box-model:b118fe - top border edge must be below bottom outer edge of earlier floats
303    // +spec:floats:415066 - Clear property: top border edge below bottom outer edge of cleared floats
304    // +spec:floats:7e4ad6 - clear property: element box may not be adjacent to earlier floats; only considers floats in same BFC
305    // +spec:floats:32e45d - clear:right causes sibling to flow below right floats
306    // +spec:floats:7f417a - clear property prevents content from flowing next to floats
307    // +spec:floats:d06304 - clear property moves element below floats, leaving blank space
308    // +spec:overflow:1a7aff - clearance calculation (incl. negative clearance) and clear on floats (constraint #10)
309    // +spec:positioning:1c2508 - clearance calculation: places border edge even with bottom outer edge of lowest cleared float (CSS 2.2 § 9.5.2)
310    // +spec:positioning:fe0912 - clearance computation: places border edge below bottom outer edge of cleared floats
311    // (clearance = amount to place border edge even with bottom outer edge of lowest
312    // float to be cleared); clearance can be negative per spec example 2
313    // +spec:floats:054a1e - Clearance computation: positions border edge below bottom outer edge of cleared floats
314    // +spec:floats:cb984c - Clearance can be negative per spec example 2; inhibits margin collapsing
315    #[must_use] pub fn clearance_offset(
316        &self,
317        clear: LayoutClear,
318        current_main_offset: f32,
319        wm: LayoutWritingMode,
320    ) -> f32 {
321        let mut max_end_offset = 0.0_f32;
322
323        let check_left = clear == LayoutClear::Left || clear == LayoutClear::Both;
324        let check_right = clear == LayoutClear::Right || clear == LayoutClear::Both;
325
326        for float in &self.floats {
327            let should_clear_this_float = (check_left && float.kind == LayoutFloat::Left)
328                || (check_right && float.kind == LayoutFloat::Right);
329
330            if should_clear_this_float {
331                // CSS 2.2 § 9.5.2: "the top border edge of the box be below the bottom outer edge"
332                // Outer edge = margin-box boundary (content + padding + border + margin)
333                let float_margin_box_end = float.rect.origin.main(wm)
334                    + float.rect.size.main(wm)
335                    + float.margin.main_end(wm);
336                max_end_offset = max_end_offset.max(float_margin_box_end);
337            }
338        }
339
340        if max_end_offset > current_main_offset {
341            max_end_offset
342        } else {
343            current_main_offset
344        }
345    }
346}
347
348/// Encapsulates all state needed to lay out a single Block Formatting Context.
349struct BfcLayoutState {
350    /// The current position for the next in-flow block element.
351    pen: LogicalPosition,
352    floats: FloatingContext,
353    margins: MarginCollapseContext,
354    /// The writing mode of the BFC root.
355    writing_mode: LayoutWritingMode,
356}
357
358// Entry Point & Dispatcher
359
360/// Main dispatcher for formatting context layout.
361///
362/// Routes layout to the appropriate formatting context handler based on the node's
363/// `formatting_context` property. This is the main entry point for all layout operations.
364///
365/// # CSS Spec References
366/// - CSS 2.2 § 9.4: Formatting contexts
367/// - CSS Flexbox § 3: Flex formatting contexts
368/// - CSS Grid § 5: Grid formatting contexts
369// +spec:block-formatting-context:b04653 - dispatches layout by formatting context type (BFC, IFC, Table, Flex, Grid)
370// +spec:block-formatting-context:e46499 - inner display type determines formatting context (BFC, IFC, table, flex, grid)
371#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
372/// # Errors
373///
374/// Returns a `LayoutError` if laying out the formatting context fails.
375pub fn layout_formatting_context<T: ParsedFontTrait>(
376    ctx: &mut LayoutContext<'_, T>,
377    tree: &mut LayoutTree,
378    text_cache: &mut TextLayoutCache,
379    node_index: usize,
380    constraints: &LayoutConstraints<'_>,
381    float_cache: &mut HashMap<usize, FloatingContext>,
382) -> Result<BfcLayoutResult> {
383    // [g147e az-web-lift DIAG] PURE-CONSTANT entry marker (0x609E0+slot) — fires before any node read,
384    // so it reliably shows whether layout_formatting_context is ENTERED for the nested div nodes 1,2.
385    #[cfg(feature = "web_lift")]
386    unsafe { crate::az_mark(((0x609E0 + (node_index & 7) * 4)) as u32, (0xC0DE0042) as u32); }
387    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
388    // [g147i az-web-lift DIAG] node REFERENCE address (0x60B80+slot) — NOT a field deref, so reliable.
389    // If nodes 0,1,2 aren't spaced by sizeof(LayoutNodeHot) → tree.get(index>0) mis-lifts the Vec stride,
390    // making nodes 1,2 garbage references (which would explain FC reading garbage + reads destabilizing).
391    #[cfg(feature = "web_lift")]
392    unsafe { crate::az_mark(((0x60B80 + (node_index & 7) * 4)) as u32, ((node as *const _ as usize) as u32) as u32); }
393
394    // [g147 az-web-lift] Recompute the IFC decision from the DOM: on the lift, the stored
395    // `node.formatting_context` reads GARBAGE for nested inline divs (2026-06-10 re-test WITHOUT
396    // this bypass: nodes 1/2 dispatch to the `_` arm + determine_formatting_context_for_display's
397    // markers never fire → the FC ASSIGNMENT path itself mis-lifts upstream — NOT fixed by the
398    // repr(C,u8) guard, NOT fixed by the leak-gated SP restore; same family as the enum/jump-table
399    // devirt class). The styled_dom IS reliable, so a block container whose children are all
400    // inline-level establishes an IFC (CSS 2.2 §9.2.1) — semantically valid recomputation, not a
401    // hack on top of garbage. web_lift-gated → native untouched. Remove when the FC-assignment
402    // mis-lift is root-caused (follow-up: bisect LayoutTreeBuilder's determine_/display match).
403    #[cfg(feature = "web_lift")]
404    {
405        let force_ifc = node
406            .dom_node_id
407            .map_or(false, |dom_id| {
408                crate::solver3::layout_tree::has_only_inline_children(ctx.styled_dom, dom_id)
409            });
410        if force_ifc {
411            unsafe { crate::az_mark(((0x60BA0 + (node_index & 7) * 4)) as u32, (0xC0DE1FC0) as u32); }
412            return layout_ifc(ctx, text_cache, tree, node_index, constraints)
413                .map(BfcLayoutResult::from_output);
414        }
415    }
416
417    // [g147b az-web-lift DIAG] per-node FormattingContext discriminant at layout_formatting_context
418    // entry (0x609A0+slot). Pairs with the dispatch-arm marker (0x609C0+slot) inside each match arm:
419    // if a text-div's FC reads Inline(2) but the arm marker shows Block(1) → match dispatch mis-lifts;
420    // if FC reads Block(1) → tree-construction FC assignment is wrong; if 0x609A0 stays unset for the
421    // div node → layout_formatting_context is never called for it (cache-hit short-circuit upstream).
422    #[cfg(feature = "web_lift")]
423    unsafe {
424        let fc_disc = match node.formatting_context {
425            FormattingContext::Block { .. } => 1u32,
426            FormattingContext::Inline => 2,
427            FormattingContext::InlineBlock => 3,
428            FormattingContext::Flex => 4,
429            FormattingContext::Grid => 5,
430            FormattingContext::Table => 6,
431            FormattingContext::TableCell => 7,
432            FormattingContext::TableCaption => 8,
433            _ => 0,
434        };
435        crate::az_mark(((0x609A0 + (node_index & 7) * 4)) as u32, (fc_disc | 0xC0DE0000) as u32);
436    }
437
438    debug_info!(
439        ctx,
440        "[layout_formatting_context] node_index={}, fc={:?}, available_size={:?}",
441        node_index,
442        node.formatting_context,
443        constraints.available_size
444    );
445
446    // +spec:block-formatting-context:06a24f - CSS 2.2 § 9.4: block-level boxes → BFC, inline-level → IFC
447    // +spec:block-formatting-context:9428cf - block container can establish both BFC and IFC simultaneously
448    // +spec:inline-formatting-context:8bfe73 - display:flow generates inline box (Inline) or block container (Block) based on outer display type
449    match node.formatting_context {
450        FormattingContext::Block { .. } => {
451            #[cfg(feature = "web_lift")]
452            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0001) as u32); }
453            layout_bfc(ctx, tree, text_cache, node_index, constraints, float_cache)
454        }
455        // +spec:inline-formatting-context:a180ed - IFC establishment: inline-level boxes fragmented into line boxes with baseline alignment
456        FormattingContext::Inline => {
457            #[cfg(feature = "web_lift")]
458            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0002) as u32); }
459            layout_ifc(ctx, text_cache, tree, node_index, constraints)
460                .map(BfcLayoutResult::from_output)
461        }
462        FormattingContext::InlineBlock => {
463            #[cfg(feature = "web_lift")]
464            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0003) as u32); }
465            // +spec:display-property:1f5ddf - inline-level boxes with non-flow inner display establish new formatting context
466            // +spec:inline-formatting-context:1ad004 - atomic inline (inline-block) establishes new formatting context
467            // CSS 2.2 § 9.4.1: "inline-blocks... establish new block formatting contexts"
468            // +spec:inline-block:8d21f6 - inline-block generates inline-level block container (BFC inside, atomic inline outside)
469            // InlineBlock ALWAYS establishes a BFC for its contents.
470            // The element itself participates as an atomic inline in its parent's IFC,
471            // but its children are laid out in a BFC, not an IFC.
472            let mut temp_float_cache = HashMap::new();
473            layout_bfc(ctx, tree, text_cache, node_index, constraints, &mut temp_float_cache)
474        }
475        // +spec:table-layout:753687 - CSS 2.2 §17.2 table model: display values map to FormattingContext variants and dispatch table layout
476        FormattingContext::Table => {
477            #[cfg(feature = "web_lift")]
478            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0006) as u32); }
479            layout_table_fc(ctx, tree, text_cache, node_index, constraints)
480                .map(BfcLayoutResult::from_output)
481        }
482        // Table-internal flex items are blockified during tree construction
483        // (blockify_flex_item_if_table_internal in layout_tree.rs), so they arrive
484        // here as Block, not TableCell etc.
485        FormattingContext::Flex | FormattingContext::Grid => {
486            #[cfg(feature = "web_lift")]
487            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0004) as u32); }
488            layout_flex_grid(ctx, tree, text_cache, node_index, constraints)
489        }
490        // that are not block boxes, so they establish new BFCs for their contents
491        FormattingContext::TableCell | FormattingContext::TableCaption => {
492            #[cfg(feature = "web_lift")]
493            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0007) as u32); }
494            let mut temp_float_cache = HashMap::new();
495            layout_bfc(ctx, tree, text_cache, node_index, constraints, &mut temp_float_cache)
496        }
497        _ => {
498            // [g147g az-web-lift DIAG] read the RAW discriminant byte (offset 0 under repr(C,u8)) of the
499            // node that fell through to `_`. node 0 won't hit `_`; nodes 1,2 (divs) write their disc to
500            // 0x60B40+slot. disc=1 ⇒ value IS Inline but the dispatch match mis-branched (match/jump-table
501            // lift bug); disc≠1 ⇒ tree-construction stored the wrong/garbage FC for the nested div.
502            #[cfg(feature = "web_lift")]
503            unsafe {
504                crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0009) as u32);
505                let disc: u8 = core::ptr::read_volatile((&node.formatting_context) as *const FormattingContext as *const u8);
506                crate::az_mark(((0x60B40 + (node_index & 7) * 4)) as u32, (0xC0DE0000 | (disc as u32)) as u32);
507            }
508            // Unknown formatting context - fall back to BFC
509            let mut temp_float_cache = HashMap::new();
510            layout_bfc(
511                ctx,
512                tree,
513                text_cache,
514                node_index,
515                constraints,
516                &mut temp_float_cache,
517            )
518        }
519    }
520}
521
522// Flex / grid layout (taffy Bridge)
523// containing block determined by grid-placement properties; Taffy handles this internally
524// (grid auto-placement §8.5 and abspos grid items use grid-area CB, not just padding box)
525
526/// Lays out a Flex or Grid formatting context using the Taffy layout engine.
527///
528/// # CSS Spec References
529///
530/// - CSS Flexbox § 9: Flex Layout Algorithm
531/// - CSS Grid § 12: Grid Layout Algorithm
532// gutters on either side of collapsed tracks collapse including distributed alignment space,
533// minimum contribution = outer size from min-width/min-height if specified size is auto else
534// min-content contribution) — all handled by Taffy grid implementation
535///
536/// # Implementation Notes
537///
538/// - Resolves explicit CSS dimensions to pixel values for `known_dimensions`
539/// - Uses `InherentSize` mode when explicit dimensions are set
540/// - Uses `ContentSize` mode for auto-sizing (shrink-to-fit)
541#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
542fn layout_flex_grid<T: ParsedFontTrait>(
543    ctx: &mut LayoutContext<'_, T>,
544    tree: &mut LayoutTree,
545    text_cache: &mut TextLayoutCache,
546    node_index: usize,
547    constraints: &LayoutConstraints<'_>,
548) -> Result<BfcLayoutResult> {
549    // Available space comes directly from constraints - margins are handled by Taffy
550    let available_space = TaffySize {
551        width: AvailableSpace::Definite(constraints.available_size.width),
552        height: AvailableSpace::Definite(constraints.available_size.height),
553    };
554
555    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
556
557    // from flex line's cross size (clamped by min/max) when align-self:stretch, cross-size:auto,
558    // and neither cross-axis margin is auto. Otherwise uses hypothetical cross size.
559    // NOTE: visibility:collapse strut size for flex items is handled internally by Taffy.
560    //
561    // Resolve explicit CSS dimensions to pixel values.
562    // This is CRITICAL for align-items: stretch to work correctly!
563    // Taffy uses known_dimensions to calculate cross_axis_available_space for children.
564    let (explicit_width, has_explicit_width) =
565        resolve_explicit_dimension_width(ctx, node, constraints);
566    let (explicit_height, has_explicit_height) =
567        resolve_explicit_dimension_height(ctx, node, constraints);
568
569    // FIX: For root nodes or nodes where the parent provides a definite size,
570    // use the available_size as known_dimensions if no explicit CSS width/height is set.
571    // This is critical for `align-self: stretch` to work - Taffy needs to know the
572    // cross-axis size of the container to stretch children to fill it.
573    let is_root = node.parent.is_none();
574
575    let bp = node.box_props.unpack();
576    let width_adjustment = bp.border.left
577        + bp.border.right
578        + bp.padding.left
579        + bp.padding.right;
580    let height_adjustment = bp.border.top
581        + bp.border.bottom
582        + bp.padding.top
583        + bp.padding.bottom;
584
585    // `constraints.available_size` is the root's CONTENT-BOX (produced by
586    // `prepare_layout_context::inner_size(final_used_size)`), not the viewport
587    // border-box. Previously, the code used it as if it were border-box,
588    // causing taffy to subtract padding a second time and shrink the content
589    // area by 2x padding. For the root, pull the actual border-box from
590    // `node.used_size` (set by `calculate_used_size_for_node` before this call).
591    let root_border_box = node.used_size;
592
593    let effective_width = if has_explicit_width {
594        explicit_width
595    } else if is_root {
596        root_border_box.as_ref().map(|s| s.width).or_else(|| {
597            if constraints.available_size.width.is_finite() {
598                // Fallback: convert content-box to border-box.
599                Some(constraints.available_size.width + width_adjustment)
600            } else {
601                None
602            }
603        })
604    } else {
605        // Non-root flex/grid container with `width: auto`: for a block-level
606        // child the parent's block layout has ALREADY resolved the used width
607        // (auto → fill containing block) before descending into this FC — pass
608        // it through as the definite border-box width, exactly like the root
609        // branch does with its used_size. Without this, known_dimensions.width
610        // stays None and taffy treats a column container's cross axis as
611        // INDEFINITE, so `align-items: stretch` items get the flex line's
612        // max-content width instead of the container width (live bug: under
613        // the injected Html menubar wrapper, AzulPaint's body laid out its
614        // header AND canvas at 315.776px — the header text's max-content —
615        // instead of the body's 624px).
616        node.used_size.as_ref().map(|s| s.width)
617    };
618    let effective_height = if has_explicit_height {
619        explicit_height
620    } else if is_root {
621        root_border_box.as_ref().map(|s| s.height).or_else(|| {
622            if constraints.available_size.height.is_finite() {
623                Some(constraints.available_size.height + height_adjustment)
624            } else {
625                None
626            }
627        })
628    } else {
629        None
630    };
631    let has_effective_width = effective_width.is_some();
632    let has_effective_height = effective_height.is_some();
633
634    // Taffy interprets known_dimensions as border-box. CSS width/height default
635    // to content-box, so explicit values need +padding+border added. For the
636    // ROOT element, however, we auto-apply box-sizing: border-box — the common
637    // CSS reset pattern — so `height:100%` + padding fits the viewport instead
638    // of overflowing by padding (which the default content-box interpretation
639    // would produce, since 100% of ICB is viewport-sized content, with padding
640    // added outside pushing border-box past the viewport).
641    let adjusted_width = if has_explicit_width && !is_root {
642        explicit_width.map(|w| w + width_adjustment)
643    } else if has_explicit_width && is_root {
644        explicit_width
645    } else {
646        effective_width
647    };
648    let adjusted_height = if has_explicit_height && !is_root {
649        explicit_height.map(|h| h + height_adjustment)
650    } else if has_explicit_height && is_root {
651        explicit_height
652    } else {
653        effective_height
654    };
655
656    // CSS Flexbox § 9.2: Use InherentSize when explicit dimensions are set,
657    // ContentSize for auto-sizing (shrink-to-fit behavior).
658    let sizing_mode = if has_effective_width || has_effective_height {
659        taffy::SizingMode::InherentSize
660    } else {
661        taffy::SizingMode::ContentSize
662    };
663
664    let known_dimensions = TaffySize {
665        width: adjusted_width,
666        height: adjusted_height,
667    };
668
669    // parent_size tells Taffy the size of the container's parent.
670    // For root nodes, the "parent" is the viewport, but since margins are already
671    // handled by calculate_used_size_for_node(), we use containing_block_size directly.
672    // For non-root nodes, containing_block_size is already the parent's content-box.
673    let parent_size = translate_taffy_size(constraints.containing_block_size);
674
675    let taffy_inputs = LayoutInput {
676        known_dimensions,
677        parent_size,
678        available_space,
679        run_mode: taffy::RunMode::PerformLayout,
680        sizing_mode,
681        axis: taffy::RequestedAxis::Both,
682        // Flex and Grid containers establish a new BFC, preventing margin collapse.
683        vertical_margins_are_collapsible: Line::FALSE,
684    };
685
686    debug_info!(
687        ctx,
688        "CALLING LAYOUT_TAFFY FOR FLEX/GRID FC node_index={:?}",
689        node_index
690    );
691
692    // For the root with auto-applied border-box: sync node.used_size so
693    // display-list rendering matches the border-box we handed taffy.
694    // Without this, the root's background/border would paint at the
695    // inflated size from calculate_used_size_for_node while taffy placed
696    // children inside a smaller content-box.
697    if is_root {
698        if let (Some(aw), Some(ah)) = (adjusted_width, adjusted_height) {
699            if let Some(node_mut) = tree.get_mut(node_index) {
700                node_mut.used_size = Some(LogicalSize::new(aw, ah));
701            }
702        }
703    }
704
705    // Cache border values before the mutable borrow in layout_taffy_subtree
706    let border_left = bp.border.left;
707    let border_top = bp.border.top;
708
709    let taffy_output =
710        taffy_bridge::layout_taffy_subtree(ctx, tree, text_cache, node_index, taffy_inputs);
711
712    // Adopt taffy's computed container border-box as this node's used_size. This is
713    // the height/width taffy actually laid the tracks/lines into. The auto-height
714    // path (cache.rs apply_content_based_height) otherwise derives the container
715    // height from taffy's `content_size`, but for a GRID that field measures each
716    // item relative to its OWN grid area (≈ the item's own height, blind to which
717    // row it sits in), so a multi-row grid collapsed to a single row's height
718    // (a 2×2 grid reported 16px instead of 42px). `taffy_output.size` is the correct
719    // row/line sum + gaps. Flex's `output.size` already equals the correct container
720    // size, so this is a no-op there. The root syncs its own used_size above.
721    if !is_root {
722        let container_bb = translate_taffy_size_back(taffy_output.size);
723        if let Some(node_mut) = tree.get_mut(node_index) {
724            node_mut.used_size = Some(container_bb);
725        }
726    }
727
728    // Collect child positions from the tree (Taffy stores results directly on nodes).
729    let mut output = LayoutOutput::default();
730    // Use content_size for overflow detection, not container size.
731    // content_size represents the actual size of all children, which may exceed the container.
732    //
733    // Taffy's content_size is measured from (0,0) of the border-box, so it includes
734    // border.top/left as a leading offset.  The scrollbar geometry and scroll clamp
735    // both measure inside the padding-box (border stripped).  Subtract the start
736    // border so that overflow_size is in the same coordinate space as the viewport
737    // (padding-box), preventing extra scroll range equal to the border width.
738    let raw = translate_taffy_size_back(taffy_output.content_size);
739    output.overflow_size = LogicalSize::new(
740        (raw.width - border_left).max(0.0),
741        (raw.height - border_top).max(0.0),
742    );
743
744    let children: Vec<usize> = tree.children(node_index).to_vec();
745    for &child_idx in &children {
746        if let Some(warm_node) = tree.warm(child_idx) {
747            if let Some(pos) = warm_node.relative_position {
748                output.positions.insert(child_idx, pos);
749            }
750        }
751    }
752
753    Ok(BfcLayoutResult::from_output(output))
754}
755
756/// Resolves explicit CSS width to pixel value for Taffy layout.
757/// Axis selector for `border_box_to_content`.
758#[derive(Clone, Copy)]
759enum Axis {
760    Width,
761    Height,
762}
763
764/// Convert a resolved explicit CSS dimension to the CONTENT-box value the
765/// `known_dimensions` pipeline expects.
766///
767/// The flex/grid `known_dimensions` code resolves the explicit CSS dimension and
768/// then unconditionally re-adds border+padding to reach taffy's border-box (see
769/// `adjusted_width`/`adjusted_height`). That is correct only when the resolved
770/// value is a content-box measurement. For `box-sizing:border-box`, an ABSOLUTE
771/// length (px/em/calc) IS already the border-box size, so re-adding border+padding
772/// double-counts it (a `height:100px; border:5px` container came out 110px instead
773/// of 100). Subtract the axis border+padding here so the caller's re-add restores
774/// the intended border-box. Percentages already resolve against the content-box
775/// available size, so they are left unchanged.
776fn border_box_to_content<T: ParsedFontTrait>(
777    ctx: &LayoutContext<'_, T>,
778    node: &LayoutNodeHot,
779    id: NodeId,
780    node_state: &StyledNodeState,
781    resolved: f32,
782    is_percentage: bool,
783    axis: Axis,
784) -> f32 {
785    if is_percentage {
786        return resolved;
787    }
788    let is_border_box = matches!(
789        get_css_box_sizing(ctx.styled_dom, id, node_state),
790        MultiValue::Exact(azul_css::props::layout::LayoutBoxSizing::BorderBox)
791    );
792    if !is_border_box {
793        return resolved;
794    }
795    let bp = node.box_props.unpack();
796    let adjustment = match axis {
797        Axis::Width => bp.border.left + bp.border.right + bp.padding.left + bp.padding.right,
798        Axis::Height => bp.border.top + bp.border.bottom + bp.padding.top + bp.padding.bottom,
799    };
800    (resolved - adjustment).max(0.0)
801}
802
803fn resolve_explicit_dimension_width<T: ParsedFontTrait>(
804    ctx: &LayoutContext<'_, T>,
805    node: &LayoutNodeHot,
806    constraints: &LayoutConstraints<'_>,
807) -> (Option<f32>, bool) {
808    node.dom_node_id
809        .map_or((None, false), |id| {
810            let width = get_css_width(
811                ctx.styled_dom,
812                id,
813                &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state,
814            );
815            match width.unwrap_or_default() {
816                LayoutWidth::Auto
817                | LayoutWidth::MinContent
818                | LayoutWidth::MaxContent
819                | LayoutWidth::FitContent(_) => (None, false),
820                LayoutWidth::Px(px) => {
821                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
822                    let pixels = resolve_size_metric(
823                        px.metric,
824                        px.number.get(),
825                        constraints.available_size.width,
826                        ctx.viewport_size,
827                        get_element_font_size(ctx.styled_dom, id, node_state),
828                        get_root_font_size(ctx.styled_dom, node_state),
829                    );
830                    let content_px = border_box_to_content(
831                        ctx, node, id, node_state, pixels, px.metric == SizeMetric::Percent, Axis::Width,
832                    );
833                    (Some(content_px), true)
834                }
835                LayoutWidth::Calc(items) => {
836                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
837                    let em = get_element_font_size(ctx.styled_dom, id, node_state);
838                    let calc_ctx = super::calc::CalcResolveContext {
839                        items, em_size: em, rem_size: DEFAULT_FONT_SIZE,
840                    };
841                    let px = super::calc::evaluate_calc(&calc_ctx, constraints.available_size.width);
842                    let content_px = border_box_to_content(
843                        ctx, node, id, node_state, px, false, Axis::Width,
844                    );
845                    (Some(content_px), true)
846                }
847            }
848        })
849}
850
851/// Resolves explicit CSS height to pixel value for Taffy layout.
852fn resolve_explicit_dimension_height<T: ParsedFontTrait>(
853    ctx: &LayoutContext<'_, T>,
854    node: &LayoutNodeHot,
855    constraints: &LayoutConstraints<'_>,
856) -> (Option<f32>, bool) {
857    node.dom_node_id
858        .map_or((None, false), |id| {
859            let height = get_css_height(
860                ctx.styled_dom,
861                id,
862                &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state,
863            );
864            match height.unwrap_or_default() {
865                LayoutHeight::Auto
866                | LayoutHeight::MinContent
867                | LayoutHeight::MaxContent
868                | LayoutHeight::FitContent(_) => (None, false),
869                LayoutHeight::Px(px) => {
870                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
871                    let pixels = resolve_size_metric(
872                        px.metric,
873                        px.number.get(),
874                        constraints.available_size.height,
875                        ctx.viewport_size,
876                        get_element_font_size(ctx.styled_dom, id, node_state),
877                        get_root_font_size(ctx.styled_dom, node_state),
878                    );
879                    // box-sizing:border-box + an ABSOLUTE length is a border-box
880                    // value; the caller re-adds border+padding to reach the
881                    // taffy border-box, so convert to content-box here to avoid
882                    // double-counting. (Percentages already resolve against the
883                    // content-box available size, so leave those alone.)
884                    let content_px = border_box_to_content(
885                        ctx, node, id, node_state, pixels, px.metric == SizeMetric::Percent, Axis::Height,
886                    );
887                    (Some(content_px), true)
888                }
889                LayoutHeight::Calc(items) => {
890                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
891                    let em = get_element_font_size(ctx.styled_dom, id, node_state);
892                    let calc_ctx = super::calc::CalcResolveContext {
893                        items, em_size: em, rem_size: DEFAULT_FONT_SIZE,
894                    };
895                    let px = super::calc::evaluate_calc(&calc_ctx, constraints.available_size.height);
896                    let content_px = border_box_to_content(
897                        ctx, node, id, node_state, px, false, Axis::Height,
898                    );
899                    (Some(content_px), true)
900                }
901            }
902        })
903}
904
905// +spec:floats:167a2c - Float positioning rules (CSS 2.2 § 9.5.1): left/right/none, precise placement constraints
906// +spec:floats:6a1769 - Float shortens line boxes, margins never collapse, stacking order
907// +spec:floats:15bfd9 - float:right positions element at line-right edge within BFC
908// +spec:floats:afc8e2 - Float positioning rules (CSS 2.2 § 9.5 rules 1-8): left/right edge containment, earlier-float stacking, outer-top constraints, and "move down" when insufficient space
909/// Position a float within a BFC, considering existing floats.
910/// Returns the `LogicalRect` (margin box) for the float.
911// +spec:box-model:db0f02 - Float positioning: line boxes shortened by floats, floats shift down if no space, BFC elements must not overlap float margin boxes
912// +spec:containing-block:136e45 - Float shifted left/right until outer edge touches containing block edge or another float
913// +spec:containing-block:3ebb4e - Content moves below floats when containing block too narrow
914// +spec:floats:45fce7 - Float positioning: pulled out of flow, line boxes shortened around float
915// +spec:floats:f6c218 - float pulled out of flow, line boxes shorten around it
916// +spec:height-calculation:86142a - CSS 2.2 §9.5 float positioning, clearance, and margin non-collapsing
917// +spec:width-calculation:761677 - float positioning: content flows around floats, line boxes shortened by float presence
918fn position_float(
919    float_ctx: &FloatingContext,
920    float_type: LayoutFloat,
921    size: LogicalSize,
922    margin: &EdgeSizes,
923    current_main_offset: f32,
924    bfc_cross_size: f32,
925    wm: LayoutWritingMode,
926) -> LogicalRect {
927    // Start at the current main-axis position (Y in horizontal-tb)
928    let mut main_start = current_main_offset;
929
930    // Calculate total size including margins
931    let total_main = size.main(wm) + margin.main_start(wm) + margin.main_end(wm);
932    let total_cross = size.cross(wm) + margin.cross_start(wm) + margin.cross_end(wm);
933
934    // +spec:floats:3d89d8 - shift float downward when not enough horizontal room
935    // Find a position where the float fits
936    let cross_start = loop {
937        let (avail_start, avail_end) = float_ctx.available_line_box_space(
938            main_start,
939            main_start + total_main,
940            bfc_cross_size,
941            wm,
942        );
943
944        let available_width = avail_end - avail_start;
945
946        if available_width >= total_cross {
947            // +spec:floats:449158 - left float positioned at line-left, content flows on right
948            // Found space that fits
949            if float_type == LayoutFloat::Left {
950                // +spec:writing-modes:84bcba - floats positioned at line-left / line-right
951                // Position at line-left (avail_start)
952                break avail_start + margin.cross_start(wm);
953            }
954            // Position at line-right (avail_end - size)
955            break avail_end - total_cross + margin.cross_start(wm);
956        }
957
958        // top is moved lower than earlier float's bottom (outer edge / margin box bottom)
959        // Not enough space at this Y, move down past the lowest overlapping float's margin box bottom
960        let next_main = float_ctx
961            .floats
962            .iter()
963            .filter(|f| {
964                let f_main_start = f.rect.origin.main(wm) - f.margin.main_start(wm);
965                let f_main_end = f_main_start + f.rect.size.main(wm)
966                    + f.margin.main_start(wm) + f.margin.main_end(wm);
967                f_main_end > main_start && f_main_start < main_start + total_main
968            })
969            .map(|f| f.rect.origin.main(wm) + f.rect.size.main(wm) + f.margin.main_end(wm))
970            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
971
972        if let Some(next) = next_main {
973            main_start = next;
974        } else {
975            // No overlapping floats found, use current position anyway
976            if float_type == LayoutFloat::Left {
977                break avail_start + margin.cross_start(wm);
978            }
979            break avail_end - total_cross + margin.cross_start(wm);
980        }
981    };
982
983    LogicalRect {
984        origin: LogicalPosition::from_main_cross(
985            main_start + margin.main_start(wm),
986            cross_start,
987            wm,
988        ),
989        size,
990    }
991}
992
993// Block Formatting Context (CSS 2.2 § 9.4.1)
994
995/// Lays out a Block Formatting Context (BFC).
996///
997/// This is the corrected, architecturally-sound implementation. It solves the
998/// "chicken-and-egg" problem by performing its own two-pass layout:
999///
1000/// 1. **Sizing Pass:** It first iterates through its children and triggers their layout recursively
1001///    by calling `calculate_layout_for_subtree`. This ensures that the `used_size` property of each
1002///    child is correctly populated.
1003///
1004/// 2. **Positioning Pass:** It then iterates through the children again. Now that each child has a
1005///    valid size, it can apply the standard block-flow logic: stacking them vertically and
1006///    advancing a "pen" by each child's outer height.
1007///
1008/// # Margin Collapsing Architecture
1009///
1010/// CSS 2.1 Section 8.3.1 compliant margin collapsing:
1011///
1012/// ```text
1013/// layout_bfc()
1014///   ├─ Check parent border/padding blockers
1015///   ├─ For each child:
1016///   │   ├─ Check child border/padding blockers
1017///   │   ├─ is_first_child?
1018///   │   │   └─ Check parent-child top collapse
1019///   │   ├─ Sibling collapse?
1020///   │   │   └─ advance_pen_with_margin_collapse()
1021///   │   │       └─ collapse_margins(prev_bottom, curr_top)
1022///   │   ├─ Position child
1023///   │   ├─ is_empty_block()?
1024///   │   │   └─ Collapse own top+bottom margins (collapse through)
1025///   │   └─ Save bottom margin for next sibling
1026///   └─ Check parent-child bottom collapse
1027/// ```
1028///
1029/// **Collapsing Rules:**
1030///
1031/// - Sibling margins: Adjacent vertical margins collapse to max (or sum if mixed signs)
1032/// - Parent-child: First child's top margin can escape parent (if no border/padding)
1033/// - Parent-child: Last child's bottom margin can escape parent (if no border/padding/height)
1034/// - Empty blocks: Top+bottom margins collapse with each other, then with siblings
1035/// - Blockers: Border, padding, inline content, or new BFC prevents collapsing
1036///
1037/// This approach is compliant with the CSS visual formatting model and works within
1038/// the constraints of the existing layout engine architecture.
1039// +spec:display-property:f38f52 - BFC handles normal flow, relative positioning offsets, and float extraction (CSS 2.2 § 9.8)
1040#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1041fn layout_bfc<T: ParsedFontTrait>(
1042    ctx: &mut LayoutContext<'_, T>,
1043    tree: &mut LayoutTree,
1044    text_cache: &mut TextLayoutCache,
1045    node_index: usize,
1046    constraints: &LayoutConstraints<'_>,
1047    float_cache: &mut HashMap<usize, FloatingContext>,
1048) -> Result<BfcLayoutResult> {
1049    let node = tree
1050        .get(node_index)
1051        .ok_or(LayoutError::InvalidTree)?
1052        .clone();
1053    // +spec:block-formatting-context:4f4ff6 - writing-mode determines block flow direction (main axis) for ordering block-level boxes in BFC
1054    let writing_mode = constraints.writing_mode;
1055    let mut output = LayoutOutput::default();
1056
1057    debug_info!(
1058        ctx,
1059        "\n[layout_bfc] ENTERED for node_index={}, children.len()={}, incoming_bfc_state={}",
1060        node_index,
1061        tree.children(node_index).len(),
1062        constraints.bfc_state.is_some()
1063    );
1064
1065    // Initialize FloatingContext for this BFC
1066    //
1067    // We always recalculate float positions in this pass, but we'll store them in the cache
1068    // so that subsequent layout passes (for auto-sizing) have access to the positioned floats
1069    let mut float_context = FloatingContext::default();
1070
1071    // +spec:containing-block:42b75f - Block element establishes containing block for inline content (IFC)
1072    // Calculate this node's content-box size for use as containing block for children
1073    // CSS 2.2 § 10.1: The containing block for in-flow children is formed by the
1074    // content edge of the parent's content box.
1075    //
1076    // We use constraints.available_size directly as this already represents the
1077    // content-box available to this node (set by parent). For nodes with explicit
1078    // sizes, used_size contains the border-box which we convert to content-box.
1079    //
1080    // NOTE(writing-modes): The containing block size uses physical width/height.
1081    // In vertical writing modes, the block progression direction is horizontal,
1082    // so the "available width" for children maps to the physical height of
1083    // the containing block. The main_pen variable below tracks block progression
1084    // using logical main-axis coordinates; the WritingModeContext in constraints
1085    // determines how main/cross map to physical x/y via from_main_cross().
1086    // +spec:inline-block:17944a - orthogonal flow roots get infinite available inline space here (not yet detected)
1087    // +spec:inline-block:a60e22 - other layout models pass through infinite inline space to contained block containers
1088    let mut children_containing_block_size = node.used_size.map_or_else(
1089        // No used_size yet - use available_size directly (this is already content-box
1090        // when coming from parent's layout constraints)
1091        || constraints.available_size,
1092        |used_size| {
1093            // Node has used_size (border-box) - convert to content-box.
1094            // For auto-height containers, the pre-layout `used_size.height` is a
1095            // placeholder (calculate_used_size_for_node returns 0 for block-level
1096            // auto-height; apply_content_based_height resolves it after children lay
1097            // out). In that window, `constraints.available_size.height` holds the
1098            // containing block's height — the value children should use as their own
1099            // containing block for percentage-height / indefinite-height semantics.
1100            let inner = node.box_props.inner_size(used_size, writing_mode);
1101            let height_is_auto = tree
1102                .warm(node_index)
1103                .is_none_or(|w| w.computed_style.height.is_none());
1104            if height_is_auto {
1105                LogicalSize::new(inner.width, constraints.available_size.height)
1106            } else {
1107                inner
1108            }
1109        },
1110    );
1111
1112    // +spec:overflow:ffe6f7 - scrollbar space subtracted from containing block per spec §11.1.1
1113    // Reserve space for vertical scrollbar when appropriate.
1114    //
1115    // - overflow: scroll  → ALWAYS reserve (CSS spec: scrollbar always shown)
1116    // - overflow: auto    → Reserve ONLY when a previous pass already determined
1117    //   a scrollbar is needed.
1118    //   On the very first pass the node has no scrollbar_info yet, so no space
1119    //   is reserved.  After `compute_scrollbar_info` detects overflow it sets
1120    //   `reflow_needed_for_scrollbars = true`, triggering a second pass where
1121    //   `node.scrollbar_info.needs_vertical == true` and space IS reserved.
1122    //   Each pass replaces `scrollbar_info` with the current state; the outer
1123    //   layout loop's iteration cap handles oscillation safety.
1124    let scrollbar_reservation = node
1125        .dom_node_id
1126        .map_or(0.0, |dom_id| {
1127            let styled_node_state = ctx
1128                .styled_dom
1129                .styled_nodes
1130                .as_container()
1131                .get(dom_id)
1132                .map(|s| s.styled_node_state)
1133                .unwrap_or_default();
1134            let overflow_y =
1135                get_overflow_y(ctx.styled_dom, dom_id, &styled_node_state);
1136            match overflow_y.unwrap_or_default() {
1137                LayoutOverflow::Scroll => {
1138                    crate::solver3::getters::get_layout_scrollbar_width_px(ctx, dom_id, &styled_node_state)
1139                }
1140                LayoutOverflow::Auto => {
1141                    let already_needs = tree.warm(node_index)
1142                        .and_then(|w| w.scrollbar_info.as_ref())
1143                        .is_some_and(|s| s.needs_vertical);
1144                    if already_needs {
1145                        crate::solver3::getters::get_layout_scrollbar_width_px(ctx, dom_id, &styled_node_state)
1146                    } else {
1147                        0.0
1148                    }
1149                }
1150                _ => 0.0,
1151            }
1152        });
1153
1154    if scrollbar_reservation > 0.0 {
1155        children_containing_block_size.width =
1156            (children_containing_block_size.width - scrollbar_reservation).max(0.0);
1157    }
1158
1159    // === Pass 1: Pre-compute child sizes (restored two-pass BFC) ===
1160    //
1161    // Inspired by Taffy's two-pass approach: first measure, then position.
1162    //
1163    // This was removed in commit 1a3e5850 and replaced with a single-pass approach
1164    // that computed sizes just-in-time during positioning. The single-pass approach
1165    // caused regression 8e092a2e because positioning decisions (margin collapsing,
1166    // float clearance, available width after floats) depend on knowing ALL sibling
1167    // sizes upfront, not just the ones visited so far.
1168    //
1169    // With the per-node cache (§9.1-§9.2), the re-added Pass 1 is efficient:
1170    // - Each child subtree is computed once and stored in NodeCache
1171    // - Pass 2 positioning reads sizes from tree nodes (used_size set by Pass 1)
1172    // - When calculate_layout_for_subtree recurses into children after layout_bfc
1173    //   returns, it hits the per-node cache (same available_size) — O(1) per child.
1174    //
1175    // Performance: O(n) for the tree. No double-computation thanks to caching.
1176    {
1177        let mut temp_positions: super::PositionVec = Vec::new();
1178        let mut temp_scrollbar_reflow = false;
1179
1180        let bfc_children = tree.children(node_index).to_vec();
1181        // [g147c az-web-lift DIAG] layout_bfc Pass-1 child-sizing loop: record bfc_children.len per parent
1182        // node (0x60A00+slot). If body shows len=2 but the divs never get the per-child "sized" marker
1183        // (0x60A40+childslot) below → the loop skips them; if they DO get it but layout_formatting_context
1184        // (0x609A0) stays unset → calculate(child,ComputeSize) cache-hit (vs 0x60A60 miss-flag in cache.rs).
1185        #[cfg(feature = "web_lift")]
1186        unsafe { crate::az_mark(((0x60A00 + (node_index & 7) * 4)) as u32, (bfc_children.len() as u32 | 0xC0DE0000) as u32); }
1187        for &child_index in &bfc_children {
1188            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1189            let child_dom_id = child_node.dom_node_id;
1190
1191            // +spec:positioning:447b06 - Absolute positioning pulls element out of flow, skip from normal layout
1192            // +spec:positioning:77a2d2 - Absolutely positioned children are ignored for auto height
1193            // +spec:positioning:b47ac2 - Only normal flow children taken into account for auto height
1194            // Skip absolutely/fixed positioned children — they're laid out separately
1195            // +spec:positioning:c7e5c5 - out-of-flow elements ignored for word boundary / hyphenation
1196            // +spec:positioning:7dd6d1 - Absolutely positioned boxes are taken out of the normal flow (no impact on later siblings, no margin collapsing)
1197            let position_type = get_position_type(ctx.styled_dom, child_dom_id);
1198            if position_type == LayoutPosition::Absolute || position_type == LayoutPosition::Fixed {
1199                continue;
1200            }
1201
1202            // Compute the child's full subtree layout with temporary positions.
1203            // Position (0,0) is intentionally wrong — Pass 1 only cares about sizing.
1204            // The correct positions are determined in Pass 2 below.
1205            // [g147c] this child IS reached by Pass-1 sizing (per-child slot).
1206            #[cfg(feature = "web_lift")]
1207            unsafe { crate::az_mark(((0x60A40 + (child_index & 7) * 4)) as u32, (0xC0DE0000 | (child_index as u32 & 0xffff)) as u32); }
1208            crate::solver3::cache::calculate_layout_for_subtree(
1209                ctx,
1210                tree,
1211                text_cache,
1212                child_index,
1213                LogicalPosition::zero(),
1214                children_containing_block_size,
1215                &mut temp_positions,
1216                &mut temp_scrollbar_reflow,
1217                float_cache,
1218                crate::solver3::cache::ComputeMode::ComputeSize,
1219            )?;
1220        }
1221    }
1222
1223    // +spec:block-formatting-context:98b633 - CSS 2.2 § 9.4.1: boxes laid out vertically, margins collapse
1224    // === Pass 2: Position children using known sizes ===
1225    //
1226    // All children now have used_size set from Pass 1. This pass handles:
1227    // - Margin collapsing (parent-child + sibling-sibling)
1228    // - Float positioning and clearance
1229    // - Normal flow block positioning
1230
1231    let mut main_pen = 0.0f32;
1232    let mut max_cross_size = 0.0f32;
1233
1234    // Track escaped margins separately from content-box height
1235    // CSS 2.2 § 8.3.1: Escaped margins don't contribute to parent's content-box height,
1236    // but DO affect sibling positioning within the parent
1237    let mut total_escaped_top_margin = 0.0f32;
1238    // Track all inter-sibling margins (collapsed) - these are also not part of content height
1239    let mut total_sibling_margins = 0.0f32;
1240
1241    // Margin collapsing state
1242    let mut last_margin_bottom = 0.0f32;
1243    let mut is_first_child = true;
1244    let mut first_child_index: Option<usize> = None;
1245    let mut last_child_index: Option<usize> = None;
1246
1247    // Parent's own margins (for escape calculation)
1248    let node_bp = node.box_props.unpack();
1249    let parent_margin_top = node_bp.margin.main_start(writing_mode);
1250    let parent_margin_bottom = node_bp.margin.main_end(writing_mode);
1251
1252    // margins do not collapse across formatting context boundaries: an independent
1253    // BFC (float, overflow != visible, display: flex/grid, etc.) isolates its
1254    // children's margins. The DOM root is NOT a BFC boundary for this purpose —
1255    // its first child's margin still collapses through it (then gets absorbed at
1256    // the root, since there's no grandparent to escape to).
1257    let establishes_own_bfc = establishes_new_bfc(ctx, &node, tree.cold(node_index));
1258    let is_bfc_root = node.parent.is_none() || establishes_own_bfc;
1259
1260    // parent_has_*_blocker inhibits parent-child margin collapse per CSS 2.2 §8.3.1.
1261    // An explicit border/padding blocks, and an independent BFC blocks, but the
1262    // root on its own does not.
1263    let parent_has_top_blocker = establishes_own_bfc
1264        || has_margin_collapse_blocker(&node_bp, writing_mode, true);
1265    let parent_has_bottom_blocker = establishes_own_bfc
1266        || has_margin_collapse_blocker(&node_bp, writing_mode, false);
1267
1268    // Track accumulated top margin for first-child escape
1269    let mut accumulated_top_margin = 0.0f32;
1270    let mut top_margin_resolved = false;
1271    // Track if first child's margin escaped (for return value)
1272    let mut top_margin_escaped = false;
1273
1274    // Track if we have any actual content (non-empty blocks)
1275    let mut has_content = false;
1276
1277    // +spec:display-property:9f6e18 - BFC dispatches normal flow, floats, and relative positioning (CSS 2.2 §9.8)
1278    let pos_children = tree.children(node_index).to_vec();
1279    for &child_index in &pos_children {
1280        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1281        let child_dom_id = child_node.dom_node_id;
1282
1283        // +spec:floats:2cec1b - 'position' and 'float' determine the positioning algorithm
1284        // +spec:positioning:dccad6 - floats only apply to non-absolutely-positioned boxes
1285        let position_type = get_position_type(ctx.styled_dom, child_dom_id);
1286        if position_type == LayoutPosition::Absolute || position_type == LayoutPosition::Fixed {
1287            continue;
1288        }
1289
1290        // +spec:floats:2cec1b - float property determines positioning algorithm (float path)
1291        // +spec:floats:f6c0b2 - floats only processed in BFC; other formatting contexts (flex/grid) inhibit floating
1292        // Check if this child is a float - if so, position it at current main_pen
1293        if let Some(node_id) = child_dom_id {
1294            let float_type = get_float_property(ctx.styled_dom, Some(node_id));
1295
1296            if float_type != LayoutFloat::None {
1297                // Calculate float size just-in-time if not already computed
1298                let float_size = if let Some(size) = child_node.used_size { size } else {
1299                    let intrinsic = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
1300                    let child_bp = child_node.box_props.unpack();
1301                    let computed_size = crate::solver3::sizing::calculate_used_size_for_node(
1302                        ctx.styled_dom,
1303                        child_dom_id,
1304                        &children_containing_block_size,
1305                        intrinsic,
1306                        &child_bp,
1307                        &ctx.viewport_size,
1308                    )?;
1309                    if let Some(node_mut) = tree.get_mut(child_index) {
1310                        node_mut.used_size = Some(computed_size);
1311                    }
1312                    computed_size
1313                };
1314                // Re-borrow after potential mutation
1315                let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1316                let child_bp2 = child_node.box_props.unpack();
1317                let float_margin = &child_bp2.margin;
1318
1319                // +spec:floats:d0d163 - clear on floats adds constraint #10: float top below cleared floats' bottom
1320                // +spec:floats:7adb9d - Clear on floats: constraint #10, top outer edge must be below earlier cleared floats
1321                let float_clear = get_clear_property(ctx.styled_dom, Some(node_id));
1322                let float_y = if float_clear == LayoutClear::None {
1323                    // +spec:floats:ef96cb - Float margins never collapse with adjacent margins
1324                    // CSS 2.2 § 9.5: Float margins don't collapse with any other margins.
1325                    main_pen + last_margin_bottom
1326                } else {
1327                    float_context.clearance_offset(float_clear, main_pen + last_margin_bottom, writing_mode)
1328                };
1329
1330                debug_info!(
1331                    ctx,
1332                    "[layout_bfc] Positioning float: index={}, type={:?}, size={:?}, at Y={} \
1333                     (main_pen={} + last_margin={})",
1334                    child_index,
1335                    float_type,
1336                    float_size,
1337                    float_y,
1338                    main_pen,
1339                    last_margin_bottom
1340                );
1341
1342                // Position the float at the CURRENT main_pen + last margin (respects DOM order!)
1343                let float_rect = position_float(
1344                    &float_context,
1345                    float_type,
1346                    float_size,
1347                    float_margin,
1348                    // Include last_margin_bottom since float margins don't collapse!
1349                    float_y,
1350                    constraints.available_size.cross(writing_mode),
1351                    writing_mode,
1352                );
1353
1354                debug_info!(ctx, "[layout_bfc] Float positioned at: {:?}", float_rect);
1355
1356                // Add to float context BEFORE positioning next element
1357                float_context.add_float(float_type, float_rect, *float_margin);
1358
1359                // Store position in output
1360                output.positions.insert(child_index, float_rect.origin);
1361
1362                debug_info!(
1363                    ctx,
1364                    "[layout_bfc] *** FLOAT POSITIONED: child={}, main_pen={} (unchanged - floats \
1365                     don't advance pen)",
1366                    child_index,
1367                    main_pen
1368                );
1369
1370                // Floats are taken out of normal flow - DON'T advance main_pen
1371                // Continue to next child
1372                continue;
1373            }
1374        }
1375
1376        // Floats `continue` above; everything reaching here is normal-flow
1377        // (non-float) content.
1378
1379        // From here: normal flow (non-float) children only
1380
1381        // Track first and last in-flow children for parent-child collapse
1382        if first_child_index.is_none() {
1383            first_child_index = Some(child_index);
1384        }
1385        last_child_index = Some(child_index);
1386
1387        // Calculate child's used_size just-in-time if not already computed
1388        // This replaces the old "Pass 1" that recursively laid out grandchildren with wrong positions
1389        let child_size = if let Some(size) = child_node.used_size { size } else {
1390            // Calculate size without recursive layout
1391            let intrinsic = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
1392            let child_used_size = crate::solver3::sizing::calculate_used_size_for_node(
1393                ctx.styled_dom,
1394                child_dom_id,
1395                &children_containing_block_size,
1396                intrinsic,
1397                &child_node.box_props.unpack(),
1398                &ctx.viewport_size,
1399            )?;
1400            // Update the node with computed size (we need to re-borrow mutably)
1401            if let Some(node_mut) = tree.get_mut(child_index) {
1402                node_mut.used_size = Some(child_used_size);
1403            }
1404            child_used_size
1405        };
1406        // Re-borrow child_node after potential mutation
1407        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1408        let child_bp = child_node.box_props.unpack();
1409        let child_margin = &child_bp.margin;
1410
1411        debug_info!(
1412            ctx,
1413            "[layout_bfc] Child {} margin from box_props: top={}, right={}, bottom={}, left={}",
1414            child_index,
1415            child_margin.top,
1416            child_margin.right,
1417            child_margin.bottom,
1418            child_margin.left
1419        );
1420
1421        // +spec:block-formatting-context:0f802c - margins use containing block's writing mode for collapsing/auto expansion in orthogonal flows
1422        let child_own_margin_top = child_margin.main_start(writing_mode);
1423        let child_own_margin_bottom = child_margin.main_end(writing_mode);
1424
1425        // CSS 2.2 § 8.3.1: If a child has no top blocker (no padding/border) and its
1426        // own BFC layout produced an escaped_top_margin, that margin represents the
1427        // collapsed value of (child's margin, child's first child's margin, ...).
1428        // Use it for sibling collapse instead of the child's own margin.
1429        let child_escaped_top = if has_margin_collapse_blocker(&child_bp, writing_mode, true) { None } else {
1430            tree.warm(child_index).and_then(|w| w.escaped_top_margin)
1431        };
1432        let child_escaped_bottom = if has_margin_collapse_blocker(&child_bp, writing_mode, false) { None } else {
1433            tree.warm(child_index).and_then(|w| w.escaped_bottom_margin)
1434        };
1435
1436        let child_margin_top = child_escaped_top.unwrap_or(child_own_margin_top);
1437        let child_margin_bottom = child_escaped_bottom.unwrap_or(child_own_margin_bottom);
1438
1439        debug_info!(
1440            ctx,
1441            "[layout_bfc] Child {} final margins: margin_top={}, margin_bottom={}",
1442            child_index,
1443            child_margin_top,
1444            child_margin_bottom
1445        );
1446
1447        // Check if this child has border/padding that prevents margin collapsing
1448        let child_has_top_blocker =
1449            has_margin_collapse_blocker(&child_bp, writing_mode, true);
1450        let child_has_bottom_blocker =
1451            has_margin_collapse_blocker(&child_bp, writing_mode, false);
1452
1453        // +spec:floats:dc195a - Clear property only applies to block-level elements (CSS 2.2 § 9.5.2)
1454        // Check for clear property FIRST - clearance affects whether element is considered empty
1455        // CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing"
1456        // An element with clearance is NOT empty even if it has no content
1457        let child_clear = if let Some(node_id) = child_dom_id {
1458            get_clear_property(ctx.styled_dom, Some(node_id))
1459        } else {
1460            LayoutClear::None
1461        };
1462        debug_info!(
1463            ctx,
1464            "[layout_bfc] Child {} clear property: {:?}",
1465            child_index,
1466            child_clear
1467        );
1468
1469        // PHASE 1: Empty Block Detection & Self-Collapse
1470        let is_empty = is_empty_block(tree, child_index);
1471
1472        // Handle empty blocks FIRST (they collapse through and don't participate in layout)
1473        // EXCEPTION: Elements with clear property are NOT skipped even if empty!
1474        // CSS 2.2 § 9.5.2: Clear property affects positioning even for empty elements
1475        if is_empty
1476            && !child_has_top_blocker
1477            && !child_has_bottom_blocker
1478            && child_clear == LayoutClear::None
1479        {
1480            // Empty block: collapse its own top and bottom margins FIRST
1481            let self_collapsed = collapse_margins(child_margin_top, child_margin_bottom);
1482
1483            // Then collapse with previous margin (sibling or parent)
1484            if is_first_child {
1485                is_first_child = false;
1486                // Empty first child: its collapsed margin can escape with parent's
1487                if parent_has_top_blocker {
1488                    // Parent has blocker: add margins
1489                    if accumulated_top_margin == 0.0 {
1490                        accumulated_top_margin = parent_margin_top;
1491                    }
1492                    main_pen += accumulated_top_margin + self_collapsed;
1493                    top_margin_resolved = true;
1494                    accumulated_top_margin = 0.0;
1495                } else {
1496                    accumulated_top_margin = collapse_margins(parent_margin_top, self_collapsed);
1497                }
1498                last_margin_bottom = self_collapsed;
1499            } else {
1500                // Empty sibling: collapse with previous sibling's bottom margin
1501                last_margin_bottom = collapse_margins(last_margin_bottom, self_collapsed);
1502            }
1503
1504            // Skip positioning and pen advance (empty has no visual presence)
1505            continue;
1506        }
1507
1508        // From here on: non-empty blocks only (or empty blocks with clear property)
1509
1510        // Apply clearance if needed
1511        // +spec:floats:148ee6 - clear:left pushes element below float; clearance added above top margin
1512        // CSS 2.2 § 9.5.2: Clearance inhibits margin collapsing.
1513        //
1514        // Per CSS 2.2 § 9.5.2, the clearance computation works as follows:
1515        // 1. Compute the "hypothetical position" — where the border edge would be
1516        //    with normal margin collapsing (as if clear:none).
1517        // 2. If the hypothetical position is NOT past the relevant floats,
1518        //    clearance is introduced and the border edge is placed at float bottom.
1519        // 3. The final border edge = max(float_bottom, hypothetical_position).
1520        //
1521        // This means child_margin_top is already accounted for in the hypothetical
1522        // position and must NOT be added again after clearance positions main_pen.
1523        let clearance_applied = if child_clear == LayoutClear::None {
1524            false
1525        } else {
1526            let hypothetical = main_pen + collapse_margins(last_margin_bottom, child_margin_top);
1527            let cleared_position =
1528                float_context.clearance_offset(child_clear, hypothetical, writing_mode);
1529            debug_info!(
1530                ctx,
1531                "[layout_bfc] Child {} clearance check: cleared_position={}, hypothetical={} (main_pen={} + collapse({}, {}))",
1532                child_index,
1533                cleared_position,
1534                hypothetical,
1535                main_pen,
1536                last_margin_bottom,
1537                child_margin_top
1538            );
1539            if cleared_position > hypothetical {
1540                debug_info!(
1541                    ctx,
1542                    "[layout_bfc] Applying clearance: child={}, clear={:?}, old_pen={}, new_pen={}",
1543                    child_index,
1544                    child_clear,
1545                    main_pen,
1546                    cleared_position
1547                );
1548                main_pen = cleared_position;
1549                true // Signal that clearance was applied
1550            } else {
1551                false
1552            }
1553        };
1554
1555        // PHASE 2: Parent-Child Top Margin Escape (First Child)
1556        //
1557        // CSS 2.2 § 8.3.1: "The top margin of a box is adjacent to the top margin of its first
1558        // in-flow child if the box has no top border, no top padding, and the child has no
1559        // clearance." CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing"
1560
1561        if is_first_child {
1562            is_first_child = false;
1563
1564            // Clearance prevents collapse (acts as invisible blocker)
1565            if clearance_applied {
1566                // Clearance inhibits all margin collapsing for this element
1567                // The clearance has already positioned main_pen at the correct
1568                // border-edge position (= max(float_bottom, hypothetical)).
1569                // The hypothetical already includes child_margin_top via
1570                // collapse_margins, so we must NOT add it again here.
1571                debug_info!(
1572                    ctx,
1573                    "[layout_bfc] First child {} with CLEARANCE: no collapse, child_margin={}, \
1574                     main_pen={}",
1575                    child_index,
1576                    child_margin_top,
1577                    main_pen
1578                );
1579            } else if !parent_has_top_blocker {
1580                // Margin Escape Case
1581                //
1582                // CSS 2.2 § 8.3.1: "The top margin of an in-flow block element collapses with
1583                // its first in-flow block-level child's top margin if the element has no top
1584                // border, no top padding, and the child has no clearance."
1585                //
1586                // When margins collapse, they "escape" upward through the parent to be resolved
1587                // in the grandparent's coordinate space. This is critical for understanding the
1588                // coordinate system separation:
1589                //
1590                // Example:
1591                // <body padding=20>
1592                //  <div margin=0>
1593                //      <div margin=30></div>
1594                //  </div>
1595                // </body>
1596                //
1597                //   - Middle div (our parent) has no padding → margins can escape
1598                //   - Inner div's 30px margin collapses with middle div's 0px margin = 30px
1599                //   - This 30px margin "escapes" to be handled by body's BFC
1600                //   - Body positions middle div at Y=30 (relative to body's content-box)
1601                //   - Middle div's content-box height does NOT include the escaped 30px
1602                //   - Inner div is positioned at Y=0 in middle div's content-box
1603                //
1604                // **NOTE**: This is a subtle but critical distinction in coordinate systems:
1605                //
1606                //   - Parent's margin belongs to grandparent's coordinate space
1607                //   - Child's margin (when escaped) also belongs to grandparent's coordinate space
1608                //   - They collapse BEFORE entering this BFC's coordinate space
1609                //   - We return the collapsed margin so grandparent can position parent correctly
1610                //
1611                // **NOTE**: Child's own blocker status (padding/border) is IRRELEVANT for
1612                // parent-child  collapse. The child may have padding that prevents
1613                // collapse with ITS OWN  children, but this doesn't prevent its
1614                // margin from escaping  through its parent.
1615                //
1616                // **NOTE**: Previously, we incorrectly added parent_margin_top to main_pen in
1617                //  the blocked case, which double-counted the margin by mixing
1618                //  coordinate systems. The parent's margin is NEVER in our (the
1619                //  parent's content-box) coordinate system!
1620                //
1621                // We collapse the parent's margin with the child's margin.
1622                // This combined margin is what "escapes" to the grandparent.
1623                // The grandparent uses this to position the parent.
1624                //
1625                // Effectively, we are saying "The parent starts here, but its effective
1626                // top margin is now max(parent_margin, child_margin)".
1627
1628                accumulated_top_margin = collapse_margins(parent_margin_top, child_margin_top);
1629                top_margin_resolved = true;
1630                top_margin_escaped = true;
1631
1632                // Track escaped margin so it gets subtracted from content-box height
1633                // The escaped margin is NOT part of our content-box - it belongs to our
1634                // parent's parent
1635                total_escaped_top_margin = accumulated_top_margin;
1636
1637                // Position child at pen (no margin applied - it escaped!)
1638                debug_info!(
1639                    ctx,
1640                    "[layout_bfc] First child {} margin ESCAPES: parent_margin={}, \
1641                     child_margin={}, collapsed={}, total_escaped={}",
1642                    child_index,
1643                    parent_margin_top,
1644                    child_margin_top,
1645                    accumulated_top_margin,
1646                    total_escaped_top_margin
1647                );
1648            } else {
1649                // Margin Blocked Case
1650                //
1651                // CSS 2.2 § 8.3.1: "no top padding and no top border" required for collapse.
1652                // When padding or border exists, margins do NOT collapse and exist in different
1653                // coordinate spaces.
1654                //
1655                // CRITICAL COORDINATE SYSTEM SEPARATION:
1656                //
1657                //   This is where the architecture becomes subtle. When layout_bfc() is called:
1658                //   1. We are INSIDE the parent's content-box coordinate space (main_pen starts at
1659                //      0)
1660                //   2. The parent's own margin was ALREADY RESOLVED by the grandparent's BFC
1661                //   3. The parent's margin is in the grandparent's coordinate space, not ours
1662                //   4. We NEVER reference the parent's margin in this BFC - it's outside our scope
1663                //
1664                // Example:
1665                //
1666                // <body padding=20>
1667                //   <div margin=30 padding=20>
1668                //      <div margin=30></div>
1669                //   </div>
1670                // </body>
1671                //
1672                //   - Middle div has padding=20 → blocker exists, margins don't collapse
1673                //   - Body's BFC positions middle div at Y=30 (middle div's margin, in body's
1674                //     space)
1675                //   - Middle div's BFC starts at its content-box (after the padding)
1676                //   - main_pen=0 at the top of middle div's content-box
1677                //   - Inner div has margin=30 → we add 30 to main_pen (in OUR coordinate space)
1678                //   - Inner div positioned at Y=30 (relative to middle div's content-box)
1679                //   - Absolute position: 20 (body padding) + 30 (middle margin) + 20 (middle
1680                //     padding) + 30 (inner margin) = 100px
1681                //
1682                // **NOTE**: Previous code incorrectly added parent_margin_top to main_pen here:
1683                //
1684                //     - main_pen += parent_margin_top;  // WRONG! Mixes coordinate systems
1685                //     - main_pen += child_margin_top;
1686                //
1687                //   This caused the "double margin" bug where margins were applied twice:
1688                //
1689                //   - Once by grandparent positioning parent (correct)
1690                //   - Again inside parent's BFC (INCORRECT - wrong coordinate system)
1691                //
1692                //   The parent's margin belongs to GRANDPARENT's coordinate space and was already
1693                //   used to position the parent. Adding it again here is like adding feet to
1694                //   meters.
1695                //
1696                //   We ONLY add the child's margin in our (parent's content-box) coordinate space.
1697                //   The parent's margin is irrelevant to us - it's outside our scope.
1698
1699                main_pen += child_margin_top;
1700                debug_info!(
1701                    ctx,
1702                    "[layout_bfc] First child {} BLOCKED: parent_has_blocker={}, advanced by \
1703                     child_margin={}, main_pen={}",
1704                    child_index,
1705                    parent_has_top_blocker,
1706                    child_margin_top,
1707                    main_pen
1708                );
1709            }
1710        } else {
1711            // Not first child: handle sibling collapse
1712            // CSS 2.2 § 8.3.1 Rule 1: "Vertical margins of adjacent block boxes in the normal flow
1713            // collapse" CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing"
1714
1715            // Resolve accumulated top margin if not yet done (for parent's first in-flow child)
1716            if !top_margin_resolved {
1717                main_pen += accumulated_top_margin;
1718                top_margin_resolved = true;
1719                debug_info!(
1720                    ctx,
1721                    "[layout_bfc] RESOLVED top margin for node {} at sibling {}: accumulated={}, \
1722                     main_pen={}",
1723                    node_index,
1724                    child_index,
1725                    accumulated_top_margin,
1726                    main_pen
1727                );
1728            }
1729
1730            if clearance_applied {
1731                // Clearance has already positioned main_pen at the correct
1732                // border-edge = max(float_bottom, hypothetical). The hypothetical
1733                // already includes collapse_margins(last_margin_bottom, child_margin_top),
1734                // so we must NOT add child_margin_top again here.
1735                debug_info!(
1736                    ctx,
1737                    "[layout_bfc] Child {} with CLEARANCE: no collapse with sibling, \
1738                     child_margin_top={}, main_pen={}",
1739                    child_index,
1740                    child_margin_top,
1741                    main_pen
1742                );
1743            } else {
1744                // Sibling Margin Collapse
1745                //
1746                // CSS 2.2 § 8.3.1: "Vertical margins of adjacent block boxes in the normal
1747                // flow collapse." The collapsed margin is the maximum of the two margins.
1748                //
1749                // IMPORTANT: Sibling margins ARE part of the parent's content-box height!
1750                //
1751                // Unlike escaped margins (which belong to grandparent's space), sibling margins
1752                // are the space BETWEEN children within our content-box.
1753                //
1754                // Example:
1755                //
1756                // <div>
1757                //  <div margin-bottom=30></div>
1758                //  <div margin-top=40></div>
1759                // </div>
1760                //
1761                //   - First child ends at Y=100 (including its content + margins)
1762                //   - Collapsed margin = max(30, 40) = 40px
1763                //   - Second child starts at Y=140 (100 + 40)
1764                //   - Parent's content-box height includes this 40px gap
1765                //
1766                // We track total_sibling_margins for debugging, but NOTE: we do **not**
1767                // subtract these from content-box height! They are part of the layout space.
1768                //
1769                // Previously we subtracted total_sibling_margins from content-box height:
1770                //
1771                //   content_box_height = main_pen - total_escaped_top_margin -
1772                // total_sibling_margins;
1773                //
1774                // This was wrong because sibling margins are between boxes (part of content),
1775                // not outside boxes (like escaped margins).
1776
1777                let collapsed = collapse_margins(last_margin_bottom, child_margin_top);
1778                main_pen += collapsed;
1779                total_sibling_margins += collapsed;
1780                debug_info!(
1781                    ctx,
1782                    "[layout_bfc] Sibling collapse for child {}: last_margin_bottom={}, \
1783                     child_margin_top={}, collapsed={}, main_pen={}, total_sibling_margins={}",
1784                    child_index,
1785                    last_margin_bottom,
1786                    child_margin_top,
1787                    collapsed,
1788                    main_pen,
1789                    total_sibling_margins
1790                );
1791            }
1792        }
1793
1794        // Position child (non-empty blocks only reach here)
1795        //
1796        // +spec:block-formatting-context:1dada5 - Normal flow boxes in BFC touch containing block edge
1797        // +spec:block-formatting-context:9f56cb - each box's left outer edge touches containing block left edge; new BFC may shrink due to floats
1798        // CSS 2.2 § 9.4.1: "In a block formatting context, each box's left outer edge touches
1799        // the left edge of the containing block (for right-to-left formatting, right edges touch).
1800        // This is true even in the presence of floats (although a box's line boxes may shrink
1801        // due to the floats), unless the box establishes a new block formatting context
1802        // (in which case the box itself may become narrower due to the floats)."
1803        //
1804        // +spec:block-formatting-context:3d2811 - Float overlap with normal flow element borders
1805        // +spec:display-property:796059 - BFC/replaced/table border box must not overlap float margin boxes; line boxes shorten around floats
1806        // +spec:floats:5214a6 - BFC/replaced/table border box must not overlap float margin boxes; shrink or clear below
1807        // CSS 2.2 § 9.5: "The border box of a table, a block-level replaced element, or an element
1808        // in the normal flow that establishes a new block formatting context (such as an element
1809        // with 'overflow' other than 'visible') must not overlap any floats in the same block
1810        // formatting context as the element itself."
1811
1812        // +spec:floats:a29f70 - BFC roots, tables, and block-level replaced elements must not overlap float margin boxes
1813        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1814        let avoids_floats = establishes_new_bfc(ctx, child_node, tree.cold(child_index))
1815            || is_block_level_replaced(ctx, child_node);
1816
1817        // Query available space considering floats ONLY if child avoids floats
1818        let (cross_start, cross_end, available_cross) = if avoids_floats {
1819            // New BFC / replaced / table: Must shrink or move down to avoid overlapping floats
1820            let child_cross_needed = child_size.cross(writing_mode);
1821            let bfc_cross = constraints.available_size.cross(writing_mode);
1822
1823            let (mut start, mut end) = float_context.available_line_box_space(
1824                main_pen,
1825                main_pen + child_size.main(writing_mode),
1826                bfc_cross,
1827                writing_mode,
1828            );
1829            let mut available = end - start;
1830
1831            // CSS 2.2 § 9.5: "If necessary, implementations should clear the said element
1832            // by placing it below any preceding floats, but may place it adjacent to such
1833            // floats if there is sufficient space."
1834            if available < child_cross_needed && !float_context.floats.is_empty() {
1835                let clear_to = float_context.floats.iter()
1836                    .filter(|f| {
1837                        let f_main_start = f.rect.origin.main(writing_mode) - f.margin.main_start(writing_mode);
1838                        let f_main_end = f_main_start + f.rect.size.main(writing_mode)
1839                            + f.margin.main_start(writing_mode) + f.margin.main_end(writing_mode);
1840                        f_main_end > main_pen && f_main_start < main_pen + child_size.main(writing_mode)
1841                    })
1842                    .map(|f| {
1843                        f.rect.origin.main(writing_mode) + f.rect.size.main(writing_mode)
1844                            + f.margin.main_end(writing_mode)
1845                    })
1846                    .fold(main_pen, f32::max);
1847
1848                if clear_to > main_pen {
1849                    main_pen = clear_to;
1850                    let (s, e) = float_context.available_line_box_space(
1851                        main_pen,
1852                        main_pen + child_size.main(writing_mode),
1853                        bfc_cross,
1854                        writing_mode,
1855                    );
1856                    start = s;
1857                    end = e;
1858                    available = end - start;
1859                }
1860            }
1861
1862            debug_info!(
1863                ctx,
1864                "[layout_bfc] Child {} avoids floats: shrinking to avoid floats, \
1865                 cross_range={}..{}, available_cross={}",
1866                child_index,
1867                start,
1868                end,
1869                available
1870            );
1871
1872            (start, end, available)
1873        } else {
1874            // Normal flow: Overlaps floats, positioned at full width
1875            // Only the child's INLINE CONTENT (if any) wraps around floats
1876            let start = 0.0;
1877            let end = constraints.available_size.cross(writing_mode);
1878            let available = end - start;
1879
1880            debug_info!(
1881                ctx,
1882                "[layout_bfc] Child {} is normal flow: overlapping floats at full width, \
1883                 available_cross={}",
1884                child_index,
1885                available
1886            );
1887
1888            (start, end, available)
1889        };
1890
1891        // Get child's margin, margin_auto, size, and formatting context
1892        let (child_margin_cloned, child_margin_auto, child_used_size, is_inline_fc, child_dom_id_for_debug) = {
1893            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1894            let cbp = child_node.box_props.unpack();
1895            (
1896                cbp.margin,
1897                cbp.margin_auto,
1898                child_node.used_size.unwrap_or_default(),
1899                child_node.formatting_context == FormattingContext::Inline,
1900                child_node.dom_node_id,
1901            )
1902        };
1903        let child_margin = &child_margin_cloned;
1904
1905        debug_info!(
1906            ctx,
1907            "[layout_bfc] Child {} margin_auto: left={}, right={}, top={}, bottom={}",
1908            child_index,
1909            child_margin_auto.left,
1910            child_margin_auto.right,
1911            child_margin_auto.top,
1912            child_margin_auto.bottom
1913        );
1914        debug_info!(
1915            ctx,
1916            "[layout_bfc] Child {} used_size: width={}, height={}",
1917            child_index,
1918            child_used_size.width,
1919            child_used_size.height
1920        );
1921
1922        // Position child
1923        // For normal flow blocks (including IFCs): position at full width (cross_start = 0)
1924        // For BFC-establishing blocks: position in available space between floats
1925        //
1926        // CSS 2.2 § 10.3.3: If margin-left and margin-right are both auto,
1927        // their used values are equal, centering the element horizontally.
1928        
1929        let (child_cross_pos, mut child_main_pos) = if avoids_floats {
1930            // BFC: Position in float-free space, but also check margin:auto centering.
1931            // A flex container or overflow:hidden box establishes a BFC (must avoid floats)
1932            // but can still be centered via margin:auto — these are independent concepts.
1933            let cross_pos = if child_margin_auto.left && child_margin_auto.right {
1934                let remaining = (available_cross - child_used_size.cross(writing_mode)).max(0.0);
1935                debug_info!(
1936                    ctx,
1937                    "[layout_bfc] Child {} BFC + margin:auto centering: available={}, size={}, offset={}",
1938                    child_index, available_cross, child_used_size.cross(writing_mode), remaining / 2.0
1939                );
1940                cross_start + remaining / 2.0
1941            } else if child_margin_auto.left {
1942                let remaining = (available_cross - child_used_size.cross(writing_mode) - child_margin.right).max(0.0);
1943                cross_start + remaining
1944            } else {
1945                cross_start + child_margin.cross_start(writing_mode)
1946            };
1947            (cross_pos, main_pen)
1948        } else {
1949            // Normal flow: Check for margin: auto centering
1950            let available_cross = constraints.available_size.cross(writing_mode);
1951            let child_cross_size = child_used_size.cross(writing_mode);
1952            
1953            debug_info!(
1954                ctx,
1955                "[layout_bfc] Child {} centering check: available_cross={}, child_cross_size={}, margin_auto.left={}, margin_auto.right={}",
1956                child_index,
1957                available_cross,
1958                child_cross_size,
1959                child_margin_auto.left,
1960                child_margin_auto.right
1961            );
1962            
1963            // +spec:block-formatting-context:d52ce5 - auto margins resolved per containing block's writing mode for centering
1964            // +spec:width-calculation:0c5044 - auto margins center element on cross axis (respects writing mode)
1965            // +spec:width-calculation:25c2fc - §10.3.3: block-level margin auto centering and over-constrained resolution
1966            // +spec:width-calculation:ba691f - auto margins treated as zero when element overflows containing block (via .max(0.0) on remaining_space)
1967            // +spec:width-calculation:324e7e - both margin-left and margin-right auto => equal used values (centering)
1968            // CSS 2.2 § 10.3.3: If both margin-left and margin-right are auto,
1969            // center the element within the available space
1970            let cross_pos = if child_margin_auto.left && child_margin_auto.right {
1971                // Center: (available - child_width) / 2
1972                let remaining_space = (available_cross - child_cross_size).max(0.0);
1973                debug_info!(
1974                    ctx,
1975                    "[layout_bfc] Child {} CENTERING: remaining_space={}, cross_pos={}",
1976                    child_index,
1977                    remaining_space,
1978                    remaining_space / 2.0
1979                );
1980                remaining_space / 2.0
1981            } else if child_margin_auto.left {
1982                // Only left is auto: push element to the right
1983                let remaining_space = (available_cross - child_cross_size - child_margin.right).max(0.0);
1984                debug_info!(
1985                    ctx,
1986                    "[layout_bfc] Child {} margin-left:auto only, pushing right: remaining_space={}",
1987                    child_index,
1988                    remaining_space
1989                );
1990                remaining_space
1991            } else if child_margin_auto.right {
1992                // Only right is auto: element stays at left with its margin
1993                debug_info!(
1994                    ctx,
1995                    "[layout_bfc] Child {} margin-right:auto only, using left margin={}",
1996                    child_index,
1997                    child_margin.cross_start(writing_mode)
1998                );
1999                child_margin.cross_start(writing_mode)
2000            } else {
2001                // +spec:box-model:218643 - over-constrained: drop end margin per containing block writing mode
2002                // +spec:width-calculation:d172a4 - over-constrained: LTR ignores margin-right, RTL ignores margin-left
2003                // in LTR, margin-right is ignored (element positioned at margin-left);
2004                // in RTL, margin-left is ignored (element positioned from right edge)
2005                let is_rtl = tree.get(node_index)
2006                    .and_then(|n| n.dom_node_id)
2007                    .is_some_and(|cb_dom_id| {
2008                        let node_state = ctx.styled_dom.styled_nodes.as_container()
2009                            .get(cb_dom_id)
2010                            .map(|s| s.styled_node_state)
2011                            .unwrap_or_default();
2012                        matches!(
2013                            get_direction_property(ctx.styled_dom, cb_dom_id, &node_state),
2014                            MultiValue::Exact(StyleDirection::Rtl)
2015                        )
2016                    });
2017                let cross_pos = if is_rtl {
2018                    // RTL: ignore margin-left, position from right edge
2019                    available_cross - child_cross_size - child_margin.cross_end(writing_mode)
2020                } else {
2021                    // LTR (default): ignore margin-right, position at margin-left
2022                    child_margin.cross_start(writing_mode)
2023                };
2024                debug_info!(
2025                    ctx,
2026                    "[layout_bfc] Child {} NO auto margins (over-constrained), is_rtl={}, cross_pos={}",
2027                    child_index,
2028                    is_rtl,
2029                    cross_pos
2030                );
2031                cross_pos
2032            };
2033            
2034            (cross_pos, main_pen)
2035        };
2036
2037        // NOTE: We do NOT adjust child_main_pos based on child's escaped_top_margin here!
2038        // The escaped_top_margin represents margins that escaped FROM the child's own children.
2039        // The child's position in THIS BFC is determined by main_pen and the child's own margin
2040        // (which was already handled in the margin collapse logic above).
2041        //
2042        // Previously, this code incorrectly added child_escaped_margin to child_main_pos,
2043        // which caused double-application of margins because:
2044        // 1. The child's margin was used to calculate its position in THIS BFC
2045        // 2. Then its escaped_top_margin (which included its own margin) was added again
2046        //
2047        // The correct behavior per CSS 2.2 § 8.3.1 is:
2048        // - The child's escaped_top_margin is used by THIS node's parent to position THIS node
2049        // - It does NOT affect how we position the child within our content-box
2050
2051        // final_pos is [CoordinateSpace::Parent] - relative to this BFC's content-box
2052        let final_pos =
2053            LogicalPosition::from_main_cross(child_main_pos, child_cross_pos, writing_mode);
2054
2055        debug_info!(
2056            ctx,
2057            "[layout_bfc] *** NORMAL FLOW BLOCK POSITIONED: child={}, final_pos={:?}, \
2058             main_pen={}, avoids_floats={}",
2059            child_index,
2060            final_pos,
2061            main_pen,
2062            avoids_floats
2063        );
2064
2065        // Re-layout IFC children with float context for correct text wrapping
2066        // Normal flow blocks WITH inline content need float context propagated
2067        if is_inline_fc && !avoids_floats {
2068            // Use cached floats if available (from previous layout passes),
2069            // otherwise use the floats positioned in this pass
2070            let floats_for_ifc = float_cache.get(&node_index).unwrap_or(&float_context);
2071
2072            debug_info!(
2073                ctx,
2074                "[layout_bfc] Re-layouting IFC child {} (normal flow) with parent's float context \
2075                 at Y={}, child_cross_pos={}",
2076                child_index,
2077                main_pen,
2078                child_cross_pos
2079            );
2080            debug_info!(
2081                ctx,
2082                "[layout_bfc]   Using {} floats (from cache: {})",
2083                floats_for_ifc.floats.len(),
2084                float_cache.contains_key(&node_index)
2085            );
2086
2087            // Translate float coordinates from BFC-relative to IFC-relative
2088            // The IFC child is positioned at (child_cross_pos, main_pen) in BFC coordinates
2089            // Floats need to be relative to the IFC's CONTENT-BOX origin (inside padding/border)
2090            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
2091            let cbp = child_node.box_props.unpack();
2092            let padding_border_cross = cbp.padding.cross_start(writing_mode)
2093                + cbp.border.cross_start(writing_mode);
2094            let padding_border_main = cbp.padding.main_start(writing_mode)
2095                + cbp.border.main_start(writing_mode);
2096
2097            // Content-box origin in BFC coordinates
2098            let content_box_cross = child_cross_pos + padding_border_cross;
2099            let content_box_main = main_pen + padding_border_main;
2100
2101            debug_info!(
2102                ctx,
2103                "[layout_bfc]   Border-box at ({}, {}), Content-box at ({}, {}), \
2104                 padding+border=({}, {})",
2105                child_cross_pos,
2106                main_pen,
2107                content_box_cross,
2108                content_box_main,
2109                padding_border_cross,
2110                padding_border_main
2111            );
2112
2113            let mut ifc_floats = FloatingContext::default();
2114            for float_box in &floats_for_ifc.floats {
2115                // Convert float position from BFC coords to IFC CONTENT-BOX relative coords
2116                let float_rel_to_ifc = LogicalRect {
2117                    origin: LogicalPosition {
2118                        x: float_box.rect.origin.x - content_box_cross,
2119                        y: float_box.rect.origin.y - content_box_main,
2120                    },
2121                    size: float_box.rect.size,
2122                };
2123
2124                debug_info!(
2125                    ctx,
2126                    "[layout_bfc] Float {:?}: BFC coords = {:?}, IFC-content-relative = {:?}",
2127                    float_box.kind,
2128                    float_box.rect,
2129                    float_rel_to_ifc
2130                );
2131
2132                ifc_floats.add_float(float_box.kind, float_rel_to_ifc, float_box.margin);
2133            }
2134
2135            // Create a BfcState with IFC-relative float coordinates
2136            let mut bfc_state = BfcState {
2137                pen: LogicalPosition::zero(), // IFC starts at its own origin
2138                floats: ifc_floats.clone(),
2139                margins: MarginCollapseContext::default(),
2140            };
2141
2142            debug_info!(
2143                ctx,
2144                "[layout_bfc]   Created IFC-relative FloatingContext with {} floats",
2145                ifc_floats.floats.len()
2146            );
2147
2148            // Get the IFC child's content-box size (after padding/border)
2149            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
2150            let child_dom_id = child_node.dom_node_id;
2151
2152            // +spec:containing-block:a8ada9 - line box width determined by containing block and floats
2153            // For inline elements (display: inline), use containing block width as available
2154            // width. Inline elements flow within the containing block and wrap at its width.
2155            // CSS 2.2 § 10.3.1: For inline elements, available width = containing block width.
2156            let display = get_display_property(ctx.styled_dom, child_dom_id).unwrap_or_default();
2157            let child_content_size = if display == LayoutDisplay::Inline {
2158                // Inline elements use the containing block's content-box width
2159                LogicalSize::new(
2160                    children_containing_block_size.width,
2161                    children_containing_block_size.height,
2162                )
2163            } else {
2164                // Block-level elements use their own content-box
2165                child_node.box_props.inner_size(child_size, writing_mode)
2166            };
2167
2168            debug_info!(
2169                ctx,
2170                "[layout_bfc]   IFC child size: border-box={:?}, content-box={:?}",
2171                child_size,
2172                child_content_size
2173            );
2174
2175            // Create new constraints with float context
2176            // IMPORTANT: Use the child's CONTENT-BOX width, not the BFC width!
2177            let ifc_constraints = LayoutConstraints {
2178                available_size: child_content_size,
2179                bfc_state: Some(&mut bfc_state),
2180                writing_mode,
2181                writing_mode_ctx: constraints.writing_mode_ctx,
2182                text_align: constraints.text_align,
2183                containing_block_size: constraints.containing_block_size,
2184                available_width_type: Text3AvailableSpace::Definite(child_content_size.width),
2185            };
2186
2187            // Re-layout the IFC with float awareness
2188            // This will pass floats as exclusion zones to text3 for line wrapping
2189            let ifc_result = layout_formatting_context(
2190                ctx,
2191                tree,
2192                text_cache,
2193                child_index,
2194                &ifc_constraints,
2195                float_cache,
2196            )?;
2197
2198            // DON'T update used_size - the box keeps its full width!
2199            // Only the text layout inside changes to wrap around floats
2200
2201            debug_info!(
2202                ctx,
2203                "[layout_bfc] IFC child {} re-layouted with float context (text will wrap, box \
2204                 stays full width)",
2205                child_index
2206            );
2207
2208            // NOTE: We do NOT merge inline-block positions from the IFC's output.positions here!
2209            // The IFC's inline-block children will be correctly positioned when 
2210            // calculate_layout_for_subtree recursively processes the IFC node (child_index).
2211            // At that point, layout_ifc will be called again, and the inline-block positions
2212            // will be relative to the IFC's content-box, which is what we want.
2213            //
2214            // Merging them here would cause them to be processed by process_inflow_child
2215            // with the BFC's content-box position (self_content_box_pos of the BFC), 
2216            // resulting in incorrect absolute positions.
2217        }
2218
2219        output.positions.insert(child_index, final_pos);
2220
2221        // CSS margin collapse: escaped margins are handled via accumulated_top_margin
2222        // at the START of layout, not by adjusting positions after layout.
2223        // We simply advance by the child's actual size.
2224        main_pen += child_size.main(writing_mode);
2225        has_content = true;
2226
2227        // Update last margin for next sibling
2228        // CSS 2.2 § 8.3.1: The bottom margin of this box will collapse with the top margin
2229        // of the next sibling (if no clearance or blockers intervene)
2230        // element (between prev sibling's bottom and this element's top margin). The cleared
2231        // element's bottom margin is still available for normal collapsing with the next sibling.
2232        // CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing and acts as spacing above
2233        // the margin-top of an element."
2234        last_margin_bottom = child_margin_bottom;
2235
2236        debug_info!(
2237            ctx,
2238            "[layout_bfc] Child {} positioned at final_pos={:?}, size={:?}, advanced main_pen to \
2239             {}, last_margin_bottom={}, clearance_applied={}",
2240            child_index,
2241            final_pos,
2242            child_size,
2243            main_pen,
2244            last_margin_bottom,
2245            clearance_applied
2246        );
2247
2248        // Track the maximum cross-axis size to determine the BFC's overflow size.
2249        let child_cross_extent =
2250            child_cross_pos + child_size.cross(writing_mode) + child_margin.cross_end(writing_mode);
2251        max_cross_size = max_cross_size.max(child_cross_extent);
2252    }
2253
2254    // Store the float context in cache for future layout passes
2255    // This happens after ALL children (floats and normal) have been positioned
2256    debug_info!(
2257        ctx,
2258        "[layout_bfc] Storing {} floats in cache for node {}",
2259        float_context.floats.len(),
2260        node_index
2261    );
2262    float_cache.insert(node_index, float_context.clone());
2263
2264    // PHASE 3: Parent-Child Bottom Margin Escape
2265    let mut escaped_top_margin = None;
2266    let mut escaped_bottom_margin = None;
2267
2268    // Handle top margin escape
2269    if top_margin_escaped {
2270        // First child's margin escaped through parent
2271        escaped_top_margin = Some(accumulated_top_margin);
2272        debug_info!(
2273            ctx,
2274            "[layout_bfc] Returning escaped top margin: accumulated={}, node={}",
2275            accumulated_top_margin,
2276            node_index
2277        );
2278    } else if !top_margin_resolved && accumulated_top_margin > 0.0 {
2279        // No content was positioned, all margins accumulated (empty blocks)
2280        escaped_top_margin = Some(accumulated_top_margin);
2281        debug_info!(
2282            ctx,
2283            "[layout_bfc] Escaping top margin (no content): accumulated={}, node={}",
2284            accumulated_top_margin,
2285            node_index
2286        );
2287    } else {
2288        // Don't set escaped_top_margin = Some(0) — that would override the child's
2289        // own margin (e.g., 30px) with 0 during sibling collapse.
2290        debug_info!(
2291            ctx,
2292            "[layout_bfc] NOT escaping top margin: top_margin_resolved={}, escaped={}, \
2293             accumulated={}, node={}",
2294            top_margin_resolved,
2295            top_margin_escaped,
2296            accumulated_top_margin,
2297            node_index
2298        );
2299    }
2300
2301    // Handle bottom margin escape
2302    if let Some(last_idx) = last_child_index {
2303        let last_child = tree.get(last_idx).ok_or(LayoutError::InvalidTree)?;
2304        let last_child_bp = last_child.box_props.unpack();
2305        let last_has_bottom_blocker =
2306            has_margin_collapse_blocker(&last_child_bp, writing_mode, false);
2307
2308        debug_info!(
2309            ctx,
2310            "[layout_bfc] Bottom margin for node {}: parent_has_bottom_blocker={}, \
2311             last_has_bottom_blocker={}, last_margin_bottom={}, main_pen_before={}",
2312            node_index,
2313            parent_has_bottom_blocker,
2314            last_has_bottom_blocker,
2315            last_margin_bottom,
2316            main_pen
2317        );
2318
2319        if !parent_has_bottom_blocker && !last_has_bottom_blocker && has_content {
2320            // Last child's bottom margin can escape and collapse with the parent's
2321            // own bottom margin, propagating to the grandparent (CSS 2.2 § 8.3.1).
2322            let collapsed_bottom = collapse_margins(parent_margin_bottom, last_margin_bottom);
2323            escaped_bottom_margin = Some(collapsed_bottom);
2324            debug_info!(
2325                ctx,
2326                "[layout_bfc] Bottom margin ESCAPED for node {}: collapsed={}",
2327                node_index,
2328                collapsed_bottom
2329            );
2330            // Don't add last_margin_bottom to pen (it escaped)
2331        } else if !parent_has_bottom_blocker && has_content {
2332            // Last child has its OWN bottom border/padding (a blocker), but THIS
2333            // parent has none: the child's bottom margin sits adjacent to the
2334            // parent's bottom content edge, so per CSS 2.2 § 8.3.1 it escapes the
2335            // parent's content box rather than being part of its height. This is the
2336            // bottom-margin mirror of the top-margin escape handled above (where a
2337            // padded first child still escapes its top margin through a padless
2338            // parent). Do NOT add it to main_pen — counting it here double-counted
2339            // the margin into a non-root parent's height (nested-container came out
2340            // 180px instead of 130px). The parent's own bottom margin still flows to
2341            // the grandparent through the normal `last_margin_bottom` path, so the
2342            // grandparent's trap is unchanged.
2343            debug_info!(
2344                ctx,
2345                "[layout_bfc] Bottom margin of blocked last child ESCAPES content box for node \
2346                 {}: last_margin_bottom={} (not added to height)",
2347                node_index,
2348                last_margin_bottom
2349            );
2350        } else {
2351            // Can't escape: add to pen
2352            main_pen += last_margin_bottom;
2353            // NOTE: We do NOT add parent_margin_bottom to main_pen here!
2354            // parent_margin_bottom is added OUTSIDE the content-box (in the margin-box)
2355            // The content-box height should only include children's content and margins
2356            debug_info!(
2357                ctx,
2358                "[layout_bfc] Bottom margin BLOCKED for node {}: added last_margin_bottom={}, \
2359                 main_pen_after={}",
2360                node_index,
2361                last_margin_bottom,
2362                main_pen
2363            );
2364        }
2365    } else {
2366        // No children: just use parent's margins
2367        if !top_margin_resolved {
2368            main_pen += parent_margin_top;
2369        }
2370        main_pen += parent_margin_bottom;
2371    }
2372
2373    // CRITICAL: If this is a root node (no parent), apply escaped margins directly
2374    // instead of propagating them upward (since there's no parent to receive them)
2375    let is_root_node = node.parent.is_none();
2376    if is_root_node {
2377        if let Some(top) = escaped_top_margin {
2378            // Adjust all child positions downward by the escaped top margin
2379            for pos in output.positions.values_mut() {
2380                let current_main = pos.main(writing_mode);
2381                *pos = LogicalPosition::from_main_cross(
2382                    current_main + top,
2383                    pos.cross(writing_mode),
2384                    writing_mode,
2385                );
2386            }
2387            main_pen += top;
2388        }
2389        if let Some(bottom) = escaped_bottom_margin {
2390            main_pen += bottom;
2391        }
2392        // For root nodes, don't propagate margins further
2393        escaped_top_margin = None;
2394        escaped_bottom_margin = None;
2395    }
2396
2397    // CSS 2.2 § 9.5: Floats don't contribute to container height with overflow:visible
2398    //
2399    // However, browsers DO expand containers to contain floats in specific cases:
2400    //
2401    // 1. If there's NO in-flow content (main_pen == 0), floats determine height
2402    // 2. If container establishes a BFC (overflow != visible)
2403    //
2404    // In this case, we have in-flow content (main_pen > 0) and overflow:visible,
2405    // so floats should NOT expand the container. Their margins can "bleed" beyond
2406    // the container boundaries into the parent.
2407    //
2408    // This matches Chrome/Firefox behavior where float margins escape through
2409    // the container's padding when there's existing in-flow content.
2410
2411    // +spec:block-formatting-context:7954a2 - 10.6.3: auto height for block-level non-replaced elements in normal flow
2412    // Content-box Height Calculation
2413    //
2414    // CSS 2.2 § 8.3.1: "The top border edge of the box is defined to coincide with
2415    // the top border edge of the [first] child" when margins collapse/escape.
2416    //
2417    // This means escaped margins do NOT contribute to the parent's content-box height.
2418    //
2419    // Calculation:
2420    //
2421    //   main_pen = total vertical space used by all children and margins
2422    //
2423    //   Components of main_pen:
2424    //
2425    //   1. Children's border-boxes (always included)
2426    //   2. Sibling collapsed margins (space BETWEEN children - part of content)
2427    //   3. First child's position (0 if margin escaped, margin_top if blocked)
2428    //
2429    //   What to subtract:
2430    //
2431    //   - total_escaped_top_margin: First child's margin that went to grandparent's space This
2432    //     margin is OUTSIDE our content-box, so we must subtract it.
2433    //
2434    //   What NOT to subtract:
2435    //
2436    //   - total_sibling_margins: These are the gaps BETWEEN children, which are
2437    //    legitimately part of our content area's layout space.
2438    //
2439    // Example with escaped margin:
2440    //   <div class="parent" padding=0>              <!-- Node 2 -->
2441    //     <div class="child1" margin=30></div>      <!-- Node 3, margin escapes -->
2442    //     <div class="child2" margin=40></div>      <!-- Node 5 -->
2443    //   </div>
2444    //
2445    //   Layout process:
2446    //
2447    //   - Node 3 positioned at main_pen=0 (margin escaped)
2448    //   - Node 3 size=140px → main_pen advances to 140
2449    //   - Sibling collapse: max(30 child1 bottom, 40 child2 top) = 40px
2450    //   - main_pen advances to 180
2451    //   - Node 5 size=130px → main_pen advances to 310
2452    //   - total_escaped_top_margin = 30
2453    //   - total_sibling_margins = 40 (tracked but NOT subtracted)
2454    //   - content_box_height = 310 - 30 = 280px ✓
2455    //
2456    // Previously, we calculated:
2457    //
2458    //   content_box_height = main_pen - total_escaped_top_margin - total_sibling_margins
2459    //
2460    // This incorrectly subtracted sibling margins, making parent too small.
2461    // Sibling margins are *between* boxes (part of layout), not *outside* boxes
2462    // (like escaped margins).
2463
2464    // +spec:box-model:4eebed - auto height for BFC = top margin-edge of topmost child to bottom margin-edge of bottommost child
2465    // +spec:box-model:4eebed - auto height = top margin-edge of topmost child to bottom margin-edge of bottommost child
2466    // +spec:height-calculation:d65226 - §10.6.7 auto heights for BFC roots: block children use
2467    // margin-edge of topmost/bottommost, floats extend height if below content edge
2468    // +spec:positioning:1a05bb - 10.6.7 auto height for BFC roots: block children use margin edges,
2469    // abspos ignored (skipped in Pass 1/2), relative considered without offset (applied after layout),
2470    // floats whose bottom margin edge exceeds content edge expand height (below)
2471    // +spec:positioning:e6712c - Auto height for BFC: distance between top/bottom margin-edges of
2472    // block children (minus escaped margins), ignoring absolutely positioned children (skipped at
2473    // line ~966), considering relatively positioned boxes without offset (applied after layout),
2474    // and extending to include floats whose bottom margin edge exceeds content edge
2475    // +spec:positioning:f94d22 - 10.6.3: block-level non-replaced auto height = distance from top content edge to last in-flow child bottom margin edge (or zero)
2476    // CSS 2.2 §8.3.1: escaped margins (both top and bottom) don't contribute to parent height
2477    let mut content_box_height = if is_root_node {
2478        // Root: the escaped margins were re-added to `main_pen` just above (there is no
2479        // grandparent to receive them); subtract them back out so the root's content box
2480        // still excludes them. Net effect is the pre-escape span.
2481        main_pen - total_escaped_top_margin - escaped_bottom_margin.unwrap_or(0.0)
2482    } else {
2483        // Non-root: the first in-flow child was positioned at main_pen == 0 (its top
2484        // margin escaped, NOT added to the pen) and an escaped bottom margin was never
2485        // advanced into the pen either. So `main_pen` already spans the first child's
2486        // border-top to the last child's border-bottom — exactly the content-box height
2487        // (CSS 2.2 §8.3.1). The escaped margins live in the PARENT's coordinate space and
2488        // reach it via `escaped_top_margin` / `escaped_bottom_margin`. Subtracting them
2489        // from THIS box's height double-removes them and collapses it (#20: a <div> around
2490        // a single <p> came out 0px tall, pulling the following sibling up by a line).
2491        main_pen
2492    };
2493
2494    // +spec:block-formatting-context:f73d3e - BFC root grows to fully contain its floats; floats from outside cannot protrude in
2495    // whose bottom margin edge exceeds bottom content edge; only floats participating
2496    // in this BFC are counted (not floats inside abspos descendants or nested BFCs)
2497    // +spec:box-model:1d4798 - auto height includes floats whose bottom margin edge exceeds content edge
2498    // only floats participating in this BFC are counted (not floats inside abspos descendants or nested BFCs)
2499    if is_bfc_root {
2500        for float_box in &float_context.floats {
2501            let float_bottom_margin_edge = float_box.rect.origin.main(writing_mode)
2502                + float_box.rect.size.main(writing_mode)
2503                + float_box.margin.main_end(writing_mode);
2504            if float_bottom_margin_edge > content_box_height {
2505                content_box_height = float_bottom_margin_edge;
2506            }
2507        }
2508    }
2509
2510    // +spec:display-contents:f6de1a - content height overflow tracked via overflow_size
2511    // +spec:overflow:043182 - overflow computed from box bounds + children overflow
2512    output.overflow_size =
2513        LogicalSize::from_main_cross(content_box_height, max_cross_size, writing_mode);
2514
2515    debug_info!(
2516        ctx,
2517        "[layout_bfc] FINAL for node {}: main_pen={}, total_escaped_top={}, \
2518         total_sibling_margins={}, content_box_height={}",
2519        node_index,
2520        main_pen,
2521        total_escaped_top_margin,
2522        total_sibling_margins,
2523        content_box_height
2524    );
2525
2526    // +spec:inline-formatting-context:2227a4 - atomic inline baseline for inline-block/inline-table
2527    // Baseline calculation would happen here in a full implementation.
2528    // CSS2 §10.8.1: For inline-block, baseline is the baseline of the last
2529    // line box in normal flow, or the bottom margin edge if no line boxes.
2530    output.baseline = None;
2531
2532    // Store escaped margins in the LayoutNode for use by parent
2533    if let Some(warm_mut) = tree.warm_mut(node_index) {
2534        warm_mut.escaped_top_margin = escaped_top_margin;
2535        warm_mut.escaped_bottom_margin = escaped_bottom_margin;
2536    }
2537
2538    if let Some(warm_mut) = tree.warm_mut(node_index) {
2539        warm_mut.baseline = output.baseline;
2540    }
2541
2542    Ok(BfcLayoutResult {
2543        output,
2544        escaped_top_margin,
2545        escaped_bottom_margin,
2546    })
2547}
2548
2549// Inline Formatting Context (CSS 2.2 § 9.4.2)
2550// +spec:display-property:ede6f4 - inline layout: mixed stream of text and inline-level boxes
2551
2552/// Lays out an Inline Formatting Context (IFC) by delegating to the `text3` engine.
2553///
2554/// This function acts as a bridge between the box-tree world of `solver3` and the
2555/// rich text layout world of `text3`. Its responsibilities are:
2556///
2557/// 1. **Collect Content**: Traverse the direct children of the IFC root and convert them into a
2558///    `Vec<InlineContent>`, the input format for `text3`. This involves:
2559///
2560///     - Recursively laying out `inline-block` children to determine their final size and baseline,
2561///       which are then passed to `text3` as opaque objects.
2562///     - Extracting raw text runs from inline text nodes.
2563///
2564/// 2. **Translate Constraints**: Convert the `LayoutConstraints` (available space, floats) from
2565///    `solver3` into the more detailed `UnifiedConstraints` that `text3` requires.
2566///
2567/// 3. **Invoke Text Layout**: Call the `text3` cache's `layout_flow` method to perform the complex
2568///    tasks of BIDI analysis, shaping, line breaking, justification, and vertical alignment.
2569///    +spec:display-property:e96c82 - inline formatting context: flow of elements/text wrapped into lines
2570///
2571/// 4. **Integrate Results**: Process the `UnifiedLayout` returned by `text3`:
2572///
2573///     - Store the rich layout result on the IFC root `LayoutNode` for the display list generation
2574///       pass.
2575///     - Update the `positions` map for all `inline-block` children based on the positions
2576///       calculated by `text3`.
2577///     - Extract the final overflow size and baseline for the IFC root itself
2578// NOTE(writing-modes): The IFC currently assumes inline direction = horizontal
2579// and block direction = vertical. In vertical writing modes, line boxes would
2580// stack horizontally and inline content would flow vertically. The writing mode
2581// is now available via constraints.writing_mode_ctx for agents to use when
2582// implementing vertical text layout in the text3 engine.
2583// +spec:display-property:574e7b - text-box-trim for inline boxes trims block-end to content edge (TODO: implement trimming per text-box-edge metric)
2584// +spec:display-property:da284a - IFC: flow inline-level boxes into line boxes, size/position each fragment
2585// +spec:inline-formatting-context:275f64 - IFC: boxes laid out horizontally into line boxes, respecting margins/borders/padding
2586#[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
2587#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2588fn layout_ifc<T: ParsedFontTrait>(
2589    ctx: &mut LayoutContext<'_, T>,
2590    text_cache: &mut TextLayoutCache,
2591    tree: &mut LayoutTree,
2592    node_index: usize,
2593    constraints: &LayoutConstraints<'_>,
2594) -> Result<LayoutOutput> {
2595    unsafe { crate::az_mark(0x60704_u32, (0x20u32)); }
2596    // [g147 az-web-lift DIAG] CALLER-side tree validity at layout_ifc entry, indexed by node_index
2597    // (0x60900+ = nodes.len, 0x60920+ = tree ptr) to dodge marker-overwrite across multiple IFCs.
2598    // Compare vs _impl's CALLEE-side (0x60940+/0x60960+): ptr differs ⇒ &mut tree mis-passes across
2599    // the call; ptr same but len differs ⇒ the tree's `nodes` Vec is emptied in place.
2600    #[cfg(feature = "web_lift")]
2601    unsafe {
2602        let slot = (node_index & 7) * 4;
2603        crate::az_mark(((0x60900 + slot)) as u32, (tree.nodes.len() as u32) as u32);
2604        crate::az_mark(((0x60920 + slot)) as u32, ((&*tree as *const LayoutTree as usize) as u32) as u32);
2605    }
2606    let float_count = constraints
2607        .bfc_state
2608        .as_ref()
2609        .map_or(0, |s| s.floats.floats.len());
2610    debug_info!(
2611        ctx,
2612        "[layout_ifc] ENTRY: node_index={}, has_bfc_state={}, float_count={}",
2613        node_index,
2614        constraints.bfc_state.is_some(),
2615        float_count
2616    );
2617    debug_ifc_layout!(ctx, "CALLED for node_index={}", node_index);
2618
2619    // +spec:display-property:7f3c1d - Anonymous inline boxes: text directly in block containers treated as anonymous inline elements in IFC
2620    // +spec:display-property:5a795c - root inline box: block container generates anonymous inline box holding all inline-level contents, inheriting from parent
2621    // For anonymous boxes, we need to find the DOM ID from a parent or child
2622    // CSS 2.2 § 9.2.1.1: Anonymous boxes inherit properties from their enclosing box
2623    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2624    let ifc_root_dom_id = if let Some(id) = node.dom_node_id { id } else {
2625        // Anonymous box - get DOM ID from parent or first child with DOM ID
2626        let parent_dom_id = node
2627            .parent
2628            .and_then(|p| tree.get(p))
2629            .and_then(|n| n.dom_node_id);
2630
2631        if let Some(id) = parent_dom_id {
2632            id
2633        } else {
2634            // Try to find DOM ID from first child
2635            tree.children(node_index)
2636                .iter()
2637                .filter_map(|&child_idx| tree.get(child_idx)).find_map(|n| n.dom_node_id)
2638                .ok_or(LayoutError::InvalidTree)?
2639        }
2640    };
2641
2642    debug_ifc_layout!(ctx, "ifc_root_dom_id={:?}", ifc_root_dom_id);
2643
2644    // +spec:display-property:a469a6 - line boxes created as needed for inline-level content in IFC
2645    // +spec:display-property:f3c875 - calculate layout bounds (size contributions) of each inline-level box
2646    // Phase 1: Collect and measure all inline-level children.
2647    let collect_result = collect_and_measure_inline_content(
2648        ctx,
2649        text_cache,
2650        tree,
2651        node_index,
2652        constraints,
2653    );
2654    // [g133 az-web-lift DIAG] which early-return fires in POSITIONING's layout_ifc.
2655    #[cfg(feature = "web_lift")]
2656    unsafe {
2657        crate::az_mark((0x60680) as u32, (collect_result.as_ref().map(|(c, _)| c.len()).unwrap_or(0) as u32) as u32);
2658        crate::az_mark((0x60684) as u32, (if collect_result.is_ok() { 0xC0DE0680u32 } else { 0x000000EEu32 }) as u32);
2659    }
2660    let (inline_content, child_map) = collect_result?;
2661
2662    // #11 fix: hash the inline content once. Used to (a) skip stale Phase 2d
2663    // fast-path reuse and (b) force a cache REPLACE when content changed even
2664    // though available width is unchanged — the display-list generator paints
2665    // text from the cached `inline_layout_result` (display_list.rs), so a
2666    // content change at a same-width constraint MUST overwrite it or the old
2667    // glyphs keep rendering (#11 stale display list).
2668    // Phase 2 (translate early): resolve the container-level (IFC) constraints now,
2669    // so the Phase 2d cache-reuse decision below can key on them too. Reuse was keyed
2670    // on available width + per-run content hash only; a change to a container-level
2671    // property (text-align, text-align-last, text-indent, direction, line-height,
2672    // white-space, columns) — which is NOT covered by the per-run content hash — would
2673    // otherwise silently reuse a stale, differently-aligned/indented cached layout.
2674    let text3_constraints =
2675        translate_to_text3_constraints(ctx, constraints, ctx.styled_dom, ifc_root_dom_id);
2676
2677    let current_content_hash = {
2678        use std::hash::{Hash, Hasher};
2679        let mut h = std::collections::hash_map::DefaultHasher::new();
2680        inline_content.hash(&mut h);
2681        // Fold the constraint-relevant container properties into the validity key.
2682        text3_constraints.text_align.hash(&mut h);
2683        text3_constraints.text_align_last.hash(&mut h);
2684        text3_constraints.white_space_mode.hash(&mut h);
2685        text3_constraints.direction.hash(&mut h);
2686        text3_constraints.columns.hash(&mut h);
2687        text3_constraints.text_indent.to_bits().hash(&mut h);
2688        match text3_constraints.line_height {
2689            text3::cache::LineHeight::Normal => 0u64.hash(&mut h),
2690            text3::cache::LineHeight::Px(v) => {
2691                1u64.hash(&mut h);
2692                v.to_bits().hash(&mut h);
2693            }
2694        }
2695        h.finish()
2696    };
2697
2698    debug_info!(
2699        ctx,
2700        "[layout_ifc] Collected {} inline content items for node {}",
2701        inline_content.len(),
2702        node_index
2703    );
2704    for (i, item) in inline_content.iter().enumerate() {
2705        match item {
2706            InlineContent::Text(run) => debug_info!(ctx, "  [{}] Text: '{}'", i, run.text),
2707            InlineContent::Marker {
2708                run,
2709                position_outside,
2710            } => debug_info!(
2711                ctx,
2712                "  [{}] Marker: '{}' (outside={})",
2713                i,
2714                run.text,
2715                position_outside
2716            ),
2717            InlineContent::Shape(_) => debug_info!(ctx, "  [{}] Shape", i),
2718            InlineContent::Image(_) => debug_info!(ctx, "  [{}] Image", i),
2719            _ => debug_info!(ctx, "  [{}] Other", i),
2720        }
2721    }
2722
2723    debug_ifc_layout!(
2724        ctx,
2725        "Collected {} inline content items",
2726        inline_content.len()
2727    );
2728
2729    if inline_content.is_empty() {
2730        debug_warning!(ctx, "inline_content is empty, returning default output!");
2731        // The node has no inline-level content this pass (e.g. its only
2732        // inline child — a text run or an inline image — was removed by a
2733        // relayout). Any `inline_layout_result` left over from a previous
2734        // frame is now stale: the display-list generator paints inline
2735        // objects (images, inline-block shapes) straight out of this cached
2736        // layout (see display_list.rs `paint_inline_*`), so a leftover entry
2737        // would re-emit the removed content AND index `styled_nodes` with a
2738        // `source_node_id` that no longer exists in the new DOM (OOB panic).
2739        // Clear it so the empty IFC renders nothing.
2740        if let Some(warm_node) = tree.warm_mut(node_index) {
2741            warm_node.inline_layout_result = None;
2742        }
2743        return Ok(LayoutOutput::default());
2744    }
2745
2746    // === Phase 2d: IFC incremental relayout decision tree ===
2747    //
2748    // Check if a cached layout exists with matching constraints. If so,
2749    // try incremental relayout (GlyphSwap or LineShift) before falling
2750    // back to full layout_flow().
2751    {
2752        let cached_ifc = tree
2753            .warm(node_index)
2754            .and_then(|n| n.inline_layout_result.as_ref());
2755
2756        // Only reuse the cached inline layout when the available WIDTH is unchanged.
2757        // This fast path was built for text edits (content changes, width constant); on a
2758        // viewport/container resize the width differs and the text must RE-WRAP, so the
2759        // cached old-width layout must NOT be reused — fall through to full layout_flow()
2760        // below. Without this guard, resizing kept the stale line breaks (#45). Real
2761        // text-edit incremental relayout (with dirty items) lives in
2762        // LayoutWindow::try_incremental_text_relayout.
2763        let resize_has_floats = constraints
2764            .bfc_state
2765            .as_ref()
2766            .is_some_and(|s| !s.floats.floats.is_empty());
2767        // #11 fix: cache validity is keyed on WIDTH only, so a same-width
2768        // RefreshDom whose text CHANGED would otherwise reuse the stale shaped
2769        // layout. Require the inline content hash to match too.
2770        let cached_ifc = cached_ifc
2771            .filter(|c| c.is_valid_for(constraints.available_width_type, resize_has_floats))
2772            .filter(|c| c.inline_content_hash == current_content_hash);
2773
2774        if let Some(cached) = cached_ifc {
2775            if let Some(ref line_breaks) = cached.line_breaks {
2776                // Collect per-item advance widths from cached metrics
2777                let old_advances: Vec<f32> = cached.item_metrics.iter()
2778                    .map(|m| m.advance_width)
2779                    .collect();
2780
2781                // Cache-reuse fast path. Real incremental relayout for text
2782                // edits lives in LayoutWindow::try_incremental_text_relayout
2783                // (window.rs) — it has the newly-shaped items and the edited
2784                // node id, so it can compute real dirty_item_indices and
2785                // take the GlyphSwap / LineShift branches. Here we only
2786                // know the IFC is being re-entered (e.g. viewport resize on
2787                // a static IFC); with nothing re-shaped yet, the best we can
2788                // do is "no items changed at this level" → trivial GlyphSwap
2789                // to return the cached layout unchanged.
2790                let result = text3::cache::try_incremental_relayout(
2791                    &[], // empty = no dirty items detected at this level
2792                    &old_advances,
2793                    &old_advances, // same advances since we haven't reshaped yet
2794                    line_breaks,
2795                );
2796
2797                if matches!(result, text3::cache::IncrementalRelayoutResult::GlyphSwap) {
2798                    // No items changed — return cached layout directly
2799                    debug_info!(ctx, "[layout_ifc] Phase 2d: GlyphSwap — reusing cached layout");
2800                    let main_frag = &cached.layout;
2801                    let frag_bounds = main_frag.bounds();
2802                    let mut output = LayoutOutput::default();
2803                    output.overflow_size = LogicalSize::new(frag_bounds.width, frag_bounds.height);
2804                    output.baseline = main_frag.last_baseline();
2805                    // Re-position inline-block children from cached layout
2806                    for positioned_item in &main_frag.items {
2807                        if let ShapedItem::Object { source, .. } = &positioned_item.item {
2808                            if let Some(&child_node_index) = child_map.get(source) {
2809                                output.positions.insert(child_node_index, LogicalPosition {
2810                                    x: positioned_item.position.x,
2811                                    y: positioned_item.position.y,
2812                                });
2813                            }
2814                        }
2815                    }
2816                    return Ok(output);
2817                }
2818                // Fall through to full layout_flow
2819            }
2820        }
2821    }
2822
2823    // Phase 2: text3_constraints was resolved early (above) so the cache-reuse key
2824    // could include container-level properties.
2825    // Clone constraints for caching (before they're moved into fragments)
2826    let cached_constraints = text3_constraints.clone();
2827
2828    debug_info!(
2829        ctx,
2830        "[layout_ifc] CALLING text_cache.layout_flow for node {} with {} exclusions",
2831        node_index,
2832        text3_constraints.shape_exclusions.len()
2833    );
2834
2835    let fragments = vec![LayoutFragment {
2836        id: "main".to_string(),
2837        constraints: text3_constraints,
2838    }];
2839
2840    // Phase 3: Invoke the text layout engine.
2841    // Get pre-loaded fonts from font manager (fonts should be loaded before layout)
2842    let loaded_fonts = ctx.font_manager.get_loaded_fonts();
2843    let text_layout_result = match text_cache.layout_flow(
2844        &inline_content,
2845        &[],
2846        &fragments,
2847        &ctx.font_manager.font_chain_cache,
2848        &ctx.font_manager.fc_cache,
2849        &loaded_fonts,
2850        ctx.debug_messages,
2851    ) {
2852        Ok(result) => {
2853            // [g133 az-web-lift DIAG] layout_flow returned Ok.
2854            #[cfg(feature = "web_lift")]
2855            unsafe { crate::az_mark((0x60688) as u32, (0xC0DE0688u32) as u32); }
2856            result
2857        }
2858        Err(e) => {
2859            // [g133 az-web-lift DIAG] layout_flow returned Err → zero-sized (text not positioned).
2860            #[cfg(feature = "web_lift")]
2861            unsafe {
2862                crate::az_mark((0x60688) as u32, (0x000000EEu32) as u32);
2863                // Read the error's first byte (discriminant) for the marker — a
2864                // `*const u8` read is always aligned + in-bounds; the old
2865                // `*const u32` read was UB on a 1-aligned / <4-byte enum.
2866                crate::az_mark((0x6068C) as u32, (*(&e as *const _ as *const u8)) as u32);
2867            }
2868            // Font errors should not stop layout of other elements.
2869            // Log the error and return a zero-sized layout.
2870            debug_warning!(ctx, "Text layout failed: {:?}", e);
2871            debug_warning!(
2872                ctx,
2873                "Continuing with zero-sized layout for node {}",
2874                node_index
2875            );
2876
2877            let mut output = LayoutOutput::default();
2878            output.overflow_size = LogicalSize::new(0.0, 0.0);
2879            return Ok(output);
2880        }
2881    };
2882    // Phase 4: Integrate results back into the solver3 layout tree.
2883    let mut output = LayoutOutput::default();
2884
2885    debug_ifc_layout!(
2886        ctx,
2887        "text_layout_result has {} fragment_layouts",
2888        text_layout_result.fragment_layouts.len()
2889    );
2890
2891    if let Some(main_frag) = text_layout_result.fragment_layouts.get("main") {
2892        let frag_bounds = main_frag.bounds();
2893        debug_ifc_layout!(
2894            ctx,
2895            "Found 'main' fragment with {} items, bounds={}x{}",
2896            main_frag.items.len(),
2897            frag_bounds.width,
2898            frag_bounds.height
2899        );
2900        debug_ifc_layout!(ctx, "Storing inline_layout_result on node {}", node_index);
2901
2902        // Determine if we should store this layout result using the new
2903        // CachedInlineLayout system. The key insight is that inline layouts
2904        // depend on available width:
2905        //
2906        // - Min-content measurement uses width ≈ 0 (maximum line wrapping)
2907        // - Max-content measurement uses width = ∞ (no line wrapping)
2908        // - Final layout uses the actual column/container width
2909        //
2910        // We must track which constraint type was used, otherwise a min-content
2911        // measurement would incorrectly be reused for final rendering.
2912        let has_floats = constraints
2913            .bfc_state
2914            .as_ref()
2915            .is_some_and(|s| !s.floats.floats.is_empty());
2916        let current_width_type = constraints.available_width_type;
2917
2918        let warm_node = tree.warm_mut(node_index).ok_or(LayoutError::InvalidTree)?;
2919
2920        let should_store = match &warm_node.inline_layout_result {
2921            None => {
2922                // No cached result - always store
2923                debug_info!(
2924                    ctx,
2925                    "[layout_ifc] Storing NEW inline_layout_result for node {} (width_type={:?}, \
2926                     has_floats={})",
2927                    node_index,
2928                    current_width_type,
2929                    has_floats
2930                );
2931                true
2932            }
2933            Some(cached) => {
2934                // Check if the new result should replace the cached one
2935                if cached.should_replace_with(current_width_type, has_floats)
2936                    || cached.inline_content_hash != current_content_hash
2937                {
2938                    // #11 fix: the cached layout is what the display-list
2939                    // generator paints from; replace it when the inline content
2940                    // changed, even if the width constraint is unchanged.
2941                    debug_info!(
2942                        ctx,
2943                        "[layout_ifc] REPLACING inline_layout_result for node {} (old: \
2944                         width={:?}, floats={}) with (new: width={:?}, floats={})",
2945                        node_index,
2946                        cached.available_width,
2947                        cached.has_floats,
2948                        current_width_type,
2949                        has_floats
2950                    );
2951                    true
2952                } else {
2953                    debug_info!(
2954                        ctx,
2955                        "[layout_ifc] KEEPING cached inline_layout_result for node {} (cached: \
2956                         width={:?}, floats={}, new: width={:?}, floats={})",
2957                        node_index,
2958                        cached.available_width,
2959                        cached.has_floats,
2960                        current_width_type,
2961                        has_floats
2962                    );
2963                    false
2964                }
2965            }
2966        };
2967
2968        if should_store {
2969            let mut cil = CachedInlineLayout::new_with_constraints(
2970                main_frag.clone(),
2971                current_width_type,
2972                has_floats,
2973                cached_constraints.clone(),
2974            );
2975            // #11 fix: record the content hash so Phase 2d only fast-path-reuses
2976            // this layout when the inline content is genuinely unchanged, and so
2977            // the store decision above can detect content changes.
2978            cil.inline_content_hash = current_content_hash;
2979            warm_node.inline_layout_result = Some(cil);
2980        }
2981
2982        // Extract the overall size and baseline for the IFC root.
2983        // +spec:display-property:a0d0ab - IFC height = top of topmost line box to bottom of bottommost line box
2984        // +spec:display-property:a63b8f - baseline-source defaults to auto (last baseline for inline-block/IFC)
2985        output.overflow_size = LogicalSize::new(frag_bounds.width, frag_bounds.height);
2986        output.baseline = main_frag.last_baseline();
2987        warm_node.baseline = output.baseline;
2988
2989        // +spec:box-model:929f42 - text-box-trim: trim half-leading from first/last formatted line
2990        // +spec:box-model:02e0f9 - text-box-trim: trim-end and trim-both, no effect with non-zero padding/border
2991        //
2992        // CSS Inline 3 § 6.2: For block containers, trim the block-start/block-end side
2993        // of the first/last formatted line. If there is intervening non-zero padding or
2994        // borders, there is no effect. Does not apply to flex, grid, or table contexts.
2995        let ifc_node_state = &ctx.styled_dom.styled_nodes.as_container()[ifc_root_dom_id].styled_node_state;
2996        // Fast path: if no node in the DOM declared text-box-trim, the cascade
2997        // walk would always return None → skip it.
2998        let text_box_trim = {
2999            let skip = ctx.styled_dom
3000                .css_property_cache
3001                .ptr
3002                .compact_cache
3003                .as_ref()
3004                .is_some_and(|cc| cc.dom_declared_flags & azul_css::compact_cache::DOM_HAS_TEXT_BOX_TRIM == 0);
3005            if skip {
3006                StyleTextBoxTrim::None
3007            } else {
3008                get_text_box_trim_property(ctx.styled_dom, ifc_root_dom_id, ifc_node_state)
3009                    .unwrap_or(StyleTextBoxTrim::None)
3010            }
3011        };
3012
3013        if text_box_trim != StyleTextBoxTrim::None && !main_frag.items.is_empty() {
3014            // Half-leading = (line-height - (ascent + descent)) / 2
3015            let half_leading = (cached_constraints.resolved_line_height()
3016                - (cached_constraints.strut_ascent + cached_constraints.strut_descent))
3017                / 2.0;
3018            let half_leading = half_leading.max(0.0);
3019
3020            // Check for intervening non-zero padding/border on block-start (top)
3021            let has_pad_or_border_top = match get_css_padding_top(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
3022                MultiValue::Exact(pv) => pv.number.get() != 0.0,
3023                _ => false,
3024            } || match get_css_border_top_width(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
3025                MultiValue::Exact(pv) => pv.number.get() != 0.0,
3026                _ => false,
3027            };
3028
3029            // Check for intervening non-zero padding/border on block-end (bottom)
3030            let has_pad_or_border_bottom = match get_css_padding_bottom(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
3031                MultiValue::Exact(pv) => pv.number.get() != 0.0,
3032                _ => false,
3033            } || match get_css_border_bottom_width(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
3034                MultiValue::Exact(pv) => pv.number.get() != 0.0,
3035                _ => false,
3036            };
3037
3038            let trim_start = matches!(text_box_trim, StyleTextBoxTrim::TrimStart | StyleTextBoxTrim::TrimBoth)
3039                && !has_pad_or_border_top;
3040            let trim_end = matches!(text_box_trim, StyleTextBoxTrim::TrimEnd | StyleTextBoxTrim::TrimBoth)
3041                && !has_pad_or_border_bottom;
3042
3043            let mut height_reduction = 0.0;
3044            if trim_start && half_leading > 0.0 {
3045                height_reduction += half_leading;
3046            }
3047            if trim_end && half_leading > 0.0 {
3048                height_reduction += half_leading;
3049            }
3050
3051            if height_reduction > 0.0 {
3052                output.overflow_size.height = (output.overflow_size.height - height_reduction).max(0.0);
3053            }
3054        }
3055
3056        // Position all the inline-block children based on text3's calculations.
3057        // [CoordinateSpace::Parent] - positions are relative to IFC's content-box (0,0)
3058        for positioned_item in &main_frag.items {
3059            if let ShapedItem::Object { source, content, .. } = &positioned_item.item {
3060                if let Some(&child_node_index) = child_map.get(source) {
3061                    // new_relative_pos is [CoordinateSpace::Parent] - relative to this IFC's content-box
3062                    let new_relative_pos = LogicalPosition {
3063                        x: positioned_item.position.x,
3064                        y: positioned_item.position.y,
3065                    };
3066                    output.positions.insert(child_node_index, new_relative_pos);
3067                }
3068            }
3069        }
3070    }
3071
3072    // [g132 az-web-lift VERIFY] Capture the IFC content geometry (the line-box bounds from
3073    // main_frag.bounds(), set above as output.overflow_size). height>0 proves the text LAID OUT
3074    // (not just shaped). Free-band addrs, f32 bits. REVERT at cleanup.
3075    #[cfg(feature = "web_lift")]
3076    unsafe {
3077        crate::az_mark((0x60670) as u32, (output.overflow_size.width.to_bits()) as u32);
3078        crate::az_mark((0x60674) as u32, (output.overflow_size.height.to_bits()) as u32);
3079        crate::az_mark((0x60678) as u32, (output.positions.len() as u32) as u32);
3080        crate::az_mark((0x6067C) as u32, (0xC0DE0132u32) as u32);
3081    }
3082
3083    Ok(output)
3084}
3085
3086const fn translate_taffy_size(size: LogicalSize) -> TaffySize<Option<f32>> {
3087    TaffySize {
3088        width: Some(size.width),
3089        height: Some(size.height),
3090    }
3091}
3092
3093/// Helper: Convert `StyleFontStyle` to `text3::cache::FontStyle`
3094#[must_use] pub const fn convert_font_style(style: StyleFontStyle) -> crate::font_traits::FontStyle {
3095    match style {
3096        StyleFontStyle::Normal => crate::font_traits::FontStyle::Normal,
3097        StyleFontStyle::Italic => crate::font_traits::FontStyle::Italic,
3098        StyleFontStyle::Oblique => crate::font_traits::FontStyle::Oblique,
3099    }
3100}
3101
3102/// Helper: Convert `StyleFontWeight` to `FcWeight`
3103#[must_use] pub const fn convert_font_weight(weight: StyleFontWeight) -> FcWeight {
3104    match weight {
3105        StyleFontWeight::W100 => FcWeight::Thin,
3106        StyleFontWeight::W200 => FcWeight::ExtraLight,
3107        StyleFontWeight::W300 | StyleFontWeight::Lighter => FcWeight::Light,
3108        StyleFontWeight::Normal => FcWeight::Normal,
3109        StyleFontWeight::W500 => FcWeight::Medium,
3110        StyleFontWeight::W600 => FcWeight::SemiBold,
3111        StyleFontWeight::Bold => FcWeight::Bold,
3112        StyleFontWeight::W800 => FcWeight::ExtraBold,
3113        StyleFontWeight::W900 | StyleFontWeight::Bolder => FcWeight::Black,
3114    }
3115}
3116
3117/// Resolves a CSS size metric to pixels.
3118///
3119/// - `metric`: The CSS unit (px, pt, em, vw, etc.)
3120/// - `value`: The numeric value
3121/// - `containing_block_size`: Size of containing block (for percentage)
3122/// - `viewport_size`: Viewport dimensions (for vw, vh, vmin, vmax)
3123/// - `element_font_size`: The element's own computed font-size (for `em`)
3124/// - `root_font_size`: The root element's computed font-size (for `rem`)
3125#[inline]
3126fn resolve_size_metric(
3127    metric: SizeMetric,
3128    value: f32,
3129    containing_block_size: f32,
3130    viewport_size: LogicalSize,
3131    element_font_size: f32,
3132    root_font_size: f32,
3133) -> f32 {
3134    match metric {
3135        SizeMetric::Px => value,
3136        SizeMetric::Pt => value * PT_TO_PX,
3137        SizeMetric::Percent => value / 100.0 * containing_block_size,
3138        SizeMetric::Em => value * element_font_size,
3139        SizeMetric::Rem => value * root_font_size,
3140        SizeMetric::Vw => value / 100.0 * viewport_size.width,
3141        SizeMetric::Vh => value / 100.0 * viewport_size.height,
3142        SizeMetric::Vmin => value / 100.0 * viewport_size.width.min(viewport_size.height),
3143        SizeMetric::Vmax => value / 100.0 * viewport_size.width.max(viewport_size.height),
3144        SizeMetric::In => value * super::calc::PX_PER_INCH,
3145        SizeMetric::Cm => value * super::calc::PX_PER_INCH / super::calc::CM_PER_INCH,
3146        SizeMetric::Mm => value * super::calc::PX_PER_INCH / super::calc::MM_PER_INCH,
3147    }
3148}
3149
3150#[must_use] pub const fn translate_taffy_size_back(size: TaffySize<f32>) -> LogicalSize {
3151    LogicalSize {
3152        width: size.width,
3153        height: size.height,
3154    }
3155}
3156
3157#[must_use] pub const fn translate_taffy_point_back(point: taffy::Point<f32>) -> LogicalPosition {
3158    LogicalPosition {
3159        x: point.x,
3160        y: point.y,
3161    }
3162}
3163
3164// +spec:block-formatting-context:40e03e - BFC root: block container establishing new BFC (contains floats, excludes external floats, suppresses margin collapsing)
3165/// Checks if a node establishes a new Block Formatting Context (BFC).
3166///
3167/// Per CSS 2.2 § 9.4.1, a BFC is established by:
3168/// - Floats (elements with float other than 'none')
3169/// - Absolutely positioned elements (position: absolute or fixed)
3170/// - Block containers that are not block boxes (e.g., inline-blocks, table-cells)
3171/// - Block boxes with 'overflow' other than 'visible' and 'clip'
3172/// - Elements with 'display: flow-root'
3173/// - Table cells, table captions, and inline-blocks
3174///
3175/// Normal flow block-level boxes do NOT establish a new BFC.
3176///
3177/// This is critical for correct float interaction: normal blocks should overlap floats
3178/// (not shrink around them), while their inline content wraps around floats.
3179// +spec:block-formatting-context:241d22 - block container establishes new BFC or continues parent's, based on overflow/position/float/display
3180// +spec:block-formatting-context:9fe441 - BFC establishment based on position, float, overflow, and display properties
3181// +spec:display-property:3c7369 - block boxes establishing independent FC create new BFC; flex containers already do; non-replaced inlines cannot
3182// +spec:positioning:1e94f6 - floats, abspos, inline-blocks/table-cells/table-captions, overflow!=visible establish new BFC
3183fn establishes_new_bfc<T: ParsedFontTrait>(ctx: &LayoutContext<'_, T>, node: &LayoutNodeHot, cold: Option<&LayoutNodeCold>) -> bool {
3184    // +spec:block-formatting-context:f39cd3 - table wrapper box establishes a BFC (CSS 2.2 §17.4)
3185    // Anonymous table wrapper boxes have no dom_node_id but must still establish BFC
3186    // +spec:height-calculation:e20498 - table wrapper box establishes BFC (CSS 2.2 §17.4)
3187    // +spec:positioning:b780d3 - Table wrapper box establishes BFC (CSS 2.2 § 17.4)
3188    if cold.and_then(|c| c.anonymous_type) == Some(AnonymousBoxType::TableWrapper) {
3189        return true;
3190    }
3191    let Some(dom_id) = node.dom_node_id else {
3192        return false;
3193    };
3194
3195    let node_state = &ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
3196
3197    // 1. Floats establish BFC
3198    let float_val = get_float(ctx.styled_dom, dom_id, node_state);
3199    if matches!(
3200        float_val,
3201        MultiValue::Exact(LayoutFloat::Left | LayoutFloat::Right)
3202    ) {
3203        return true;
3204    }
3205
3206    // +spec:positioning:69468c - absolute/fixed forces independent formatting context
3207    let position = get_position_type(ctx.styled_dom, Some(dom_id));
3208    if matches!(position, LayoutPosition::Absolute | LayoutPosition::Fixed) {
3209        return true;
3210    }
3211
3212    // 3. Inline-blocks, table-cells, table-captions establish BFC
3213    let display = get_display_property(ctx.styled_dom, Some(dom_id));
3214    if matches!(
3215        display,
3216        MultiValue::Exact(
3217            LayoutDisplay::InlineBlock | LayoutDisplay::TableCell | LayoutDisplay::TableCaption
3218        )
3219    ) {
3220        return true;
3221    }
3222
3223    // 4. display: flow-root establishes BFC
3224    // +spec:display-property:14bae6 - flow-root establishes a formatting context that contains/excludes floats
3225    if matches!(display, MultiValue::Exact(LayoutDisplay::FlowRoot)) {
3226        return true;
3227    }
3228
3229    // +spec:overflow:0a944d - clip does NOT establish BFC; hidden/scroll/auto do establish BFC
3230    // +spec:overflow:631a4c - scroll containers establish independent formatting context (BFC)
3231    // +spec:overflow:f6a186 - overflow:clip does NOT establish BFC; use display:flow-root for that
3232    // +spec:overflow:717de1 - overflow != visible/clip establishes BFC per CSS 2.2 §9.4.1
3233    // +spec:positioning:6feb32 - overflow:clip does NOT establish new formatting context; hidden/scroll/auto do
3234    // 5. Block boxes with overflow other than 'visible' or 'clip' establish BFC
3235    // +spec:overflow:b34aef - Block boxes with overflow other than 'visible' or 'clip' establish BFC
3236    // Note: 'clip' does NOT establish BFC per CSS Overflow Module Level 3
3237    let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, node_state);
3238    let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, node_state);
3239
3240    let creates_bfc_via_overflow = |ov: &MultiValue<LayoutOverflow>| {
3241        matches!(
3242            ov,
3243            &MultiValue::Exact(
3244                LayoutOverflow::Hidden | LayoutOverflow::Scroll | LayoutOverflow::Auto
3245            )
3246        )
3247    };
3248
3249    if creates_bfc_via_overflow(&overflow_x) || creates_bfc_via_overflow(&overflow_y) {
3250        return true;
3251    }
3252
3253    // 6. Table, Flex, and Grid containers establish BFC (via FormattingContext)
3254    // +spec:block-formatting-context:f15b87 - display:table participates in a BFC
3255    if matches!(
3256        node.formatting_context,
3257        FormattingContext::Table | FormattingContext::Flex | FormattingContext::Grid
3258    ) {
3259        return true;
3260    }
3261
3262    // +spec:block-formatting-context:f15b87 - a flex/grid ITEM establishes an
3263    // independent formatting context for its contents (CSS Flexbox 1 § 3, CSS Grid 1
3264    // § 6). Its children's margins are therefore contained and must NOT collapse
3265    // through it — without this, the last child's margin-bottom escapes and the item
3266    // (e.g. the invoice `.head`'s inner div) reports a cross size short by that margin,
3267    // so the whole flex container is under-tall. Detect it from the parent's display.
3268    {
3269        let hierarchy = ctx.styled_dom.node_hierarchy.as_container();
3270        if let Some(parent_dom_id) = hierarchy[dom_id].parent_id() {
3271            let parent_display = get_display_property(ctx.styled_dom, Some(parent_dom_id));
3272            if matches!(
3273                parent_display,
3274                MultiValue::Exact(
3275                    LayoutDisplay::Flex
3276                        | LayoutDisplay::InlineFlex
3277                        | LayoutDisplay::Grid
3278                        | LayoutDisplay::InlineGrid
3279                )
3280            ) {
3281                return true;
3282            }
3283        }
3284    }
3285
3286    // +spec:block-formatting-context:33e6cd - block container with different writing-mode than parent establishes independent BFC
3287    // CSS Writing Modes 4 § 3.2: if a block container has a different writing-mode
3288    // than its parent, its inner display type computes to flow-root (i.e., it establishes BFC).
3289    {
3290        let hierarchy = ctx.styled_dom.node_hierarchy.as_container();
3291        if let Some(parent_dom_id) = hierarchy[dom_id].parent_id() {
3292            let parent_state = &ctx.styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
3293            let child_wm = get_writing_mode(ctx.styled_dom, dom_id, node_state).unwrap_or_default();
3294            let parent_wm = get_writing_mode(ctx.styled_dom, parent_dom_id, parent_state).unwrap_or_default();
3295            if child_wm != parent_wm {
3296                return true;
3297            }
3298        }
3299    }
3300
3301    // Normal flow block boxes do NOT establish BFC
3302    // NOTE: align-content != normal should also establish BFC per CSS-DISPLAY-3, but align-content is not yet implemented for block containers
3303    false
3304}
3305
3306// +spec:display-property:5e5420 - replaced element identification (glossary: replaced elements have natural dimensions, establish independent formatting context)
3307/// CSS 2.2 § 9.5: "The border box of a table, a block-level replaced element, or an element
3308/// in the normal flow that establishes a new block formatting context [...] must not overlap
3309/// the margin box of any floats in the same block formatting context as the element itself."
3310fn is_block_level_replaced<T: ParsedFontTrait>(ctx: &LayoutContext<'_, T>, node: &LayoutNodeHot) -> bool {
3311    let Some(dom_id) = node.dom_node_id else {
3312        return false;
3313    };
3314
3315    // Check display is block-level
3316    let display = get_display_property(ctx.styled_dom, Some(dom_id));
3317    let is_block_level = matches!(
3318        display,
3319        MultiValue::Exact(LayoutDisplay::Block | LayoutDisplay::ListItem | LayoutDisplay::FlowRoot)
3320    );
3321
3322    if !is_block_level {
3323        return false;
3324    }
3325
3326    // Check if the element is a replaced element (image, video, etc.)
3327    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
3328    matches!(
3329        node_data.get_node_type(),
3330        NodeType::Image(_)
3331    )
3332}
3333
3334/// Translates solver3 layout constraints into the text3 engine's unified constraints.
3335#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
3336#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3337fn translate_to_text3_constraints<'a, T: ParsedFontTrait>(
3338    ctx: &mut LayoutContext<'_, T>,
3339    constraints: &'a LayoutConstraints<'a>,
3340    styled_dom: &StyledDom,
3341    dom_id: NodeId,
3342) -> UnifiedConstraints {
3343    use azul_css::compact_cache::{
3344        DOM_HAS_SHAPE_INSIDE, DOM_HAS_SHAPE_OUTSIDE, DOM_HAS_TEXT_JUSTIFY,
3345        DOM_HAS_TEXT_INDENT, DOM_HAS_COLUMN_COUNT, DOM_HAS_COLUMN_GAP,
3346        DOM_HAS_COLUMN_WIDTH,
3347        DOM_HAS_INITIAL_LETTER, DOM_HAS_INITIAL_LETTER_ALIGN,
3348        DOM_HAS_LINE_CLAMP, DOM_HAS_HANGING_PUNCTUATION,
3349        DOM_HAS_TEXT_COMBINE_UPRIGHT, DOM_HAS_EXCLUSION_MARGIN,
3350        DOM_HAS_SHAPE_MARGIN,
3351        DOM_HAS_HYPHENATION_LANGUAGE, DOM_HAS_UNICODE_BIDI,
3352        DOM_HAS_HYPHENS, DOM_HAS_WORD_BREAK, DOM_HAS_OVERFLOW_WRAP,
3353        DOM_HAS_LINE_BREAK, DOM_HAS_TEXT_ALIGN_LAST, DOM_HAS_LINE_HEIGHT,
3354    };
3355    unsafe { crate::az_mark(0x60704_u32, (0x30u32)); }
3356    // DOM-level declared flags: if a bit is clear, no node in this DOM
3357    // declared the corresponding property → cascade walks always return
3358    // None, and we use the default value directly. All flags default to
3359    // "set" when there is no compact cache (paranoid fallback).
3360    let dom_declared = styled_dom
3361        .css_property_cache
3362        .ptr
3363        .compact_cache
3364        .as_ref()
3365        .map_or(!0u32, |cc| cc.dom_declared_flags);
3366
3367    // Convert floats into exclusion zones for text3 to flow around.
3368    let mut shape_exclusions = if let Some(ref bfc_state) = constraints.bfc_state {
3369        debug_info!(
3370            ctx,
3371            "[translate_to_text3] dom_id={:?}, converting {} floats to exclusions",
3372            dom_id,
3373            bfc_state.floats.floats.len()
3374        );
3375        bfc_state
3376            .floats
3377            .floats
3378            .iter()
3379            .enumerate()
3380            .map(|(i, float_box)| {
3381                let rect = text3::cache::Rect {
3382                    x: float_box.rect.origin.x,
3383                    y: float_box.rect.origin.y,
3384                    width: float_box.rect.size.width,
3385                    height: float_box.rect.size.height,
3386                };
3387                debug_info!(
3388                    ctx,
3389                    "[translate_to_text3]   Exclusion #{}: {:?} at ({}, {}) size {}x{}",
3390                    i,
3391                    float_box.kind,
3392                    rect.x,
3393                    rect.y,
3394                    rect.width,
3395                    rect.height
3396                );
3397                ShapeBoundary::Rectangle(rect)
3398            })
3399            .collect()
3400    } else {
3401        debug_info!(
3402            ctx,
3403            "[translate_to_text3] dom_id={:?}, NO bfc_state - no float exclusions",
3404            dom_id
3405        );
3406        Vec::new()
3407    };
3408
3409    debug_info!(
3410        ctx,
3411        "[translate_to_text3] dom_id={:?}, available_size={}x{}, shape_exclusions.len()={}",
3412        dom_id,
3413        constraints.available_size.width,
3414        constraints.available_size.height,
3415        shape_exclusions.len()
3416    );
3417
3418    // Map text-align and justify-content from CSS to text3 enums.
3419    let id = dom_id;
3420    let node_data = &styled_dom.node_data.as_container()[id];
3421    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3422
3423    // Read CSS Shapes properties
3424    // For reference box, use the element's CSS height if available, otherwise available_size
3425    // This is important because available_size.height might be infinite during auto height
3426    // calculation
3427    let ref_box_height = if constraints.available_size.height.is_finite() {
3428        constraints.available_size.height
3429    } else {
3430        // Try to get explicit CSS height
3431        // NOTE: If height is infinite, we can't properly resolve % heights
3432        // This is a limitation - shape-inside with % heights requires finite containing block
3433        styled_dom
3434            .css_property_cache
3435            .ptr
3436            .get_height(node_data, &id, node_state)
3437            .and_then(|v| v.get_property())
3438            .and_then(|h| match h {
3439                LayoutHeight::Px(v) => {
3440                    // Only accept absolute units (px, pt, in, cm, mm) - no %, em, rem
3441                    // since we can't resolve relative units without proper context
3442                    match v.metric {
3443                        SizeMetric::Px => Some(v.number.get()),
3444                        SizeMetric::Pt => Some(v.number.get() * PT_TO_PX),
3445                        SizeMetric::In => Some(v.number.get() * super::calc::PX_PER_INCH),
3446                        SizeMetric::Cm => Some(v.number.get() * super::calc::PX_PER_INCH / super::calc::CM_PER_INCH),
3447                        SizeMetric::Mm => Some(v.number.get() * super::calc::PX_PER_INCH / super::calc::MM_PER_INCH),
3448                        _ => None, // Ignore %, em, rem
3449                    }
3450                }
3451                _ => None,
3452            })
3453            .unwrap_or(constraints.available_size.width) // Fallback: use width as height (square)
3454    };
3455
3456    let reference_box = text3::cache::Rect {
3457        x: 0.0,
3458        y: 0.0,
3459        width: constraints.available_size.width,
3460        height: ref_box_height,
3461    };
3462
3463    // shape-inside: Text flows within the shape boundary
3464    debug_info!(ctx, "Checking shape-inside for node {:?}", id);
3465    debug_info!(
3466        ctx,
3467        "Reference box: {:?} (available_size height was: {})",
3468        reference_box,
3469        constraints.available_size.height
3470    );
3471
3472    let shape_boundaries = if dom_declared & DOM_HAS_SHAPE_INSIDE != 0 {
3473        styled_dom
3474            .css_property_cache
3475            .ptr
3476            .get_shape_inside(node_data, &id, node_state)
3477            .and_then(|v| {
3478                debug_info!(ctx, "Got shape-inside value: {:?}", v);
3479                v.get_property()
3480            })
3481            .and_then(|shape_inside| {
3482                debug_info!(ctx, "shape-inside property: {:?}", shape_inside);
3483                if let ShapeInside::Shape(css_shape) = shape_inside {
3484                    debug_info!(
3485                        ctx,
3486                        "Converting CSS shape to ShapeBoundary: {:?}",
3487                        css_shape
3488                    );
3489                    let boundary =
3490                        ShapeBoundary::from_css_shape(css_shape, reference_box, ctx.debug_messages);
3491                    debug_info!(ctx, "Created ShapeBoundary: {:?}", boundary);
3492                    Some(vec![boundary])
3493                } else {
3494                    debug_info!(ctx, "shape-inside is None");
3495                    None
3496                }
3497            })
3498            .unwrap_or_default()
3499    } else {
3500        Vec::new()
3501    };
3502
3503    debug_info!(
3504        ctx,
3505        "Final shape_boundaries count: {}",
3506        shape_boundaries.len()
3507    );
3508
3509    // shape-outside: Text wraps around the shape (adds to exclusions)
3510    debug_info!(ctx, "Checking shape-outside for node {:?}", id);
3511    if dom_declared & DOM_HAS_SHAPE_OUTSIDE != 0 {
3512        if let Some(shape_outside_value) = styled_dom
3513            .css_property_cache
3514            .ptr
3515            .get_shape_outside(node_data, &id, node_state)
3516        {
3517            debug_info!(ctx, "Got shape-outside value: {:?}", shape_outside_value);
3518            if let Some(shape_outside) = shape_outside_value.get_property() {
3519                debug_info!(ctx, "shape-outside property: {:?}", shape_outside);
3520                if let ShapeOutside::Shape(css_shape) = shape_outside {
3521                    debug_info!(
3522                        ctx,
3523                        "Converting CSS shape-outside to ShapeBoundary: {:?}",
3524                        css_shape
3525                    );
3526                    let boundary =
3527                        ShapeBoundary::from_css_shape(css_shape, reference_box, ctx.debug_messages);
3528                    debug_info!(ctx, "Created ShapeBoundary (exclusion): {:?}", boundary);
3529                    shape_exclusions.push(boundary);
3530                }
3531            }
3532        } else {
3533            debug_info!(ctx, "No shape-outside value found");
3534        }
3535    }
3536
3537    // TODO: clip-path will be used for rendering clipping (not text layout)
3538
3539    let writing_mode = get_writing_mode(styled_dom, id, node_state).unwrap_or_default();
3540
3541    let text_align = get_text_align(styled_dom, id, node_state).unwrap_or_default();
3542
3543    let text_justify = if dom_declared & DOM_HAS_TEXT_JUSTIFY != 0 {
3544        styled_dom
3545            .css_property_cache
3546            .ptr
3547            .get_text_justify(node_data, &id, node_state)
3548            .and_then(|s| s.get_property().copied())
3549            .unwrap_or_default()
3550    } else {
3551        LayoutTextJustify::default()
3552    };
3553
3554    // Get font-size for resolving line-height
3555    // Use helper function which checks dependency chain first
3556    let font_size = get_element_font_size(styled_dom, id, node_state);
3557
3558    let line_height_value = if dom_declared & DOM_HAS_LINE_HEIGHT != 0 {
3559        styled_dom
3560            .css_property_cache
3561            .ptr
3562            .get_line_height(node_data, &id, node_state)
3563            .and_then(|s| s.get_property().copied())
3564            .unwrap_or_default()
3565    } else {
3566        azul_css::props::style::text::StyleLineHeight::default()
3567    };
3568
3569    let hyphenation = if dom_declared & DOM_HAS_HYPHENS != 0 {
3570        styled_dom
3571            .css_property_cache
3572            .ptr
3573            .get_hyphens(node_data, &id, node_state)
3574            .and_then(|s| s.get_property().copied())
3575            .unwrap_or_default()
3576    } else {
3577        StyleHyphens::default()
3578    };
3579
3580    let word_break_css = if dom_declared & DOM_HAS_WORD_BREAK != 0 {
3581        styled_dom
3582            .css_property_cache
3583            .ptr
3584            .get_word_break(node_data, &id, node_state)
3585            .and_then(|s| s.get_property().copied())
3586            .unwrap_or_default()
3587    } else {
3588        StyleWordBreak::default()
3589    };
3590
3591    let overflow_wrap_css = if dom_declared & DOM_HAS_OVERFLOW_WRAP != 0 {
3592        styled_dom
3593            .css_property_cache
3594            .ptr
3595            .get_overflow_wrap(node_data, &id, node_state)
3596            .and_then(|s| s.get_property().copied())
3597            .unwrap_or_default()
3598    } else {
3599        StyleOverflowWrap::default()
3600    };
3601
3602    let line_break_css = if dom_declared & DOM_HAS_LINE_BREAK != 0 {
3603        styled_dom
3604            .css_property_cache
3605            .ptr
3606            .get_line_break(node_data, &id, node_state)
3607            .and_then(|s| s.get_property().copied())
3608            .unwrap_or_default()
3609    } else {
3610        StyleLineBreak::default()
3611    };
3612
3613    let text_align_last_css = if dom_declared & DOM_HAS_TEXT_ALIGN_LAST != 0 {
3614        styled_dom
3615            .css_property_cache
3616            .ptr
3617            .get_text_align_last(node_data, &id, node_state)
3618            .and_then(|s| s.get_property().copied())
3619            .unwrap_or_default()
3620    } else {
3621        StyleTextAlignLast::default()
3622    };
3623
3624    let overflow_behaviour = get_overflow_x(styled_dom, id, node_state).unwrap_or_default();
3625
3626    // +spec:display-property:21f728 - vertical-align shorthand resolves inline-level box alignment
3627    // +spec:display-property:98fa8e - alignment-baseline values for inline-level boxes in IFC (implemented via vertical-align shorthand)
3628    // +spec:display-property:1f71ad - baseline-shift + alignment-baseline longhands mapped through vertical-align
3629    // +spec:display-property:89dd7b - line-relative shift values (top/center/bottom) and aligned subtree alignment
3630    // +spec:inline-formatting-context:21da06 - vertical-align uses line-over/line-under sides via writing_mode logical mapping
3631    // +spec:inline-formatting-context:295603 - baseline alignment: vertical-align determines how inline boxes align (baseline, super, sub, etc.)
3632    // +spec:inline-formatting-context:7351bf - default alignment baseline is alphabetic in horizontal typographic mode
3633    // +spec:inline-formatting-context:85de3d - vertical-align shorthand: alignment within line box
3634    // +spec:inline-formatting-context:aa8af0 - alignment baseline chosen by vertical-align, defaults to parent's dominant baseline
3635    // +spec:inline-formatting-context:e475d2 - baseline and vertical-align control transverse alignment of inline content on line boxes
3636    // +spec:overflow:d44eac - vertical-align inline box alignment (CSS 2.2 model covers baseline/top/middle/bottom/sub/super/text-top/text-bottom)
3637    // +spec:writing-modes:313575 - alignment-baseline: inline-level boxes align baselines within parent inline box's alignment context along inline axis
3638    // +spec:writing-modes:60ad67 - inline layout aligns boxes in block axis via baselines
3639    // +spec:writing-modes:0127e5 - line-relative directions: line-over/under map to vertical-align top/bottom
3640    // Get vertical-align from CSS property cache (defaults to Baseline per CSS spec)
3641    // +spec:inline-formatting-context:686f8b - vertical-align shorthand: alignment-baseline + baseline-shift for inline boxes
3642    // +spec:inline-formatting-context:e579b6 - vertical-align / baseline alignment in inline context
3643    // +spec:inline-formatting-context:a01a75 - dominant baseline alignment for atomic inlines
3644    let vertical_align = match get_vertical_align_property(styled_dom, id, node_state) {
3645        MultiValue::Exact(v) => v,
3646        _ => StyleVerticalAlign::default(),
3647    };
3648
3649    // +spec:display-property:c03a6b - baseline-shift (sub/super/length/percentage) and line-relative (top/center/bottom) shifts handled via vertical-align
3650    let vertical_align = match vertical_align {
3651        StyleVerticalAlign::Baseline => text3::cache::VerticalAlign::Baseline,
3652        StyleVerticalAlign::Top => text3::cache::VerticalAlign::Top,
3653        StyleVerticalAlign::Middle => text3::cache::VerticalAlign::Middle,
3654        StyleVerticalAlign::Bottom => text3::cache::VerticalAlign::Bottom,
3655        StyleVerticalAlign::Sub => text3::cache::VerticalAlign::Sub,
3656        // +spec:inline-formatting-context:fe563c - vertical-align: super shifts inline to superscript position
3657        // +spec:inline-formatting-context:fe563c - vertical-align:super shifts child to superscript position
3658        StyleVerticalAlign::Superscript => text3::cache::VerticalAlign::Super,
3659        StyleVerticalAlign::TextTop => text3::cache::VerticalAlign::TextTop,
3660        StyleVerticalAlign::TextBottom => text3::cache::VerticalAlign::TextBottom,
3661        // §10.8.1: <percentage> refers to line-height of the element itself
3662        StyleVerticalAlign::Percentage(p) => {
3663            let lh_n = line_height_value.inner.normalized();
3664            let resolved_lh = if lh_n < 0.0 { -lh_n } else { lh_n * font_size };
3665            let offset = p.normalized() * resolved_lh;
3666            text3::cache::VerticalAlign::Offset(offset)
3667        }
3668        // §10.8.1: <length> is absolute offset from baseline
3669        StyleVerticalAlign::Length(l) => {
3670            // Resolve viewport units (vw/vh/vmin/vmax) against the real viewport
3671            // instead of falling through `resolve_pixel_value`'s "treat 50vw as 50px".
3672            let offset = super::calc::resolve_pixel_value_with_viewport(
3673                &l,
3674                0.0,
3675                font_size,
3676                font_size,
3677                ctx.viewport_size.width,
3678                ctx.viewport_size.height,
3679            );
3680            text3::cache::VerticalAlign::Offset(offset)
3681        }
3682    };
3683    // +spec:block-formatting-context:987746 - text-orientation property (mixed/upright/sideways) for vertical writing modes
3684    // +spec:inline-formatting-context:cbe738 - text-orientation (mixed/upright/sideways) bi-orientational transform for vertical text
3685    // +spec:writing-modes:09a1bb - vertical typesetting orientation (upright/sideways) for vertical-rl/vertical-lr
3686    // +spec:writing-modes:2eb1b2 - text-orientation (mixed/upright/sideways) applied to vertical text layout
3687    let text_orientation = match get_text_orientation_property(styled_dom, id, node_state) {
3688        MultiValue::Exact(o) => match o {
3689            StyleTextOrientation::Mixed => text3::cache::TextOrientation::Mixed,
3690            StyleTextOrientation::Upright => text3::cache::TextOrientation::Upright,
3691            // +spec:block-formatting-context:a606e6 - sideways text typeset rotated 90° CW in vertical modes
3692            StyleTextOrientation::Sideways => text3::cache::TextOrientation::Sideways,
3693        },
3694        _ => text3::cache::TextOrientation::default(),
3695    };
3696
3697    // +spec:display-property:8364c0 - direction property (ltr/rtl) sets paragraph embedding level for bidi algorithm
3698    // +spec:text-alignment-spacing:97b93a - direction property affects text-align:justify last-line alignment
3699    // +spec:writing-modes:73aaff - block elements inherit base direction from parent via CSS direction property
3700    // +spec:writing-modes:8a888b - line box inline base direction from containing block's direction
3701    // Get the direction property from the CSS cache (defaults to LTR if not set)
3702    // +spec:display-property:da3b59 - direction property specifies inline base direction for ordering inline-level content
3703    // +spec:inline-formatting-context:97af40 - direction property sets inline base direction for bidi, text alignment, overflow
3704    // +spec:writing-modes:2deb38 - bidirectional reordering via CSS direction property
3705    // +spec:writing-modes:fbb332 - in vertical writing modes, text-orientation:upright forces used direction to ltr
3706    let direction = match constraints.writing_mode {
3707        LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr
3708            if matches!(text_orientation, text3::cache::TextOrientation::Upright) =>
3709        {
3710            Some(text3::cache::BidiDirection::Ltr)
3711        }
3712        _ => match get_direction_property(styled_dom, id, node_state) {
3713            MultiValue::Exact(d) => Some(match d {
3714                StyleDirection::Ltr => text3::cache::BidiDirection::Ltr,
3715                StyleDirection::Rtl => text3::cache::BidiDirection::Rtl,
3716            }),
3717            _ => None,
3718        },
3719    };
3720
3721    // Get unicode-bidi property for bidi algorithm configuration
3722    // +spec:containing-block:0d4914 - unicode-bidi: plaintext causes P2/P3 heuristics instead of HL1 override
3723    let unicode_bidi_val = if dom_declared & DOM_HAS_UNICODE_BIDI != 0 {
3724        match get_unicode_bidi_property(styled_dom, id, node_state) {
3725            MultiValue::Exact(u) => match u {
3726                StyleUnicodeBidi::Normal => text3::cache::UnicodeBidi::Normal,
3727                StyleUnicodeBidi::Embed => text3::cache::UnicodeBidi::Embed,
3728                StyleUnicodeBidi::Isolate => text3::cache::UnicodeBidi::Isolate,
3729                StyleUnicodeBidi::BidiOverride => text3::cache::UnicodeBidi::BidiOverride,
3730                StyleUnicodeBidi::IsolateOverride => text3::cache::UnicodeBidi::IsolateOverride,
3731                StyleUnicodeBidi::Plaintext => text3::cache::UnicodeBidi::Plaintext,
3732            },
3733            _ => text3::cache::UnicodeBidi::Normal,
3734        }
3735    } else {
3736        text3::cache::UnicodeBidi::Normal
3737    };
3738
3739    debug_info!(
3740        ctx,
3741        "dom_id={:?}, available_size={}x{}, setting available_width={}",
3742        dom_id,
3743        constraints.available_size.width,
3744        constraints.available_size.height,
3745        constraints.available_size.width
3746    );
3747
3748    // +spec:box-model:8113d7 - text-indent treated as margin on start edge of line box
3749    // +spec:display-contents:5f95ac - text-indent: percentage=0 for intrinsic sizing, each-line and hanging keywords
3750    // +spec:floats:17c74a - text-indent applied to first line (5em indentation with no floats)
3751    // +spec:positioning:1e32b1 - text-indent with hanging/each-line keywords resolved and passed to text layout
3752    let text_indent_prop = if dom_declared & DOM_HAS_TEXT_INDENT != 0 {
3753        styled_dom
3754            .css_property_cache
3755            .ptr
3756            .get_text_indent(node_data, &id, node_state)
3757            .and_then(|s| s.get_property().copied())
3758    } else {
3759        None
3760    };
3761    let is_intrinsic_sizing = matches!(
3762        constraints.available_width_type,
3763        Text3AvailableSpace::MinContent | Text3AvailableSpace::MaxContent
3764    );
3765    // +spec:intrinsic-sizing:0e8625 - percentage text-indent treated as 0 for intrinsic size contributions
3766    let text_indent = text_indent_prop
3767        .map_or(0.0, |ti| {
3768            // CSS Text 3 §8.1: "Percentages must be treated as 0 for the purpose
3769            // of calculating intrinsic size contributions"
3770            if is_intrinsic_sizing && ti.inner.to_percent().is_some() {
3771                return 0.0;
3772            }
3773            let context = ResolutionContext {
3774                element_font_size: get_element_font_size(styled_dom, id, node_state),
3775                parent_font_size: get_parent_font_size(styled_dom, id, node_state),
3776                root_font_size: get_root_font_size(styled_dom, node_state),
3777                containing_block_size: PhysicalSize::new(constraints.available_size.width, 0.0),
3778                element_size: None,
3779                viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
3780            };
3781            ti.inner
3782                .resolve_with_context(&context, PropertyContext::Other)
3783        });
3784    let text_indent_each_line = text_indent_prop.is_some_and(|ti| ti.each_line);
3785    let text_indent_hanging = text_indent_prop.is_some_and(|ti| ti.hanging);
3786
3787    // ResolutionContext shared by column-gap and column-width (both resolve
3788    // lengths against the same font/viewport, with no containing-block size).
3789    let column_resolve_ctx = ResolutionContext {
3790        element_font_size: get_element_font_size(styled_dom, id, node_state),
3791        parent_font_size: get_parent_font_size(styled_dom, id, node_state),
3792        root_font_size: get_root_font_size(styled_dom, node_state),
3793        containing_block_size: PhysicalSize::new(0.0, 0.0),
3794        element_size: None,
3795        viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
3796    };
3797
3798    // Read a declared CSS property from the cache, returning None when the
3799    // DOM-level declared bit is clear (no node sets the property).
3800    macro_rules! declared_prop {
3801        ($bit:expr, $getter:ident) => {
3802            if dom_declared & $bit != 0 {
3803                styled_dom
3804                    .css_property_cache
3805                    .ptr
3806                    .$getter(node_data, &id, node_state)
3807                    .and_then(|s| s.get_property())
3808            } else {
3809                None
3810            }
3811        };
3812    }
3813
3814    // Get column-gap for multi-column layout (default: normal = 1em)
3815    let column_gap = declared_prop!(DOM_HAS_COLUMN_GAP, get_column_gap)
3816        .map(|cg| {
3817            cg.inner
3818                .resolve_with_context(&column_resolve_ctx, PropertyContext::Other)
3819        })
3820        .unwrap_or_else(|| get_element_font_size(styled_dom, id, node_state));
3821
3822    // Get column-width for multi-column layout (None = auto)
3823    let column_width =
3824        declared_prop!(DOM_HAS_COLUMN_WIDTH, get_column_width).and_then(|cw| match cw {
3825            ColumnWidth::Auto => None,
3826            ColumnWidth::Length(px) => {
3827                Some(px.resolve_with_context(&column_resolve_ctx, PropertyContext::Other))
3828            }
3829        });
3830
3831    // Get column-count for multi-column layout (default: 1 = no columns)
3832    let explicit_column_count =
3833        declared_prop!(DOM_HAS_COLUMN_COUNT, get_column_count).copied();
3834
3835    // CSS multi-column: derive column count from column-width when column-count is auto.
3836    // Per spec: N = max(1, floor((available-width + column-gap) / (column-width + column-gap)))
3837    let columns = match (explicit_column_count, column_width) {
3838        (Some(ColumnCount::Integer(n)), _) => n,
3839        (_, Some(cw)) if cw > 0.0 => {
3840            let avail = constraints.available_size.width;
3841            ((avail + column_gap) / (cw + column_gap)).floor().max(1.0) as u32
3842        }
3843        _ => 1,
3844    };
3845
3846    // +spec:line-breaking:b4928e - white-space values mapped to wrap/whitespace processing rules
3847    // Map white-space CSS property to TextWrap
3848    let resolved_ws = match get_white_space_property(styled_dom, id, node_state) {
3849        MultiValue::Exact(ws) => ws,
3850        _ => StyleWhiteSpace::Normal,
3851    };
3852    let text_wrap = match resolved_ws {
3853        StyleWhiteSpace::Normal
3854        | StyleWhiteSpace::PreWrap
3855        | StyleWhiteSpace::PreLine
3856        | StyleWhiteSpace::BreakSpaces => text3::cache::TextWrap::Wrap,
3857        StyleWhiteSpace::Nowrap | StyleWhiteSpace::Pre => text3::cache::TextWrap::NoWrap,
3858    };
3859    let white_space_mode = match resolved_ws {
3860        StyleWhiteSpace::Normal => text3::cache::WhiteSpaceMode::Normal,
3861        StyleWhiteSpace::Nowrap => text3::cache::WhiteSpaceMode::Nowrap,
3862        StyleWhiteSpace::Pre => text3::cache::WhiteSpaceMode::Pre,
3863        StyleWhiteSpace::PreWrap => text3::cache::WhiteSpaceMode::PreWrap,
3864        StyleWhiteSpace::PreLine => text3::cache::WhiteSpaceMode::PreLine,
3865        StyleWhiteSpace::BreakSpaces => text3::cache::WhiteSpaceMode::BreakSpaces,
3866    };
3867
3868    // +spec:block-formatting-context:fd60a8 - initial letter box is in-flow in its BFC, originating line box
3869    // +spec:block-formatting-context:c5ba02 - initial letter inline flow layout (alignment, white space collapsing)
3870    // +spec:block-formatting-context:83f8a7 - initial letter wrapping modes (none, all, first)
3871    // +spec:block-formatting-context:fef28d - initial letter box is in-flow in its BFC, part of originating line box
3872    // +spec:box-model:c3ce58 - initial letter block-start margin edge must be below containing block content edge
3873    // +spec:display-contents:568fe2 - initial letter participates in same IFC as its line
3874    // +spec:display-property:a89adb - initial letter boxes from non-replaced inline boxes and atomic inlines
3875    // +spec:display-property:4b59ce - initial-letter applies to inline-level boxes at start of first line
3876    // +spec:display-property:756cad - initial-letter sizing: drop/raise/sunken initial computation
3877    // +spec:display-property:8b08f4 - initial-letter applied to first inline-level child of block container
3878    // +spec:display-property:8c1dce - initial-letter property: size/sink for drop caps on inline-level boxes
3879    // +spec:display-property:b453a3 - initial-letter applies to inline-level boxes in IFC
3880    // +spec:display-property:b5e149 - initial letters are in-flow inline-level content, not floats
3881    // +spec:display-property:fa044e - initial-letter applies to first-child inline-level boxes
3882    // +spec:line-height:306d87 - initial-letter sizing must use containing block's line-height, not spanned lines' heights
3883    // +spec:writing-modes:903310 - atomic initial letters use normal sizing; only positioning is special
3884    // Get initial-letter for drop caps
3885    // +spec:display-property:4c69bf - read initial-letter-align for alignment points
3886    let initial_letter_align = if dom_declared & DOM_HAS_INITIAL_LETTER_ALIGN != 0 {
3887        styled_dom
3888            .css_property_cache
3889            .ptr
3890            .get_initial_letter_align(node_data, &id, node_state)
3891            .and_then(|s| s.get_property())
3892            .map_or(text3::cache::InitialLetterAlign::Auto, |a| match a {
3893                azul_css::props::style::text::StyleInitialLetterAlign::Auto => text3::cache::InitialLetterAlign::Auto,
3894                azul_css::props::style::text::StyleInitialLetterAlign::Alphabetic => text3::cache::InitialLetterAlign::Alphabetic,
3895                azul_css::props::style::text::StyleInitialLetterAlign::Hanging => text3::cache::InitialLetterAlign::Hanging,
3896                azul_css::props::style::text::StyleInitialLetterAlign::Ideographic => text3::cache::InitialLetterAlign::Ideographic,
3897            })
3898    } else {
3899        text3::cache::InitialLetterAlign::Auto
3900    };
3901    // +spec:display-property:5af252 - initial-letter on inline-level box not at line start uses normal
3902    // +spec:text-alignment-spacing:a17609 - sunken initial letters suppress letter-spacing and justification (not word-spacing) with adjacent content
3903    // +spec:display-property:68ab22 - initial-letter only applies in IFC (inline-level);
3904    // float!=none or position!=static causes display to compute to block (BFC), so
3905    // initial-letter naturally does not apply to those elements
3906    // +spec:writing-modes:c89d19 - initial-letter block-axis positioning: sink determines block offset
3907    // +spec:display-property:b67500 - initial-letter size/sink: values other than normal make box an initial letter box (inline-level, in-flow)
3908    // +spec:display-property:416f27 - initial-letter sink defaults to "drop" (sink = size floored) when omitted
3909    let initial_letter = if dom_declared & DOM_HAS_INITIAL_LETTER != 0 {
3910        styled_dom
3911            .css_property_cache
3912            .ptr
3913            .get_initial_letter(node_data, &id, node_state)
3914            .and_then(|s| s.get_property())
3915            .map(|il| {
3916                use std::num::NonZeroUsize;
3917                let sink = match il.sink {
3918                    azul_css::corety::OptionU32::Some(s) => s,
3919                    azul_css::corety::OptionU32::None => il.size, // "drop" assumed: sink = size
3920                };
3921                text3::cache::InitialLetter {
3922                    size: il.size as f32,
3923                    sink,
3924                    count: NonZeroUsize::new(1).unwrap(),
3925                    align: initial_letter_align,
3926                }
3927            })
3928    } else {
3929        None
3930    };
3931
3932    // If initial-letter is set, compute the drop cap exclusion area and add it
3933    // to the shape exclusions so that text wraps around the enlarged letter.
3934    // +spec:box-model:d4adf6 - ancestor inline boundaries excluded via geometric exclusion
3935    // +spec:floats:c5e23f - floats in subsequent lines adjacent to a sunk initial letter must clear it
3936    if let Some(ref il) = initial_letter {
3937        let lh_n = line_height_value.inner.normalized();
3938        let computed_line_height = if lh_n < 0.0 { -lh_n } else { lh_n * font_size };
3939        let (letter_w, letter_h) = layout_initial_letter(
3940            il.size,
3941            il.sink,
3942            constraints.available_size.width,
3943            computed_line_height,
3944        );
3945        if letter_w > 0.0 && letter_h > 0.0 {
3946            // Place the exclusion at the inline-start (x=0, y=0 relative to the IFC).
3947            // This creates a rectangular exclusion that text flows around.
3948            shape_exclusions.push(ShapeBoundary::Rectangle(text3::cache::Rect {
3949                x: 0.0,
3950                y: 0.0,
3951                width: letter_w,
3952                height: letter_h,
3953            }));
3954        }
3955    }
3956
3957    // Get line-clamp for limiting visible lines
3958    let line_clamp = if dom_declared & DOM_HAS_LINE_CLAMP != 0 {
3959        styled_dom
3960            .css_property_cache
3961            .ptr
3962            .get_line_clamp(node_data, &id, node_state)
3963            .and_then(|s| s.get_property())
3964            .and_then(|lc| std::num::NonZeroUsize::new(lc.max_lines))
3965    } else {
3966        None
3967    };
3968
3969    // Get hanging-punctuation for hanging punctuation marks
3970    let hanging_punctuation = if dom_declared & DOM_HAS_HANGING_PUNCTUATION != 0 {
3971        styled_dom
3972            .css_property_cache
3973            .ptr
3974            .get_hanging_punctuation(node_data, &id, node_state)
3975            .and_then(|s| s.get_property())
3976            .is_some_and(azul_css::props::style::StyleHangingPunctuation::is_enabled)
3977    } else {
3978        false
3979    };
3980
3981    // Get text-combine-upright for vertical text combination
3982    // +spec:line-breaking:9f150a - text-combine-upright:all composes glyphs horizontally, ignoring letter-spacing and forced line breaks
3983    // +spec:line-breaking:1b88cd - text-combine-upright:all layout: inline-block with 1em square, ignoring forced line breaks
3984    // +spec:inline-formatting-context:c8d8d9 - text-combine-upright compression passed to text shaping engine
3985    // +spec:inline-formatting-context:f4ef7d - text-combine-upright layout rules (1em square composition)
3986    let text_combine_upright = if dom_declared & DOM_HAS_TEXT_COMBINE_UPRIGHT != 0 {
3987        styled_dom
3988            .css_property_cache
3989            .ptr
3990            .get_text_combine_upright(node_data, &id, node_state)
3991            .and_then(|s| s.get_property())
3992            // +spec:display-property:6f174d - text-combine-upright horizontal-in-vertical composition
3993            .map(|tcu| match tcu {
3994                StyleTextCombineUpright::None => text3::cache::TextCombineUpright::None,
3995                StyleTextCombineUpright::All => text3::cache::TextCombineUpright::All,
3996                StyleTextCombineUpright::Digits(n) => text3::cache::TextCombineUpright::Digits(*n),
3997            })
3998    } else {
3999        None
4000    };
4001
4002    // Get exclusion-margin (CSS Exclusions L1) and shape-margin (CSS Shapes L1)
4003    // for shape exclusions. We sum both into a single margin knob — strictly,
4004    // they apply to different sources (exclusion-margin → CSS Exclusions,
4005    // shape-margin → shape-outside), but the layout solver currently keeps
4006    // a single per-IFC margin value, so the two get added.
4007    let exclusion_margin_base = if dom_declared & DOM_HAS_EXCLUSION_MARGIN != 0 {
4008        styled_dom
4009            .css_property_cache
4010            .ptr
4011            .get_exclusion_margin(node_data, &id, node_state)
4012            .and_then(|s| s.get_property())
4013            .map_or(0.0, |em| em.inner.get())
4014    } else {
4015        0.0
4016    };
4017
4018    let shape_margin = if dom_declared & DOM_HAS_SHAPE_MARGIN != 0 {
4019        styled_dom
4020            .css_property_cache
4021            .ptr
4022            .get_shape_margin(node_data, &id, node_state)
4023            .and_then(|s| s.get_property())
4024            .map_or(0.0, |sm| sm.inner.number.get())
4025    } else {
4026        0.0
4027    };
4028
4029    let exclusion_margin = exclusion_margin_base + shape_margin;
4030
4031    // Get hyphenation-language for language-specific hyphenation
4032    let hyphenation_language = if dom_declared & DOM_HAS_HYPHENATION_LANGUAGE != 0 {
4033        styled_dom
4034            .css_property_cache
4035            .ptr
4036            .get_hyphenation_language(node_data, &id, node_state)
4037            .and_then(|s| s.get_property())
4038            .and_then(|hl| {
4039                #[cfg(feature = "text_layout_hyphenation")]
4040                {
4041                    use hyphenation::{Language, Load};
4042                    // Parse BCP 47 language code to hyphenation::Language
4043                    match hl.inner.as_str() {
4044                        "en-US" | "en" => Some(Language::EnglishUS),
4045                        "de-DE" | "de" => Some(Language::German1996),
4046                        "fr-FR" | "fr" => Some(Language::French),
4047                        "es-ES" | "es" => Some(Language::Spanish),
4048                        "it-IT" | "it" => Some(Language::Italian),
4049                        "pt-PT" | "pt" => Some(Language::Portuguese),
4050                        "nl-NL" | "nl" => Some(Language::Dutch),
4051                        "pl-PL" | "pl" => Some(Language::Polish),
4052                        "ru-RU" | "ru" => Some(Language::Russian),
4053                        "zh-CN" | "zh" => Some(Language::Chinese),
4054                        _ => None, // Unsupported language
4055                    }
4056                }
4057                #[cfg(not(feature = "text_layout_hyphenation"))]
4058                {
4059                    None::<crate::text3::script::Language>
4060                }
4061            })
4062    } else {
4063        None
4064    };
4065
4066    UnifiedConstraints {
4067        exclusion_margin,
4068        hyphenation_language,
4069        text_indent,
4070        text_indent_each_line,
4071        text_indent_hanging,
4072        initial_letter,
4073        line_clamp,
4074        columns,
4075        column_gap,
4076        hanging_punctuation,
4077        text_wrap,
4078        white_space_mode,
4079        text_combine_upright,
4080        segment_alignment: SegmentAlignment::Total,
4081        overflow: match overflow_behaviour {
4082            LayoutOverflow::Visible => text3::cache::OverflowBehavior::Visible,
4083            LayoutOverflow::Hidden | LayoutOverflow::Clip => text3::cache::OverflowBehavior::Hidden,
4084            LayoutOverflow::Scroll => text3::cache::OverflowBehavior::Scroll,
4085            LayoutOverflow::Auto => text3::cache::OverflowBehavior::Auto,
4086        },
4087        // Use the semantic available_width_type directly instead of converting from float.
4088        // This preserves MinContent/MaxContent semantics for intrinsic sizing.
4089        available_width: constraints.available_width_type,
4090        // For scrollable containers (overflow: scroll/auto), don't constrain height
4091        // so that the full content is laid out and content_size is calculated correctly.
4092        available_height: match overflow_behaviour {
4093            LayoutOverflow::Scroll | LayoutOverflow::Auto => None,
4094            _ => Some(constraints.available_size.height),
4095        },
4096        shape_boundaries, // CSS shape-inside: text flows within shape
4097        shape_exclusions, // CSS shape-outside + floats: text wraps around shapes
4098        writing_mode: Some(match writing_mode {
4099            LayoutWritingMode::HorizontalTb => text3::cache::WritingMode::HorizontalTb,
4100            LayoutWritingMode::VerticalRl => text3::cache::WritingMode::VerticalRl,
4101            LayoutWritingMode::VerticalLr => text3::cache::WritingMode::VerticalLr,
4102        }),
4103        direction, // Use the CSS direction property (currently defaulting to LTR)
4104        unicode_bidi: unicode_bidi_val,
4105        // +spec:overflow:7ff7d1 - hyphens property: none/manual/auto hyphenation control
4106        hyphenation: match hyphenation {
4107            StyleHyphens::None => text3::cache::Hyphens::None,
4108            StyleHyphens::Manual => text3::cache::Hyphens::Manual,
4109            StyleHyphens::Auto => text3::cache::Hyphens::Auto,
4110        },
4111        text_orientation,
4112        // +spec:text-alignment-spacing:6cb965 - text-align shorthand sets text-align-all (mapped here from computed value)
4113        // +spec:text-alignment-spacing:838967 - map text-align values (start/end/left/right/center/justify) to inline alignment
4114        // +spec:text-alignment-spacing:d9ea45 - property index: text-align, text-justify, letter-spacing mapped to layout
4115        // +spec:text-alignment-spacing:600fda - text-align values (left/right/center/justify) mapped per CSS Text §6.1
4116        text_align: match text_align {
4117            StyleTextAlign::Start => text3::cache::TextAlign::Start,
4118            StyleTextAlign::End => text3::cache::TextAlign::End,
4119            StyleTextAlign::Left => text3::cache::TextAlign::Left,
4120            StyleTextAlign::Right => text3::cache::TextAlign::Right,
4121            StyleTextAlign::Center => text3::cache::TextAlign::Center,
4122            StyleTextAlign::Justify => text3::cache::TextAlign::Justify,
4123        },
4124        // +spec:text-alignment-spacing:0ea31d - text-justify inter-word/inter-character/distribute mapped per §6.4
4125        // +spec:text-alignment-spacing:01244f - text-justify: none disables justification, auto uses inter-word as universal default
4126        text_justify: match text_justify {
4127            LayoutTextJustify::None => text3::cache::JustifyContent::None,
4128            LayoutTextJustify::Auto | LayoutTextJustify::InterWord => {
4129                text3::cache::JustifyContent::InterWord
4130            }
4131            // distribute computes to inter-character
4132            LayoutTextJustify::InterCharacter | LayoutTextJustify::Distribute => {
4133                text3::cache::JustifyContent::InterCharacter
4134            }
4135        },
4136        // +spec:line-height:79f3aa - line-height resolved: `normal` uses the font's real
4137        // metrics (ascent - descent + line_gap), <number>/<percentage> × font-size.
4138        // When line-height is NOT declared the computed value is `normal`; pass
4139        // LineHeight::Normal through so text3 resolves it against the run's actual
4140        // font metrics (CoreText/Chrome parity) instead of a synthetic 1.2 ratio.
4141        // Negative normalized() = absolute px value (convention from parser for "50px" etc.)
4142        line_height: if dom_declared & DOM_HAS_LINE_HEIGHT == 0 {
4143            text3::cache::LineHeight::Normal
4144        } else {
4145            text3::cache::LineHeight::Px({
4146                let n = line_height_value.inner.normalized();
4147                if n < 0.0 { -n } else { n * font_size }
4148            })
4149        },
4150        // Strut metrics for the container's first available font, approximated as
4151        // 80%/20%/50% of font_size (typical Latin ratios).
4152        // TODO(superplan): use the resolved primary font's real OS/2 metrics
4153        // (`ParsedFontTrait::get_font_metrics` → ascent/descent/x_height scaled by
4154        // units_per_em) and `get_space_width` for `ch_width`. The font is not
4155        // resolved here: picking the element's primary `ParsedFont` requires the
4156        // font-chain machinery in `getters::resolve_font_chains` (font-family →
4157        // fc_cache → loaded font), which isn't threaded into this function. The
4158        // strut only sizes empty / whitespace-only lines — non-empty runs already
4159        // use each run's real font metrics during shaping in text3.
4160        strut_ascent: font_size * 0.8,
4161        strut_descent: font_size * 0.2,
4162        strut_x_height: font_size * 0.5, // 0.5em fallback per CSS Inline 3 Appendix A
4163        ch_width: font_size * 0.5,
4164        vertical_align,
4165        // +spec:inline-formatting-context:48ce44 - overflow-wrap property: break at otherwise disallowed points to prevent overflow
4166        // +spec:line-breaking:bbb5f7 - overflow-wrap: anywhere vs break-word distinction for min-content
4167        overflow_wrap: if word_break_css == StyleWordBreak::BreakWord {
4168            // +spec:line-breaking:815882 - break-word forces overflow-wrap: anywhere
4169            text3::cache::OverflowWrap::Anywhere
4170        } else {
4171            match overflow_wrap_css {
4172                StyleOverflowWrap::Normal => text3::cache::OverflowWrap::Normal,
4173                StyleOverflowWrap::Anywhere => text3::cache::OverflowWrap::Anywhere,
4174                StyleOverflowWrap::BreakWord => text3::cache::OverflowWrap::BreakWord,
4175            }
4176        },
4177        text_align_last: match text_align_last_css {
4178            StyleTextAlignLast::Auto => text3::cache::TextAlign::default(),
4179            StyleTextAlignLast::Start => text3::cache::TextAlign::Start,
4180            StyleTextAlignLast::End => text3::cache::TextAlign::End,
4181            StyleTextAlignLast::Left => text3::cache::TextAlign::Left,
4182            StyleTextAlignLast::Right => text3::cache::TextAlign::Right,
4183            StyleTextAlignLast::Center => text3::cache::TextAlign::Center,
4184            StyleTextAlignLast::Justify => text3::cache::TextAlign::Justify,
4185        },
4186        // +spec:line-breaking:815882 - word-break: break-word => normal + overflow-wrap: anywhere
4187        word_break: match word_break_css {
4188            StyleWordBreak::Normal | StyleWordBreak::BreakWord => text3::cache::WordBreak::Normal,
4189            StyleWordBreak::BreakAll => text3::cache::WordBreak::BreakAll,
4190            StyleWordBreak::KeepAll => text3::cache::WordBreak::KeepAll,
4191        },
4192        // +spec:white-space-processing:bc5f7b - line-break with break-spaces allows breaking before first space
4193        // CSS Text Level 3 §5.3: The line-break property affects preserved white space behavior:
4194        // - normal/pre-line: preserved white space at end/start of line is discarded
4195        // - nowrap/pre: wrapping is forbidden altogether
4196        // - pre-wrap: preserved white space hangs
4197        // - break-spaces: allows breaking before first space of a sequence
4198        // break-spaces allows wrapping preserved spaces to next line; for other white-space values,
4199        // preserved spaces at line ends are either discarded (normal, pre-line), wrapping is
4200        // forbidden (nowrap, pre), or they hang (pre-wrap).
4201        line_break: match line_break_css {
4202            StyleLineBreak::Auto => text3::cache::LineBreakStrictness::Auto,
4203            StyleLineBreak::Loose => text3::cache::LineBreakStrictness::Loose,
4204            StyleLineBreak::Normal => text3::cache::LineBreakStrictness::Normal,
4205            StyleLineBreak::Strict => text3::cache::LineBreakStrictness::Strict,
4206            StyleLineBreak::Anywhere => text3::cache::LineBreakStrictness::Anywhere,
4207        },
4208    }
4209}
4210
4211// Table Formatting Context (CSS 2.2 § 17)
4212// +spec:display-property:d887c0 - Table wrapper box BFC, caption-side, table grid layout (§17.4-17.5)
4213// +spec:positioning:930891 - Table formatting context implementation (CSS 2.2 § 17 introduction)
4214
4215// +spec:inline-formatting-context:9c272d - CSS table model: row-primary structure, display-to-table-element mapping, visual formatting as rectangular grid
4216/// Lays out a Table Formatting Context.
4217/// Table column information for layout calculations
4218#[derive(Copy, Debug, Clone)]
4219pub struct TableColumnInfo {
4220    /// Minimum width required for this column
4221    pub min_width: f32,
4222    /// Maximum width desired for this column
4223    pub max_width: f32,
4224    /// Computed final width for this column
4225    pub computed_width: Option<f32>,
4226}
4227
4228/// Information about a table cell for layout
4229#[derive(Copy, Debug, Clone)]
4230pub struct TableCellInfo {
4231    /// Node index in the layout tree
4232    pub node_index: usize,
4233    /// Column index (0-based)
4234    pub column: usize,
4235    /// Number of columns this cell spans
4236    pub colspan: usize,
4237    /// Row index (0-based)
4238    pub row: usize,
4239    /// Number of rows this cell spans
4240    pub rowspan: usize,
4241}
4242
4243/// Table layout context - holds all information needed for table layout
4244#[derive(Debug)]
4245struct TableLayoutContext {
4246    /// Information about each column
4247    columns: Vec<TableColumnInfo>,
4248    /// Information about each cell
4249    cells: Vec<TableCellInfo>,
4250    /// Number of rows in the table
4251    num_rows: usize,
4252    /// Whether to use fixed or auto layout algorithm
4253    use_fixed_layout: bool,
4254    /// Computed height for each row
4255    row_heights: Vec<f32>,
4256    /// Computed baseline offset for each row (distance from row top to row baseline)
4257    row_baselines: Vec<f32>,
4258    // +spec:inline-formatting-context:440ca9 - border-collapse/border-spacing/visibility:collapse table properties (CSS 2.2 §17.5-17.6)
4259    /// Border collapse mode
4260    border_collapse: StyleBorderCollapse,
4261    /// Border spacing (only used when `border_collapse` is Separate)
4262    border_spacing: LayoutBorderSpacing,
4263    /// CSS 2.2 Section 17.4: Index of table-caption child, if any
4264    caption_index: Option<usize>,
4265    //   from display without forcing table re-layout
4266    /// CSS 2.2 Section 17.6: Rows with visibility:collapse (dynamic effects)
4267    /// Set of row indices that have visibility:collapse
4268    collapsed_rows: std::collections::HashSet<usize>,
4269    /// CSS 2.2 Section 17.6: Columns with visibility:collapse (dynamic effects)
4270    /// Set of column indices that have visibility:collapse
4271    collapsed_columns: std::collections::HashSet<usize>,
4272    /// Rows that are hidden-empty (zero height, border-spacing on only one side)
4273    hidden_empty_rows: std::collections::HashSet<usize>,
4274    /// Layout tree indices for each row (row index → layout node index)
4275    row_node_indices: Vec<usize>,
4276    /// Per-column rowspan occupancy: for column `c`, the number of upcoming rows
4277    /// (including the current one during processing) still covered by a cell that
4278    /// began in an earlier row with rowspan > 1. Decremented after each row.
4279    /// Used so a later row's cells skip columns already taken by a spanning cell.
4280    col_occupied: Vec<usize>,
4281}
4282
4283impl TableLayoutContext {
4284    fn new() -> Self {
4285        Self {
4286            columns: Vec::new(),
4287            cells: Vec::new(),
4288            num_rows: 0,
4289            use_fixed_layout: false,
4290            row_heights: Vec::new(),
4291            row_baselines: Vec::new(),
4292            border_collapse: StyleBorderCollapse::Separate,
4293            border_spacing: LayoutBorderSpacing::default(),
4294            caption_index: None,
4295            collapsed_rows: std::collections::HashSet::new(),
4296            collapsed_columns: std::collections::HashSet::new(),
4297            hidden_empty_rows: std::collections::HashSet::new(),
4298            row_node_indices: Vec::new(),
4299            col_occupied: Vec::new(),
4300        }
4301    }
4302}
4303
4304// +spec:table-layout:485791 - Six superimposed table layers: table, column-group, column, row-group, row, cell (bottom to top)
4305// +spec:table-layout:dcdf1b - Collapsing border model: border conflict resolution uses layer priority (cell > row > row-group > column > column-group > table)
4306/// Source of a border in the border conflict resolution algorithm
4307#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
4308pub enum BorderSource {
4309    Table = 0,
4310    ColumnGroup = 1,
4311    Column = 2,
4312    RowGroup = 3,
4313    Row = 4,
4314    Cell = 5,
4315}
4316
4317/// Information about a border for conflict resolution
4318#[derive(Copy, Debug, Clone)]
4319pub struct BorderInfo {
4320    pub width: f32,
4321    pub style: BorderStyle,
4322    pub color: ColorU,
4323    pub source: BorderSource,
4324}
4325
4326impl BorderInfo {
4327    #[must_use] pub const fn new(width: f32, style: BorderStyle, color: ColorU, source: BorderSource) -> Self {
4328        Self {
4329            width,
4330            style,
4331            color,
4332            source,
4333        }
4334    }
4335
4336    // +spec:block-formatting-context:f772ae - border style priority for table border conflict resolution
4337    /// Get the priority of a border style for conflict resolution
4338    /// Higher number = higher priority
4339    #[must_use] pub const fn style_priority(style: &BorderStyle) -> u8 {
4340        match style {
4341            BorderStyle::Hidden => 255, // Highest - suppresses all borders
4342            BorderStyle::None => 0,     // Lowest - loses to everything
4343            BorderStyle::Double => 8,
4344            BorderStyle::Solid => 7,
4345            BorderStyle::Dashed => 6,
4346            BorderStyle::Dotted => 5,
4347            BorderStyle::Ridge => 4,
4348            BorderStyle::Outset => 3,
4349            BorderStyle::Groove => 2,
4350            BorderStyle::Inset => 1,
4351        }
4352    }
4353
4354    // +spec:box-model:2255c2 - Collapsing border conflict resolution (hidden wins, then none loses, then wider wins, then style priority)
4355    // +spec:box-model:b42c79 - border conflict resolution: hidden wins, then wider, then style priority, then source
4356    // +spec:box-model:503e9e - border conflict resolution: hidden wins, then wider, then style priority, then source priority
4357    // +spec:box-model:7eb217 - Border conflict resolution: hidden > none < wider > style priority > source priority > left/top
4358    // +spec:overflow:1fb482 - Border conflict resolution per CSS 2.2 §17.6.2.1 (hidden wins, then wider, then style priority, then source priority)
4359    // +spec:table-layout:882560 - Border conflict resolution (17.6.2.1): hidden wins, none loses, wider wins, style priority, source priority
4360    /// Compare two borders for conflict resolution per CSS 2.2 Section 17.6.2.1
4361    /// Returns the winning border
4362    // +spec:table-layout:21053b - border conflict resolution: hidden suppresses all, style priorities
4363    // +spec:table-layout:076617 - border conflict resolution algorithm and border style semantics in collapsing model
4364    #[must_use] pub fn resolve_conflict(a: &Self, b: &Self) -> Option<Self> {
4365        // 1. 'hidden' wins and suppresses all borders
4366        if a.style == BorderStyle::Hidden || b.style == BorderStyle::Hidden {
4367            return None;
4368        }
4369
4370        // 2. Filter out 'none' - if both are none, no border
4371        let a_is_none = a.style == BorderStyle::None;
4372        let b_is_none = b.style == BorderStyle::None;
4373
4374        if a_is_none && b_is_none {
4375            return None;
4376        }
4377        if a_is_none {
4378            return Some(*b);
4379        }
4380        if b_is_none {
4381            return Some(*a);
4382        }
4383
4384        // 3. Wider border wins
4385        if a.width > b.width {
4386            return Some(*a);
4387        }
4388        if b.width > a.width {
4389            return Some(*b);
4390        }
4391
4392        // 4. If same width, compare style priority
4393        let a_priority = Self::style_priority(&a.style);
4394        let b_priority = Self::style_priority(&b.style);
4395
4396        if a_priority > b_priority {
4397            return Some(*a);
4398        }
4399        if b_priority > a_priority {
4400            return Some(*b);
4401        }
4402
4403        // 5. If same style, source priority:
4404        // Cell > Row > RowGroup > Column > ColumnGroup > Table
4405        if a.source > b.source {
4406            return Some(*a);
4407        }
4408        if b.source > a.source {
4409            return Some(*b);
4410        }
4411
4412        // 6. Same priority - prefer first one (left/top in LTR)
4413        Some(*a)
4414    }
4415}
4416
4417/// Get border information for a node
4418#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
4419#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4420fn get_border_info<T: ParsedFontTrait>(
4421    ctx: &LayoutContext<'_, T>,
4422    node: &LayoutNodeHot,
4423    source: BorderSource,
4424) -> (BorderInfo, BorderInfo, BorderInfo, BorderInfo) {
4425    use azul_css::props::{
4426        basic::{
4427            pixel::{PhysicalSize, PropertyContext, ResolutionContext},
4428            ColorU,
4429        },
4430        style::BorderStyle,
4431    };
4432    use get_element_font_size;
4433    use get_parent_font_size;
4434    use get_root_font_size;
4435
4436    let default_border = BorderInfo::new(
4437        0.0,
4438        BorderStyle::None,
4439        ColorU {
4440            r: 0,
4441            g: 0,
4442            b: 0,
4443            a: 0,
4444        },
4445        source,
4446    );
4447
4448    let Some(dom_id) = node.dom_node_id else {
4449        return (
4450            default_border,
4451            default_border,
4452            default_border,
4453            default_border,
4454        );
4455    };
4456
4457    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4458    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4459    let cache = &ctx.styled_dom.css_property_cache.ptr;
4460
4461    // FAST PATH: compact cache for normal state
4462    if let Some(ref cc) = cache.compact_cache {
4463        let idx = dom_id.index();
4464
4465        // Border styles from packed u16
4466        let bts = cc.get_border_top_style(idx);
4467        let brs = cc.get_border_right_style(idx);
4468        let bbs = cc.get_border_bottom_style(idx);
4469        let bls = cc.get_border_left_style(idx);
4470
4471        // Border colors from u32 RGBA
4472        let make_color = |raw: u32| -> ColorU {
4473            if raw == 0 {
4474                ColorU { r: 0, g: 0, b: 0, a: 0 }
4475            } else {
4476                ColorU {
4477                    r: ((raw >> 24) & 0xFF) as u8,
4478                    g: ((raw >> 16) & 0xFF) as u8,
4479                    b: ((raw >> 8) & 0xFF) as u8,
4480                    a: (raw & 0xFF) as u8,
4481                }
4482            }
4483        };
4484
4485        let btc = make_color(cc.get_border_top_color_raw(idx));
4486        let brc = make_color(cc.get_border_right_color_raw(idx));
4487        let bbc = make_color(cc.get_border_bottom_color_raw(idx));
4488        let blc = make_color(cc.get_border_left_color_raw(idx));
4489
4490        // Border widths from i16 × 10
4491        let decode_width = |raw: i16| -> f32 {
4492            if raw >= azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
4493                0.0 // sentinel → fall back to 0
4494            } else {
4495                f32::from(raw) / 10.0
4496            }
4497        };
4498
4499        let btw = decode_width(cc.get_border_top_width_raw(idx));
4500        let brw = decode_width(cc.get_border_right_width_raw(idx));
4501        let bbw = decode_width(cc.get_border_bottom_width_raw(idx));
4502        let blw = decode_width(cc.get_border_left_width_raw(idx));
4503
4504        let top = if bts == BorderStyle::None { default_border }
4505            else { BorderInfo::new(btw, bts, btc, source) };
4506        let right = if brs == BorderStyle::None { default_border }
4507            else { BorderInfo::new(brw, brs, brc, source) };
4508        let bottom = if bbs == BorderStyle::None { default_border }
4509            else { BorderInfo::new(bbw, bbs, bbc, source) };
4510        let left = if bls == BorderStyle::None { default_border }
4511            else { BorderInfo::new(blw, bls, blc, source) };
4512
4513        return (top, right, bottom, left);
4514    }
4515
4516    // SLOW PATH: full cascade resolution
4517    let cache = &ctx.styled_dom.css_property_cache.ptr;
4518
4519    // Create resolution context for border-width (em/rem support, no % support)
4520    let element_font_size = get_element_font_size(ctx.styled_dom, dom_id, &node_state);
4521    let parent_font_size = get_parent_font_size(ctx.styled_dom, dom_id, &node_state);
4522    let root_font_size = get_root_font_size(ctx.styled_dom, &node_state);
4523
4524    let resolution_context = ResolutionContext {
4525        element_font_size,
4526        parent_font_size,
4527        root_font_size,
4528        // Not used for border-width
4529        containing_block_size: PhysicalSize::new(0.0, 0.0),
4530        // Not used for border-width
4531        element_size: None,
4532        viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
4533    };
4534
4535    // Top border
4536    let top = cache
4537        .get_border_top_style(node_data, &dom_id, &node_state)
4538        .and_then(|s| s.get_property())
4539        .map_or_else(|| default_border, |style_val| {
4540            let width = cache
4541                .get_border_top_width(node_data, &dom_id, &node_state)
4542                .and_then(|w| w.get_property())
4543                .map_or(0.0, |w| {
4544                    w.inner
4545                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4546                });
4547            let color = cache
4548                .get_border_top_color(node_data, &dom_id, &node_state)
4549                .and_then(|c| c.get_property())
4550                .map_or(ColorU {
4551                    r: 0,
4552                    g: 0,
4553                    b: 0,
4554                    a: 255,
4555                }, |c| c.inner);
4556            BorderInfo::new(width, style_val.inner, color, source)
4557        });
4558
4559    // Right border
4560    let right = cache
4561        .get_border_right_style(node_data, &dom_id, &node_state)
4562        .and_then(|s| s.get_property())
4563        .map_or_else(|| default_border, |style_val| {
4564            let width = cache
4565                .get_border_right_width(node_data, &dom_id, &node_state)
4566                .and_then(|w| w.get_property())
4567                .map_or(0.0, |w| {
4568                    w.inner
4569                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4570                });
4571            let color = cache
4572                .get_border_right_color(node_data, &dom_id, &node_state)
4573                .and_then(|c| c.get_property())
4574                .map_or(ColorU {
4575                    r: 0,
4576                    g: 0,
4577                    b: 0,
4578                    a: 255,
4579                }, |c| c.inner);
4580            BorderInfo::new(width, style_val.inner, color, source)
4581        });
4582
4583    // Bottom border
4584    let bottom = cache
4585        .get_border_bottom_style(node_data, &dom_id, &node_state)
4586        .and_then(|s| s.get_property())
4587        .map_or_else(|| default_border, |style_val| {
4588            let width = cache
4589                .get_border_bottom_width(node_data, &dom_id, &node_state)
4590                .and_then(|w| w.get_property())
4591                .map_or(0.0, |w| {
4592                    w.inner
4593                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4594                });
4595            let color = cache
4596                .get_border_bottom_color(node_data, &dom_id, &node_state)
4597                .and_then(|c| c.get_property())
4598                .map_or(ColorU {
4599                    r: 0,
4600                    g: 0,
4601                    b: 0,
4602                    a: 255,
4603                }, |c| c.inner);
4604            BorderInfo::new(width, style_val.inner, color, source)
4605        });
4606
4607    // Left border
4608    let left = cache
4609        .get_border_left_style(node_data, &dom_id, &node_state)
4610        .and_then(|s| s.get_property())
4611        .map_or_else(|| default_border, |style_val| {
4612            let width = cache
4613                .get_border_left_width(node_data, &dom_id, &node_state)
4614                .and_then(|w| w.get_property())
4615                .map_or(0.0, |w| {
4616                    w.inner
4617                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4618                });
4619            let color = cache
4620                .get_border_left_color(node_data, &dom_id, &node_state)
4621                .and_then(|c| c.get_property())
4622                .map_or(ColorU {
4623                    r: 0,
4624                    g: 0,
4625                    b: 0,
4626                    a: 255,
4627                }, |c| c.inner);
4628            BorderInfo::new(width, style_val.inner, color, source)
4629        });
4630
4631    (top, right, bottom, left)
4632}
4633
4634// +spec:table-layout:c5e446 - table-layout property (auto|fixed) controls layout algorithm selection
4635/// Get the table-layout property for a table node
4636fn get_table_layout_property<T: ParsedFontTrait>(
4637    ctx: &LayoutContext<'_, T>,
4638    node: &LayoutNodeHot,
4639) -> LayoutTableLayout {
4640    let Some(dom_id) = node.dom_node_id else {
4641        return LayoutTableLayout::Auto;
4642    };
4643
4644    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4645    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4646
4647    ctx.styled_dom
4648        .css_property_cache
4649        .ptr
4650        .get_table_layout(node_data, &dom_id, &node_state)
4651        .and_then(|prop| prop.get_property().copied())
4652        .unwrap_or(LayoutTableLayout::Auto)
4653}
4654
4655/// Get the border-collapse property for a table node
4656fn get_border_collapse_property<T: ParsedFontTrait>(
4657    ctx: &LayoutContext<'_, T>,
4658    node: &LayoutNodeHot,
4659) -> StyleBorderCollapse {
4660    let Some(dom_id) = node.dom_node_id else {
4661        return StyleBorderCollapse::Separate;
4662    };
4663
4664    // FAST PATH: compact cache
4665    if let Some(ref cc) = ctx.styled_dom.css_property_cache.ptr.compact_cache {
4666        return cc.get_border_collapse(dom_id.index());
4667    }
4668
4669    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4670    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4671
4672    ctx.styled_dom
4673        .css_property_cache
4674        .ptr
4675        .get_border_collapse(node_data, &dom_id, &node_state)
4676        .and_then(|prop| prop.get_property().copied())
4677        .unwrap_or(StyleBorderCollapse::Separate)
4678}
4679
4680/// Get the border-spacing property for a table node
4681fn get_border_spacing_property<T: ParsedFontTrait>(
4682    ctx: &LayoutContext<'_, T>,
4683    node: &LayoutNodeHot,
4684) -> LayoutBorderSpacing {
4685    if let Some(dom_id) = node.dom_node_id {
4686        // FAST PATH: compact cache
4687        if let Some(ref cc) = ctx.styled_dom.css_property_cache.ptr.compact_cache {
4688            let idx = dom_id.index();
4689            let h_raw = cc.get_border_spacing_h_raw(idx);
4690            let v_raw = cc.get_border_spacing_v_raw(idx);
4691            // If both are non-sentinel, use compact values
4692            if h_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
4693                && v_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
4694            {
4695                return LayoutBorderSpacing::new_separate(
4696                    azul_css::props::basic::pixel::PixelValue::px(f32::from(h_raw) / 10.0),
4697                    azul_css::props::basic::pixel::PixelValue::px(f32::from(v_raw) / 10.0),
4698                );
4699            }
4700            // sentinel → fall through to slow path
4701        }
4702
4703        let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4704        let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4705
4706        if let Some(prop) = ctx.styled_dom.css_property_cache.ptr.get_border_spacing(
4707            node_data,
4708            &dom_id,
4709            &node_state,
4710        ) {
4711            if let Some(value) = prop.get_property() {
4712                return *value;
4713            }
4714        }
4715    }
4716
4717    LayoutBorderSpacing::default() // Default: 0
4718}
4719
4720/// Get the empty-cells property for a table-cell node.
4721/// Returns Show (default) or Hide.
4722fn get_empty_cells_property<T: ParsedFontTrait>(
4723    ctx: &LayoutContext<'_, T>,
4724    node: &LayoutNodeHot,
4725) -> StyleEmptyCells {
4726    let Some(dom_id) = node.dom_node_id else {
4727        return StyleEmptyCells::Show;
4728    };
4729
4730    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4731    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4732
4733    ctx.styled_dom
4734        .css_property_cache
4735        .ptr
4736        .get_empty_cells(node_data, &dom_id, &node_state)
4737        .and_then(|prop| prop.get_property().copied())
4738        .unwrap_or(StyleEmptyCells::Show)
4739}
4740
4741/// CSS 2.2 Section 17.4 - Tables in the visual formatting model:
4742///
4743/// "The caption box is a block box that retains its own content, padding,
4744/// border, and margin areas. The caption-side property specifies the position
4745/// of the caption box with respect to the table box."
4746///
4747/// Get the caption-side property for a table node.
4748/// Returns Top (default) or Bottom.
4749fn get_caption_side_property<T: ParsedFontTrait>(
4750    ctx: &LayoutContext<'_, T>,
4751    node: &LayoutNodeHot,
4752) -> StyleCaptionSide {
4753    if let Some(dom_id) = node.dom_node_id {
4754        let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4755        let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4756
4757        if let Some(prop) =
4758            ctx.styled_dom
4759                .css_property_cache
4760                .ptr
4761                .get_caption_side(node_data, &dom_id, &node_state)
4762        {
4763            if let Some(value) = prop.get_property() {
4764                return *value;
4765            }
4766        }
4767    }
4768
4769    StyleCaptionSide::Top // Default per CSS 2.2
4770}
4771
4772//   removes entire row or column from display; space made available for other content;
4773//   spanned content clipped; does not otherwise affect table layout
4774// +spec:inline-formatting-context:9f5f31 - visibility:collapse for table rows/columns, border-collapse and border-spacing
4775/// CSS 2.2 Section 17.6 - Dynamic row and column effects:
4776///
4777// +spec:box-model:547563 - visibility:collapse removes table rows/columns; elsewhere same as hidden
4778/// "The 'visibility' value 'collapse' removes a row or column from display,
4779/// but it has a different effect than 'visibility: hidden' on other elements.
4780/// When a row or column is collapsed, the space normally occupied by the row
4781/// or column is removed."
4782///
4783/// Check if a node has visibility:collapse set.
4784///
4785/// This is used for table rows and columns to optimize dynamic hiding.
4786/// // +spec:overflow:ebb1f9 - For non-table elements, collapse == hidden (no special handling needed)
4787fn is_visibility_collapsed<T: ParsedFontTrait>(
4788    ctx: &LayoutContext<'_, T>,
4789    node: &LayoutNodeHot,
4790) -> bool {
4791    if let Some(dom_id) = node.dom_node_id {
4792        let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4793
4794        if let MultiValue::Exact(value) = get_visibility(ctx.styled_dom, dom_id, &node_state) {
4795            return matches!(value, StyleVisibility::Collapse);
4796        }
4797    }
4798
4799    false
4800}
4801
4802// +spec:overflow:af97a8 - empty-cells in separated borders model; collapsing border overflow
4803// +spec:table-layout:dcdf1b - empty-cells property controls rendering of borders/backgrounds around empty cells in separated borders model
4804/// CSS 2.2 Section 17.6.1.1 - Borders and Backgrounds around empty cells
4805///
4806/// In the separated borders model, the 'empty-cells' property controls the rendering of
4807/// borders and backgrounds around cells that have no visible content. Empty means it has no
4808/// children, or has children that are only collapsed whitespace."
4809///
4810/// Check if a table cell is empty (has no visible content).
4811///
4812/// This is used by the rendering pipeline to decide whether to paint borders/backgrounds
4813/// when empty-cells: hide is set in separated border model.
4814///
4815//   in-flow content (including empty elements) other than collapsed whitespace
4816/// A cell is considered empty if:
4817///
4818/// - It has no children, OR
4819/// - It has children but no `inline_layout_result` (no rendered content)
4820///
4821/// Note: Full whitespace detection would require checking text content during rendering.
4822/// This function provides a basic check suitable for layout phase.
4823fn is_cell_empty(tree: &LayoutTree, cell_index: usize) -> bool {
4824    if tree.get(cell_index).is_none() {
4825        return true; // Invalid cell is considered empty
4826    }
4827
4828    // No children = empty
4829    if tree.children(cell_index).is_empty() {
4830        return true;
4831    }
4832
4833    // If cell has an inline layout result, check if it's empty
4834    if let Some(warm_node) = tree.warm(cell_index) {
4835        if let Some(ref cached_layout) = warm_node.inline_layout_result {
4836            // Check if inline layout has any rendered content
4837            // Empty inline layouts have no items (glyphs/fragments)
4838            // Note: This is a heuristic - full detection requires text content analysis
4839            return cached_layout.layout.items.is_empty();
4840        }
4841    }
4842
4843    // Check if all children have no content
4844    // A more thorough check would recursively examine all descendants
4845    //
4846    // For now, we use a simple heuristic: if there are children, assume not empty
4847    // unless proven otherwise by inline_layout_result
4848
4849    // Cell with children but no inline layout = likely has block-level content = not empty
4850    false
4851}
4852
4853/// Main function to layout a table formatting context
4854// +spec:table-layout:235e8e - CSS 2.2 §17.1-17.2 table model: fixed/auto algorithms, row/column/cell/caption structure
4855// +spec:table-layout:a6422d - CSS table model: table structure analysis, row/column/cell layout, caption, border-collapse
4856#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
4857#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4858/// # Panics
4859///
4860/// Panics if the table's root node has no associated DOM node id.
4861/// # Errors
4862///
4863/// Returns a `LayoutError` if laying out the table fails.
4864pub fn layout_table_fc<T: ParsedFontTrait>(
4865    ctx: &mut LayoutContext<'_, T>,
4866    tree: &mut LayoutTree,
4867    text_cache: &mut TextLayoutCache,
4868    node_index: usize,
4869    constraints: &LayoutConstraints<'_>,
4870) -> Result<LayoutOutput> {
4871    debug_log!(ctx, "Laying out table");
4872
4873    debug_table_layout!(
4874        ctx,
4875        "node_index={}, available_size={:?}, writing_mode={:?}",
4876        node_index,
4877        constraints.available_size,
4878        constraints.writing_mode
4879    );
4880
4881    // Multi-pass table layout algorithm:
4882    //
4883    // 1. Analyze table structure - identify rows, cells, columns
4884    // 2. Determine table-layout property (fixed vs auto)
4885    // 3. Calculate column widths
4886    // 4. Layout cells and calculate row heights
4887    // 5. Position cells in final grid
4888
4889    // Get the table node to read CSS properties
4890    let table_node = tree
4891        .get(node_index)
4892        .ok_or(LayoutError::InvalidTree)?
4893        .clone();
4894
4895    // Calculate the table's border-box width for column distribution
4896    // This accounts for the table's own width property (e.g., width: 100%)
4897    let table_border_box_width = if let Some(dom_id) = table_node.dom_node_id {
4898        // Use calculate_used_size_for_node to resolve table width (respects width:100%)
4899        let intrinsic = tree.warm(node_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
4900        let containing_block_size = LogicalSize {
4901            width: constraints.available_size.width,
4902            height: constraints.available_size.height,
4903        };
4904
4905        let table_bp = table_node.box_props.unpack();
4906        let table_size = crate::solver3::sizing::calculate_used_size_for_node(
4907            ctx.styled_dom,
4908            Some(dom_id),
4909            &containing_block_size,
4910            intrinsic,
4911            &table_bp,
4912            &ctx.viewport_size,
4913        )?;
4914
4915        table_size.width
4916    } else {
4917        constraints.available_size.width
4918    };
4919
4920    // Subtract padding and border to get content-box width for column distribution
4921    let tbp = table_node.box_props.unpack();
4922    let table_content_box_width = {
4923        let padding_width = tbp.padding.left + tbp.padding.right;
4924        let border_width = tbp.border.left + tbp.border.right;
4925        (table_border_box_width - padding_width - border_width).max(0.0)
4926    };
4927
4928    debug_table_layout!(ctx, "Table Layout Debug");
4929    debug_table_layout!(ctx, "Node index: {}", node_index);
4930    debug_table_layout!(
4931        ctx,
4932        "Available size from parent: {:.2} x {:.2}",
4933        constraints.available_size.width,
4934        constraints.available_size.height
4935    );
4936    debug_table_layout!(ctx, "Table border-box width: {:.2}", table_border_box_width);
4937    debug_table_layout!(
4938        ctx,
4939        "Table content-box width: {:.2}",
4940        table_content_box_width
4941    );
4942    debug_table_layout!(
4943        ctx,
4944        "Table padding: L={:.2} R={:.2}",
4945        tbp.padding.left,
4946        tbp.padding.right
4947    );
4948    debug_table_layout!(
4949        ctx,
4950        "Table border: L={:.2} R={:.2}",
4951        tbp.border.left,
4952        tbp.border.right
4953    );
4954    debug_table_layout!(ctx, "=");
4955
4956    // Phase 1: Analyze table structure
4957    let mut table_ctx = analyze_table_structure(tree, node_index, ctx)?;
4958
4959    // +spec:table-layout:ff5671 - table-layout property (fixed vs auto) controls column width algorithm
4960    // +spec:width-calculation:7a5b23 - table-layout property determines fixed vs auto algorithm (CSS 2.2 §17.5.2)
4961    // Phase 2: Read CSS properties and determine layout algorithm
4962    let table_layout = get_table_layout_property(ctx, &table_node);
4963    table_ctx.use_fixed_layout = matches!(table_layout, LayoutTableLayout::Fixed);
4964
4965    // +spec:containing-block:cc1453 - collapsing border model: border-collapse property drives table border handling
4966    // Read border properties
4967    table_ctx.border_collapse = get_border_collapse_property(ctx, &table_node);
4968    table_ctx.border_spacing = get_border_spacing_property(ctx, &table_node);
4969
4970    debug_log!(
4971        ctx,
4972        "Table layout: {:?}, border-collapse: {:?}, border-spacing: {:?}",
4973        table_layout,
4974        table_ctx.border_collapse,
4975        table_ctx.border_spacing
4976    );
4977
4978    // +spec:width-calculation:431d60 - fixed vs auto table layout column width algorithms (CSS 2.2 §17.5.2.1, §17.5.2.2)
4979    // Phase 3: Calculate column widths
4980    if table_ctx.use_fixed_layout {
4981        // DEBUG: Log available width passed into fixed column calculation
4982        debug_table_layout!(
4983            ctx,
4984            "FIXED layout: table_content_box_width={:.2}",
4985            table_content_box_width
4986        );
4987        calculate_column_widths_fixed(ctx, tree, &mut table_ctx, table_content_box_width);
4988    } else {
4989        // Pass table_content_box_width for column distribution in auto layout
4990        calculate_column_widths_auto_with_width(
4991            &mut table_ctx,
4992            tree,
4993            text_cache,
4994            ctx,
4995            constraints,
4996            table_content_box_width,
4997        )?;
4998    }
4999
5000    debug_table_layout!(ctx, "After column width calculation:");
5001    debug_table_layout!(ctx, "  Number of columns: {}", table_ctx.columns.len());
5002    for (i, col) in table_ctx.columns.iter().enumerate() {
5003        debug_table_layout!(
5004            ctx,
5005            "  Column {}: width={:.2}",
5006            i,
5007            col.computed_width.unwrap_or(0.0)
5008        );
5009    }
5010    let total_col_width: f32 = table_ctx
5011        .columns
5012        .iter()
5013        .filter_map(|c| c.computed_width)
5014        .sum();
5015    debug_table_layout!(ctx, "  Total column width: {:.2}", total_col_width);
5016
5017    // Phase 4: Calculate row heights based on cell content
5018    calculate_row_heights(&mut table_ctx, tree, text_cache, ctx, constraints)?;
5019
5020    // Phase 5: Position cells in final grid and collect positions
5021    let mut cell_positions =
5022        position_table_cells(&table_ctx, tree, ctx, node_index, constraints)?;
5023
5024    // Calculate final table size including border-spacing
5025    let mut table_width: f32 = table_ctx
5026        .columns
5027        .iter()
5028        .filter_map(|col| col.computed_width)
5029        .sum();
5030    let mut table_height: f32 = table_ctx.row_heights.iter().sum();
5031
5032    debug_table_layout!(
5033        ctx,
5034        "After calculate_row_heights: table_height={:.2}, row_heights={:?}",
5035        table_height,
5036        table_ctx.row_heights
5037    );
5038
5039    // +spec:box-model:494f6b - collapsing border model: row-width formula and table border width computation
5040    // +spec:box-model:e7d0a3 - Separated borders model: border-spacing, empty-cells, collapsing border width calculation
5041    // +spec:box-sizing:ee702c - separated borders model: border-spacing between adjoining cells
5042    // Add border-spacing to table size if border-collapse is separate
5043    // +spec:box-model:acb81f - separated borders model: border-spacing between adjoining cell borders
5044    // +spec:box-model:e480b1 - table width = left inner padding edge to right inner padding edge (including border-spacing)
5045    if table_ctx.border_collapse == StyleBorderCollapse::Separate {
5046        use get_element_font_size;
5047        use get_parent_font_size;
5048        use get_root_font_size;
5049        use PhysicalSize;
5050        use PropertyContext;
5051        use ResolutionContext;
5052
5053        let styled_dom = ctx.styled_dom;
5054        // Anonymous table wrapper boxes have no dom_node_id; without a styled
5055        // node we cannot resolve font-relative border-spacing units, so fall
5056        // back to zero spacing rather than panicking.
5057        let (h_spacing, v_spacing) = if let Some(table_id) = tree.nodes[node_index].dom_node_id {
5058            let table_state = &styled_dom.styled_nodes.as_container()[table_id].styled_node_state;
5059
5060            let spacing_context = ResolutionContext {
5061                element_font_size: get_element_font_size(styled_dom, table_id, table_state),
5062                parent_font_size: get_parent_font_size(styled_dom, table_id, table_state),
5063                root_font_size: get_root_font_size(styled_dom, table_state),
5064                containing_block_size: PhysicalSize::new(0.0, 0.0),
5065                element_size: None,
5066                viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
5067            };
5068
5069            let h_spacing = table_ctx
5070                .border_spacing
5071                .horizontal
5072                .resolve_with_context(&spacing_context, PropertyContext::Other)
5073                .max(0.0);
5074            let v_spacing = table_ctx
5075                .border_spacing
5076                .vertical
5077                .resolve_with_context(&spacing_context, PropertyContext::Other)
5078                .max(0.0);
5079            (h_spacing, v_spacing)
5080        } else {
5081            (0.0f32, 0.0f32)
5082        };
5083
5084        // Add spacing: left + (n-1 between columns) + right = n+1 spacings
5085        let num_cols = table_ctx.columns.len();
5086        if num_cols > 0 {
5087            table_width += h_spacing * (num_cols + 1) as f32;
5088        }
5089
5090        // Add spacing: top + (n-1 between rows) + bottom = n+1 spacings
5091        if table_ctx.num_rows > 0 {
5092            let full_spacings = (table_ctx.num_rows + 1) as f32;
5093            // Each hidden-empty row loses one side of border-spacing
5094            let hidden_empty_count = table_ctx.hidden_empty_rows.len() as f32;
5095            table_height += v_spacing * (full_spacings - hidden_empty_count);
5096        }
5097    }
5098
5099    // +spec:table-layout:24dbf9 - §17.4 table wrapper box model: caption positioning, BFC establishment
5100    // +spec:width-calculation:600f98 - caption-side positions caption above/below table box (CSS 2.2 §17.4)
5101    // CSS 2.2 Section 17.4: Layout and position the caption if present
5102    //
5103    // "The caption box is a block box that retains its own content,
5104    // padding, border, and margin areas."
5105    let caption_side = get_caption_side_property(ctx, &table_node);
5106    let mut caption_height = 0.0;
5107    let mut table_y_offset = 0.0;
5108
5109    if let Some(caption_idx) = table_ctx.caption_index {
5110        debug_log!(
5111            ctx,
5112            "Laying out caption with caption-side: {:?}",
5113            caption_side
5114        );
5115
5116        // Layout caption as a block with the table's width as available width
5117        let caption_constraints = LayoutConstraints {
5118            available_size: LogicalSize {
5119                width: table_width,
5120                height: constraints.available_size.height,
5121            },
5122            writing_mode: constraints.writing_mode,
5123            writing_mode_ctx: constraints.writing_mode_ctx,
5124            bfc_state: None, // Caption creates its own BFC
5125            text_align: constraints.text_align,
5126            containing_block_size: constraints.containing_block_size,
5127            available_width_type: Text3AvailableSpace::Definite(table_width),
5128        };
5129
5130        // Layout the caption node
5131        let mut empty_float_cache = HashMap::new();
5132        let caption_result = layout_formatting_context(
5133            ctx,
5134            tree,
5135            text_cache,
5136            caption_idx,
5137            &caption_constraints,
5138            &mut empty_float_cache,
5139        )?;
5140        caption_height = caption_result.output.overflow_size.height;
5141
5142        let caption_position = match caption_side {
5143            StyleCaptionSide::Top => {
5144                // Caption on top: position at y=0, table starts below caption
5145                table_y_offset = caption_height;
5146                LogicalPosition { x: 0.0, y: 0.0 }
5147            }
5148            StyleCaptionSide::Bottom => {
5149                // Caption on bottom: table starts at y=0, caption below table
5150                LogicalPosition {
5151                    x: 0.0,
5152                    y: table_height,
5153                }
5154            }
5155        };
5156
5157        // Add caption position to the positions map
5158        cell_positions.insert(caption_idx, caption_position);
5159
5160        debug_log!(
5161            ctx,
5162            "Caption positioned at x={:.2}, y={:.2}, height={:.2}",
5163            caption_position.x,
5164            caption_position.y,
5165            caption_height
5166        );
5167    }
5168
5169    // Adjust all table cell positions if caption is on top
5170    if table_y_offset > 0.0 {
5171        debug_log!(
5172            ctx,
5173            "Adjusting table cells by y offset: {:.2}",
5174            table_y_offset
5175        );
5176
5177        // Adjust cell positions in the map
5178        for cell_info in &table_ctx.cells {
5179            if let Some(pos) = cell_positions.get_mut(&cell_info.node_index) {
5180                pos.y += table_y_offset;
5181            }
5182        }
5183    }
5184
5185    let total_height = table_height + caption_height;
5186
5187    debug_table_layout!(ctx, "Final table dimensions:");
5188    debug_table_layout!(ctx, "  Content width (columns): {:.2}", table_width);
5189    debug_table_layout!(ctx, "  Content height (rows): {:.2}", table_height);
5190    debug_table_layout!(ctx, "  Caption height: {:.2}", caption_height);
5191    debug_table_layout!(ctx, "  Total height: {:.2}", total_height);
5192    debug_table_layout!(ctx, "End Table Debug");
5193
5194    // CSS 2.2 §10.8.1: the baseline of a table is the baseline of its first
5195    // in-flow row — used when the table is an `inline-table` aligned on a line.
5196    // `row_baselines[0]` is that row's baseline measured from the row's top;
5197    // every row-0 cell shares the row top, so add any row-0 cell's (already
5198    // caption-adjusted) y position. Falls back to `None` for an empty table,
5199    // where the caller treats the bottom content edge as the baseline.
5200    // TODO(superplan): a rowspan cell that *starts* in row 0 but whose content
5201    // baseline sits in a later row is approximated by `row_baselines[0]` here.
5202    let table_baseline = table_ctx
5203        .row_baselines
5204        .first()
5205        .copied()
5206        .and_then(|row0_baseline| {
5207            table_ctx
5208                .cells
5209                .iter()
5210                .find(|c| c.row == 0)
5211                .and_then(|c| cell_positions.get(&c.node_index))
5212                .map(|pos| pos.y + row0_baseline)
5213        });
5214
5215    // Create output with the table's final size and cell positions
5216    // +spec:box-model:52fcfe - overflow_size must include borders that spill into margin in collapsing border model
5217    let output = LayoutOutput {
5218        overflow_size: LogicalSize {
5219            width: table_width,
5220            height: total_height,
5221        },
5222        // Cell positions calculated in position_table_cells
5223        positions: cell_positions,
5224        // First in-flow row's baseline (CSS 2.2 §10.8.1); None ⇒ bottom edge.
5225        baseline: table_baseline,
5226    };
5227
5228    Ok(output)
5229}
5230
5231// +spec:display-property:f47f8a - Table structure analysis: caption positioning, row/column/row-group traversal per CSS 2.2 §17.4-17.5
5232/// Analyze the table structure to identify rows, cells, and columns
5233fn analyze_table_structure<T: ParsedFontTrait>(
5234    tree: &LayoutTree,
5235    table_index: usize,
5236    ctx: &mut LayoutContext<'_, T>,
5237) -> Result<TableLayoutContext> {
5238    let mut table_ctx = TableLayoutContext::new();
5239
5240    let table_node = tree.get(table_index).ok_or(LayoutError::InvalidTree)?;
5241
5242    // +spec:width-calculation:0a2766 - table internal elements form rectangular grid of rows/columns (CSS 2.2 §17.5)
5243    // CSS 2.2 Section 17.4: A table may have one table-caption child.
5244    // Traverse children to find caption, columns/colgroups, rows, and row groups
5245    for &child_idx in tree.children(table_index) {
5246        if let Some(child) = tree.get(child_idx) {
5247            // Check if this is a table caption
5248            if matches!(child.formatting_context, FormattingContext::TableCaption) {
5249                debug_log!(ctx, "Found table caption at index {}", child_idx);
5250                table_ctx.caption_index = Some(child_idx);
5251                continue;
5252            }
5253
5254            // CSS 2.2 Section 17.2: Check for column groups
5255            if matches!(
5256                child.formatting_context,
5257                FormattingContext::TableColumnGroup
5258            ) {
5259                analyze_table_colgroup(tree, child_idx, &table_ctx, ctx)?;
5260                continue;
5261            }
5262
5263            // Check if this is a table row or row group
5264            match child.formatting_context {
5265                FormattingContext::TableRow => {
5266                    analyze_table_row(tree, child_idx, &mut table_ctx, ctx)?;
5267                }
5268                FormattingContext::TableRowGroup => {
5269                    // Process rows within the row group
5270                    for &row_idx in tree.children(child_idx) {
5271                        if let Some(row) = tree.get(row_idx) {
5272                            if matches!(row.formatting_context, FormattingContext::TableRow) {
5273                                analyze_table_row(tree, row_idx, &mut table_ctx, ctx)?;
5274                            }
5275                        }
5276                    }
5277                }
5278                _ => {}
5279            }
5280        }
5281    }
5282
5283    debug_log!(
5284        ctx,
5285        "Table structure: {} rows, {} columns, {} cells{}",
5286        table_ctx.num_rows,
5287        table_ctx.columns.len(),
5288        table_ctx.cells.len(),
5289        if table_ctx.caption_index.is_some() {
5290            ", has caption"
5291        } else {
5292            ""
5293        }
5294    );
5295
5296    Ok(table_ctx)
5297}
5298
5299/// Analyze a table column group to identify columns and track collapsed columns
5300///
5301/// - CSS 2.2 Section 17.2: Column groups contain columns
5302/// - CSS 2.2 Section 17.6: Columns can have visibility:collapse
5303fn analyze_table_colgroup<T: ParsedFontTrait>(
5304    tree: &LayoutTree,
5305    colgroup_index: usize,
5306    table_ctx: &TableLayoutContext,
5307    ctx: &mut LayoutContext<'_, T>,
5308) -> Result<()> {
5309    let colgroup_node = tree.get(colgroup_index).ok_or(LayoutError::InvalidTree)?;
5310
5311    // Check if the colgroup itself has visibility:collapse
5312    if is_visibility_collapsed(ctx, colgroup_node) {
5313        // All columns in this group should be collapsed
5314        // TODO: For now, just mark the group (actual column indices will be determined later)
5315        debug_log!(
5316            ctx,
5317            "Column group at index {} has visibility:collapse",
5318            colgroup_index
5319        );
5320    }
5321
5322    // Check for individual column elements within the group
5323    for &col_idx in tree.children(colgroup_index) {
5324        if let Some(col_node) = tree.get(col_idx) {
5325            // Note: Individual columns don't have a FormattingContext::TableColumn
5326            // They are represented as children of TableColumnGroup
5327            // Check visibility:collapse on each column
5328            if is_visibility_collapsed(ctx, col_node) {
5329                // We need to determine the actual column index this represents
5330                // For now, we'll track it during cell analysis
5331                debug_log!(ctx, "Column at index {} has visibility:collapse", col_idx);
5332            }
5333        }
5334    }
5335
5336    Ok(())
5337}
5338
5339/// Read the HTML `colspan` / `rowspan` of a table cell from its DOM node.
5340///
5341/// These are HTML presentational attributes (`AttributeType::ColSpan`/`RowSpan`
5342/// on `NodeData`), not CSS properties. Missing or non-positive values default to
5343/// 1 per the HTML parsing rules.
5344#[allow(clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5345fn get_cell_spans(styled_dom: &StyledDom, dom_id: NodeId) -> (usize, usize) {
5346    let mut colspan = 1usize;
5347    let mut rowspan = 1usize;
5348    let node_data = &styled_dom.node_data.as_container()[dom_id];
5349    for attr in node_data.attributes().as_ref() {
5350        match attr {
5351            // Clamp to the HTML limits (colspan 1000, rowspan 65534): an
5352            // unclamped span grows the column/row vectors unboundedly -> OOM/hang.
5353            azul_core::dom::AttributeType::ColSpan(n) => colspan = (*n).clamp(1, 1000) as usize,
5354            azul_core::dom::AttributeType::RowSpan(n) => rowspan = (*n).clamp(1, 65534) as usize,
5355            _ => {}
5356        }
5357    }
5358    (colspan, rowspan)
5359}
5360
5361// +spec:display-property:7f167c - Table grid cell placement: rows fill table top-to-bottom, cells placed left-to-right with colspan/rowspan
5362/// Analyze a table row to identify cells and update column count
5363fn analyze_table_row<T: ParsedFontTrait>(
5364    tree: &LayoutTree,
5365    row_index: usize,
5366    table_ctx: &mut TableLayoutContext,
5367    ctx: &mut LayoutContext<'_, T>,
5368) -> Result<()> {
5369    // +spec:inline-formatting-context:3f8091 - table visual layout: cells occupy grid cells, row/column spanning
5370    let row_node = tree.get(row_index).ok_or(LayoutError::InvalidTree)?;
5371    let row_num = table_ctx.num_rows;
5372    table_ctx.num_rows += 1;
5373    // Track the layout tree index for this row (for positioning/painting)
5374    if table_ctx.row_node_indices.len() <= row_num {
5375        table_ctx.row_node_indices.resize(row_num + 1, 0);
5376    }
5377    table_ctx.row_node_indices[row_num] = row_index;
5378
5379    // CSS 2.2 Section 17.6: Check if this row has visibility:collapse
5380    if is_visibility_collapsed(ctx, row_node) {
5381        debug_log!(ctx, "Row {} has visibility:collapse", row_num);
5382        table_ctx.collapsed_rows.insert(row_num);
5383    }
5384
5385    let mut col_index = 0;
5386
5387    for &cell_idx in tree.children(row_index) {
5388        if let Some(cell) = tree.get(cell_idx) {
5389            if matches!(cell.formatting_context, FormattingContext::TableCell) {
5390                // Read colspan/rowspan from the cell's HTML attributes (default 1).
5391                let (colspan, rowspan) = cell
5392                    .dom_node_id
5393                    .map_or((1, 1), |dom_id| get_cell_spans(ctx.styled_dom, dom_id));
5394
5395                // Skip columns still occupied by a rowspan cell from an earlier row,
5396                // so this cell lands in the next free grid slot (CSS 2.2 §17.5 cell
5397                // placement). Without this, a cell under a rowspan overlapped it.
5398                while table_ctx
5399                    .col_occupied
5400                    .get(col_index)
5401                    .is_some_and(|&n| n > 0)
5402                {
5403                    col_index += 1;
5404                }
5405
5406                let cell_info = TableCellInfo {
5407                    node_index: cell_idx,
5408                    column: col_index,
5409                    colspan,
5410                    row: row_num,
5411                    rowspan,
5412                };
5413
5414                table_ctx.cells.push(cell_info);
5415
5416                // Update column count
5417                let max_col = col_index + colspan;
5418                while table_ctx.columns.len() < max_col {
5419                    table_ctx.columns.push(TableColumnInfo {
5420                        min_width: 0.0,
5421                        max_width: 0.0,
5422                        computed_width: None,
5423                    });
5424                }
5425
5426                // Reserve this cell's columns for the rows it spans downward. Store
5427                // the full rowspan; the end-of-row decrement below turns it into the
5428                // count of REMAINING rows for subsequent rows to skip.
5429                if rowspan > 1 {
5430                    if table_ctx.col_occupied.len() < max_col {
5431                        table_ctx.col_occupied.resize(max_col, 0);
5432                    }
5433                    for occ in &mut table_ctx.col_occupied[col_index..max_col] {
5434                        *occ = rowspan;
5435                    }
5436                }
5437
5438                col_index += colspan;
5439            }
5440        }
5441    }
5442
5443    // End of row: one row of every pending rowspan has now been consumed.
5444    for occ in &mut table_ctx.col_occupied {
5445        *occ = occ.saturating_sub(1);
5446    }
5447
5448    Ok(())
5449}
5450
5451// +spec:overflow:66f584 - Fixed table layout: cells use overflow property to clip overflowing content
5452// +spec:positioning:46070a - Fixed table layout (17.5.2.1) and auto table layout (17.5.2.2) column width algorithms
5453// +spec:table-layout:875401 - Fixed table layout algorithm (17.5.2.1): column widths from first-row cells, remaining columns divide space equally, table width = max(width property, sum of columns)
5454/// Calculate column widths using the fixed table layout algorithm
5455/// // +spec:overflow:de613c - Fixed table layout algorithm (CSS 2.2 Section 17.5.2.1)
5456// +spec:table-layout:8b72b3 - fixed table layout: column width from column elements/first-row cells, remaining columns equal division
5457///
5458/// CSS 2.2 Section 17.5.2.1: In fixed table layout, the horizontal layout
5459/// does not depend on cell contents. Column widths are determined by:
5460/// 1. Column elements with explicit (non-auto) width
5461/// 2. First-row cells with explicit (non-auto) width
5462/// 3. Remaining columns equally divide remaining horizontal space
5463///
5464/// CSS 2.2 Section 17.6: Columns with visibility:collapse are excluded
5465/// from width calculations
5466// +spec:table-layout:c5e446 - Fixed table layout algorithm: column widths from col elements or first-row cells, remaining columns divide equally
5467/// +spec:width-calculation:8c958a - Fixed table layout: column widths from col elements, first-row cells, then equal distribution (CSS 2.2 §17.5.2.1)
5468#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5469#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5470fn calculate_column_widths_fixed<T: ParsedFontTrait>(
5471    ctx: &mut LayoutContext<'_, T>,
5472    tree: &LayoutTree,
5473    table_ctx: &mut TableLayoutContext,
5474    available_width: f32,
5475) {
5476    debug_table_layout!(
5477        ctx,
5478        "calculate_column_widths_fixed: num_cols={}, available_width={:.2}",
5479        table_ctx.columns.len(),
5480        available_width
5481    );
5482
5483    let num_cols = table_ctx.columns.len();
5484    if num_cols == 0 {
5485        return;
5486    }
5487
5488    let num_visible_cols = num_cols - table_ctx.collapsed_columns.len();
5489    if num_visible_cols == 0 {
5490        for col in &mut table_ctx.columns {
5491            col.computed_width = Some(0.0);
5492        }
5493        return;
5494    }
5495
5496    // Step 1 (column elements) is skipped because column elements don't store
5497    // explicit widths in the current table structure analysis.
5498    // Step 2: Check first-row cells for explicit width properties.
5499    let mut col_has_width = vec![false; num_cols];
5500
5501    for cell_info in &table_ctx.cells {
5502        if cell_info.row != 0 {
5503            continue; // Only consider cells in the first row
5504        }
5505        if table_ctx.collapsed_columns.contains(&cell_info.column) {
5506            continue;
5507        }
5508
5509        // Look up the cell's CSS width via its dom_node_id
5510        let Some(dom_id) = tree.get(cell_info.node_index).and_then(|n| n.dom_node_id) else {
5511            continue;
5512        };
5513
5514        let node_state = &ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
5515        let css_width = get_css_width(ctx.styled_dom, dom_id, node_state);
5516
5517        let explicit_px = match css_width.unwrap_or_default() {
5518            LayoutWidth::Px(px) => {
5519                resolve_size_metric(
5520                    px.metric,
5521                    px.number.get(),
5522                    available_width,
5523                    ctx.viewport_size,
5524                    get_element_font_size(ctx.styled_dom, dom_id, node_state),
5525                    get_root_font_size(ctx.styled_dom, node_state),
5526                )
5527            }
5528            LayoutWidth::Auto | LayoutWidth::MinContent | LayoutWidth::MaxContent
5529            | LayoutWidth::Calc(_) | LayoutWidth::FitContent(_) => continue,
5530        };
5531
5532        if cell_info.colspan == 1 {
5533            table_ctx.columns[cell_info.column].computed_width = Some(explicit_px);
5534            col_has_width[cell_info.column] = true;
5535        } else {
5536            let mut visible_span_count = 0;
5537            for offset in 0..cell_info.colspan {
5538                let col_idx = cell_info.column + offset;
5539                if col_idx < num_cols && !table_ctx.collapsed_columns.contains(&col_idx) {
5540                    visible_span_count += 1;
5541                }
5542            }
5543            if visible_span_count > 0 {
5544                let per_col = explicit_px / visible_span_count as f32;
5545                for offset in 0..cell_info.colspan {
5546                    let col_idx = cell_info.column + offset;
5547                    if col_idx < num_cols
5548                        && !table_ctx.collapsed_columns.contains(&col_idx)
5549                        && !col_has_width[col_idx]
5550                    {
5551                        table_ctx.columns[col_idx].computed_width = Some(per_col);
5552                        col_has_width[col_idx] = true;
5553                    }
5554                }
5555            }
5556        }
5557    }
5558
5559    let used_width: f32 = table_ctx.columns.iter().enumerate()
5560        .filter(|(idx, _)| col_has_width[*idx] && !table_ctx.collapsed_columns.contains(idx))
5561        .filter_map(|(_, c)| c.computed_width)
5562        .sum();
5563    let remaining_width = (available_width - used_width).max(0.0);
5564    let num_remaining = table_ctx.columns.iter().enumerate()
5565        .filter(|(idx, _)| !col_has_width[*idx] && !table_ctx.collapsed_columns.contains(idx))
5566        .count();
5567
5568    if num_remaining > 0 {
5569        let width_per_remaining = remaining_width / num_remaining as f32;
5570        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5571            if table_ctx.collapsed_columns.contains(&col_idx) {
5572                col.computed_width = Some(0.0);
5573            } else if !col_has_width[col_idx] {
5574                col.computed_width = Some(width_per_remaining);
5575            }
5576        }
5577    }
5578
5579    // Set collapsed columns to zero width
5580    for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5581        if table_ctx.collapsed_columns.contains(&col_idx) {
5582            col.computed_width = Some(0.0);
5583        }
5584    }
5585
5586    let total_col_width: f32 = table_ctx.columns.iter()
5587        .filter_map(|c| c.computed_width)
5588        .sum();
5589    if available_width > total_col_width && num_visible_cols > 0 {
5590        let extra = available_width - total_col_width;
5591        let extra_per_col = extra / num_visible_cols as f32;
5592        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5593            if !table_ctx.collapsed_columns.contains(&col_idx) {
5594                if let Some(ref mut w) = col.computed_width {
5595                    *w += extra_per_col;
5596                }
5597            }
5598        }
5599    }
5600}
5601
5602/// Recursively clear the layout cache for every node in a subtree.
5603///
5604/// A fixed-depth walk is not enough: a table cell like
5605/// `<td><span><a>text</a></span></td>` has 4+ levels once the anonymous IFC
5606/// wrapper is inserted, and any stale cache below that level would feed a
5607/// narrow intrinsic width back into `measure_cell_content_width`.
5608fn clear_subtree_cache(
5609    tree: &LayoutTree,
5610    cache_map: &mut crate::solver3::cache::LayoutCacheMap,
5611    root: usize,
5612) {
5613    if root < cache_map.entries.len() {
5614        cache_map.entries[root].clear();
5615    }
5616    let child_ids: Vec<usize> = tree.children(root).to_vec();
5617    for child in child_ids {
5618        clear_subtree_cache(tree, cache_map, child);
5619    }
5620}
5621
5622/// Measure a cell's content width for a given intrinsic sizing mode.
5623///
5624/// CSS 2.2 Section 17.5.2.2: shared helper for min-content and max-content
5625/// width measurement. Lays out the cell subtree in `ComputeSize` mode and
5626/// returns the border-box width (content + padding + border).
5627fn measure_cell_content_width<T: ParsedFontTrait>(
5628    ctx: &mut LayoutContext<'_, T>,
5629    tree: &mut LayoutTree,
5630    text_cache: &mut TextLayoutCache,
5631    cell_index: usize,
5632    constraints: &LayoutConstraints<'_>,
5633    sizing_mode: text3::cache::AvailableSpace,
5634) -> Result<f32> {
5635    let width_type = match sizing_mode {
5636        text3::cache::AvailableSpace::MinContent => Text3AvailableSpace::MinContent,
5637        text3::cache::AvailableSpace::MaxContent => Text3AvailableSpace::MaxContent,
5638        text3::cache::AvailableSpace::Definite(w) => Text3AvailableSpace::Definite(w),
5639    };
5640    let cell_constraints = LayoutConstraints {
5641        available_size: LogicalSize {
5642            width: sizing_mode.to_f32_for_layout(),
5643            height: f32::INFINITY,
5644        },
5645        writing_mode: constraints.writing_mode,
5646        writing_mode_ctx: constraints.writing_mode_ctx,
5647        bfc_state: None,
5648        text_align: constraints.text_align,
5649        containing_block_size: constraints.containing_block_size,
5650        available_width_type: width_type,
5651    };
5652
5653    let mut temp_positions: super::PositionVec = Vec::new();
5654    let mut temp_scrollbar_reflow = false;
5655    let mut temp_float_cache = HashMap::new();
5656
5657    // Clear cached layout for this cell and ALL its descendants so that
5658    // min/max-content measurement uses unconstrained width, not a stale
5659    // result from a previous pass with narrower constraints. Deeply nested
5660    // inlines (`<td><span><a>text</a></span></td>`) need recursion; a fixed
5661    // 2-level walk left the `<a>` at level 3 with a stale cached 0-width.
5662    clear_subtree_cache(tree, &mut ctx.cache_map, cell_index);
5663
5664    crate::solver3::cache::calculate_layout_for_subtree(
5665        ctx,
5666        tree,
5667        text_cache,
5668        cell_index,
5669        LogicalPosition::zero(),
5670        cell_constraints.available_size,
5671        &mut temp_positions,
5672        &mut temp_scrollbar_reflow,
5673        &mut temp_float_cache,
5674        crate::solver3::cache::ComputeMode::ComputeSize,
5675    )?;
5676
5677    let cell_bp = tree.get(cell_index)
5678        .ok_or(LayoutError::InvalidTree)?
5679        .box_props.unpack();
5680    let padding = &cell_bp.padding;
5681    let border = &cell_bp.border;
5682    let wm = constraints.writing_mode;
5683
5684    // For min/max-content measurement, use the overflow content size (actual
5685    // content width) rather than used_size. used_size for auto-width blocks
5686    // fills the containing block, which is huge (f32::MAX/2) during
5687    // intrinsic sizing — that would make every column appear infinitely wide.
5688    let content_width = tree.warm(cell_index)
5689        .and_then(|w| w.overflow_content_size)
5690        .map_or_else(|| {
5691            tree.get(cell_index)
5692                .and_then(|n| n.used_size)
5693                .map_or(0.0, |s| s.width)
5694        }, |s| s.width);
5695
5696    Ok(content_width
5697        + padding.cross_start(wm) + padding.cross_end(wm)
5698        + border.cross_start(wm) + border.cross_end(wm))
5699}
5700
5701/// Measure a cell's minimum content width (with maximum wrapping)
5702fn measure_cell_min_content_width<T: ParsedFontTrait>(
5703    ctx: &mut LayoutContext<'_, T>,
5704    tree: &mut LayoutTree,
5705    text_cache: &mut TextLayoutCache,
5706    cell_index: usize,
5707    constraints: &LayoutConstraints<'_>,
5708) -> Result<f32> {
5709    measure_cell_content_width(
5710        ctx, tree, text_cache, cell_index, constraints,
5711        text3::cache::AvailableSpace::MinContent,
5712    )
5713}
5714
5715/// Measure a cell's maximum content width (without wrapping)
5716fn measure_cell_max_content_width<T: ParsedFontTrait>(
5717    ctx: &mut LayoutContext<'_, T>,
5718    tree: &mut LayoutTree,
5719    text_cache: &mut TextLayoutCache,
5720    cell_index: usize,
5721    constraints: &LayoutConstraints<'_>,
5722) -> Result<f32> {
5723    measure_cell_content_width(
5724        ctx, tree, text_cache, cell_index, constraints,
5725        text3::cache::AvailableSpace::MaxContent,
5726    )
5727}
5728
5729/// Calculate column widths using the auto table layout algorithm
5730fn calculate_column_widths_auto<T: ParsedFontTrait>(
5731    table_ctx: &mut TableLayoutContext,
5732    tree: &mut LayoutTree,
5733    text_cache: &mut TextLayoutCache,
5734    ctx: &mut LayoutContext<'_, T>,
5735    constraints: &LayoutConstraints<'_>,
5736) -> Result<()> {
5737    calculate_column_widths_auto_with_width(
5738        table_ctx,
5739        tree,
5740        text_cache,
5741        ctx,
5742        constraints,
5743        constraints.available_size.width,
5744    )
5745}
5746
5747/// Calculate column widths using the auto table layout algorithm with explicit table width
5748// +spec:display-property:05c8e8 - CSS 2.2 §17.5.2.2 automatic table layout: column min/max widths, table width = max(W or CB, CAPMIN, MIN), extra width distributed over columns
5749/// +spec:overflow:29edde - CSS 2.2 §17.5.2.2 automatic table layout: MCW/max-content per cell, column min/max, colspan distribution, final width determination
5750// +spec:table-layout:23a215 - automatic table layout: MCW/max cell widths, column min/max, colspan distribution, table width from MAX/MIN/CAPMIN
5751// +spec:table-layout:5e1145 - Automatic table layout: MCW/max-content per cell, column min/max, colspan distribution, final width from MIN/MAX
5752// +spec:width-calculation:42dfca - CSS 2.2 §17.5.2.2 automatic table layout: MCW/max-content per cell, column min/max, multi-span distribution, final table width
5753/// +spec:width-calculation:335ef1 - Automatic table layout: width given by column widths and borders (CSS 2.2 §17.5.2.2)
5754#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
5755#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5756#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5757fn calculate_column_widths_auto_with_width<T: ParsedFontTrait>(
5758    table_ctx: &mut TableLayoutContext,
5759    tree: &mut LayoutTree,
5760    text_cache: &mut TextLayoutCache,
5761    ctx: &mut LayoutContext<'_, T>,
5762    constraints: &LayoutConstraints<'_>,
5763    table_width: f32,
5764) -> Result<()> {
5765    // Auto layout: calculate min/max content width for each cell
5766    let num_cols = table_ctx.columns.len();
5767    if num_cols == 0 {
5768        return Ok(());
5769    }
5770
5771    // Step 1: Measure all cells to determine column min/max widths
5772    // CSS 2.2 Section 17.6: Skip cells in collapsed columns
5773    for cell_info in &table_ctx.cells {
5774        // Skip cells in collapsed columns
5775        if table_ctx.collapsed_columns.contains(&cell_info.column) {
5776            continue;
5777        }
5778
5779        // Skip cells that span into collapsed columns
5780        let mut spans_collapsed = false;
5781        for col_offset in 0..cell_info.colspan {
5782            if table_ctx
5783                .collapsed_columns
5784                .contains(&(cell_info.column + col_offset))
5785            {
5786                spans_collapsed = true;
5787                break;
5788            }
5789        }
5790        if spans_collapsed {
5791            continue;
5792        }
5793
5794        let min_width = measure_cell_min_content_width(
5795            ctx,
5796            tree,
5797            text_cache,
5798            cell_info.node_index,
5799            constraints,
5800        )?;
5801
5802        let max_width = measure_cell_max_content_width(
5803            ctx,
5804            tree,
5805            text_cache,
5806            cell_info.node_index,
5807            constraints,
5808        )?;
5809
5810        // Handle single-column cells
5811        if cell_info.colspan == 1 {
5812            let col = &mut table_ctx.columns[cell_info.column];
5813            col.min_width = col.min_width.max(min_width);
5814            col.max_width = col.max_width.max(max_width);
5815        } else {
5816            // Handle multi-column cells (colspan > 1)
5817            // Distribute the cell's min/max width across the spanned columns
5818            distribute_cell_width_across_columns(
5819                &mut table_ctx.columns,
5820                cell_info.column,
5821                cell_info.colspan,
5822                min_width,
5823                max_width,
5824                &table_ctx.collapsed_columns,
5825            );
5826        }
5827    }
5828
5829    // Step 2: Calculate final column widths based on available space
5830    // Exclude collapsed columns from total width calculations
5831    let total_min_width: f32 = table_ctx
5832        .columns
5833        .iter()
5834        .enumerate()
5835        .filter(|(idx, _)| !table_ctx.collapsed_columns.contains(idx))
5836        .map(|(_, c)| c.min_width)
5837        .sum();
5838    let total_max_width: f32 = table_ctx
5839        .columns
5840        .iter()
5841        .enumerate()
5842        .filter(|(idx, _)| !table_ctx.collapsed_columns.contains(idx))
5843        .map(|(_, c)| c.max_width)
5844        .sum();
5845    let available_width = table_width; // Use table's content-box width, not constraints
5846
5847    debug_table_layout!(
5848        ctx,
5849        "calculate_column_widths_auto: min={:.2}, max={:.2}, table_width={:.2}",
5850        total_min_width,
5851        total_max_width,
5852        table_width
5853    );
5854
5855    // Handle infinity and NaN cases
5856    if !total_max_width.is_finite() || !available_width.is_finite() {
5857        // If max_width is infinite or unavailable, distribute available width equally
5858        let num_non_collapsed = table_ctx.columns.len() - table_ctx.collapsed_columns.len();
5859        let width_per_column = if num_non_collapsed > 0 {
5860            available_width / num_non_collapsed as f32
5861        } else {
5862            0.0
5863        };
5864
5865        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5866            if table_ctx.collapsed_columns.contains(&col_idx) {
5867                col.computed_width = Some(0.0);
5868            } else {
5869                // Use the larger of min_width and equal distribution
5870                col.computed_width = Some(col.min_width.max(width_per_column));
5871            }
5872        }
5873    } else if available_width >= total_max_width {
5874        // Case 1: More space than max-content - distribute excess proportionally
5875        //
5876        // CSS 2.1 Section 17.5.2.2: Distribute extra space proportionally to
5877        // max-content widths
5878        let excess_width = available_width - total_max_width;
5879
5880        // First pass: collect column info (max_width) to avoid borrowing issues
5881        let column_info: Vec<(usize, f32, bool)> = table_ctx
5882            .columns
5883            .iter()
5884            .enumerate()
5885            .map(|(idx, c)| (idx, c.max_width, table_ctx.collapsed_columns.contains(&idx)))
5886            .collect();
5887
5888        // Calculate total weight for proportional distribution (use max_width as weight)
5889        let total_weight: f32 = column_info.iter()
5890            .filter(|(_, _, is_collapsed)| !is_collapsed)
5891            .map(|(_, max_w, _)| max_w.max(1.0)) // Avoid division by zero
5892            .sum();
5893
5894        let num_non_collapsed = column_info
5895            .iter()
5896            .filter(|(_, _, is_collapsed)| !is_collapsed)
5897            .count();
5898
5899        // Second pass: set computed widths
5900        for (col_idx, max_width, is_collapsed) in column_info {
5901            let col = &mut table_ctx.columns[col_idx];
5902            if is_collapsed {
5903                col.computed_width = Some(0.0);
5904            } else {
5905                // Start with max-content width, then add proportional share of excess
5906                let weight_factor = if total_weight > 0.0 {
5907                    max_width.max(1.0) / total_weight
5908                } else {
5909                    // If all columns have 0 max_width, distribute equally
5910                    1.0 / num_non_collapsed.max(1) as f32
5911                };
5912
5913                let final_width = max_width + (excess_width * weight_factor);
5914                col.computed_width = Some(final_width);
5915            }
5916        }
5917    } else if available_width >= total_min_width {
5918        // Case 2: Between min and max - interpolate proportionally
5919        // Avoid division by zero if min == max
5920        let scale = if total_max_width > total_min_width {
5921            (available_width - total_min_width) / (total_max_width - total_min_width)
5922        } else {
5923            0.0 // If min == max, just use min width
5924        };
5925        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5926            if table_ctx.collapsed_columns.contains(&col_idx) {
5927                col.computed_width = Some(0.0);
5928            } else {
5929                let interpolated = col.min_width + (col.max_width - col.min_width) * scale;
5930                col.computed_width = Some(interpolated);
5931            }
5932        }
5933    } else {
5934        // Case 3: Not enough space - columns must not shrink below their
5935        // min-content width (CSS 2.1 §17.5.2). Floor each column at min_width;
5936        // the table overflows its containing block instead of squeezing content.
5937        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5938            if table_ctx.collapsed_columns.contains(&col_idx) {
5939                col.computed_width = Some(0.0);
5940            } else {
5941                col.computed_width = Some(col.min_width);
5942            }
5943        }
5944    }
5945
5946    Ok(())
5947}
5948
5949/// Distribute a multi-column cell's width across the columns it spans
5950#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5951fn distribute_cell_width_across_columns(
5952    columns: &mut [TableColumnInfo],
5953    start_col: usize,
5954    colspan: usize,
5955    cell_min_width: f32,
5956    cell_max_width: f32,
5957    collapsed_columns: &std::collections::HashSet<usize>,
5958) {
5959    let end_col = start_col + colspan;
5960    if end_col > columns.len() {
5961        return;
5962    }
5963
5964    // Calculate current total of spanned non-collapsed columns
5965    let current_min_total: f32 = columns[start_col..end_col]
5966        .iter()
5967        .enumerate()
5968        .filter(|(idx, _)| !collapsed_columns.contains(&(start_col + idx)))
5969        .map(|(_, c)| c.min_width)
5970        .sum();
5971    let current_max_total: f32 = columns[start_col..end_col]
5972        .iter()
5973        .enumerate()
5974        .filter(|(idx, _)| !collapsed_columns.contains(&(start_col + idx)))
5975        .map(|(_, c)| c.max_width)
5976        .sum();
5977
5978    // Count non-collapsed columns in the span
5979    let num_visible_cols = (start_col..end_col)
5980        .filter(|idx| !collapsed_columns.contains(idx))
5981        .count();
5982
5983    if num_visible_cols == 0 {
5984        return; // All spanned columns are collapsed
5985    }
5986
5987    // Only distribute if the cell needs more space than currently available
5988    if cell_min_width > current_min_total {
5989        let extra_min = cell_min_width - current_min_total;
5990        let per_col = extra_min / num_visible_cols as f32;
5991        for (idx, col) in columns[start_col..end_col].iter_mut().enumerate() {
5992            if !collapsed_columns.contains(&(start_col + idx)) {
5993                col.min_width += per_col;
5994            }
5995        }
5996    }
5997
5998    if cell_max_width > current_max_total {
5999        let extra_max = cell_max_width - current_max_total;
6000        let per_col = extra_max / num_visible_cols as f32;
6001        for (idx, col) in columns[start_col..end_col].iter_mut().enumerate() {
6002            if !collapsed_columns.contains(&(start_col + idx)) {
6003                col.max_width += per_col;
6004            }
6005        }
6006    }
6007}
6008
6009/// Layout a cell with its computed column width to determine its content height
6010#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6011fn layout_cell_for_height<T: ParsedFontTrait>(
6012    ctx: &mut LayoutContext<'_, T>,
6013    tree: &mut LayoutTree,
6014    text_cache: &mut TextLayoutCache,
6015    cell_index: usize,
6016    cell_width: f32,
6017    constraints: &LayoutConstraints<'_>,
6018) -> Result<f32> {
6019    let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
6020    let cell_dom_id = cell_node.dom_node_id.ok_or(LayoutError::InvalidTree)?;
6021
6022    // Check if cell has text content directly in DOM (not in LayoutTree)
6023    // Text nodes are intentionally not included in LayoutTree per CSS spec,
6024    // but we need to measure them for table cell height calculation.
6025    let has_text_children = cell_dom_id
6026        .az_children(&ctx.styled_dom.node_hierarchy.as_container())
6027        .any(|child_id| {
6028            let node_data = &ctx.styled_dom.node_data.as_container()[child_id];
6029            matches!(node_data.get_node_type(), NodeType::Text(_))
6030        });
6031
6032    debug_table_layout!(
6033        ctx,
6034        "layout_cell_for_height: cell_index={}, has_text_children={}",
6035        cell_index,
6036        has_text_children
6037    );
6038
6039    // Get padding and border to calculate content width
6040    let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
6041    let cell_bp = cell_node.box_props.unpack();
6042    let padding = &cell_bp.padding;
6043    let border = &cell_bp.border;
6044    let writing_mode = constraints.writing_mode;
6045
6046    // cell_width is the border-box width (includes padding/border from column
6047    // width calculation) but layout functions need content-box width
6048    let content_width = cell_width
6049        - padding.cross_start(writing_mode)
6050        - padding.cross_end(writing_mode)
6051        - border.cross_start(writing_mode)
6052        - border.cross_end(writing_mode);
6053
6054    debug_table_layout!(
6055        ctx,
6056        "Cell width: border_box={:.2}, content_box={:.2}",
6057        cell_width,
6058        content_width
6059    );
6060
6061    let content_height = if has_text_children {
6062        // Cell contains text - use IFC to measure it
6063        debug_table_layout!(ctx, "Using IFC to measure text content");
6064
6065        let cell_constraints = LayoutConstraints {
6066            available_size: LogicalSize {
6067                width: content_width, // Use content width, not border-box width
6068                height: f32::INFINITY,
6069            },
6070            writing_mode: constraints.writing_mode,
6071            writing_mode_ctx: constraints.writing_mode_ctx,
6072            bfc_state: None,
6073            text_align: constraints.text_align,
6074            containing_block_size: constraints.containing_block_size,
6075            // Use definite width for final cell layout!
6076            // This replaces any previous MinContent/MaxContent measurement.
6077            available_width_type: Text3AvailableSpace::Definite(content_width),
6078        };
6079
6080        let output = layout_ifc(ctx, text_cache, tree, cell_index, &cell_constraints)?;
6081
6082        // The cell now owns the authoritative IFC result. Clear any duplicate
6083        // inline_layout_result from text children that was set during the cell's
6084        // prior BFC Pass 1 (which ran before layout_cell_for_height).
6085        let cell_children: Vec<usize> = tree.children(cell_index).to_vec();
6086        for child_idx in cell_children {
6087            if let Some(warm) = tree.warm_mut(child_idx) {
6088                warm.inline_layout_result = None;
6089            }
6090        }
6091
6092        debug_table_layout!(
6093            ctx,
6094            "IFC returned height={:.2}",
6095            output.overflow_size.height
6096        );
6097
6098        output.overflow_size.height
6099    } else {
6100        // Cell contains block-level children or is empty - use regular layout
6101        debug_table_layout!(ctx, "Using regular layout for block children");
6102
6103        let cell_constraints = LayoutConstraints {
6104            available_size: LogicalSize {
6105                width: content_width, // Use content width, not border-box width
6106                height: f32::INFINITY,
6107            },
6108            writing_mode: constraints.writing_mode,
6109            writing_mode_ctx: constraints.writing_mode_ctx,
6110            bfc_state: None,
6111            text_align: constraints.text_align,
6112            containing_block_size: constraints.containing_block_size,
6113            // Use Definite width for final cell layout!
6114            available_width_type: Text3AvailableSpace::Definite(content_width),
6115        };
6116
6117        let mut temp_positions: super::PositionVec = Vec::new();
6118        let mut temp_scrollbar_reflow = false;
6119        let mut temp_float_cache = HashMap::new();
6120
6121        crate::solver3::cache::calculate_layout_for_subtree(
6122            ctx,
6123            tree,
6124            text_cache,
6125            cell_index,
6126            LogicalPosition::zero(),
6127            cell_constraints.available_size,
6128            &mut temp_positions,
6129            &mut temp_scrollbar_reflow,
6130            &mut temp_float_cache,
6131            // PerformLayout: final table cell layout with definite width
6132            crate::solver3::cache::ComputeMode::PerformLayout,
6133        )?;
6134
6135        let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
6136        cell_node.used_size.unwrap_or_default().height
6137    };
6138
6139    // Add padding and border to get the total height
6140    let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
6141    let cell_bp = cell_node.box_props.unpack();
6142    let padding = &cell_bp.padding;
6143    let border = &cell_bp.border;
6144    let writing_mode = constraints.writing_mode;
6145
6146    let total_height = content_height
6147        + padding.main_start(writing_mode)
6148        + padding.main_end(writing_mode)
6149        + border.main_start(writing_mode)
6150        + border.main_end(writing_mode);
6151
6152    debug_table_layout!(
6153        ctx,
6154        "Cell total height: cell_index={}, content={:.2}, padding/border={:.2}, total={:.2}",
6155        cell_index,
6156        content_height,
6157        padding.main_start(writing_mode)
6158            + padding.main_end(writing_mode)
6159            + border.main_start(writing_mode)
6160            + border.main_end(writing_mode),
6161        total_height
6162    );
6163
6164    Ok(total_height)
6165}
6166
6167// or bottom of content edge if no such line box exists
6168// +spec:box-model:b64fa0 - Cell baseline is first in-flow line box or bottom of content edge
6169// +spec:overflow:3fa86f - Table cell baseline: first in-flow line box or bottom of content edge; scrolling boxes treated as at origin
6170// +spec:inline-formatting-context:c4a20d - cell baseline: first in-flow line box or bottom of content edge
6171// +spec:inline-formatting-context:17a9c1 - vertical-align baseline/top/bottom/middle for table cells
6172fn compute_cell_baseline(cell_index: usize, tree: &LayoutTree) -> f32 {
6173    let Some(cell_node) = tree.get(cell_index) else {
6174        return 0.0;
6175    };
6176
6177    let cell_bp = cell_node.box_props.unpack();
6178
6179    // +spec:inline-formatting-context:27be38 - cell baseline is first in-flow line box or bottom of content edge
6180    // Check if the cell has inline layout (first in-flow line box)
6181    if let Some(warm_node) = tree.warm(cell_index) {
6182        if let Some(ref cached_layout) = warm_node.inline_layout_result {
6183            let inline_result = &cached_layout.layout;
6184            // The baseline is the ascent of the first item from the top of the cell
6185            if let Some(first_item) = inline_result.items.first() {
6186                let (item_ascent, _) = text3::cache::get_item_vertical_metrics_approx(&first_item.item);
6187                let padding_top = cell_bp.padding.top;
6188                let border_top = cell_bp.border.top;
6189                return padding_top + border_top + first_item.position.y + item_ascent;
6190            }
6191        }
6192    }
6193
6194    // Check children for first in-flow line box
6195    let children = tree.children(cell_index);
6196    for &child_idx in children {
6197        if child_idx < tree.nodes.len() {
6198            if let Some(child_warm) = tree.warm(child_idx) {
6199                if child_warm.inline_layout_result.is_some() {
6200                    let child_baseline = compute_cell_baseline(child_idx, tree);
6201                    let padding_top = cell_bp.padding.top;
6202                    let border_top = cell_bp.border.top;
6203                    return padding_top + border_top + child_baseline;
6204                }
6205            }
6206        }
6207    }
6208
6209    // No line box found: baseline is the bottom of the content edge
6210    let used_size = cell_node.used_size.unwrap_or_default();
6211    let padding_bottom = cell_bp.padding.bottom;
6212    let border_bottom = cell_bp.border.bottom;
6213    used_size.height - padding_bottom - border_bottom
6214}
6215
6216/// +spec:box-model:72b495 - Table row height = max of computed height and MIN required by cells; baseline alignment
6217// +spec:display-property:728144 - Table height algorithm: row heights from cell content, rowspan distribution, vertical-align in cells (top/middle/bottom/baseline, sub/super/text-top/text-bottom/length/percentage fall back to baseline), cell baseline computation, and horizontal alignment via text-align
6218// +spec:positioning:3eaadd - Table height algorithms (§17.5.3): row height = max of cell heights/MIN,
6219//   rowspan distribution, vertical-align in table cells, cell baseline definition
6220/// Calculate row heights based on cell content after column widths are determined
6221// +spec:inline-formatting-context:87b90d - Table height algorithms: row height = max(computed height, cell heights, MIN); vertical-align in cells (baseline/top/middle/bottom, sub/super/etc. fall back to baseline)
6222#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
6223#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6224fn calculate_row_heights<T: ParsedFontTrait>(
6225    table_ctx: &mut TableLayoutContext,
6226    tree: &mut LayoutTree,
6227    text_cache: &mut TextLayoutCache,
6228    ctx: &mut LayoutContext<'_, T>,
6229    constraints: &LayoutConstraints<'_>,
6230) -> Result<()> {
6231    debug_table_layout!(
6232        ctx,
6233        "calculate_row_heights: num_rows={}, available_size={:?}",
6234        table_ctx.num_rows,
6235        constraints.available_size
6236    );
6237
6238    // +spec:inline-formatting-context:a7c7a0 - row height = max of computed height, cell heights, and MIN; vertical-align per cell
6239    // Initialize row heights and baselines
6240    table_ctx.row_heights = vec![0.0; table_ctx.num_rows];
6241    table_ctx.row_baselines = vec![0.0; table_ctx.num_rows];
6242
6243    // CSS 2.2 Section 17.6: Set collapsed rows to height 0
6244    for &row_idx in &table_ctx.collapsed_rows {
6245        if row_idx < table_ctx.row_heights.len() {
6246            table_ctx.row_heights[row_idx] = 0.0;
6247        }
6248    }
6249
6250    // required by content; 'height' property can influence row height but does not
6251    // increase cell box height
6252    // First pass: Calculate heights for cells that don't span multiple rows
6253    for cell_info in &table_ctx.cells {
6254        // Skip cells in collapsed rows
6255        if table_ctx.collapsed_rows.contains(&cell_info.row) {
6256            continue;
6257        }
6258
6259        // Get the cell's width (sum of column widths if colspan > 1)
6260        let mut cell_width = 0.0;
6261        for col_idx in cell_info.column..(cell_info.column + cell_info.colspan) {
6262            if let Some(col) = table_ctx.columns.get(col_idx) {
6263                if let Some(width) = col.computed_width {
6264                    cell_width += width;
6265                }
6266            }
6267        }
6268
6269        debug_table_layout!(
6270            ctx,
6271            "Cell layout: node_index={}, row={}, col={}, width={:.2}",
6272            cell_info.node_index,
6273            cell_info.row,
6274            cell_info.column,
6275            cell_width
6276        );
6277
6278        // Layout the cell to get its height
6279        let cell_height = layout_cell_for_height(
6280            ctx,
6281            tree,
6282            text_cache,
6283            cell_info.node_index,
6284            cell_width,
6285            constraints,
6286        )?;
6287
6288        debug_table_layout!(
6289            ctx,
6290            "Cell height calculated: node_index={}, height={:.2}",
6291            cell_info.node_index,
6292            cell_height
6293        );
6294
6295        //   row height = max of all single-span cell heights in the row
6296        if cell_info.rowspan == 1 {
6297            let current_height = table_ctx.row_heights[cell_info.row];
6298            table_ctx.row_heights[cell_info.row] = current_height.max(cell_height);
6299        }
6300
6301        // +spec:box-model:073652 - Table height: baseline-aligned cells establish row baseline, then top/bottom/middle cells positioned
6302        // The baseline of a cell is the baseline of its first line box (from inline layout)
6303        // or the bottom of the content box if no inline content.
6304        if cell_info.rowspan == 1 {
6305            let cell_baseline = compute_cell_baseline(cell_info.node_index, tree);
6306            let current_baseline = table_ctx.row_baselines[cell_info.row];
6307            table_ctx.row_baselines[cell_info.row] = current_baseline.max(cell_baseline);
6308        }
6309    }
6310
6311    // involved must be great enough to encompass the cell spanning the rows
6312    // Second pass: Handle cells that span multiple rows (rowspan > 1)
6313    for cell_info in &table_ctx.cells {
6314        // Skip cells that start in collapsed rows
6315        if table_ctx.collapsed_rows.contains(&cell_info.row) {
6316            continue;
6317        }
6318
6319        if cell_info.rowspan > 1 {
6320            // Get the cell's width
6321            let mut cell_width = 0.0;
6322            for col_idx in cell_info.column..(cell_info.column + cell_info.colspan) {
6323                if let Some(col) = table_ctx.columns.get(col_idx) {
6324                    if let Some(width) = col.computed_width {
6325                        cell_width += width;
6326                    }
6327                }
6328            }
6329
6330            // Layout the cell to get its height
6331            let cell_height = layout_cell_for_height(
6332                ctx,
6333                tree,
6334                text_cache,
6335                cell_info.node_index,
6336                cell_width,
6337                constraints,
6338            )?;
6339
6340            // Calculate the current total height of spanned rows (excluding collapsed rows)
6341            // Clamp to the actual row count: a rowspan extending past the last
6342            // row would slice row_heights out of bounds (panic on e.g. a
6343            // rowspan="2" cell in a single-row table).
6344            let end_row = (cell_info.row + cell_info.rowspan).min(table_ctx.row_heights.len());
6345            let current_total: f32 = table_ctx.row_heights[cell_info.row..end_row]
6346                .iter()
6347                .enumerate()
6348                .filter(|(idx, _)| !table_ctx.collapsed_rows.contains(&(cell_info.row + idx)))
6349                .map(|(_, height)| height)
6350                .sum();
6351
6352            // If the cell needs more height, distribute extra height across
6353            // non-collapsed spanned rows
6354            if cell_height > current_total {
6355                let extra_height = cell_height - current_total;
6356
6357                // Count non-collapsed rows in span
6358                let non_collapsed_rows = (cell_info.row..end_row)
6359                    .filter(|row_idx| !table_ctx.collapsed_rows.contains(row_idx))
6360                    .count();
6361
6362                if non_collapsed_rows > 0 {
6363                    let per_row = extra_height / non_collapsed_rows as f32;
6364
6365                    for row_idx in cell_info.row..end_row {
6366                        if !table_ctx.collapsed_rows.contains(&row_idx) {
6367                            table_ctx.row_heights[row_idx] += per_row;
6368                        }
6369                    }
6370                }
6371            }
6372        }
6373    }
6374
6375    // CSS 2.2 Section 17.6: Final pass - ensure collapsed rows have height 0
6376    for &row_idx in &table_ctx.collapsed_rows {
6377        if row_idx < table_ctx.row_heights.len() {
6378            table_ctx.row_heights[row_idx] = 0.0;
6379        }
6380    }
6381
6382    //   visible content, the row has zero height and v-spacing on only one side
6383    // +spec:table-layout:7370dc - empty-cells:hide in separated borders model
6384    // +spec:box-model:1e9cf1 - empty-cells:hide rows get zero height with v-spacing on only one side
6385    // +spec:overflow:a44925 - CSS 2.2 §17.6.1.1: empty-cells:hide suppresses borders/backgrounds; all-hidden rows get zero height
6386    // +spec:table-layout:dc8bc3 - separated borders model: border-spacing, empty-cells, row zero-height
6387    if table_ctx.border_collapse == StyleBorderCollapse::Separate {
6388        for row_idx in 0..table_ctx.num_rows {
6389            if table_ctx.collapsed_rows.contains(&row_idx) {
6390                continue;
6391            }
6392            // Collect cells in this row
6393            let row_cells: Vec<usize> = table_ctx
6394                .cells
6395                .iter()
6396                .filter(|c| c.row == row_idx && c.rowspan == 1)
6397                .map(|c| c.node_index)
6398                .collect();
6399            if row_cells.is_empty() {
6400                continue;
6401            }
6402            // +spec:box-model:0ab9b0 - empty-cells:hide suppresses borders/backgrounds, row gets zero height if all cells hidden+empty
6403            // Check if ALL cells in this row have empty-cells:hide and are empty
6404            let all_hidden_empty = row_cells.iter().all(|&cell_idx| {
6405                tree.get(cell_idx).is_none_or(|cell_node| {
6406                    let ec = get_empty_cells_property(ctx, cell_node);
6407                    ec == StyleEmptyCells::Hide && is_cell_empty(tree, cell_idx)
6408                })
6409            });
6410            if all_hidden_empty {
6411                table_ctx.row_heights[row_idx] = 0.0;
6412                table_ctx.hidden_empty_rows.insert(row_idx);
6413            }
6414        }
6415    }
6416
6417    Ok(())
6418}
6419
6420/// Position all cells in the table grid with calculated widths and heights
6421#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
6422#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
6423#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6424fn position_table_cells<T: ParsedFontTrait>(
6425    table_ctx: &TableLayoutContext,
6426    tree: &mut LayoutTree,
6427    ctx: &mut LayoutContext<'_, T>,
6428    table_index: usize,
6429    constraints: &LayoutConstraints<'_>,
6430) -> Result<BTreeMap<usize, LogicalPosition>> {
6431    debug_log!(ctx, "Positioning table cells in grid");
6432
6433    let mut positions = BTreeMap::new();
6434
6435    // +spec:box-model:54e86a - Separated borders model: individual cell borders, border-spacing between cells, empty-cells handling
6436    //   rows, columns, row groups, column groups cannot have borders (UA must ignore border props);
6437    //   row/column/rowgroup/colgroup backgrounds are invisible in border-spacing area (table bg shows through);
6438    //   distance from table edge to edge-cell border = table padding + border-spacing
6439    //   (table padding is already accounted for by the containing block; h_spacing is the border-spacing)
6440    // Get border spacing values if border-collapse is separate
6441    let (h_spacing, v_spacing) = if table_ctx.border_collapse == StyleBorderCollapse::Separate {
6442        let styled_dom = ctx.styled_dom;
6443        // Anonymous table wrapper boxes have no dom_node_id; without a styled
6444        // node we cannot resolve font-relative border-spacing units, so fall
6445        // back to zero spacing rather than panicking.
6446        if let Some(table_id) = tree.nodes[table_index].dom_node_id {
6447            let table_state = &styled_dom.styled_nodes.as_container()[table_id].styled_node_state;
6448
6449            let spacing_context = ResolutionContext {
6450                element_font_size: get_element_font_size(styled_dom, table_id, table_state),
6451                parent_font_size: get_parent_font_size(styled_dom, table_id, table_state),
6452                root_font_size: get_root_font_size(styled_dom, table_state),
6453                containing_block_size: PhysicalSize::new(0.0, 0.0),
6454                element_size: None,
6455                viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
6456            };
6457
6458            let h = table_ctx
6459                .border_spacing
6460                .horizontal
6461                .resolve_with_context(&spacing_context, PropertyContext::Other)
6462                .max(0.0);
6463
6464            let v = table_ctx
6465                .border_spacing
6466                .vertical
6467                .resolve_with_context(&spacing_context, PropertyContext::Other)
6468                .max(0.0);
6469
6470            (h, v)
6471        } else {
6472            (0.0, 0.0)
6473        }
6474    } else {
6475        (0.0, 0.0)
6476    };
6477
6478    debug_log!(
6479        ctx,
6480        "Border spacing: h={:.2}, v={:.2}",
6481        h_spacing,
6482        v_spacing
6483    );
6484
6485    // Calculate cumulative column positions (x-offsets) with spacing
6486    let mut col_positions = vec![0.0; table_ctx.columns.len()];
6487    let mut x_offset = h_spacing; // Start with spacing on the left
6488    for (i, col) in table_ctx.columns.iter().enumerate() {
6489        col_positions[i] = x_offset;
6490        if let Some(width) = col.computed_width {
6491            // Collapsed columns: gutters on either side collapse (width is 0, skip spacing)
6492            if table_ctx.collapsed_columns.contains(&i) {
6493                // No width, no gutter added
6494            } else {
6495                x_offset += width + h_spacing; // Add spacing between columns
6496            }
6497        }
6498    }
6499
6500    // Calculate cumulative row positions (y-offsets) with spacing
6501    let mut row_positions = vec![0.0; table_ctx.num_rows];
6502    let mut y_offset = v_spacing; // Start with spacing on the top
6503    for (i, &height) in table_ctx.row_heights.iter().enumerate() {
6504        row_positions[i] = y_offset;
6505        // Collapsed rows: gutters on either side collapse (height is 0, skip spacing)
6506        if table_ctx.collapsed_rows.contains(&i) {
6507            // No height, no gutter added
6508        } else if table_ctx.hidden_empty_rows.contains(&i) {
6509            // Hidden-empty row: zero height, only one side of spacing
6510            // (we already added spacing before this row, so skip the spacing after)
6511            y_offset += height; // height is 0.0
6512        } else {
6513            y_offset += height + v_spacing; // Add spacing between rows
6514        }
6515    }
6516
6517    // Store row positions and sizes so paint_element_background can paint row backgrounds.
6518    // Row width = sum of column widths + spacing. Row height from row_heights.
6519    {
6520        let total_col_width: f32 = table_ctx.columns.iter().map(|c| c.computed_width.unwrap_or(0.0)).sum::<f32>()
6521            + h_spacing * (table_ctx.columns.len().max(1) - 1) as f32
6522            + h_spacing * 2.0; // border-spacing on left+right edges
6523        for (i, &row_y) in row_positions.iter().enumerate() {
6524            if let Some(&row_node_idx) = table_ctx.row_node_indices.get(i) {
6525                let row_height = table_ctx.row_heights.get(i).copied().unwrap_or(0.0);
6526                if let Some(row_node) = tree.get_mut(row_node_idx) {
6527                    row_node.used_size = Some(LogicalSize {
6528                        width: total_col_width,
6529                        height: row_height,
6530                    });
6531                }
6532                // Don't add to `positions` map (feeds position_bfc_child_descendants,
6533                // would double-offset cells). The display list computes row paint
6534                // rects from the row's cell children.
6535            }
6536        }
6537    }
6538
6539    // Position each cell
6540    for cell_info in &table_ctx.cells {
6541        let precomputed_cell_baseline = compute_cell_baseline(cell_info.node_index, tree);
6542
6543        let cell_node = tree
6544            .get_mut(cell_info.node_index)
6545            .ok_or(LayoutError::InvalidTree)?;
6546
6547        // Calculate cell position
6548        let x = col_positions.get(cell_info.column).copied().unwrap_or(0.0);
6549        let y = row_positions.get(cell_info.row).copied().unwrap_or(0.0);
6550
6551        // Calculate cell size (sum of spanned columns/rows)
6552        let mut width = 0.0;
6553        debug_info!(
6554            ctx,
6555            "[position_table_cells] Cell {}: calculating width from cols {}..{}",
6556            cell_info.node_index,
6557            cell_info.column,
6558            cell_info.column + cell_info.colspan
6559        );
6560        for col_idx in cell_info.column..(cell_info.column + cell_info.colspan) {
6561            if let Some(col) = table_ctx.columns.get(col_idx) {
6562                debug_info!(
6563                    ctx,
6564                    "[position_table_cells]   Col {}: computed_width={:?}",
6565                    col_idx,
6566                    col.computed_width
6567                );
6568                if let Some(col_width) = col.computed_width {
6569                    width += col_width;
6570                    // Add spacing between spanned columns (but not after the last one)
6571                    if col_idx < cell_info.column + cell_info.colspan - 1 {
6572                        width += h_spacing;
6573                    }
6574                } else {
6575                    debug_info!(
6576                        ctx,
6577                        "[position_table_cells]   WARN:  Col {} has NO computed_width!",
6578                        col_idx
6579                    );
6580                }
6581            } else {
6582                debug_info!(
6583                    ctx,
6584                    "[position_table_cells]   WARN:  Col {} not found in table_ctx.columns!",
6585                    col_idx
6586                );
6587            }
6588        }
6589
6590        let mut height = 0.0;
6591        let end_row = cell_info.row + cell_info.rowspan;
6592        for row_idx in cell_info.row..end_row {
6593            if let Some(&row_height) = table_ctx.row_heights.get(row_idx) {
6594                height += row_height;
6595                // Add spacing between spanned rows (but not after the last one)
6596                if row_idx < end_row - 1 {
6597                    height += v_spacing;
6598                }
6599            }
6600        }
6601
6602        // Update cell's used size and position
6603        let writing_mode = constraints.writing_mode;
6604        // Table layout works in main/cross axes, must convert back to logical width/height
6605
6606        debug_info!(
6607            ctx,
6608            "[position_table_cells] Cell {}: BEFORE from_main_cross: width={}, height={}, \
6609             writing_mode={:?}",
6610            cell_info.node_index,
6611            width,
6612            height,
6613            writing_mode
6614        );
6615
6616        cell_node.used_size = Some(LogicalSize::from_main_cross(height, width, writing_mode));
6617
6618        debug_info!(
6619            ctx,
6620            "[position_table_cells] Cell {}: AFTER from_main_cross: used_size={:?}",
6621            cell_info.node_index,
6622            cell_node.used_size
6623        );
6624
6625        debug_info!(
6626            ctx,
6627            "[position_table_cells] Cell {}: setting used_size to {}x{} (row_heights={:?})",
6628            cell_info.node_index,
6629            width,
6630            height,
6631            table_ctx.row_heights
6632        );
6633
6634        // Save hot fields needed for vertical alignment before dropping the mutable borrow
6635        let cell_dom_node_id = cell_node.dom_node_id;
6636        let cell_box_props = cell_node.box_props.unpack();
6637        drop(cell_node);
6638
6639        // +spec:inline-formatting-context:20e8e8 - table cell vertical-align alignment order (baseline first, then top, then bottom/middle)
6640        // receive extra top or bottom padding; vertical-align determines alignment
6641        // +spec:inline-formatting-context:4545e8 - vertical-align on table cells maps to align-content: top→start, bottom→end, middle→center
6642        // +spec:inline-formatting-context:e216be - vertical-align on table cells (baseline, middle, top, bottom)
6643        // +spec:positioning:156e49 - table cell vertical-align ordering and extra padding per CSS 2.2 §17.5.3
6644        // Apply vertical-align to cell content if it has inline layout
6645        // We need to compute the y_offset using immutable borrows first, then apply it mutably.
6646        let vertical_align_adjustment = if let Some(warm_node) = tree.warm(cell_info.node_index) {
6647            if let Some(ref cached_layout) = warm_node.inline_layout_result {
6648                let inline_result = &cached_layout.layout;
6649
6650                // Get vertical-align property from styled_dom
6651                let vertical_align = if let Some(dom_id) = cell_dom_node_id {
6652                    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
6653
6654                    match get_vertical_align_property(ctx.styled_dom, dom_id, &node_state) {
6655                        MultiValue::Exact(v) => v,
6656                        _ => StyleVerticalAlign::Baseline,
6657                    }
6658                } else {
6659                    StyleVerticalAlign::Baseline
6660                };
6661
6662                // Calculate content height from inline layout bounds
6663                let content_bounds = inline_result.bounds();
6664                let content_height = content_bounds.height;
6665
6666                // Get padding and border to calculate content-box height
6667                // height is border-box, but vertical alignment should be within content-box
6668                let padding = &cell_box_props.padding;
6669                let border = &cell_box_props.border;
6670                let content_box_height = height
6671                    - padding.main_start(writing_mode)
6672                    - padding.main_end(writing_mode)
6673                    - border.main_start(writing_mode)
6674                    - border.main_end(writing_mode);
6675
6676                // top: top of cell box aligned with top of first row it spans
6677                // bottom: bottom of cell box aligned with bottom of last row it spans
6678                // middle: center of cell aligned with center of rows it spans
6679                //   the cell is aligned at the baseline instead
6680                let y_offset = match vertical_align {
6681                    StyleVerticalAlign::Top => 0.0,
6682                    StyleVerticalAlign::Middle => (content_box_height - content_height) * 0.5,
6683                    StyleVerticalAlign::Bottom => content_box_height - content_height,
6684                    // align with the row baseline. cell_baseline = distance from top of cell box
6685                    // to cell's baseline; row_baseline = distance from top of row to row's baseline
6686                    StyleVerticalAlign::Baseline
6687                    | StyleVerticalAlign::Sub
6688                    | StyleVerticalAlign::Superscript
6689                    | StyleVerticalAlign::TextTop
6690                    | StyleVerticalAlign::TextBottom
6691                    | StyleVerticalAlign::Percentage(_)
6692                    | StyleVerticalAlign::Length(_) => {
6693                        let row_baseline = table_ctx.row_baselines.get(cell_info.row).copied().unwrap_or(0.0);
6694                        (row_baseline - precomputed_cell_baseline).max(0.0)
6695                    }
6696                };
6697
6698                debug_info!(
6699                    ctx,
6700                    "[position_table_cells] Cell {}: vertical-align={:?}, border_box_height={}, \
6701                     content_box_height={}, content_height={}, y_offset={}",
6702                    cell_info.node_index,
6703                    vertical_align,
6704                    height,
6705                    content_box_height,
6706                    content_height,
6707                    y_offset
6708                );
6709
6710                if y_offset.abs() > 0.01 {
6711                    Some((y_offset, cached_layout.available_width, cached_layout.has_floats))
6712                } else {
6713                    None
6714                }
6715            } else {
6716                None
6717            }
6718        } else {
6719            None
6720        };
6721
6722        // Apply the vertical alignment adjustment (requires mutable borrow)
6723        if let Some((y_offset, available_width, has_floats)) = vertical_align_adjustment {
6724            if let Some(warm_mut) = tree.warm_mut(cell_info.node_index) {
6725                if let Some(ref cached_layout) = warm_mut.inline_layout_result {
6726                    use std::sync::Arc;
6727                    use crate::text3::cache::{PositionedItem, UnifiedLayout};
6728
6729                    let adjusted_items: Vec<PositionedItem> = cached_layout.layout
6730                        .items
6731                        .iter()
6732                        .map(|item| PositionedItem {
6733                            item: item.item.clone(),
6734                            position: text3::cache::Point {
6735                                x: item.position.x,
6736                                y: item.position.y + y_offset,
6737                            },
6738                            line_index: item.line_index,
6739                        })
6740                        .collect();
6741
6742                    let adjusted_layout = UnifiedLayout {
6743                        items: adjusted_items,
6744                        overflow: cached_layout.layout.overflow.clone(),
6745                    };
6746
6747                    // Keep the same constraint type from the cached layout
6748                    let mut cil = CachedInlineLayout::new(
6749                        Arc::new(adjusted_layout),
6750                        available_width,
6751                        has_floats,
6752                    );
6753                    // LineShift preserves content; carry the hash so Phase 2d
6754                    // can still validly fast-path this layout (#11).
6755                    cil.inline_content_hash = cached_layout.inline_content_hash;
6756                    warm_mut.inline_layout_result = Some(cil);
6757                }
6758            }
6759        }
6760
6761        // +spec:inline-formatting-context:4545e8 - vertical-align on a table cell
6762        // centers/bottom-aligns its *content* within the cell box. The block above
6763        // only handles cells whose content is direct text (they carry an
6764        // `inline_layout_result`). A cell whose content is block-level
6765        // (`<td><p>…</p></td>`, `<td><div>…</div></td>`) has none, so its children
6766        // stayed top-aligned regardless of `vertical-align`. Shift the cell's
6767        // in-flow block children by the same offset so the UA default
6768        // `vertical-align: middle` actually centers block content, like browsers.
6769        let cell_has_inline = tree
6770            .warm(cell_info.node_index)
6771            .is_some_and(|w| w.inline_layout_result.is_some());
6772        if !cell_has_inline {
6773            let vertical_align = cell_dom_node_id.map_or(StyleVerticalAlign::Baseline, |dom_id| {
6774                let node_state =
6775                    ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
6776                match get_vertical_align_property(ctx.styled_dom, dom_id, &node_state) {
6777                    MultiValue::Exact(v) => v,
6778                    _ => StyleVerticalAlign::Baseline,
6779                }
6780            });
6781
6782            // Only middle/bottom reposition block content; top and baseline leave it
6783            // at the content-box top (the default block position).
6784            let factor = match vertical_align {
6785                StyleVerticalAlign::Middle => 0.5,
6786                StyleVerticalAlign::Bottom => 1.0,
6787                _ => 0.0,
6788            };
6789            if factor > 0.0 {
6790                let children: Vec<usize> = tree.children(cell_info.node_index).to_vec();
6791                // Natural content height = furthest in-flow child bottom edge,
6792                // measured from the cell content-box top (relative_position is
6793                // relative to the parent content box).
6794                let mut content_height = 0.0f32;
6795                let mut inflow: Vec<usize> = Vec::new();
6796                for &c in &children {
6797                    let dom_id = tree.get(c).and_then(|n| n.dom_node_id);
6798                    if matches!(
6799                        get_position_type(ctx.styled_dom, dom_id),
6800                        LayoutPosition::Absolute | LayoutPosition::Fixed
6801                    ) {
6802                        continue; // out-of-flow children are unaffected by vertical-align
6803                    }
6804                    let top = tree
6805                        .warm(c)
6806                        .and_then(|w| w.relative_position)
6807                        .map_or(0.0, |p| p.y);
6808                    let h = tree.get(c).and_then(|n| n.used_size).map_or(0.0, |s| s.height);
6809                    content_height = content_height.max(top + h);
6810                    inflow.push(c);
6811                }
6812                let content_box_height = height
6813                    - cell_box_props.padding.main_start(writing_mode)
6814                    - cell_box_props.padding.main_end(writing_mode)
6815                    - cell_box_props.border.main_start(writing_mode)
6816                    - cell_box_props.border.main_end(writing_mode);
6817                let y_offset = (content_box_height - content_height) * factor;
6818                if y_offset > 0.01 {
6819                    for &c in &inflow {
6820                        if let Some(w) = tree.warm_mut(c) {
6821                            if let Some(pos) = w.relative_position.as_mut() {
6822                                pos.y += y_offset;
6823                            }
6824                        }
6825                    }
6826                }
6827            }
6828        }
6829
6830        // Store position relative to table origin
6831        let position = LogicalPosition::from_main_cross(y, x, writing_mode);
6832
6833        // Insert position into map so cache module can position the cell
6834        positions.insert(cell_info.node_index, position);
6835
6836        debug_log!(
6837            ctx,
6838            "Cell at row={}, col={}: pos=({:.2}, {:.2}), size=({:.2}x{:.2})",
6839            cell_info.row,
6840            cell_info.column,
6841            x,
6842            y,
6843            width,
6844            height
6845        );
6846    }
6847
6848    Ok(positions)
6849}
6850
6851/// Gathers all inline content for `text3`, recursively laying out `inline-block` children
6852/// to determine their size and baseline before passing them to the text engine.
6853///
6854/// This function also assigns IFC membership to all participating nodes:
6855/// - The IFC root gets an `ifc_id` assigned
6856/// - Each text/inline child gets `ifc_membership` set with a reference back to the IFC root
6857///
6858/// This mapping enables efficient cursor hit-testing: when a text node is clicked,
6859/// we can find its parent IFC's `inline_layout_result` via `ifc_membership.ifc_root_layout_index`.
6860// +spec:display-property:63a38b - inline box boundaries and out-of-flow elements are ignored for text adjacency (white space, line-breaking, text-transform)
6861fn collect_and_measure_inline_content<T: ParsedFontTrait>(
6862    ctx: &mut LayoutContext<'_, T>,
6863    text_cache: &mut TextLayoutCache,
6864    tree: &mut LayoutTree,
6865    ifc_root_index: usize,
6866    constraints: &LayoutConstraints<'_>,
6867) -> Result<(Vec<InlineContent>, HashMap<ContentIndex, usize>)> {
6868    use crate::solver3::layout_tree::{IfcId, IfcMembership};
6869    use crate::text3::cache::InlineContent;
6870
6871    let mut content = Vec::new();
6872    let mut child_map = HashMap::new();
6873    collect_and_measure_inline_content_impl(
6874        ctx,
6875        text_cache,
6876        tree,
6877        ifc_root_index,
6878        constraints,
6879        &mut content,
6880        &mut child_map,
6881    )?;
6882    Ok((content, child_map))
6883}
6884
6885#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
6886#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6887fn collect_and_measure_inline_content_impl<T: ParsedFontTrait>(
6888    ctx: &mut LayoutContext<'_, T>,
6889    text_cache: &mut TextLayoutCache,
6890    tree: &mut LayoutTree,
6891    ifc_root_index: usize,
6892    constraints: &LayoutConstraints<'_>,
6893    content: &mut Vec<InlineContent>,
6894    child_map: &mut HashMap<ContentIndex, usize>,
6895) -> Result<()> {
6896    use crate::solver3::layout_tree::{IfcId, IfcMembership};
6897
6898    debug_ifc_layout!(
6899        ctx,
6900        "collect_and_measure_inline_content: node_index={}",
6901        ifc_root_index
6902    );
6903
6904    // Generate a unique IFC ID for this inline formatting context
6905    let ifc_id = IfcId::unique();
6906
6907    // Store IFC ID on the IFC root node
6908    if let Some(cold_node) = tree.cold_mut(ifc_root_index) {
6909        cold_node.ifc_id = Some(ifc_id);
6910    }
6911
6912    // [g129/g130 az-web-lift] `content` and `child_map` are now caller-provided out-params
6913    // (the by-value `(Vec, HashMap)` return mis-lifted its len on the web backend). The caller
6914    // passes them in EMPTY; this body fills them exactly as before. `child_map` maps the
6915    // `ContentIndex` used by text3 back to the `LayoutNode` index.
6916    // Track the current run index for IFC membership assignment
6917    let mut current_run_index: u32 = 0;
6918
6919    // [g134/g135 az-web-lift DIAG] out-param pointer + tree validity at _impl entry. The early Err is
6920    // the `tree.get(ifc_root_index).ok_or(InvalidTree)?` at 6449/6706 (no other `?` before the first
6921    // content push) — capture whether `tree` is valid (nodes.len) and tree.get(idx) actually works.
6922    #[cfg(feature = "web_lift")]
6923    unsafe {
6924        crate::az_mark((0x60690) as u32, (content as *const _ as usize as u32) as u32);
6925        crate::az_mark((0x60694) as u32, (ifc_root_index as u32 | 0xC0DE0000u32) as u32);
6926        crate::az_mark((0x606A8) as u32, (tree.nodes.len() as u32) as u32);
6927        crate::az_mark((0x606AC) as u32, (tree.get(ifc_root_index).is_some() as u32 | 0xC0DE0000u32) as u32);
6928        crate::az_mark((0x606B0) as u32, (tree.root as u32) as u32);
6929        // [g147 az-web-lift DIAG] CALLEE-side tree ptr + nodes.len indexed by ifc_root_index
6930        // (0x60940+ = nodes.len, 0x60960+ = tree ptr). Pair with layout_ifc's 0x60900+/0x60920+.
6931        let slot = (ifc_root_index & 7) * 4;
6932        crate::az_mark(((0x60940 + slot)) as u32, (tree.nodes.len() as u32) as u32);
6933        crate::az_mark(((0x60960 + slot)) as u32, ((&*tree as *const LayoutTree as usize) as u32) as u32);
6934    }
6935
6936    let ifc_root_node = tree.get(ifc_root_index).ok_or(LayoutError::InvalidTree)?;
6937    // [g135] reached past the 6449 tree.get.
6938    #[cfg(feature = "web_lift")]
6939    unsafe { crate::az_mark((0x606A4) as u32, (0x0000_6449u32) as u32); }
6940
6941    // Check if this is an anonymous IFC wrapper (has no DOM ID)
6942    let is_anonymous = ifc_root_node.dom_node_id.is_none();
6943
6944    // Get the DOM node ID of the IFC root, or find it from parent/children for anonymous boxes
6945    // CSS 2.2 § 9.2.1.1: Anonymous boxes inherit properties from their enclosing box
6946    let ifc_root_dom_id = if let Some(id) = ifc_root_node.dom_node_id { id } else {
6947        // Anonymous box - get DOM ID from parent or first child with DOM ID
6948        let parent_dom_id = ifc_root_node
6949            .parent
6950            .and_then(|p| tree.get(p))
6951            .and_then(|n| n.dom_node_id);
6952
6953        if let Some(id) = parent_dom_id {
6954            id
6955        } else {
6956            // Try to find DOM ID from first child
6957            if let Some(id) = tree.children(ifc_root_index)
6958                .iter()
6959                .filter_map(|&child_idx| tree.get(child_idx)).find_map(|n| n.dom_node_id) { id } else {
6960                debug_warning!(ctx, "IFC root and all ancestors/children have no DOM ID");
6961                return Ok(());
6962            }
6963        }
6964    };
6965
6966    // Collect children to avoid holding an immutable borrow during iteration
6967    let children: Vec<_> = tree.children(ifc_root_index).to_vec();
6968    drop(ifc_root_node);
6969
6970    debug_ifc_layout!(
6971        ctx,
6972        "Node {} has {} layout children, is_anonymous={}",
6973        ifc_root_index,
6974        children.len(),
6975        is_anonymous
6976    );
6977
6978    // For anonymous IFC wrappers, we collect content from layout tree children
6979    // For regular IFC roots, we also check DOM children for text nodes
6980    if is_anonymous {
6981        // Anonymous IFC wrapper - iterate over layout tree children and collect their content
6982        for (item_idx, &child_index) in children.iter().enumerate() {
6983            let content_index = ContentIndex {
6984                run_index: ifc_root_index as u32,
6985                item_index: item_idx as u32,
6986            };
6987
6988            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
6989            let Some(dom_id) = child_node.dom_node_id else {
6990                debug_warning!(
6991                    ctx,
6992                    "Anonymous IFC child at index {} has no DOM ID",
6993                    child_index
6994                );
6995                continue;
6996            };
6997
6998            let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
6999
7000            // Check if this is a text node
7001            if let NodeType::Text(ref text_content) = node_data.get_node_type() {
7002                debug_info!(
7003                    ctx,
7004                    "[collect_and_measure_inline_content] OK: Found text node (DOM {:?}) in anonymous wrapper: '{}'",
7005                    dom_id,
7006                    text_content.as_str()
7007                );
7008                // Get style from the TEXT NODE itself (dom_id), not the IFC root
7009                // This ensures inline styles like color: #666666 are applied to the text
7010                let style = Arc::new(get_style_properties(ctx.styled_dom, dom_id, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height)));
7011                let text_items = split_text_for_whitespace(
7012                    ctx.styled_dom,
7013                    dom_id,
7014                    text_content.as_str(),
7015                    &style,
7016                );
7017                content.extend(text_items);
7018                child_map.insert(content_index, child_index);
7019                
7020                // Set IFC membership on the text node - drop child_node borrow first
7021                drop(child_node);
7022                if let Some(warm_mut) = tree.warm_mut(child_index) {
7023                    warm_mut.ifc_membership = Some(IfcMembership {
7024                        ifc_id,
7025                        ifc_root_layout_index: ifc_root_index,
7026                        run_index: current_run_index,
7027                    });
7028                }
7029                current_run_index += 1;
7030
7031                continue;
7032            }
7033
7034            // A <br> forces a hard line break inside this IFC (see UA css: <br>
7035            // is inline). Without this it would collect as an empty inline span
7036            // and never break the line.
7037            if matches!(node_data.get_node_type(), NodeType::Br) {
7038                content.push(InlineContent::LineBreak(InlineBreak {
7039                    break_type: BreakType::Hard,
7040                    clear: ClearType::None,
7041                    content_index: content.len(),
7042                }));
7043                continue;
7044            }
7045
7046            // +spec:positioning:17239f - abspos elements are taken out of flow and must
7047            // not contribute their content to this IFC (laid out independently).
7048            if matches!(
7049                get_position_type(ctx.styled_dom, Some(dom_id)),
7050                LayoutPosition::Absolute | LayoutPosition::Fixed
7051            ) {
7052                continue;
7053            }
7054
7055            // Non-text inline child - add as shape for inline-block
7056            let display = get_display_property(ctx.styled_dom, Some(dom_id)).unwrap_or_default();
7057
7058            if display == LayoutDisplay::Inline {
7059                // Regular inline element - collect its text children
7060                let span_style = get_style_properties(ctx.styled_dom, dom_id, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height));
7061                collect_inline_span_recursive(
7062                    ctx,
7063                    tree,
7064                    dom_id,
7065                    &span_style,
7066                    content,
7067                    &children,
7068                    constraints,
7069                )?;
7070            } else {
7071                // +spec:display-property:a37a9a - atomic inline-level boxes treated as neutral characters in bidi reordering
7072                // This is an atomic inline-level box (e.g., inline-block, image).
7073                // We must determine its size and baseline before passing it to text3.
7074
7075                // The intrinsic sizing pass has already calculated its preferred size.
7076                let intrinsic_size = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
7077                let box_props = child_node.box_props.unpack();
7078
7079                let styled_node_state = ctx
7080                    .styled_dom
7081                    .styled_nodes
7082                    .as_container()
7083                    .get(dom_id)
7084                    .map(|n| n.styled_node_state)
7085                    .unwrap_or_default();
7086
7087                // Calculate tentative border-box size based on CSS properties
7088                let tentative_size = crate::solver3::sizing::calculate_used_size_for_node(
7089                    ctx.styled_dom,
7090                    Some(dom_id),
7091                    &constraints.containing_block_size,
7092                    intrinsic_size,
7093                    &box_props,
7094                    &ctx.viewport_size,
7095                )?;
7096
7097                let writing_mode = get_writing_mode(ctx.styled_dom, dom_id, &styled_node_state)
7098                    .unwrap_or_default();
7099
7100                // Determine content-box size for laying out children
7101                let content_box_size = box_props.inner_size(tentative_size, writing_mode);
7102
7103                // To find its height and baseline, we must lay out its contents.
7104                let child_wm_ctx = super::geometry::WritingModeContext::new(
7105                    writing_mode,
7106                    get_direction_property(ctx.styled_dom, dom_id, &styled_node_state)
7107                        .unwrap_or_default(),
7108                    get_text_orientation_property(ctx.styled_dom, dom_id, &styled_node_state)
7109                        .unwrap_or_default(),
7110                );
7111                let child_constraints = LayoutConstraints {
7112                    available_size: LogicalSize::new(content_box_size.width, f32::INFINITY),
7113                    writing_mode,
7114                    writing_mode_ctx: child_wm_ctx,
7115                    bfc_state: None,
7116                    text_align: TextAlign::Start,
7117                    containing_block_size: constraints.containing_block_size,
7118                    available_width_type: Text3AvailableSpace::Definite(content_box_size.width),
7119                };
7120
7121                // Drop the immutable borrow before calling layout_formatting_context
7122                drop(child_node);
7123
7124                // Recursively lay out the inline-block to get its final height and baseline.
7125                let mut empty_float_cache = HashMap::new();
7126                let layout_result = layout_formatting_context(
7127                    ctx,
7128                    tree,
7129                    text_cache,
7130                    child_index,
7131                    &child_constraints,
7132                    &mut empty_float_cache,
7133                )?;
7134
7135                let css_height = get_css_height(ctx.styled_dom, dom_id, &styled_node_state);
7136
7137                // Replaced elements (image / VirtualView) have no flow content, so the
7138                // measured content_height is 0 — treat their auto height like an
7139                // explicit height (CSS/intrinsic-resolved tentative_size).
7140                let is_replaced_atomic = {
7141                    let nd = &ctx.styled_dom.node_data.as_container()[dom_id];
7142                    matches!(nd.get_node_type(), NodeType::Image(_)) || nd.is_virtual_view_node()
7143                };
7144                // Determine final border-box height
7145                let final_height = match css_height.unwrap_or_default() {
7146                    LayoutHeight::Auto if !is_replaced_atomic => {
7147                        let content_height = layout_result.output.overflow_size.height;
7148                        content_height
7149                            + box_props.padding.main_sum(writing_mode)
7150                            + box_props.border.main_sum(writing_mode)
7151                    }
7152                    _ => tentative_size.height,
7153                };
7154
7155                let final_size = LogicalSize::new(tentative_size.width, final_height);
7156
7157                // Update the node in the tree with its now-known used size.
7158                tree.get_mut(child_index).unwrap().used_size = Some(final_size);
7159
7160                // CSS 2.2 § 10.8.1: inline-block baseline fallback
7161                // If overflow is not 'visible', use bottom margin edge as baseline
7162                let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7163                let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7164                let overflow_is_visible = matches!(
7165                    (overflow_x, overflow_y),
7166                    (LayoutOverflow::Visible, LayoutOverflow::Visible)
7167                );
7168                let baseline_offset = if overflow_is_visible {
7169                    layout_result.output.baseline.unwrap_or(final_height)
7170                } else {
7171                    final_height
7172                };
7173
7174                // +spec:box-model:66ad24 - inline-axis margins, borders, padding respected for inline-level boxes (no collapsing)
7175                // The margin-box size is used so text3 positions inline-blocks with proper spacing
7176                let margin = &box_props.margin;
7177                let margin_box_width = final_size.width + margin.left + margin.right;
7178                let margin_box_height = final_size.height + margin.top + margin.bottom;
7179
7180                // For inline-block shapes, text3 uses the content array index as run_index
7181                // and always item_index=0 for objects. We must match this when inserting into child_map.
7182                let shape_content_index = ContentIndex {
7183                    run_index: content.len() as u32,
7184                    item_index: 0,
7185                };
7186                content.push(InlineContent::Shape(InlineShape {
7187                    shape_def: ShapeDefinition::Rectangle {
7188                        size: crate::text3::cache::Size {
7189                            // Use margin-box size for positioning in inline flow
7190                            width: margin_box_width,
7191                            height: margin_box_height,
7192                        },
7193                        corner_radius: None,
7194                    },
7195                    fill: None,
7196                    stroke: None,
7197                    // Adjust baseline offset by top margin
7198                    baseline_offset: baseline_offset + margin.top,
7199                    alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, dom_id),
7200                    source_node_id: Some(dom_id),
7201                }));
7202                child_map.insert(shape_content_index, child_index);
7203            }
7204        }
7205
7206        return Ok(());
7207    }
7208
7209    // Regular (non-anonymous) IFC root - check for list markers and use DOM traversal
7210
7211    // Check if this IFC root OR its parent is a list-item and needs a marker
7212    // Case 1: IFC root itself is list-item (e.g., <li> with display: list-item)
7213    // Case 2: IFC root's parent is list-item (e.g., <li><text>...</text></li>)
7214    let ifc_root_node = tree.get(ifc_root_index).ok_or(LayoutError::InvalidTree)?;
7215    // [g135] reached past the 6706 tree.get.
7216    #[cfg(feature = "web_lift")]
7217    unsafe { crate::az_mark((0x606A4) as u32, (0x0000_6706u32) as u32); }
7218    let mut list_item_dom_id: Option<NodeId> = None;
7219
7220    // Check IFC root itself
7221    if let Some(dom_id) = ifc_root_node.dom_node_id {
7222        use crate::solver3::getters::get_display_property;
7223        if let MultiValue::Exact(display) = get_display_property(ctx.styled_dom, Some(dom_id)) {
7224            use LayoutDisplay;
7225            if display == LayoutDisplay::ListItem {
7226                debug_ifc_layout!(ctx, "IFC root NodeId({:?}) is list-item", dom_id);
7227                list_item_dom_id = Some(dom_id);
7228            }
7229        }
7230    }
7231
7232    // Check IFC root's parent
7233    if list_item_dom_id.is_none() {
7234        if let Some(parent_idx) = ifc_root_node.parent {
7235            if let Some(parent_node) = tree.get(parent_idx) {
7236                if let Some(parent_dom_id) = parent_node.dom_node_id {
7237                    use crate::solver3::getters::get_display_property;
7238                    if let MultiValue::Exact(display) = get_display_property(ctx.styled_dom, Some(parent_dom_id)) {
7239                        use LayoutDisplay;
7240                        if display == LayoutDisplay::ListItem {
7241                            debug_ifc_layout!(
7242                                ctx,
7243                                "IFC root parent NodeId({:?}) is list-item",
7244                                parent_dom_id
7245                            );
7246                            list_item_dom_id = Some(parent_dom_id);
7247                        }
7248                    }
7249                }
7250            }
7251        }
7252    }
7253
7254    // If we found a list-item, generate markers
7255    if let Some(list_dom_id) = list_item_dom_id {
7256        debug_ifc_layout!(
7257            ctx,
7258            "Found list-item (NodeId({:?})), generating marker",
7259            list_dom_id
7260        );
7261
7262        // Find the layout node index for the list-item DOM node
7263        let list_item_layout_idx = tree
7264            .nodes
7265            .iter()
7266            .enumerate()
7267            .find(|(idx, node)| {
7268                node.dom_node_id == Some(list_dom_id) && tree.warm(*idx).and_then(|w| w.pseudo_element).is_none()
7269            })
7270            .map(|(idx, _)| idx);
7271
7272        if let Some(list_idx) = list_item_layout_idx {
7273            // Per CSS spec, the ::marker pseudo-element is the first child of the list-item
7274            // Find the ::marker pseudo-element in the list-item's children
7275            let marker_idx = tree.children(list_idx)
7276                .iter()
7277                .find(|&&child_idx| {
7278                    tree.warm(child_idx)
7279                        .is_some_and(|w| w.pseudo_element == Some(PseudoElement::Marker))
7280                })
7281                .copied();
7282
7283            if let Some(marker_idx) = marker_idx {
7284                debug_ifc_layout!(ctx, "Found ::marker pseudo-element at index {}", marker_idx);
7285
7286                // Get the DOM ID for style resolution (marker references the same DOM node as
7287                // list-item)
7288                let list_dom_id_for_style = tree
7289                    .get(marker_idx)
7290                    .and_then(|n| n.dom_node_id)
7291                    .unwrap_or(list_dom_id);
7292
7293                // Get list-style-position to determine marker positioning
7294                // Default is 'outside' per CSS Lists Module Level 3
7295
7296                let list_style_position =
7297                    get_list_style_position(ctx.styled_dom, Some(list_dom_id));
7298                let position_outside =
7299                    matches!(list_style_position, StyleListStylePosition::Outside);
7300
7301                debug_ifc_layout!(
7302                    ctx,
7303                    "List marker list-style-position: {:?} (outside={})",
7304                    list_style_position,
7305                    position_outside
7306                );
7307
7308                // Generate marker text segments - font fallback happens during shaping
7309                let base_style =
7310                    Arc::new(get_style_properties(ctx.styled_dom, list_dom_id_for_style, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height)));
7311                let marker_segments = generate_list_marker_segments(
7312                    tree,
7313                    ctx.styled_dom,
7314                    marker_idx, // Pass the marker index, not the list-item index
7315                    ctx.counters,
7316                    base_style,
7317                    ctx.debug_messages,
7318                );
7319
7320                debug_ifc_layout!(
7321                    ctx,
7322                    "Generated {} list marker segments",
7323                    marker_segments.len()
7324                );
7325
7326                // Add markers as InlineContent::Marker with position information
7327                // Outside markers will be positioned in the padding gutter by the layout engine
7328                for segment in marker_segments {
7329                    content.push(InlineContent::Marker {
7330                        run: segment,
7331                        position_outside,
7332                    });
7333                }
7334            } else {
7335                debug_ifc_layout!(
7336                    ctx,
7337                    "WARNING: List-item at index {} has no ::marker pseudo-element",
7338                    list_idx
7339                );
7340            }
7341        }
7342    }
7343
7344    drop(ifc_root_node);
7345
7346    // IMPORTANT: We need to traverse the DOM, not just the layout tree!
7347    //
7348    // According to CSS spec, a block container with inline-level children establishes
7349    // an IFC and should collect ALL inline content, including text nodes.
7350    // Text nodes exist in the DOM but might not have their own layout tree nodes.
7351
7352    // Debug: Check what the node_hierarchy says about this node
7353    let node_hier_item = &ctx.styled_dom.node_hierarchy.as_container()[ifc_root_dom_id];
7354    debug_info!(
7355        ctx,
7356        "[collect_and_measure_inline_content] DEBUG: node_hier_item.first_child={:?}, \
7357         last_child={:?}",
7358        node_hier_item.first_child_id(ifc_root_dom_id),
7359        node_hier_item.last_child_id()
7360    );
7361
7362    let ifc_root_node_data = &ctx.styled_dom.node_data.as_container()[ifc_root_dom_id];
7363
7364    // SPECIAL CASE: If the IFC root itself is a text node (leaf node),
7365    // add its text content directly instead of iterating over children
7366    if let NodeType::Text(ref text_content) = ifc_root_node_data.get_node_type() {
7367        let style = Arc::new(get_style_properties(ctx.styled_dom, ifc_root_dom_id, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height)));
7368        let text_items = split_text_for_whitespace(
7369            ctx.styled_dom,
7370            ifc_root_dom_id,
7371            text_content.as_str(),
7372            &style,
7373        );
7374        content.extend(text_items);
7375        return Ok(());
7376    }
7377
7378    let _ifc_root_node_type = match ifc_root_node_data.get_node_type() {
7379        NodeType::Div => "Div",
7380        NodeType::Text(_) => "Text",
7381        NodeType::Body => "Body",
7382        _ => "Other",
7383    };
7384
7385    // [g138 az-web-lift] Collect `dom_children` HERE — immediately before the loop, AFTER the
7386    // get_node_type() calls above. Those calls were corrupting the `dom_children` Vec's stack-slot
7387    // header (g137 PROVED: `dom_children.len()` reads 1 right after `.collect()` but 0 in the loop's
7388    // `0..len` range a few calls later — the recurring SP-leak / stack-address mis-lift). With NO call
7389    // between this `.collect()` and the loop, the header survives the range evaluation + first index.
7390    let dom_children: Vec<NodeId> = ifc_root_dom_id
7391        .az_children(&ctx.styled_dom.node_hierarchy.as_container())
7392        .collect();
7393    // [g139 az-web-lift] The loop's `dom_children.len()` read MIS-LIFTS to 0 even though the in-memory
7394    // value is 1 (g138: the volatile marker reads 1 but the loop's `0..len` range reads 0 with NOTHING
7395    // between — the optimizer's SROA'd len read is mis-tracked by the lift; only a FORCED/volatile read is
7396    // correct; same Vec-len mis-lift class as the original sret bug, here on std `collect()` which can't be
7397    // out-param'd). Read len via a volatile round-trip (guaranteed-correct, like the marker) and index via
7398    // get_unchecked (the index's bounds-check len read mis-lifts the same way; len is valid → sound).
7399    // [g195 — collect_and_measure_inline_content_impl is DEAD on the web lift (NOT lifted for hello-world
7400    // OR web-nested-text; both lay out via measure_intrinsic_widths + layout_flow instead). So this g139
7401    // Vec-len workaround never executes on the web lift → it's irrelevant/deletable (kept: harmless, and
7402    // unverified-dead for other layouts). The cron's "collect_and_measure Vec-len" target is a DEAD PATH.]
7403    #[cfg(feature = "web_lift")]
7404    let dom_children_len = unsafe {
7405        crate::az_mark((0x606B4) as u32, (dom_children.len() as u32) as u32);
7406        crate::az_mark((0x606A4) as u32, (0x0000_6863u32) as u32);
7407        crate::az_mark_read(0x606B4) as usize
7408    };
7409    #[cfg(not(feature = "web_lift"))]
7410    let dom_children_len = dom_children.len();
7411
7412    for item_idx in 0..dom_children_len {
7413        let dom_child_id = unsafe { *dom_children.get_unchecked(item_idx) };
7414        let content_index = ContentIndex {
7415            run_index: ifc_root_index as u32,
7416            item_index: item_idx as u32,
7417        };
7418
7419        let node_data = &ctx.styled_dom.node_data.as_container()[dom_child_id];
7420        // [g136] loop body entered; capture the FIRST child's node_type (does it read as Text?).
7421        #[cfg(feature = "web_lift")]
7422        unsafe {
7423            if item_idx == 0 {
7424                crate::az_mark((0x606B8) as u32, (match node_data.get_node_type() {
7425                    NodeType::Text(_) => 0xC0DE_7E70u32,
7426                    NodeType::Div => 0xC0DE_D11Fu32,
7427                    NodeType::Body => 0xC0DE_B0D1u32,
7428                    _ => 0xC0DE_0000u32,
7429                }) as u32);
7430            }
7431            crate::az_mark((0x606A4) as u32, (0x0000_6896u32) as u32);
7432        }
7433
7434        // Check if this is a text node
7435        if let NodeType::Text(ref text_content) = node_data.get_node_type() {
7436            debug_info!(
7437                ctx,
7438                "[collect_and_measure_inline_content] OK: Found text node (DOM child {:?}): '{}'",
7439                dom_child_id,
7440                text_content.as_str()
7441            );
7442
7443            // Get style from the TEXT NODE itself (dom_child_id), not the IFC root
7444            // This ensures inline styles like color: #666666 are applied to the text
7445            // Uses split_text_for_whitespace to correctly handle white-space: pre with \n
7446            let style = Arc::new(get_style_properties(ctx.styled_dom, dom_child_id, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height)));
7447            let text_items = split_text_for_whitespace(
7448                ctx.styled_dom,
7449                dom_child_id,
7450                text_content.as_str(),
7451                &style,
7452            );
7453            content.extend(text_items);
7454            // [g136] TEXT branch taken + pushed; content.len now.
7455            #[cfg(feature = "web_lift")]
7456            unsafe {
7457                crate::az_mark((0x606A4) as u32, (0x0000_6905u32) as u32);
7458                crate::az_mark((0x606BC) as u32, (content.len() as u32) as u32);
7459            }
7460
7461            // Set IFC membership on the text node's layout node (if it exists)
7462            // Text nodes may or may not have their own layout tree entry depending on
7463            // whether they're wrapped in an anonymous IFC wrapper
7464            if let Some(&layout_idx) = tree.dom_to_layout.get(&dom_child_id).and_then(|v| v.first()) {
7465                if let Some(warm_mut) = tree.warm_mut(layout_idx) {
7466                    warm_mut.ifc_membership = Some(IfcMembership {
7467                        ifc_id,
7468                        ifc_root_layout_index: ifc_root_index,
7469                        run_index: current_run_index,
7470                    });
7471                }
7472            }
7473            current_run_index += 1;
7474
7475            continue;
7476        }
7477
7478        // A <br> forces a hard line break inside this IFC (see UA css: <br> is
7479        // inline). It needs no layout node of its own — just emit the break.
7480        if matches!(node_data.get_node_type(), NodeType::Br) {
7481            content.push(InlineContent::LineBreak(InlineBreak {
7482                break_type: BreakType::Hard,
7483                clear: ClearType::None,
7484                content_index: content.len(),
7485            }));
7486            continue;
7487        }
7488
7489        // For non-text nodes, find their corresponding layout tree node
7490        let child_index = children
7491            .iter()
7492            .find(|&&idx| {
7493                tree.get(idx)
7494                    .and_then(|n| n.dom_node_id)
7495                    .is_some_and(|id| id == dom_child_id)
7496            })
7497            .copied();
7498
7499        let Some(child_index) = child_index else {
7500            debug_info!(
7501                ctx,
7502                "[collect_and_measure_inline_content] WARN: DOM child {:?} has no layout node",
7503                dom_child_id
7504            );
7505            continue;
7506        };
7507
7508        // [g136] NON-TEXT branch taken (text child mis-classified?) — reached tree.get(child_index).
7509        #[cfg(feature = "web_lift")]
7510        unsafe { crate::az_mark((0x606A4) as u32, (0x0000_6942u32) as u32); }
7511        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
7512        // At this point we have a non-text DOM child with a layout node
7513        let dom_id = child_node.dom_node_id.unwrap();
7514
7515        // +spec:positioning:17239f - abspos elements are taken out of flow
7516        // An out-of-flow child (position:absolute/fixed) is removed from normal flow
7517        // entirely: neither its box nor its (recursively) flattened text may participate
7518        // in this containing block's inline formatting context. It is laid out
7519        // independently by `process_out_of_flow_children`; contributing its content here
7520        // would double-render it at the static position. (Same predicate as
7521        // process_out_of_flow_children.)
7522        if matches!(
7523            get_position_type(ctx.styled_dom, Some(dom_id)),
7524            LayoutPosition::Absolute | LayoutPosition::Fixed
7525        ) {
7526            continue;
7527        }
7528
7529        let display = get_display_property(ctx.styled_dom, Some(dom_id)).unwrap_or_default();
7530        if display != LayoutDisplay::Inline {
7531            // This is an atomic inline-level box (e.g., inline-block, image).
7532            // We must determine its size and baseline before passing it to text3.
7533
7534            // The intrinsic sizing pass has already calculated its preferred size.
7535            let intrinsic_size = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
7536            let box_props = child_node.box_props.unpack();
7537
7538            let styled_node_state = ctx
7539                .styled_dom
7540                .styled_nodes
7541                .as_container()
7542                .get(dom_id)
7543                .map(|n| n.styled_node_state)
7544                .unwrap_or_default();
7545
7546            // Calculate tentative border-box size based on CSS properties
7547            // This correctly handles explicit width/height, box-sizing, and constraints
7548            let tentative_size = crate::solver3::sizing::calculate_used_size_for_node(
7549                ctx.styled_dom,
7550                Some(dom_id),
7551                &constraints.containing_block_size,
7552                intrinsic_size,
7553                &box_props,
7554                &ctx.viewport_size,
7555            )?;
7556
7557            let writing_mode =
7558                get_writing_mode(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7559
7560            // Determine content-box size for laying out children
7561            let content_box_size = box_props.inner_size(tentative_size, writing_mode);
7562
7563            debug_info!(
7564                ctx,
7565                "[collect_and_measure_inline_content] Inline-block NodeId({:?}): \
7566                 tentative_border_box={:?}, content_box={:?}",
7567                dom_id,
7568                tentative_size,
7569                content_box_size
7570            );
7571
7572            // To find its height and baseline, we must lay out its contents.
7573            let child_wm_ctx = super::geometry::WritingModeContext::new(
7574                writing_mode,
7575                get_direction_property(ctx.styled_dom, dom_id, &styled_node_state)
7576                    .unwrap_or_default(),
7577                get_text_orientation_property(ctx.styled_dom, dom_id, &styled_node_state)
7578                    .unwrap_or_default(),
7579            );
7580            let child_constraints = LayoutConstraints {
7581                available_size: LogicalSize::new(content_box_size.width, f32::INFINITY),
7582                writing_mode,
7583                writing_mode_ctx: child_wm_ctx,
7584                // Inline-blocks establish a new BFC, so no state is passed in.
7585                bfc_state: None,
7586                // Does not affect size/baseline of the container.
7587                text_align: TextAlign::Start,
7588                containing_block_size: constraints.containing_block_size,
7589                available_width_type: Text3AvailableSpace::Definite(content_box_size.width),
7590            };
7591
7592            // Drop the immutable borrow before calling layout_formatting_context
7593            drop(child_node);
7594
7595            // Recursively lay out the inline-block to get its final height and baseline.
7596            // Note: This does not affect its final position, only its dimensions.
7597            let mut empty_float_cache = HashMap::new();
7598            let layout_result = layout_formatting_context(
7599                ctx,
7600                tree,
7601                text_cache,
7602                child_index,
7603                &child_constraints,
7604                &mut empty_float_cache,
7605            )?;
7606
7607            let css_height = get_css_height(ctx.styled_dom, dom_id, &styled_node_state);
7608
7609            // Replaced elements (image / VirtualView) have no flow content, so the
7610            // measured content_height is 0 — treat their auto height like an explicit
7611            // height (use the CSS/intrinsic-resolved tentative_size). Fixes 0-height
7612            // images / VirtualViews laid out as atomic inline-blocks.
7613            let is_replaced_atomic = {
7614                let nd = &ctx.styled_dom.node_data.as_container()[dom_id];
7615                matches!(nd.get_node_type(), NodeType::Image(_)) || nd.is_virtual_view_node()
7616            };
7617            // Determine final border-box height
7618            let final_height = match css_height.clone().unwrap_or_default() {
7619                LayoutHeight::Auto if !is_replaced_atomic => {
7620                    // For auto height, add padding and border to the content height
7621                    let content_height = layout_result.output.overflow_size.height;
7622                    content_height
7623                        + box_props.padding.main_sum(writing_mode)
7624                        + box_props.border.main_sum(writing_mode)
7625                }
7626                // Explicit height (calculate_used_size_for_node gave the border-box
7627                // height), OR a replaced element's auto height (intrinsic/CSS-resolved).
7628                _ => tentative_size.height,
7629            };
7630
7631            debug_info!(
7632                ctx,
7633                "[collect_and_measure_inline_content] Inline-block NodeId({:?}): \
7634                 layout_content_height={}, css_height={:?}, final_border_box_height={}",
7635                dom_id,
7636                layout_result.output.overflow_size.height,
7637                css_height,
7638                final_height
7639            );
7640
7641            let final_size = LogicalSize::new(tentative_size.width, final_height);
7642
7643            // Update the node in the tree with its now-known used size.
7644            tree.get_mut(child_index).unwrap().used_size = Some(final_size);
7645
7646            // CSS 2.2 § 10.8.1: For inline-block elements, the baseline is the baseline of the
7647            // last line box in the normal flow, unless it has either no in-flow line boxes or
7648            // if its 'overflow' property has a computed value other than 'visible', in which
7649            // case the baseline is the bottom margin edge.
7650            //
7651            // `layout_result.output.baseline` returns the Y-position of the baseline measured
7652            // from the TOP of the content box. But `get_item_vertical_metrics` expects
7653            // `baseline_offset` to be the distance from the BOTTOM to the baseline.
7654            //
7655            // Conversion: baseline_offset_from_bottom = height - baseline_from_top
7656            //
7657            // If no baseline is found (e.g., the inline-block has no text), or if
7658            // overflow is not 'visible', we fall back to the bottom margin edge
7659            // (baseline_offset = 0, meaning baseline at bottom).
7660            let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7661            let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7662            let overflow_is_visible = matches!(
7663                (overflow_x, overflow_y),
7664                (LayoutOverflow::Visible, LayoutOverflow::Visible)
7665            );
7666            let baseline_from_top = layout_result.output.baseline;
7667            let baseline_offset = match baseline_from_top {
7668                Some(baseline_y) if overflow_is_visible => {
7669                    // baseline_y is measured from top of content box
7670                    // We need to add padding and border to get the position within the border-box
7671                    let content_box_top = box_props.padding.top + box_props.border.top;
7672                    let baseline_from_border_box_top = baseline_y + content_box_top;
7673                    // Convert to distance from bottom
7674                    (final_height - baseline_from_border_box_top).max(0.0)
7675                }
7676                _ => {
7677                    // No baseline found or overflow != visible - use bottom margin edge
7678                    0.0
7679                }
7680            };
7681            
7682            debug_info!(
7683                ctx,
7684                "[collect_and_measure_inline_content] Inline-block NodeId({:?}): \
7685                 baseline_from_top={:?}, final_height={}, baseline_offset_from_bottom={}",
7686                dom_id,
7687                baseline_from_top,
7688                final_height,
7689                baseline_offset
7690            );
7691
7692            // Get margins for inline-block positioning
7693            // For inline-blocks, we need to include margins in the shape size
7694            // so that text3 positions them correctly with spacing
7695            let margin = &box_props.margin;
7696            let margin_box_width = final_size.width + margin.left + margin.right;
7697            let margin_box_height = final_size.height + margin.top + margin.bottom;
7698
7699            // For inline-block shapes, text3 uses the content array index as run_index
7700            // and always item_index=0 for objects. We must match this when inserting into child_map.
7701            let shape_content_index = ContentIndex {
7702                run_index: content.len() as u32,
7703                item_index: 0,
7704            };
7705            // the box used for alignment is the margin box" - using margin_box_width/height here
7706            content.push(InlineContent::Shape(InlineShape {
7707                shape_def: ShapeDefinition::Rectangle {
7708                    size: crate::text3::cache::Size {
7709                        // Use margin-box size for positioning in inline flow
7710                        width: margin_box_width,
7711                        height: margin_box_height,
7712                    },
7713                    corner_radius: None,
7714                },
7715                fill: None,
7716                stroke: None,
7717                // Adjust baseline offset by top margin
7718                baseline_offset: baseline_offset + margin.top,
7719                alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, dom_id),
7720                source_node_id: Some(dom_id),
7721            }));
7722            child_map.insert(shape_content_index, child_index);
7723        } else if let NodeType::Image(image_ref) =
7724            ctx.styled_dom.node_data.as_container()[dom_id].get_node_type()
7725        {
7726            // +spec:replaced-elements:31a782 - replaced elements (img) not rendered purely by CSS box concepts
7727            // Images are replaced elements - they have intrinsic dimensions
7728            // and CSS width/height can constrain them
7729            
7730            // Re-get child_node since we dropped it earlier for the inline-block case
7731            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
7732            let box_props = child_node.box_props.unpack();
7733
7734            // Get intrinsic size from the image data or fall back to layout node
7735            let intrinsic_size = tree.warm(child_index)
7736                .and_then(|w| w.intrinsic_sizes)
7737                .unwrap_or_else(|| IntrinsicSizes {
7738                    max_content_width: 50.0,
7739                    max_content_height: 50.0,
7740                    ..Default::default()
7741                });
7742            
7743            // Get styled node state for CSS property lookup
7744            let styled_node_state = ctx
7745                .styled_dom
7746                .styled_nodes
7747                .as_container()
7748                .get(dom_id)
7749                .map(|n| n.styled_node_state)
7750                .unwrap_or_default();
7751            
7752            // Calculate the used size respecting CSS width/height constraints
7753            let tentative_size = crate::solver3::sizing::calculate_used_size_for_node(
7754                ctx.styled_dom,
7755                Some(dom_id),
7756                &constraints.containing_block_size,
7757                intrinsic_size,
7758                &box_props,
7759                &ctx.viewport_size,
7760            )?;
7761            
7762            // Drop immutable borrow before mutable access
7763            drop(child_node);
7764            
7765            // Set the used_size on the layout node so paint_rect works correctly
7766            let final_size = LogicalSize::new(tentative_size.width, tentative_size.height);
7767            tree.get_mut(child_index).unwrap().used_size = Some(final_size);
7768            
7769            // Calculate display size for text3 (this is what text3 uses for positioning)
7770            let display_width = if final_size.width > 0.0 { 
7771                Some(final_size.width) 
7772            } else { 
7773                None 
7774            };
7775            let display_height = if final_size.height > 0.0 { 
7776                Some(final_size.height) 
7777            } else { 
7778                None 
7779            };
7780            
7781            content.push(InlineContent::Image(InlineImage {
7782                source: ImageSource::Ref(image_ref.as_ref().clone()),
7783                intrinsic_size: crate::text3::cache::Size {
7784                    width: intrinsic_size.max_content_width,
7785                    height: intrinsic_size.max_content_height,
7786                },
7787                display_size: if display_width.is_some() || display_height.is_some() {
7788                    Some(crate::text3::cache::Size {
7789                        width: display_width.unwrap_or(intrinsic_size.max_content_width),
7790                        height: display_height.unwrap_or(intrinsic_size.max_content_height),
7791                    })
7792                } else {
7793                    None
7794                },
7795                // Images are bottom-aligned with the baseline by default
7796                baseline_offset: 0.0,
7797                alignment: text3::cache::VerticalAlign::Baseline,
7798                object_fit: ObjectFit::Fill,
7799            }));
7800            // For images, text3 uses the content array index as run_index
7801            // and always item_index=0 for objects. We must match this.
7802            let image_content_index = ContentIndex {
7803                run_index: (content.len() - 1) as u32,  // -1 because we just pushed
7804                item_index: 0,
7805            };
7806            child_map.insert(image_content_index, child_index);
7807        } else {
7808            // This is a regular inline box (display: inline) - e.g., <span>, <em>, <strong>
7809            //
7810            // According to CSS Inline-3 spec §2, inline boxes are "transparent" wrappers
7811            // We must recursively collect their text children with inherited style
7812            debug_info!(
7813                ctx,
7814                "[collect_and_measure_inline_content] Found inline span (DOM {:?}), recursing",
7815                dom_id
7816            );
7817
7818            let span_style = get_style_properties(ctx.styled_dom, dom_id, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height));
7819            collect_inline_span_recursive(
7820                ctx,
7821                tree,
7822                dom_id,
7823                &span_style,
7824                content,
7825                &children,
7826                constraints,
7827            )?;
7828        }
7829    }
7830    // [g134 az-web-lift DIAG] _impl reached its FINAL return; content.len as _impl sees it.
7831    #[cfg(feature = "web_lift")]
7832    unsafe {
7833        crate::az_mark((0x60698) as u32, (content.len() as u32) as u32);
7834        crate::az_mark((0x6069C) as u32, (0xC0DE069Cu32) as u32);
7835    }
7836    Ok(())
7837}
7838
7839// +spec:display-property:c05c53 - inlinifying boxes can't contain block-level boxes; children are recursively inlinified
7840// it recursively inlinifies all of its in-flow children, so that no block-level descendants
7841// break up the inline formatting context in which it participates.
7842// +spec:display-property:aee879 - recursively inlinifies in-flow children of inline boxes
7843/// Recursively collects inline content from an inline span (display: inline) element.
7844///
7845/// According to CSS Inline Layout Module Level 3 §2:
7846///
7847/// "Inline boxes are transparent wrappers that wrap their content."
7848///
7849/// They don't create a new formatting context - their children participate in the
7850/// same IFC as the parent. This function processes:
7851///
7852/// - Text nodes: collected with the span's inherited style
7853/// - Nested inline spans: recursively descended
7854/// - Inline-blocks, images: measured and added as shapes
7855#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7856fn collect_inline_span_recursive<T: ParsedFontTrait>(
7857    ctx: &mut LayoutContext<'_, T>,
7858    tree: &mut LayoutTree,
7859    span_dom_id: NodeId,
7860    span_style: &StyleProperties,
7861    content: &mut Vec<InlineContent>,
7862    parent_children: &[usize], // Layout tree children of parent IFC
7863    constraints: &LayoutConstraints<'_>,
7864) -> Result<()> {
7865    debug_info!(
7866        ctx,
7867        "[collect_inline_span_recursive] Processing inline span {:?}",
7868        span_dom_id
7869    );
7870
7871    // Get DOM children of this span
7872    let span_dom_children: Vec<NodeId> = span_dom_id
7873        .az_children(&ctx.styled_dom.node_hierarchy.as_container())
7874        .collect();
7875
7876    debug_info!(
7877        ctx,
7878        "[collect_inline_span_recursive] Span has {} DOM children",
7879        span_dom_children.len()
7880    );
7881
7882    // +spec:box-model:b7428d - empty inline boxes still have margins, padding, borders, line-height
7883    // +spec:box-model:cc79a4 - empty inline elements still have margins, padding, borders and line height
7884    if span_dom_children.is_empty() {
7885        let node_state = &ctx.styled_dom.styled_nodes.as_container()[span_dom_id].styled_node_state;
7886        let font_size = get_element_font_size(ctx.styled_dom, span_dom_id, node_state);
7887
7888        let line_height_value = crate::solver3::getters::get_line_height_value(
7889            ctx.styled_dom, span_dom_id, node_state
7890        );
7891        let line_height = line_height_value
7892            .map_or(text3::cache::LineHeight::Normal, |v| {
7893                // Absolute px line-heights are stored as a negative normalized
7894                // value; a positive value is a unitless multiplier of font-size.
7895                let n = v.inner.normalized();
7896                let px = if n < 0.0 { -n } else { n * font_size };
7897                text3::cache::LineHeight::Px(px)
7898            });
7899
7900        let cb_width = constraints.containing_block_size.main(constraints.writing_mode);
7901        let padding_top = get_css_padding_top(ctx.styled_dom, span_dom_id, node_state)
7902            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7903        let padding_bottom = get_css_padding_bottom(ctx.styled_dom, span_dom_id, node_state)
7904            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7905        let padding_left = crate::solver3::getters::get_css_padding_left(ctx.styled_dom, span_dom_id, node_state)
7906            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7907        let padding_right = crate::solver3::getters::get_css_padding_right(ctx.styled_dom, span_dom_id, node_state)
7908            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7909        let border_top = get_css_border_top_width(ctx.styled_dom, span_dom_id, node_state)
7910            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7911        let border_bottom = get_css_border_bottom_width(ctx.styled_dom, span_dom_id, node_state)
7912            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7913        let border_left = crate::solver3::getters::get_css_border_left_width(ctx.styled_dom, span_dom_id, node_state)
7914            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7915        let border_right = crate::solver3::getters::get_css_border_right_width(ctx.styled_dom, span_dom_id, node_state)
7916            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7917        let margin_left = crate::solver3::getters::get_css_margin_left(ctx.styled_dom, span_dom_id, node_state)
7918            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7919        let margin_right = crate::solver3::getters::get_css_margin_right(ctx.styled_dom, span_dom_id, node_state)
7920            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7921
7922        let resolved_line_height = line_height.resolve(font_size, 0.0, 0.0, 0.0, 0);
7923        let total_height = resolved_line_height + padding_top + padding_bottom + border_top + border_bottom;
7924        let total_width = margin_left + padding_left + border_left
7925            + border_right + padding_right + margin_right;
7926
7927        content.push(InlineContent::Shape(InlineShape {
7928            shape_def: ShapeDefinition::Rectangle {
7929                size: crate::text3::cache::Size {
7930                    width: total_width,
7931                    height: total_height,
7932                },
7933                corner_radius: None,
7934            },
7935            fill: None,
7936            stroke: None,
7937            baseline_offset: 0.0,
7938            alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, span_dom_id),
7939            source_node_id: Some(span_dom_id),
7940        }));
7941
7942        return Ok(());
7943    }
7944
7945    for &child_dom_id in &span_dom_children {
7946        let node_data = &ctx.styled_dom.node_data.as_container()[child_dom_id];
7947
7948        // CASE 1: Text node - collect with span's style
7949        if let NodeType::Text(ref text_content) = node_data.get_node_type() {
7950            debug_info!(
7951                ctx,
7952                "[collect_inline_span_recursive] ✓ Found text in span: '{}'",
7953                text_content.as_str()
7954            );
7955            let text_items = split_text_for_whitespace(
7956                ctx.styled_dom,
7957                child_dom_id,
7958                text_content.as_str(),
7959                &Arc::new(span_style.clone()),
7960            );
7961            content.extend(text_items);
7962            continue;
7963        }
7964
7965        // CASE 1b: <br> inside an inline span forces a hard line break.
7966        if matches!(node_data.get_node_type(), NodeType::Br) {
7967            content.push(InlineContent::LineBreak(InlineBreak {
7968                break_type: BreakType::Hard,
7969                clear: ClearType::None,
7970                content_index: content.len(),
7971            }));
7972            continue;
7973        }
7974
7975        // +spec:positioning:17239f - abspos elements are taken out of flow: an
7976        // out-of-flow descendant of an in-flow inline span must not contribute its
7977        // content to the enclosing IFC (it is laid out independently).
7978        if matches!(
7979            get_position_type(ctx.styled_dom, Some(child_dom_id)),
7980            LayoutPosition::Absolute | LayoutPosition::Fixed
7981        ) {
7982            continue;
7983        }
7984
7985        // CASE 2: Element node - check its display type
7986        let child_display =
7987            get_display_property(ctx.styled_dom, Some(child_dom_id)).unwrap_or_default();
7988
7989        // Find the corresponding layout tree node
7990        let child_index = parent_children
7991            .iter()
7992            .find(|&&idx| {
7993                tree.get(idx)
7994                    .and_then(|n| n.dom_node_id)
7995                    .is_some_and(|id| id == child_dom_id)
7996            })
7997            .copied();
7998
7999        match child_display {
8000            LayoutDisplay::Inline => {
8001                // Nested inline span - recurse with child's style
8002                debug_info!(
8003                    ctx,
8004                    "[collect_inline_span_recursive] Found nested inline span {:?}",
8005                    child_dom_id
8006                );
8007                let child_style = get_style_properties(ctx.styled_dom, child_dom_id, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height));
8008                collect_inline_span_recursive(
8009                    ctx,
8010                    tree,
8011                    child_dom_id,
8012                    &child_style,
8013                    content,
8014                    parent_children,
8015                    constraints,
8016                )?;
8017            }
8018            LayoutDisplay::InlineBlock => {
8019                // Inline-block inside span - measure and add as shape
8020                let Some(child_index) = child_index else {
8021                    debug_info!(
8022                        ctx,
8023                        "[collect_inline_span_recursive] WARNING: inline-block {:?} has no layout \
8024                         node",
8025                        child_dom_id
8026                    );
8027                    continue;
8028                };
8029
8030                let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
8031                let intrinsic_size = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
8032                let width = intrinsic_size.max_content_width;
8033
8034                let styled_node_state = ctx
8035                    .styled_dom
8036                    .styled_nodes
8037                    .as_container()
8038                    .get(child_dom_id)
8039                    .map(|n| n.styled_node_state)
8040                    .unwrap_or_default();
8041                let writing_mode =
8042                    get_writing_mode(ctx.styled_dom, child_dom_id, &styled_node_state)
8043                        .unwrap_or_default();
8044                let child_wm_ctx = super::geometry::WritingModeContext::new(
8045                    writing_mode,
8046                    get_direction_property(ctx.styled_dom, child_dom_id, &styled_node_state)
8047                        .unwrap_or_default(),
8048                    get_text_orientation_property(ctx.styled_dom, child_dom_id, &styled_node_state)
8049                        .unwrap_or_default(),
8050                );
8051                let child_constraints = LayoutConstraints {
8052                    available_size: LogicalSize::new(width, f32::INFINITY),
8053                    writing_mode,
8054                    writing_mode_ctx: child_wm_ctx,
8055                    bfc_state: None,
8056                    text_align: TextAlign::Start,
8057                    containing_block_size: constraints.containing_block_size,
8058                    available_width_type: Text3AvailableSpace::Definite(width),
8059                };
8060
8061                drop(child_node);
8062
8063                let mut empty_float_cache = HashMap::new();
8064                let layout_result = layout_formatting_context(
8065                    ctx,
8066                    tree,
8067                    &mut TextLayoutCache::default(),
8068                    child_index,
8069                    &child_constraints,
8070                    &mut empty_float_cache,
8071                )?;
8072                let final_height = layout_result.output.overflow_size.height;
8073                let final_size = LogicalSize::new(width, final_height);
8074
8075                tree.get_mut(child_index).unwrap().used_size = Some(final_size);
8076
8077                // CSS 2.2 § 10.8.1: inline-block baseline fallback
8078                let overflow_x = get_overflow_x(ctx.styled_dom, child_dom_id, &styled_node_state).unwrap_or_default();
8079                let overflow_y = get_overflow_y(ctx.styled_dom, child_dom_id, &styled_node_state).unwrap_or_default();
8080                let overflow_is_visible = matches!(
8081                    (overflow_x, overflow_y),
8082                    (LayoutOverflow::Visible, LayoutOverflow::Visible)
8083                );
8084                let baseline_offset = if overflow_is_visible {
8085                    layout_result.output.baseline.unwrap_or(final_height)
8086                } else {
8087                    final_height
8088                };
8089
8090                content.push(InlineContent::Shape(InlineShape {
8091                    shape_def: ShapeDefinition::Rectangle {
8092                        size: crate::text3::cache::Size {
8093                            width,
8094                            height: final_height,
8095                        },
8096                        corner_radius: None,
8097                    },
8098                    fill: None,
8099                    stroke: None,
8100                    baseline_offset,
8101                    alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, child_dom_id),
8102                    source_node_id: Some(child_dom_id),
8103                }));
8104
8105                // Note: We don't add to child_map here because this is inside a span
8106                debug_info!(
8107                    ctx,
8108                    "[collect_inline_span_recursive] Added inline-block shape {}x{}",
8109                    width,
8110                    final_height
8111                );
8112            }
8113            _ => {
8114                // +spec:display-property:0684c4 - block box inlinified: inner display becomes flow-root (treated as atomic inline)
8115                // in-flow children of an inline box are recursively inlinified so they
8116                // don't break the IFC. Treat them as inline spans and recurse into their
8117                // children to collect text and inline content.
8118                debug_info!(
8119                    ctx,
8120                    "[collect_inline_span_recursive] Inlinifying block-level child {:?} \
8121                     (display: {:?}) inside inline span per css-display-3 §2.7",
8122                    child_dom_id,
8123                    child_display
8124                );
8125                let child_style = get_style_properties(ctx.styled_dom, child_dom_id, ctx.system_style.as_ref(), PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height));
8126                collect_inline_span_recursive(
8127                    ctx,
8128                    tree,
8129                    child_dom_id,
8130                    &child_style,
8131                    content,
8132                    parent_children,
8133                    constraints,
8134                )?;
8135            }
8136        }
8137    }
8138
8139    Ok(())
8140}
8141
8142/// Positions a floated child within the BFC and updates the floating context.
8143/// This function is fully writing-mode aware.
8144fn position_floated_child(
8145    _child_index: usize,
8146    child_margin_box_size: LogicalSize,
8147    float_type: LayoutFloat,
8148    constraints: &LayoutConstraints<'_>,
8149    _bfc_content_box: LogicalRect,
8150    current_main_offset: f32,
8151    floating_context: &mut FloatingContext,
8152) -> Result<LogicalPosition> {
8153    let wm = constraints.writing_mode;
8154    let child_main_size = child_margin_box_size.main(wm);
8155    let child_cross_size = child_margin_box_size.cross(wm);
8156    let bfc_cross_size = constraints.available_size.cross(wm);
8157    let mut placement_main_offset = current_main_offset;
8158
8159    loop {
8160        // 1. Determine the available cross-axis space at the current
8161        // `placement_main_offset`.
8162        let (available_cross_start, available_cross_end) = floating_context
8163            .available_line_box_space(
8164                placement_main_offset,
8165                placement_main_offset + child_main_size,
8166                bfc_cross_size,
8167                wm,
8168            );
8169
8170        let available_cross_width = available_cross_end - available_cross_start;
8171
8172        // 2. Check if the new float can fit in the available space.
8173        if child_cross_size <= available_cross_width {
8174            // It fits! Determine the final position and add it to the context.
8175            // +spec:floats:5cfc93 - float:right positions box at cross-end, content flows on left
8176            let final_cross_pos = match float_type {
8177                LayoutFloat::Left => available_cross_start,
8178                // +spec:floats:5cfc93 - float:right positions box at cross-end, content flows on left
8179                LayoutFloat::Right => available_cross_end - child_cross_size,
8180                LayoutFloat::None => {
8181                    return Err(LayoutError::PositioningFailed);
8182                }
8183            };
8184            let final_pos =
8185                LogicalPosition::from_main_cross(placement_main_offset, final_cross_pos, wm);
8186
8187            let new_float_box = FloatBox {
8188                kind: float_type,
8189                rect: LogicalRect::new(final_pos, child_margin_box_size),
8190                margin: EdgeSizes::default(), // TODO: Pass actual margin if this function is used
8191            };
8192            floating_context.floats.push(new_float_box);
8193            return Ok(final_pos);
8194        }
8195        {
8196            // +spec:floats:3d89d8 - shift float downward when not enough horizontal room
8197            // It doesn't fit. We must move the float down past an obstacle.
8198            // Find the lowest main-axis end of all floats that are blocking
8199            // the current line.
8200            let mut next_main_offset = f32::INFINITY;
8201            for existing_float in &floating_context.floats {
8202                let float_main_start = existing_float.rect.origin.main(wm);
8203                let float_main_end = float_main_start + existing_float.rect.size.main(wm);
8204
8205                // Consider only floats that are above or at the current placement line.
8206                if placement_main_offset < float_main_end {
8207                    next_main_offset = next_main_offset.min(float_main_end);
8208                }
8209            }
8210
8211            if next_main_offset.is_infinite() {
8212                // This indicates an unrecoverable state, e.g., a float wider
8213                // than the container.
8214                return Err(LayoutError::PositioningFailed);
8215            }
8216            placement_main_offset = next_main_offset;
8217        }
8218    }
8219}
8220
8221// CSS Property Getters
8222
8223/// Get the CSS `float` property for a node.
8224fn get_float_property(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> LayoutFloat {
8225    let Some(id) = dom_id else {
8226        return LayoutFloat::None;
8227    };
8228    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
8229    get_float(styled_dom, id, node_state).unwrap_or(LayoutFloat::None)
8230}
8231
8232fn get_clear_property(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> LayoutClear {
8233    let Some(id) = dom_id else {
8234        return LayoutClear::None;
8235    };
8236    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
8237    get_clear(styled_dom, id, node_state).unwrap_or(LayoutClear::None)
8238}
8239/// Helper to determine if scrollbars are needed.
8240///
8241/// # CSS Spec Reference
8242/// CSS Overflow Module Level 3 § 3: Scrollable overflow
8243// +spec:block-formatting-context:50d915 - overflow-x handles horizontal, overflow-y handles vertical
8244// +spec:box-model:63d6f2 - scrollable overflow extends beyond padding edge, needs scroll mechanism
8245// +spec:box-model:45b5fb - scrollbar space subtracted from content area, inserted between inner border edge and outer padding edge
8246// +spec:box-model:70a0a4 - UAs must start assuming no scrollbars needed, recalculate if they are
8247// +spec:box-model:c1b0b2 - scrollbar gutter is space between inner border edge and outer padding edge
8248// +spec:overflow:4f5b99 - scrollable overflow rectangle: content_size is the minimal axis-aligned rect containing scrollable overflow
8249// +spec:overflow:e983f4 - overflow:auto/scroll boxes must allow user to access overflowed content via scrollbars
8250// +spec:overflow:97c257 - relative positioning causing overflow in auto/scroll boxes must trigger scrollbar creation
8251#[must_use] pub fn check_scrollbar_necessity(
8252    content_size: LogicalSize,
8253    container_size: LogicalSize,
8254    overflow_x: OverflowBehavior,
8255    overflow_y: OverflowBehavior,
8256    scrollbar_width_px: f32,
8257) -> ScrollbarRequirements {
8258    // Use epsilon for float comparisons to avoid showing scrollbars due to 
8259    // floating-point rounding errors. Without this, content that exactly fits
8260    // may show scrollbars due to sub-pixel differences (e.g., 299.9999 vs 300.0).
8261    const EPSILON: f32 = 1.0;
8262
8263    // +spec:height-calculation:c5af64 - assume no scrollbars initially; only add if content overflows
8264    // Determine if scrolling is needed based on overflow properties.
8265    // +spec:overflow:30a49c - start assuming no scrollbars, recalculate if needed
8266    // Note: scrollbar_width_px can be 0 for overlay scrollbars (e.g. macOS),
8267    // but we still need to register scroll nodes so that scrolling works —
8268    // overlay scrollbars just don't reserve any layout space.
8269    let mut needs_horizontal = match overflow_x {
8270        OverflowBehavior::Visible | OverflowBehavior::Hidden | OverflowBehavior::Clip => false,
8271        OverflowBehavior::Scroll => true,
8272        OverflowBehavior::Auto => content_size.width > container_size.width + EPSILON,
8273    };
8274
8275    let mut needs_vertical = match overflow_y {
8276        OverflowBehavior::Visible | OverflowBehavior::Hidden | OverflowBehavior::Clip => false,
8277        OverflowBehavior::Scroll => true,
8278        OverflowBehavior::Auto => content_size.height > container_size.height + EPSILON,
8279    };
8280
8281    // +spec:box-model:c3d73f - scrollbar presence affects available content area; padding preserved at scroll end
8282    // +spec:overflow:d79159 - scrollbar sizing: adding a scrollbar reduces available space,
8283    // which may cause content to overflow, confirming the scrollbar is needed (two-pass check)
8284    // A classic layout problem: a vertical scrollbar can reduce horizontal space,
8285    // causing a horizontal scrollbar to appear, which can reduce vertical space...
8286    // A full solution involves a loop, but this two-pass check handles most cases.
8287    // Only relevant when scrollbars reserve layout space (non-overlay).
8288    if scrollbar_width_px > 0.0 {
8289        if needs_vertical && !needs_horizontal && overflow_x == OverflowBehavior::Auto
8290            && content_size.width > (container_size.width - scrollbar_width_px) + EPSILON {
8291                needs_horizontal = true;
8292            }
8293        if needs_horizontal && !needs_vertical && overflow_y == OverflowBehavior::Auto
8294            && content_size.height > (container_size.height - scrollbar_width_px) + EPSILON {
8295                needs_vertical = true;
8296            }
8297    }
8298
8299    ScrollbarRequirements {
8300        needs_horizontal,
8301        needs_vertical,
8302        scrollbar_width: if needs_vertical {
8303            scrollbar_width_px
8304        } else {
8305            0.0
8306        },
8307        scrollbar_height: if needs_horizontal {
8308            scrollbar_width_px
8309        } else {
8310            0.0
8311        },
8312        // visual_width_px is set by the caller (compute_scrollbar_info_core)
8313        // since this function doesn't have access to the CSS style context.
8314        visual_width_px: 0.0,
8315    }
8316}
8317
8318/// Calculates a single collapsed margin from two adjoining vertical margins.
8319///
8320/// Implements the rules from CSS 2.1 section 8.3.1:
8321/// - If both margins are positive, the result is the larger of the two.
8322/// - If both margins are negative, the result is the more negative of the two.
8323/// - If the margins have mixed signs, they are effectively summed.
8324// +spec:margin-collapsing:814a26 - vertical margins between sibling blocks collapse
8325#[must_use] pub fn collapse_margins(a: f32, b: f32) -> f32 {
8326    if a.is_sign_positive() && b.is_sign_positive() {
8327        a.max(b)
8328    } else if a.is_sign_negative() && b.is_sign_negative() {
8329        a.min(b)
8330    } else {
8331        a + b
8332    }
8333}
8334
8335/// Helper function to advance the pen position with margin collapsing.
8336///
8337/// This implements CSS 2.1 margin collapsing for adjacent block-level boxes in a BFC.
8338///
8339/// - `pen` - Current main-axis position (will be modified)
8340/// - `last_margin_bottom` - The bottom margin of the previous in-flow element
8341/// - `current_margin_top` - The top margin of the current element
8342///
8343/// # Returns
8344///
8345/// The new `last_margin_bottom` value (the bottom margin of the current element)
8346///
8347/// # CSS Spec Compliance
8348///
8349/// Per CSS 2.1 Section 8.3.1 "Collapsing margins":
8350///
8351/// - Adjacent vertical margins of block boxes collapse
8352/// - The resulting margin width is the maximum of the adjoining margins (if both positive)
8353/// - Or the sum of the most positive and most negative (if signs differ)
8354fn advance_pen_with_margin_collapse(
8355    pen: &mut f32,
8356    last_margin_bottom: f32,
8357    current_margin_top: f32,
8358) -> f32 {
8359    // Collapse the previous element's bottom margin with current element's top margin
8360    let collapsed_margin = collapse_margins(last_margin_bottom, current_margin_top);
8361
8362    // Advance pen by the collapsed margin
8363    *pen += collapsed_margin;
8364
8365    // Return collapsed_margin so caller knows how much space was actually added
8366    collapsed_margin
8367}
8368
8369/// Checks if an element's border or padding prevents margin collapsing.
8370///
8371/// Per CSS 2.1 Section 8.3.1:
8372///
8373/// - Border between margins prevents collapsing
8374/// - Padding between margins prevents collapsing
8375///
8376/// # Arguments
8377///
8378/// - `box_props` - The box properties containing border and padding
8379/// - `writing_mode` - The writing mode to determine main axis
8380/// - `check_start` - If true, check main-start (top); if false, check main-end (bottom)
8381///
8382/// # Returns
8383///
8384/// `true` if border or padding exists and prevents collapsing
8385// +spec:box-model:ca8ceb - margin collapsing uses block-start/block-end per writing mode
8386fn has_margin_collapse_blocker(
8387    box_props: &crate::solver3::geometry::BoxProps,
8388    writing_mode: LayoutWritingMode,
8389    check_start: bool, // true = check top/start, false = check bottom/end
8390) -> bool {
8391    if check_start {
8392        // Check if there's border-top or padding-top
8393        let border_start = box_props.border.main_start(writing_mode);
8394        let padding_start = box_props.padding.main_start(writing_mode);
8395        border_start > 0.0 || padding_start > 0.0
8396    } else {
8397        // Check if there's border-bottom or padding-bottom
8398        let border_end = box_props.border.main_end(writing_mode);
8399        let padding_end = box_props.padding.main_end(writing_mode);
8400        border_end > 0.0 || padding_end > 0.0
8401    }
8402}
8403
8404/// Checks if an element is empty (has no content).
8405///
8406/// Per CSS 2.1 Section 8.3.1:
8407///
8408/// > If a block element has no border, padding, inline content, height, or min-height,
8409/// > then its top and bottom margins collapse with each other.
8410///
8411/// # Arguments
8412///
8413/// - `node` - The layout node to check
8414///
8415/// # Returns
8416///
8417/// `true` if the element is empty and its margins can collapse internally
8418fn is_empty_block(tree: &LayoutTree, node_index: usize) -> bool {
8419    let Some(node) = tree.get(node_index) else {
8420        return true;
8421    };
8422    // Per CSS 2.2 § 8.3.1: An empty block is one that:
8423    // - Has zero computed 'min-height'
8424    // - Has zero or 'auto' computed 'height'
8425    // - Has no in-flow children
8426    // - Has no line boxes (no text/inline content)
8427
8428    // Check if node has children
8429    if !tree.children(node_index).is_empty() {
8430        return false;
8431    }
8432
8433    // Check if node has inline content (text)
8434    if tree.warm(node_index).and_then(|w| w.inline_layout_result.as_ref()).is_some() {
8435        return false;
8436    }
8437
8438    // Check if node has explicit height > 0
8439    // CSS 2.2 § 8.3.1: Elements with explicit height are NOT empty
8440    if let Some(size) = node.used_size {
8441        if size.height > 0.0 {
8442            return false;
8443        }
8444    }
8445
8446    // Empty block: no children, no inline content, no height
8447    true
8448}
8449
8450/// Generates marker text for a list item marker.
8451///
8452/// This function looks up the counter value from the cache and formats it
8453/// according to the list-style-type property.
8454///
8455/// Per CSS Lists Module Level 3, the `::marker` pseudo-element is the first child
8456/// of the list-item, and references the same DOM node. Counter resolution happens
8457/// on the list-item (parent) node.
8458fn generate_list_marker_text(
8459    tree: &LayoutTree,
8460    styled_dom: &StyledDom,
8461    marker_index: usize,
8462    counters: &HashMap<(usize, String), i32>,
8463    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8464) -> String {
8465    use crate::solver3::counters::format_counter;
8466
8467    // Get the marker node
8468    let Some(marker_node) = tree.get(marker_index) else {
8469        return String::new();
8470    };
8471
8472    // Verify this is actually a ::marker pseudo-element
8473    // Per spec, markers must be pseudo-elements, not anonymous boxes
8474    let marker_pseudo = tree.warm(marker_index).and_then(|w| w.pseudo_element);
8475    let marker_anonymous_type = tree.cold(marker_index).and_then(|c| c.anonymous_type);
8476    if marker_pseudo != Some(PseudoElement::Marker) {
8477        if let Some(msgs) = debug_messages {
8478            msgs.push(LayoutDebugMessage::warning(format!(
8479                "[generate_list_marker_text] WARNING: Node {marker_index} is not a ::marker pseudo-element \
8480                 (pseudo={marker_pseudo:?}, anonymous_type={marker_anonymous_type:?})"
8481            )));
8482        }
8483        // Fallback for old-style anonymous markers during transition
8484        if marker_anonymous_type != Some(AnonymousBoxType::ListItemMarker) {
8485            return String::new();
8486        }
8487    }
8488
8489    // Get the parent list-item node (::marker is first child of list-item)
8490    let Some(list_item_index) = marker_node.parent else {
8491        if let Some(msgs) = debug_messages {
8492            msgs.push(LayoutDebugMessage::error(
8493                "[generate_list_marker_text] ERROR: Marker has no parent".to_string(),
8494            ));
8495        }
8496        return String::new();
8497    };
8498
8499    let Some(list_item_node) = tree.get(list_item_index) else {
8500        return String::new();
8501    };
8502
8503    let Some(list_item_dom_id) = list_item_node.dom_node_id else {
8504        if let Some(msgs) = debug_messages {
8505            msgs.push(LayoutDebugMessage::error(
8506                "[generate_list_marker_text] ERROR: List-item has no DOM ID".to_string(),
8507            ));
8508        }
8509        return String::new();
8510    };
8511
8512    if let Some(msgs) = debug_messages {
8513        msgs.push(LayoutDebugMessage::info(format!(
8514            "[generate_list_marker_text] marker_index={marker_index}, list_item_index={list_item_index}, \
8515             list_item_dom_id={list_item_dom_id:?}"
8516        )));
8517    }
8518
8519    // Get list-style-type from the list-item or its container
8520    let list_container_dom_id = list_item_node.parent.and_then(|grandparent_index| {
8521        tree.get(grandparent_index).and_then(|grandparent| grandparent.dom_node_id)
8522    });
8523
8524    // Try to get list-style-type from the list container first,
8525    // then fall back to the list-item
8526    let list_style_type = list_container_dom_id.map_or_else(|| get_list_style_type(styled_dom, Some(list_item_dom_id)), |container_id| {
8527        let container_type = get_list_style_type(styled_dom, Some(container_id));
8528        if container_type == StyleListStyleType::default() {
8529            get_list_style_type(styled_dom, Some(list_item_dom_id))
8530        } else {
8531            container_type
8532        }
8533    });
8534
8535    // Get the counter value for "list-item" counter from the LIST-ITEM node
8536    // Per CSS spec, counters are scoped to elements, and the list-item counter
8537    // is incremented at the list-item element, not the marker pseudo-element
8538    let counter_value = counters
8539        .get(&(list_item_index, "list-item".to_string()))
8540        .copied()
8541        .unwrap_or_else(|| {
8542            if let Some(msgs) = debug_messages {
8543                msgs.push(LayoutDebugMessage::warning(format!(
8544                    "[generate_list_marker_text] WARNING: No counter found for list-item at index \
8545                     {list_item_index}, defaulting to 1"
8546                )));
8547            }
8548            1
8549        });
8550
8551    if let Some(msgs) = debug_messages {
8552        msgs.push(LayoutDebugMessage::info(format!(
8553            "[generate_list_marker_text] counter_value={counter_value} for list_item_index={list_item_index}"
8554        )));
8555    }
8556
8557    // Format the counter according to the list-style-type
8558    let marker_text = format_counter(counter_value, list_style_type);
8559
8560    // For ordered lists (non-symbolic markers), add a period and space
8561    // For unordered lists (symbolic markers like •, ◦, ▪), just add a space
8562    if matches!(
8563        list_style_type,
8564        StyleListStyleType::Decimal
8565            | StyleListStyleType::DecimalLeadingZero
8566            | StyleListStyleType::LowerAlpha
8567            | StyleListStyleType::UpperAlpha
8568            | StyleListStyleType::LowerRoman
8569            | StyleListStyleType::UpperRoman
8570            | StyleListStyleType::LowerGreek
8571            | StyleListStyleType::UpperGreek
8572    ) {
8573        format!("{marker_text}. ")
8574    } else {
8575        format!("{marker_text} ")
8576    }
8577}
8578
8579/// Generates marker text segments for a list item marker.
8580///
8581/// Simply returns a single `StyledRun` with the marker text using the `base_style`.
8582/// The font stack in `base_style` already includes fallbacks with 100% Unicode coverage,
8583/// so font resolution happens during text shaping, not here.
8584fn generate_list_marker_segments(
8585    tree: &LayoutTree,
8586    styled_dom: &StyledDom,
8587    marker_index: usize,
8588    counters: &HashMap<(usize, String), i32>,
8589    base_style: Arc<StyleProperties>,
8590    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8591) -> Vec<StyledRun> {
8592    // Generate the marker text
8593    let marker_text =
8594        generate_list_marker_text(tree, styled_dom, marker_index, counters, debug_messages);
8595    if marker_text.is_empty() {
8596        return Vec::new();
8597    }
8598
8599    if let Some(msgs) = debug_messages {
8600        let font_families: Vec<&str> = match &base_style.font_stack {
8601            text3::cache::FontStack::Stack(selectors) => {
8602                selectors.iter().map(|f| f.family.as_str()).collect()
8603            }
8604            text3::cache::FontStack::Ref(_) => vec!["<embedded-font>"],
8605        };
8606        msgs.push(LayoutDebugMessage::info(format!(
8607            "[generate_list_marker_segments] Marker text: '{marker_text}' with font stack: {font_families:?}"
8608        )));
8609    }
8610
8611    // Return single segment - font fallback happens during shaping
8612    // List markers are generated content, not from DOM nodes
8613    vec![StyledRun {
8614        text: marker_text,
8615        style: base_style,
8616        logical_start_byte: 0,
8617        source_node_id: None,
8618    }]
8619}
8620
8621/// Returns true if a character has Unicode line breaking class BK (mandatory break)
8622/// or NL (next line). Per CSS Text 3 §5.1, these must be treated as forced line
8623/// breaks regardless of the white-space property value.
8624#[inline]
8625const fn is_bk_or_nl_class(c: char) -> bool {
8626    matches!(c, '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}')
8627}
8628
8629/// Splits text at all forced break points: newlines (\n, \r\n, \r) and BK/NL class chars.
8630/// Used for white-space modes that preserve segment breaks (pre, pre-wrap, pre-line, break-spaces).
8631// +spec:white-space-processing:af4e3f - each newline/segment break in text is treated as a segment break, interpreted per white-space property
8632fn split_at_forced_breaks(text: &str) -> Vec<String> {
8633    let mut segments = Vec::new();
8634    let mut current = String::new();
8635    let mut chars = text.chars().peekable();
8636    while let Some(c) = chars.next() {
8637        if c == '\n' {
8638            segments.push(std::mem::take(&mut current));
8639        } else if c == '\r' {
8640            segments.push(std::mem::take(&mut current));
8641            if chars.peek() == Some(&'\n') {
8642                chars.next();
8643            }
8644        } else if is_bk_or_nl_class(c) {
8645            segments.push(std::mem::take(&mut current));
8646        } else {
8647            current.push(c);
8648        }
8649    }
8650    segments.push(current);
8651    segments
8652}
8653
8654/// Splits text only at BK/NL class characters (not \n which is collapsed in normal/nowrap).
8655/// Used for white-space: normal/nowrap where \n is collapsed to space but BK/NL chars
8656/// still produce forced breaks per CSS Text 3 §5.1.
8657fn split_at_bk_nl_chars(text: &str) -> Vec<String> {
8658    let mut segments = Vec::new();
8659    let mut current = String::new();
8660    for c in text.chars() {
8661        if is_bk_or_nl_class(c) {
8662            segments.push(std::mem::take(&mut current));
8663        } else {
8664            current.push(c);
8665        }
8666    }
8667    segments.push(current);
8668    segments
8669}
8670
8671/// Returns true if the character is East Asian (CJK) for the purposes of
8672/// segment break transformation rules (CSS Text Level 3, §4.1.3).
8673fn is_east_asian_wide(c: char) -> bool {
8674    let cp = c as u32;
8675    // CJK Unified Ideographs
8676    (0x4E00..=0x9FFF).contains(&cp)
8677    || (0x3400..=0x4DBF).contains(&cp)
8678    || (0x20000..=0x2A6DF).contains(&cp)
8679    || (0xF900..=0xFAFF).contains(&cp)
8680    // Hiragana
8681    || (0x3040..=0x309F).contains(&cp)
8682    // Katakana
8683    || (0x30A0..=0x30FF).contains(&cp)
8684    || (0x31F0..=0x31FF).contains(&cp)
8685    // CJK Radicals / Kangxi / Ideographic Description
8686    || (0x2E80..=0x2EFF).contains(&cp)
8687    || (0x2F00..=0x2FDF).contains(&cp)
8688    || (0x2FF0..=0x2FFF).contains(&cp)
8689    // CJK Symbols and Punctuation
8690    || (0x3000..=0x303F).contains(&cp)
8691    || (0x3200..=0x32FF).contains(&cp)
8692    || (0x3300..=0x33FF).contains(&cp)
8693    // Bopomofo
8694    || (0x3100..=0x312F).contains(&cp)
8695    // Hangul Syllables
8696    || (0xAC00..=0xD7AF).contains(&cp)
8697    // Fullwidth forms
8698    || (0xFF01..=0xFF60).contains(&cp)
8699    || (0xFFE0..=0xFFE6).contains(&cp)
8700}
8701
8702// +spec:block-formatting-context:b78223 - fullwidth/wide chars treated as vertical script, halfwidth as horizontal per UAX#11
8703fn is_east_asian_fullwidth_or_wide(ch: char) -> bool {
8704    let cp = ch as u32;
8705    // Exclude Hangul
8706    if (0x1100..=0x11FF).contains(&cp)
8707        || (0x3130..=0x318F).contains(&cp)
8708        || (0xAC00..=0xD7AF).contains(&cp)
8709        || (0xA960..=0xA97F).contains(&cp)
8710        || (0xD7B0..=0xD7FF).contains(&cp)
8711    {
8712        return false;
8713    }
8714    is_east_asian_wide(ch)
8715        || (0xFF61..=0xFFDC).contains(&cp)
8716        || (0xFFE8..=0xFFEE).contains(&cp)
8717        || (0xA000..=0xA4CF).contains(&cp)
8718}
8719
8720/// +spec:white-space-processing:159dbf - segment breaks converted to spaces (default transform)
8721/// +spec:white-space-processing:79891b - segment break transform: convert to space or remove
8722// +spec:white-space-processing:7e9529 - Segment break transformation rules (§4.1.3): collapse consecutive breaks, remove around ZWSP/CJK, else convert to space
8723/// Transforms segment breaks (newlines) in text according to CSS Text Level 3 §4.1.3.
8724/// - If adjacent to a zero-width space (U+200B), the segment break is removed.
8725/// - If both adjacent chars are East Asian F/W/H (not Hangul), removed entirely.
8726/// - Otherwise, converted to a single space.
8727fn apply_segment_break_transform(text: &str) -> String {
8728    let chars: Vec<char> = text.chars().collect();
8729    let len = chars.len();
8730    let mut result = String::with_capacity(text.len());
8731    let mut i = 0;
8732
8733    while i < len {
8734        let ch = chars[i];
8735        if ch == '\n' || ch == '\r' {
8736            let break_end = if ch == '\r' && i + 1 < len && chars[i + 1] == '\n' {
8737                i + 2
8738            } else {
8739                i + 1
8740            };
8741
8742            // +spec:white-space-processing:3c3680 - remove tabs/spaces around segment break before transform
8743            // §4.1.1: remove collapsible whitespace around segment breaks
8744            while result.ends_with(' ') || result.ends_with('\t') {
8745                result.pop();
8746            }
8747
8748            let mut after_idx = break_end;
8749            while after_idx < len && (chars[after_idx] == ' ' || chars[after_idx] == '\t') {
8750                after_idx += 1;
8751            }
8752
8753            let char_before = result.chars().last();
8754            let char_after = if after_idx < len { Some(chars[after_idx]) } else { None };
8755
8756            // Rule 1: adjacent to zero-width space → remove
8757            if char_before == Some('\u{200B}') || char_after == Some('\u{200B}') {
8758                // remove segment break
8759            }
8760            // Rule 2: both sides East Asian F/W/H (not Hangul) → remove
8761            else if let (Some(before), Some(after)) = (char_before, char_after) {
8762                if is_east_asian_fullwidth_or_wide(before) && is_east_asian_fullwidth_or_wide(after) {
8763                    // remove segment break
8764                } else {
8765                    result.push(' ');
8766                }
8767            } else {
8768                result.push(' ');
8769            }
8770
8771            i = after_idx;
8772        } else {
8773            result.push(ch);
8774            i += 1;
8775        }
8776    }
8777
8778    result
8779}
8780
8781// ============================================================================
8782// +spec:white-space-processing:b64e38 - parser may normalize/collapse whitespace before CSS; CSS cannot restore
8783
8784// +spec:display-property:1389e3 - bidi control characters per UAX #9 for Unicode bidirectional algorithm
8785// +spec:display-property:aad99b - inline boxes can be split into fragments due to bidi text processing
8786// Bidi_Control property (UAX #9). These characters are ignored during white-space processing.
8787const fn is_bidi_control(c: char) -> bool {
8788    matches!(c,
8789        '\u{200E}' | // LEFT-TO-RIGHT MARK
8790        '\u{200F}' | // RIGHT-TO-LEFT MARK
8791        '\u{202A}' | // LEFT-TO-RIGHT EMBEDDING
8792        '\u{202B}' | // RIGHT-TO-LEFT EMBEDDING
8793        '\u{202C}' | // POP DIRECTIONAL FORMATTING
8794        '\u{202D}' | // LEFT-TO-RIGHT OVERRIDE
8795        '\u{202E}' | // RIGHT-TO-LEFT OVERRIDE
8796        '\u{2066}' | // LEFT-TO-RIGHT ISOLATE
8797        '\u{2067}' | // RIGHT-TO-LEFT ISOLATE
8798        '\u{2068}' | // FIRST STRONG ISOLATE
8799        '\u{2069}' | // POP DIRECTIONAL ISOLATE
8800        '\u{061C}'   // ARABIC LETTER MARK
8801    )
8802}
8803
8804/// +spec:white-space-processing:1188f6 - only spaces, tabs, and segment breaks are document white space
8805/// Returns true if `c` is a CSS "document white space character" per CSS Text Level 3 §4.1.
8806/// Only spaces (U+0020), tabs (U+0009), and segment breaks (LF, CR, FF) qualify.
8807/// Other Unicode whitespace (e.g. U+00A0 non-breaking space) is NOT document white space.
8808#[inline]
8809const fn is_css_document_whitespace(c: char) -> bool {
8810    matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0C')
8811}
8812
8813// +spec:white-space-processing:efbece - white-space property controls collapsing/preserving of formatting characters for rendering
8814// +spec:writing-modes:b87688 - inlines laid out with bidi reordering and white-space wrapping
8815// +spec:writing-modes:cdd4f1 - white space trimming before bidi reordering preserves end-of-line spaces per UAX9 L1
8816// white space characters are processed prior to line breaking and bidi reordering
8817// +spec:inline-block:381c0c - white-space property: collapsing, wrapping, and forced breaks per mode
8818// +spec:display-property:8acfaa - Phase I white-space collapsing for each inline in an IFC, ignoring bidi controls
8819/// Splits text content into `InlineContent` items based on white-space CSS property.
8820///
8821/// For `white-space: pre`, `pre-wrap`, and `pre-line`, newlines (`\n`) are treated as
8822/// forced line breaks per CSS Text Level 3 specification:
8823/// <https://www.w3.org/TR/css-text-3/#white-space-property>
8824///
8825/// Additionally, Unicode characters with BK or NL line breaking class (VT, FF, NEL, LS, PS)
8826/// are always treated as forced line breaks regardless of the white-space value.
8827///
8828/// This function:
8829/// 1. Checks the white-space property of the node (or its parent for text nodes)
8830/// 2. If `pre`, `pre-wrap`, or `pre-line`: splits text by `\n` and inserts `InlineContent::LineBreak`
8831/// 3. Otherwise: returns the text as a single `InlineContent::Text`
8832/// 4. In ALL modes: BK/NL class chars (VT, FF, NEL, LS, PS) produce forced breaks
8833///
8834/// Returns a Vec of `InlineContent` items that correctly represent line breaks.
8835#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8836pub fn split_text_for_whitespace(
8837    styled_dom: &StyledDom,
8838    dom_id: NodeId,
8839    text: &str,
8840    style: &Arc<StyleProperties>,
8841) -> Vec<InlineContent> {
8842    // (characters with the Bidi_Control property) as if they were not there"
8843    // Strip bidi control characters before white-space processing so they don't
8844    // interfere with collapsing (e.g. a bidi mark between two spaces).
8845    let text_owned;
8846    let text: &str = if text.chars().any(is_bidi_control) {
8847        text_owned = text.chars().filter(|c| !is_bidi_control(*c)).collect::<String>();
8848        &text_owned
8849    } else {
8850        text
8851    };
8852
8853    // Get the white-space property - TEXT NODES inherit from parent!
8854    // We need to check the parent element's white-space, not the text node itself
8855    let node_hierarchy = styled_dom.node_hierarchy.as_container();
8856    let parent_id = node_hierarchy[dom_id].parent_id();
8857    
8858    // Try parent first, then fall back to the node itself
8859    let white_space = parent_id.map_or(StyleWhiteSpace::Normal, |parent| {
8860        let styled_nodes = styled_dom.styled_nodes.as_container();
8861        let parent_state = styled_nodes
8862            .get(parent)
8863            .map(|n| n.styled_node_state)
8864            .unwrap_or_default();
8865        
8866        match get_white_space_property(styled_dom, parent, &parent_state) {
8867            MultiValue::Exact(ws) => ws,
8868            _ => StyleWhiteSpace::Normal,
8869        }
8870    });
8871
8872    let mut result = Vec::new();
8873
8874    // +spec:white-space-processing:3a0f58 - HTML newlines normalized to U+000A, each treated as segment break
8875    // +spec:white-space-processing:6eb1a2 - CR (U+000D) not treated as segment break by HTML; handle if inserted via DOM
8876    // HTML parsers convert \r to \n during preprocessing, but \r can survive
8877    // via escape sequences (e.g. &#x0d;). Any remaining U+000D must be
8878    // treated identically to U+000A (line feed).
8879    let text_cr;
8880    let text: &str = if text.contains('\r') {
8881        text_cr = text.replace("\r\n", "\n").replace('\r', "\n");
8882        &text_cr
8883    } else {
8884        text
8885    };
8886
8887    // +spec:white-space-processing:bd11da - white-space property: new lines, spaces/tabs, wrapping per value table
8888    // +spec:white-space-processing:b166c5 - segment breaks preserved as forced line feeds for pre/pre-wrap/break-spaces/pre-line
8889    // For `pre`, `pre-wrap`, `pre-line`, and `break-spaces`, newlines must be preserved as forced breaks
8890    // CSS Text Level 3: "Newlines in the source will be honored as forced line breaks."
8891    match white_space {
8892        StyleWhiteSpace::Pre | StyleWhiteSpace::PreWrap | StyleWhiteSpace::BreakSpaces => {
8893            // Pre, pre-wrap, break-spaces: preserve whitespace and honor newlines
8894            // Split by newlines and BK/NL class chars, insert LineBreak between parts
8895            // Also handle tab characters (\t) by inserting InlineContent::Tab
8896            let segments = split_at_forced_breaks(text);
8897            let segment_count = segments.len();
8898            let mut content_index = 0;
8899
8900            for (seg_idx, segment) in segments.into_iter().enumerate() {
8901                // Split the segment by tab characters and insert Tab elements
8902                let mut tab_parts = segment.split('\t').peekable();
8903                while let Some(part) = tab_parts.next() {
8904                    if !part.is_empty() {
8905                        result.push(InlineContent::Text(StyledRun {
8906                            text: part.to_string(),
8907                            style: Arc::clone(style),
8908                            logical_start_byte: 0,
8909                            source_node_id: Some(dom_id),
8910                        }));
8911                    }
8912
8913                    if tab_parts.peek().is_some() {
8914                        result.push(InlineContent::Tab { style: Arc::clone(style) });
8915                    }
8916                }
8917
8918                if seg_idx + 1 < segment_count {
8919                    result.push(InlineContent::LineBreak(InlineBreak {
8920                        break_type: BreakType::Hard,
8921                        clear: ClearType::None,
8922                        content_index,
8923                    }));
8924                    content_index += 1;
8925                }
8926            }
8927        }
8928        StyleWhiteSpace::PreLine => {
8929            // Pre-line: collapse whitespace but honor newlines and BK/NL class chars
8930            let segments = split_at_forced_breaks(text);
8931            let segment_count = segments.len();
8932            let mut content_index = 0;
8933
8934            for (seg_idx, segment) in segments.into_iter().enumerate() {
8935                // Collapse only CSS document white space within the line (not all Unicode whitespace)
8936                let collapsed: String = segment
8937                    .split(|c: char| is_css_document_whitespace(c))
8938                    .filter(|s| !s.is_empty())
8939                    .collect::<Vec<_>>()
8940                    .join(" ");
8941
8942                if !collapsed.is_empty() {
8943                    result.push(InlineContent::Text(StyledRun {
8944                        text: collapsed,
8945                        style: Arc::clone(style),
8946                        logical_start_byte: 0,
8947                        source_node_id: Some(dom_id),
8948                    }));
8949                }
8950
8951                if seg_idx + 1 < segment_count {
8952                    result.push(InlineContent::LineBreak(InlineBreak {
8953                        break_type: BreakType::Hard,
8954                        clear: ClearType::None,
8955                        content_index,
8956                    }));
8957                    content_index += 1;
8958                }
8959            }
8960        }
8961        StyleWhiteSpace::Normal | StyleWhiteSpace::Nowrap => {
8962            // +spec:white-space-processing:adbebb - Phase I collapsing for normal/nowrap modes
8963            // CSS Text Level 3, Section 4.1.1 - Phase I: Collapsing and Transformation
8964            // https://www.w3.org/TR/css-text-3/#white-space-phase-1
8965            //
8966            // For `white-space: normal` and `nowrap`:
8967            // 1. Segment breaks are transformed per §4.1.3
8968            // 2. Any sequence of consecutive spaces/tabs is collapsed to a single space
8969            // 3. Leading/trailing spaces at line boundaries are handled during line layout
8970            //
8971            // are forced breaks regardless of white-space value. Split on them first,
8972            // then collapse whitespace within each segment.
8973            let segments = split_at_bk_nl_chars(text);
8974            let segment_count = segments.len();
8975            let mut content_index = 0;
8976
8977            for (seg_idx, segment) in segments.into_iter().enumerate() {
8978                let after_segment_breaks = apply_segment_break_transform(&segment);
8979
8980                // Collapse document white space within this segment (normal/nowrap rules)
8981                let collapsed: String = after_segment_breaks
8982                    .chars()
8983                    .map(|c| if is_css_document_whitespace(c) { ' ' } else { c })
8984                    .collect::<String>()
8985                    .split(' ')
8986                    .filter(|s| !s.is_empty())
8987                    .collect::<Vec<_>>()
8988                    .join(" ");
8989
8990                let final_text = if collapsed.is_empty() && !segment.is_empty() {
8991                    " ".to_string()
8992                } else if !collapsed.is_empty() {
8993                    // Check if original had leading/trailing document whitespace
8994                    let had_leading = segment.chars().next().is_some_and(is_css_document_whitespace);
8995                    let had_trailing = segment.chars().last().is_some_and(is_css_document_whitespace);
8996
8997                    let mut r = String::new();
8998                    if had_leading { r.push(' '); }
8999                    r.push_str(&collapsed);
9000                    if had_trailing && !had_leading { r.push(' '); }
9001                    else if had_trailing && had_leading && collapsed.is_empty() { /* already have one space */ }
9002                    else if had_trailing { r.push(' '); }
9003                    r
9004                } else {
9005                    collapsed
9006                };
9007
9008                if !final_text.is_empty() {
9009                    result.push(InlineContent::Text(StyledRun {
9010                        text: final_text,
9011                        style: Arc::clone(style),
9012                        logical_start_byte: 0,
9013                        source_node_id: Some(dom_id),
9014                    }));
9015                }
9016
9017                // Insert forced break between segments (for BK/NL chars)
9018                if seg_idx + 1 < segment_count {
9019                    result.push(InlineContent::LineBreak(InlineBreak {
9020                        break_type: BreakType::Hard,
9021                        clear: ClearType::None,
9022                        content_index,
9023                    }));
9024                    content_index += 1;
9025                }
9026            }
9027        }
9028    }
9029
9030    // +spec:white-space-processing:5e3f70 - text-transform applied after Phase I collapsing, before Phase II trimming
9031    // This means full-width only transforms spaces (U+0020) to U+3000 IDEOGRAPHIC SPACE
9032    // within preserved white space, because non-preserved spaces were already collapsed in Phase I above.
9033    let text_transform = style.text_transform;
9034    if text_transform != text3::cache::TextTransform::None {
9035        for item in &mut result {
9036            if let InlineContent::Text(run) = item {
9037                run.text = apply_text_transform(&run.text, text_transform);
9038            }
9039        }
9040    }
9041
9042    result
9043}
9044
9045fn apply_text_transform(text: &str, transform: text3::cache::TextTransform) -> String {
9046    use crate::text3::cache::TextTransform;
9047    match transform {
9048        TextTransform::None => text.to_string(),
9049        TextTransform::Uppercase => text.to_uppercase(),
9050        TextTransform::Lowercase => text.to_lowercase(),
9051        TextTransform::Capitalize => {
9052            let mut result = String::with_capacity(text.len());
9053            let mut prev_is_word_boundary = true;
9054            for c in text.chars() {
9055                if prev_is_word_boundary && c.is_alphabetic() {
9056                    for uc in c.to_uppercase() {
9057                        result.push(uc);
9058                    }
9059                    prev_is_word_boundary = false;
9060                } else {
9061                    result.push(c);
9062                    prev_is_word_boundary = c.is_whitespace() || c.is_ascii_punctuation();
9063                }
9064            }
9065            result
9066        }
9067        TextTransform::FullWidth => {
9068            // Full-width transforms ASCII characters to their full-width equivalents.
9069            // Spaces (U+0020) become U+3000 IDEOGRAPHIC SPACE — but only those that
9070            // survived Phase I collapsing (i.e. preserved white space).
9071            text.chars().map(|c| match c {
9072                ' ' => '\u{3000}',  // U+0020 SPACE -> U+3000 IDEOGRAPHIC SPACE
9073                '!' ..= '~' => {
9074                    // ASCII printable range U+0021..U+007E -> fullwidth U+FF01..U+FF5E
9075                    char::from_u32(c as u32 - 0x0021 + 0xFF01).unwrap_or(c)
9076                }
9077                _ => c,
9078            }).collect()
9079        }
9080    }
9081}
9082
9083// ============================================================================
9084// INITIAL LETTER / DROP CAPS STUB
9085// ============================================================================
9086
9087/// Computes the geometric exclusion area for an initial letter (drop cap).
9088///
9089/// CSS Inline Layout Module Level 3, section 3:
9090/// The `initial-letter` property specifies styling for dropped, raised, and sunken
9091/// initial letters. When set, the first glyph(s) of the first line are enlarged to
9092/// span multiple lines, with the remaining text wrapping around them.
9093///
9094// +spec:box-model:c93797 - initial-letter alignment points determined from contents (not border-box)
9095///
9096/// # Algorithm
9097///
9098/// 1. The letter box height spans `size` lines: `height = size * line_height`.
9099/// 2. The letter box width is estimated using a typical capital letter aspect ratio
9100///    (cap-height-to-advance-width ~0.7 for Latin text). A proper implementation
9101///    would measure the actual glyph, but this gives a reasonable default.
9102/// 3. The letter is positioned at the inline-start of the first line.
9103/// 4. The `sink` value determines how many lines the letter drops below the
9104///    first baseline. When `sink == size`, this is a classic drop cap.
9105///    When `sink < size`, the letter rises above the first line (raised cap).
9106/// 5. A small gap (4px default) is added between the letter box and adjacent text.
9107///
9108/// # Parameters
9109/// - `initial_letter_size`: The number of lines the initial letter should span (e.g., 3.0)
9110/// - `initial_letter_sink`: How many lines the letter sinks below the first line
9111/// - `content_box_width`: Available width in the content box (for clamping)
9112/// - `line_height`: The computed line height for the containing block
9113///
9114/// # Returns
9115/// A tuple of `(letter_width, letter_height)` representing the space reserved for
9116/// the initial letter exclusion, or `(0.0, 0.0)` if the parameters are invalid.
9117///
9118/// The caller should use these dimensions to create a float-like exclusion at the
9119/// start of the block container, causing subsequent lines to wrap around the letter.
9120// +spec:width-calculation:7f4f68 - initial-letter-wrap exclusion area (none behavior; first/grid require glyph outlines)
9121#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
9122#[must_use] pub fn layout_initial_letter(
9123    initial_letter_size: f32,
9124    initial_letter_sink: u32,
9125    content_box_width: f32,
9126    line_height: f32,
9127) -> (f32, f32) {
9128    // Estimate the letter width using a typical Latin capital letter aspect ratio.
9129    // The advance width of a capital letter is approximately 0.7x the cap height.
9130    // This is a heuristic; a full implementation would measure the actual glyph(s).
9131    const CAP_WIDTH_RATIO: f32 = 0.7;
9132
9133    // Add a small gap between the letter box and the adjacent inline content.
9134    // CSS Inline Level 3 section 3.5: browsers typically add ~4px padding.
9135    const LETTER_GAP: f32 = 4.0;
9136
9137    // Guard against degenerate values
9138    if initial_letter_size <= 0.0 || line_height <= 0.0 || content_box_width <= 0.0 {
9139        return (0.0, 0.0);
9140    }
9141
9142    // +spec:overflow:dd0679 - auto-sized initial letter content box fits exactly to content; alignment props do not apply
9143    // +spec:width-calculation:170742 - atomic initial letters with auto block size use inline initial letter sizing
9144    // CSS Inline Level 3 section 3.3: The initial letter box height spans `size` lines.
9145    let letter_height = initial_letter_size * line_height;
9146
9147    let letter_width_raw = letter_height * CAP_WIDTH_RATIO;
9148
9149    let letter_width = (letter_width_raw + LETTER_GAP).min(content_box_width);
9150
9151    // +spec:containing-block:67fd99 - block-axis positioning: size >= sink shifts by (sink-1)*line_height toward block-end
9152    // The actual exclusion height accounts for the sink value.
9153    // sink == size means the letter is fully dropped (classic drop cap).
9154    // sink < size means part of the letter rises above the first line (raised cap).
9155    // The exclusion area height is always `sink * line_height` since that's how
9156    // many lines of subsequent text need to wrap around the letter.
9157    let exclusion_height = (initial_letter_sink as f32) * line_height;
9158
9159    // Use the larger of exclusion_height and letter_height as the actual
9160    // vertical space consumed. For raised caps (sink < size), the letter
9161    // extends above the first line but the exclusion only covers sink lines.
9162    // For sunken caps (sink >= size), the exclusion covers the full letter height.
9163    let effective_height = exclusion_height.max(letter_height);
9164
9165    (letter_width, effective_height)
9166}
9167
9168#[cfg(test)]
9169#[allow(clippy::float_cmp, clippy::too_many_lines)]
9170mod autotest_generated {
9171    use azul_core::dom::{AttributeType, Dom, IdOrClass};
9172
9173    use super::*;
9174    use crate::{
9175        solver3::geometry::{MarginAuto, PackedBoxProps},
9176        text3::cache::{OverflowInfo, TextTransform, UnifiedLayout},
9177    };
9178
9179    // ------------------------------------------------------------------
9180    // Fixtures
9181    // ------------------------------------------------------------------
9182
9183    const HTB: LayoutWritingMode = LayoutWritingMode::HorizontalTb;
9184    const VRL: LayoutWritingMode = LayoutWritingMode::VerticalRl;
9185
9186    fn size(w: f32, h: f32) -> LogicalSize {
9187        LogicalSize::new(w, h)
9188    }
9189
9190    fn edges(top: f32, right: f32, bottom: f32, left: f32) -> EdgeSizes {
9191        EdgeSizes {
9192            top,
9193            right,
9194            bottom,
9195            left,
9196        }
9197    }
9198
9199    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
9200        LogicalRect::new(LogicalPosition::new(x, y), size(w, h))
9201    }
9202
9203    fn box_props(margin: EdgeSizes, border: EdgeSizes, padding: EdgeSizes) -> BoxProps {
9204        BoxProps {
9205            margin,
9206            padding,
9207            border,
9208            margin_auto: MarginAuto::default(),
9209        }
9210    }
9211
9212    fn hot(
9213        parent: Option<usize>,
9214        used_size: Option<LogicalSize>,
9215        bp: &BoxProps,
9216    ) -> LayoutNodeHot {
9217        LayoutNodeHot {
9218            box_props: PackedBoxProps::pack(bp),
9219            dom_node_id: None,
9220            used_size,
9221            formatting_context: FormattingContext::Block {
9222                establishes_new_context: false,
9223            },
9224            parent,
9225        }
9226    }
9227
9228    /// Builds a `LayoutTree` from hot nodes + per-node child lists.
9229    fn build_tree(
9230        nodes: Vec<LayoutNodeHot>,
9231        warm: Vec<LayoutNodeWarm>,
9232        child_lists: &[Vec<usize>],
9233    ) -> LayoutTree {
9234        let n = nodes.len();
9235        let mut children_arena: Vec<usize> = Vec::new();
9236        let mut children_offsets: Vec<(u32, u32)> = Vec::with_capacity(n);
9237        for cl in child_lists {
9238            let start = u32::try_from(children_arena.len()).unwrap();
9239            children_arena.extend_from_slice(cl);
9240            children_offsets.push((start, u32::try_from(cl.len()).unwrap()));
9241        }
9242        while children_offsets.len() < n {
9243            children_offsets.push((0, 0));
9244        }
9245        LayoutTree {
9246            nodes,
9247            warm,
9248            cold: vec![LayoutNodeCold::default(); n],
9249            root: 0,
9250            dom_to_layout: BTreeMap::new(),
9251            children_arena,
9252            children_offsets,
9253            subtree_needs_intrinsic: Vec::new(),
9254        }
9255    }
9256
9257    /// An inline layout result carrying `item_count` (always-empty) items.
9258    /// Only `items.is_empty()` is ever inspected by the functions under test.
9259    fn empty_inline_layout() -> CachedInlineLayout {
9260        CachedInlineLayout::new(
9261            Arc::new(UnifiedLayout {
9262                items: Vec::new(),
9263                overflow: OverflowInfo::default(),
9264            }),
9265            Text3AvailableSpace::MaxContent,
9266            false,
9267        )
9268    }
9269
9270    fn constraints(available: LogicalSize, wm: LayoutWritingMode) -> LayoutConstraints<'static> {
9271        LayoutConstraints {
9272            available_size: available,
9273            writing_mode: wm,
9274            writing_mode_ctx: super::super::geometry::WritingModeContext::default(),
9275            bfc_state: None,
9276            text_align: TextAlign::Start,
9277            containing_block_size: available,
9278            available_width_type: Text3AvailableSpace::Definite(available.width),
9279        }
9280    }
9281
9282    fn styled(dom: Dom, css_str: &str) -> StyledDom {
9283        let mut dom = dom;
9284        let (css, _warnings) = azul_css::parser2::new_from_str(css_str);
9285        StyledDom::create(&mut dom, css)
9286    }
9287
9288    /// `body(0) > div.p(1) > text(2)` — DOM ids are the depth-first pre-order index.
9289    fn text_dom(text: &str, css_str: &str) -> StyledDom {
9290        styled(
9291            Dom::create_body().with_child(
9292                Dom::create_div()
9293                    .with_ids_and_classes(vec![IdOrClass::Class("p".into())].into())
9294                    .with_child(Dom::create_text(text)),
9295            ),
9296            css_str,
9297        )
9298    }
9299
9300    const TEXT_NODE: NodeId = NodeId::new(2);
9301    const DIV_NODE: NodeId = NodeId::new(1);
9302
9303    fn plain_style() -> Arc<StyleProperties> {
9304        Arc::new(StyleProperties::default())
9305    }
9306
9307    fn text_of(item: &InlineContent) -> Option<&str> {
9308        match item {
9309            InlineContent::Text(run) => Some(run.text.as_str()),
9310            _ => None,
9311        }
9312    }
9313
9314    // ==================================================================
9315    // OverflowBehavior (predicates)
9316    // ==================================================================
9317
9318    const ALL_OVERFLOW: [OverflowBehavior; 5] = [
9319        OverflowBehavior::Visible,
9320        OverflowBehavior::Hidden,
9321        OverflowBehavior::Clip,
9322        OverflowBehavior::Scroll,
9323        OverflowBehavior::Auto,
9324    ];
9325
9326    #[test]
9327    fn overflow_behavior_is_clipped_is_exhaustive_and_total() {
9328        assert!(!OverflowBehavior::Visible.is_clipped());
9329        assert!(OverflowBehavior::Hidden.is_clipped());
9330        assert!(OverflowBehavior::Clip.is_clipped());
9331        assert!(OverflowBehavior::Scroll.is_clipped());
9332        assert!(OverflowBehavior::Auto.is_clipped());
9333        // Visible is the only non-clipping value.
9334        assert_eq!(ALL_OVERFLOW.iter().filter(|o| o.is_clipped()).count(), 4);
9335    }
9336
9337    #[test]
9338    fn overflow_behavior_is_scroll_only_for_scroll_and_auto() {
9339        assert!(!OverflowBehavior::Visible.is_scroll());
9340        assert!(!OverflowBehavior::Hidden.is_scroll());
9341        assert!(!OverflowBehavior::Clip.is_scroll());
9342        assert!(OverflowBehavior::Scroll.is_scroll());
9343        assert!(OverflowBehavior::Auto.is_scroll());
9344    }
9345
9346    #[test]
9347    fn overflow_behavior_scroll_implies_clipped_invariant() {
9348        // A scrollable box always clips: is_scroll() must be a subset of is_clipped().
9349        for o in ALL_OVERFLOW {
9350            assert!(
9351                !o.is_scroll() || o.is_clipped(),
9352                "{o:?} is scroll but not clipped"
9353            );
9354        }
9355    }
9356
9357    // ==================================================================
9358    // BfcLayoutResult::from_output / BfcState::new / TableLayoutContext::new
9359    // ==================================================================
9360
9361    #[test]
9362    fn bfc_layout_result_from_output_preserves_output_and_nulls_escaped_margins() {
9363        let mut positions = BTreeMap::new();
9364        positions.insert(usize::MAX, LogicalPosition::new(f32::MIN, f32::MAX));
9365        let output = LayoutOutput {
9366            positions,
9367            overflow_size: size(f32::NAN, f32::INFINITY),
9368            baseline: Some(-0.0),
9369        };
9370        let res = BfcLayoutResult::from_output(output);
9371        assert!(res.escaped_top_margin.is_none());
9372        assert!(res.escaped_bottom_margin.is_none());
9373        assert_eq!(res.output.positions.len(), 1);
9374        assert!(res.output.overflow_size.width.is_nan());
9375        assert!(res.output.overflow_size.height.is_infinite());
9376        assert_eq!(res.output.baseline, Some(-0.0));
9377    }
9378
9379    #[test]
9380    fn bfc_state_new_matches_default_and_starts_empty() {
9381        let s = BfcState::new();
9382        assert_eq!(s.pen, LogicalPosition::zero());
9383        assert!(s.floats.floats.is_empty());
9384        assert_eq!(s.margins.last_in_flow_margin_bottom, 0.0);
9385
9386        let d = BfcState::default();
9387        assert_eq!(d.pen, s.pen);
9388        assert!(d.floats.floats.is_empty());
9389    }
9390
9391    #[test]
9392    fn table_layout_context_new_starts_empty_and_separate() {
9393        let t = TableLayoutContext::new();
9394        assert!(t.columns.is_empty());
9395        assert!(t.cells.is_empty());
9396        assert_eq!(t.num_rows, 0);
9397        assert!(!t.use_fixed_layout);
9398        assert!(t.row_heights.is_empty());
9399        assert!(t.row_baselines.is_empty());
9400        assert!(matches!(t.border_collapse, StyleBorderCollapse::Separate));
9401        assert!(t.caption_index.is_none());
9402        assert!(t.collapsed_rows.is_empty());
9403        assert!(t.collapsed_columns.is_empty());
9404        assert!(t.hidden_empty_rows.is_empty());
9405        assert!(t.row_node_indices.is_empty());
9406    }
9407
9408    // ==================================================================
9409    // FloatingContext (numeric)
9410    // ==================================================================
9411
9412    #[test]
9413    fn add_float_accepts_extreme_geometry_without_panicking() {
9414        let mut fc = FloatingContext::default();
9415        fc.add_float(LayoutFloat::Left, rect(0.0, 0.0, 0.0, 0.0), EdgeSizes::default());
9416        fc.add_float(
9417            LayoutFloat::Right,
9418            rect(f32::MIN, f32::MIN, f32::MAX, f32::MAX),
9419            edges(f32::MAX, f32::MAX, f32::MAX, f32::MAX),
9420        );
9421        fc.add_float(
9422            LayoutFloat::None,
9423            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
9424            edges(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0),
9425        );
9426        assert_eq!(fc.floats.len(), 3);
9427        // The context is a pure accumulator: nothing is normalised or rejected.
9428        assert!(fc.floats[2].rect.size.width.is_nan());
9429    }
9430
9431    #[test]
9432    fn available_line_box_space_with_no_floats_returns_the_full_band() {
9433        let fc = FloatingContext::default();
9434        assert_eq!(fc.available_line_box_space(0.0, 0.0, 0.0, HTB), (0.0, 0.0));
9435        assert_eq!(
9436            fc.available_line_box_space(0.0, 20.0, 300.0, HTB),
9437            (0.0, 300.0)
9438        );
9439        // A negative BFC size is passed straight through (start > end: an empty band).
9440        assert_eq!(
9441            fc.available_line_box_space(-50.0, -10.0, -5.0, HTB),
9442            (0.0, -5.0)
9443        );
9444        let (s, e) = fc.available_line_box_space(f32::MIN, f32::MAX, f32::MAX, HTB);
9445        assert_eq!(s, 0.0);
9446        assert_eq!(e, f32::MAX);
9447    }
9448
9449    #[test]
9450    fn available_line_box_space_subtracts_left_and_right_float_margin_boxes() {
9451        let mut fc = FloatingContext::default();
9452        // Left float: content 0..100 on the cross axis, +10px margins on each side.
9453        fc.add_float(
9454            LayoutFloat::Left,
9455            rect(10.0, 10.0, 100.0, 50.0),
9456            edges(10.0, 10.0, 10.0, 10.0),
9457        );
9458        // Right float: content box ends at 290, +10px margin -> starts at 280.
9459        fc.add_float(
9460            LayoutFloat::Right,
9461            rect(200.0, 10.0, 90.0, 50.0),
9462            edges(10.0, 10.0, 10.0, 10.0),
9463        );
9464
9465        // Line inside the floats' main-axis band: both margin boxes are excluded.
9466        let (start, end) = fc.available_line_box_space(10.0, 20.0, 300.0, HTB);
9467        assert_eq!(start, 120.0); // 10 (origin) - 10 (margin) + 100 + 10 + 10
9468        assert_eq!(end, 190.0); // 200 - 10 (left margin of the right float)
9469
9470        // A line entirely below both floats sees the full band again.
9471        assert_eq!(
9472            fc.available_line_box_space(1000.0, 1010.0, 300.0, HTB),
9473            (0.0, 300.0)
9474        );
9475    }
9476
9477    #[test]
9478    fn available_line_box_space_main_axis_overlap_is_half_open() {
9479        let mut fc = FloatingContext::default();
9480        // Margin box spans main 0..50 exactly (no margins).
9481        fc.add_float(
9482            LayoutFloat::Left,
9483            rect(0.0, 0.0, 100.0, 50.0),
9484            EdgeSizes::default(),
9485        );
9486        // A line starting exactly at the float's bottom edge does NOT overlap.
9487        assert_eq!(
9488            fc.available_line_box_space(50.0, 70.0, 300.0, HTB),
9489            (0.0, 300.0)
9490        );
9491        // A zero-height line just inside the float does not overlap either
9492        // (main_end > float_start is false at the very top edge).
9493        assert_eq!(
9494            fc.available_line_box_space(0.0, 0.0, 300.0, HTB),
9495            (0.0, 300.0)
9496        );
9497        // One pixel of overlap is enough.
9498        assert_eq!(
9499            fc.available_line_box_space(49.0, 70.0, 300.0, HTB),
9500            (100.0, 300.0)
9501        );
9502    }
9503
9504    #[test]
9505    fn available_line_box_space_ignores_nan_geometry_instead_of_panicking() {
9506        let mut fc = FloatingContext::default();
9507        fc.add_float(
9508            LayoutFloat::Left,
9509            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
9510            EdgeSizes::default(),
9511        );
9512        // Every NaN comparison is false, so the float never registers as overlapping.
9513        assert_eq!(
9514            fc.available_line_box_space(0.0, 20.0, 300.0, HTB),
9515            (0.0, 300.0)
9516        );
9517        // A NaN query range is likewise inert.
9518        let (s, e) = fc.available_line_box_space(f32::NAN, f32::NAN, 300.0, HTB);
9519        assert_eq!((s, e), (0.0, 300.0));
9520    }
9521
9522    #[test]
9523    fn available_line_box_space_is_writing_mode_aware() {
9524        let mut fc = FloatingContext::default();
9525        // vertical-rl: main = x, cross = y.
9526        fc.add_float(
9527            LayoutFloat::Left,
9528            rect(0.0, 0.0, 50.0, 100.0),
9529            EdgeSizes::default(),
9530        );
9531        // Same rect, read in vertical-rl: main span 0..50 (x), cross 0..100 (y).
9532        assert_eq!(
9533            fc.available_line_box_space(10.0, 20.0, 300.0, VRL),
9534            (100.0, 300.0)
9535        );
9536        // In horizontal-tb the very same float spans main 0..100 (y), cross 0..50 (x).
9537        assert_eq!(
9538            fc.available_line_box_space(10.0, 20.0, 300.0, HTB),
9539            (50.0, 300.0)
9540        );
9541    }
9542
9543    #[test]
9544    fn clearance_offset_never_moves_content_upwards() {
9545        let mut fc = FloatingContext::default();
9546        fc.add_float(
9547            LayoutFloat::Left,
9548            rect(0.0, 0.0, 100.0, 80.0),
9549            edges(0.0, 0.0, 20.0, 0.0), // 20px bottom margin -> outer edge at 100
9550        );
9551        fc.add_float(
9552            LayoutFloat::Right,
9553            rect(200.0, 0.0, 100.0, 40.0),
9554            EdgeSizes::default(),
9555        );
9556
9557        assert_eq!(fc.clearance_offset(LayoutClear::Left, 0.0, HTB), 100.0);
9558        assert_eq!(fc.clearance_offset(LayoutClear::Right, 0.0, HTB), 40.0);
9559        assert_eq!(fc.clearance_offset(LayoutClear::Both, 0.0, HTB), 100.0);
9560        // clear:none never consults the floats.
9561        assert_eq!(fc.clearance_offset(LayoutClear::None, 25.0, HTB), 25.0);
9562        // Already below every float: the pen stays where it is (no negative clearance).
9563        assert_eq!(fc.clearance_offset(LayoutClear::Both, 500.0, HTB), 500.0);
9564    }
9565
9566    #[test]
9567    fn clearance_offset_clamps_negative_offsets_to_zero() {
9568        let fc = FloatingContext::default();
9569        // Documented consequence of `max_end_offset` starting at 0.0: with no floats
9570        // at all, a negative pen is still pulled up to 0 rather than passed through.
9571        assert_eq!(fc.clearance_offset(LayoutClear::Both, -10.0, HTB), 0.0);
9572        assert_eq!(fc.clearance_offset(LayoutClear::None, -10.0, HTB), 0.0);
9573        // Non-negative offsets are untouched.
9574        assert_eq!(fc.clearance_offset(LayoutClear::None, 0.0, HTB), 0.0);
9575    }
9576
9577    #[test]
9578    fn clearance_offset_handles_nan_and_infinite_floats() {
9579        let mut fc = FloatingContext::default();
9580        fc.add_float(
9581            LayoutFloat::Left,
9582            rect(0.0, 0.0, 10.0, f32::INFINITY),
9583            EdgeSizes::default(),
9584        );
9585        assert!(fc.clearance_offset(LayoutClear::Left, 0.0, HTB).is_infinite());
9586
9587        // A NaN pen short-circuits the `>` test and is returned unchanged.
9588        assert!(fc.clearance_offset(LayoutClear::Left, f32::NAN, HTB).is_nan());
9589
9590        let mut nan_fc = FloatingContext::default();
9591        nan_fc.add_float(
9592            LayoutFloat::Left,
9593            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
9594            EdgeSizes::default(),
9595        );
9596        // f32::max() discards NaN, so a NaN float contributes nothing.
9597        assert_eq!(nan_fc.clearance_offset(LayoutClear::Left, 5.0, HTB), 5.0);
9598    }
9599
9600    #[test]
9601    fn clearance_offset_saturates_at_float_extremes() {
9602        let mut fc = FloatingContext::default();
9603        fc.add_float(
9604            LayoutFloat::Right,
9605            rect(f32::MAX, 0.0, f32::MAX, f32::MAX),
9606            edges(0.0, 0.0, f32::MAX, 0.0),
9607        );
9608        let out = fc.clearance_offset(LayoutClear::Right, f32::MIN, HTB);
9609        // MAX + MAX + MAX saturates to +inf rather than wrapping or panicking.
9610        assert!(out.is_infinite() && out.is_sign_positive());
9611    }
9612
9613    // ==================================================================
9614    // position_float (numeric)
9615    // ==================================================================
9616
9617    #[test]
9618    fn position_float_places_left_and_right_floats_at_the_band_edges() {
9619        let fc = FloatingContext::default();
9620        let m = edges(10.0, 10.0, 10.0, 10.0);
9621
9622        let left = position_float(&fc, LayoutFloat::Left, size(100.0, 50.0), &m, 0.0, 300.0, HTB);
9623        assert_eq!(left.origin.x, 10.0); // cross-start + left margin
9624        assert_eq!(left.origin.y, 10.0); // main offset + top margin
9625        assert_eq!(left.size, size(100.0, 50.0)); // the border box is passed through
9626
9627        let right = position_float(&fc, LayoutFloat::Right, size(100.0, 50.0), &m, 0.0, 300.0, HTB);
9628        // 300 - (100 + 10 + 10) + 10 => content box ends at 290, margin box at 300.
9629        assert_eq!(right.origin.x, 190.0);
9630        assert_eq!(right.origin.y, 10.0);
9631    }
9632
9633    #[test]
9634    fn position_float_at_zero_size_and_zero_band() {
9635        let fc = FloatingContext::default();
9636        let r = position_float(
9637            &fc,
9638            LayoutFloat::Left,
9639            size(0.0, 0.0),
9640            &EdgeSizes::default(),
9641            0.0,
9642            0.0,
9643            HTB,
9644        );
9645        assert_eq!(r.origin, LogicalPosition::zero());
9646        assert_eq!(r.size, size(0.0, 0.0));
9647    }
9648
9649    #[test]
9650    fn position_float_wider_than_the_bfc_does_not_hang() {
9651        let fc = FloatingContext::default();
9652        // No float can ever fit; the loop must bail out instead of spinning.
9653        let left = position_float(
9654            &fc,
9655            LayoutFloat::Left,
9656            size(500.0, 50.0),
9657            &EdgeSizes::default(),
9658            0.0,
9659            300.0,
9660            HTB,
9661        );
9662        assert_eq!(left.origin.x, 0.0);
9663
9664        let right = position_float(
9665            &fc,
9666            LayoutFloat::Right,
9667            size(500.0, 50.0),
9668            &EdgeSizes::default(),
9669            0.0,
9670            300.0,
9671            HTB,
9672        );
9673        // Overflows the cross-start edge, but deterministically so.
9674        assert_eq!(right.origin.x, -200.0);
9675    }
9676
9677    #[test]
9678    fn position_float_stacks_then_shifts_down_past_the_lowest_float() {
9679        let mut fc = FloatingContext::default();
9680        fc.add_float(
9681            LayoutFloat::Left,
9682            rect(0.0, 0.0, 100.0, 50.0),
9683            EdgeSizes::default(),
9684        );
9685
9686        // Fits next to the existing float.
9687        let beside = position_float(
9688            &fc,
9689            LayoutFloat::Left,
9690            size(100.0, 50.0),
9691            &EdgeSizes::default(),
9692            0.0,
9693            300.0,
9694            HTB,
9695        );
9696        assert_eq!(beside.origin.x, 100.0);
9697        assert_eq!(beside.origin.y, 0.0);
9698
9699        // Too wide for the remaining 200px -> must drop below the float's bottom edge.
9700        let below = position_float(
9701            &fc,
9702            LayoutFloat::Left,
9703            size(250.0, 50.0),
9704            &EdgeSizes::default(),
9705            0.0,
9706            300.0,
9707            HTB,
9708        );
9709        assert_eq!(below.origin.x, 0.0);
9710        assert_eq!(below.origin.y, 50.0);
9711    }
9712
9713    #[test]
9714    fn position_float_terminates_on_nan_and_infinite_sizes() {
9715        let mut fc = FloatingContext::default();
9716        fc.add_float(
9717            LayoutFloat::Left,
9718            rect(0.0, 0.0, 100.0, 50.0),
9719            EdgeSizes::default(),
9720        );
9721
9722        // NaN cross size: `available_width >= total_cross` is false, and no float can
9723        // report a NaN-overlapping band, so the loop exits on the first pass.
9724        let nan = position_float(
9725            &fc,
9726            LayoutFloat::Left,
9727            size(f32::NAN, f32::NAN),
9728            &EdgeSizes::default(),
9729            0.0,
9730            300.0,
9731            HTB,
9732        );
9733        assert!(nan.size.width.is_nan());
9734        assert!(nan.origin.x.is_finite());
9735
9736        // Infinite size: never fits, drops past the one float, then bails out.
9737        let inf = position_float(
9738            &fc,
9739            LayoutFloat::Left,
9740            size(f32::INFINITY, f32::INFINITY),
9741            &EdgeSizes::default(),
9742            0.0,
9743            300.0,
9744            HTB,
9745        );
9746        assert_eq!(inf.origin.x, 0.0);
9747    }
9748
9749    #[test]
9750    fn position_float_honours_vertical_writing_mode() {
9751        let fc = FloatingContext::default();
9752        // vertical-rl: main = x, cross = y.
9753        let r = position_float(
9754            &fc,
9755            LayoutFloat::Left,
9756            size(50.0, 100.0),
9757            &EdgeSizes::default(),
9758            20.0,
9759            300.0,
9760            VRL,
9761        );
9762        assert_eq!(r.origin.x, 20.0); // main offset lands on x
9763        assert_eq!(r.origin.y, 0.0); // cross-start lands on y
9764    }
9765
9766    // ==================================================================
9767    // position_floated_child (numeric)
9768    // ==================================================================
9769
9770    #[test]
9771    fn position_floated_child_rejects_float_none() {
9772        let mut fc = FloatingContext::default();
9773        let c = constraints(size(300.0, 300.0), HTB);
9774        let out = position_floated_child(
9775            0,
9776            size(10.0, 10.0),
9777            LayoutFloat::None,
9778            &c,
9779            rect(0.0, 0.0, 300.0, 300.0),
9780            0.0,
9781            &mut fc,
9782        );
9783        assert!(matches!(out, Err(LayoutError::PositioningFailed)));
9784        assert!(fc.floats.is_empty());
9785    }
9786
9787    #[test]
9788    fn position_floated_child_places_and_records_the_float() {
9789        let mut fc = FloatingContext::default();
9790        let c = constraints(size(300.0, 300.0), HTB);
9791
9792        let left = position_floated_child(
9793            0,
9794            size(100.0, 50.0),
9795            LayoutFloat::Left,
9796            &c,
9797            rect(0.0, 0.0, 300.0, 300.0),
9798            0.0,
9799            &mut fc,
9800        )
9801        .expect("fits");
9802        assert_eq!(left, LogicalPosition::new(0.0, 0.0));
9803        assert_eq!(fc.floats.len(), 1);
9804
9805        let right = position_floated_child(
9806            1,
9807            size(100.0, 50.0),
9808            LayoutFloat::Right,
9809            &c,
9810            rect(0.0, 0.0, 300.0, 300.0),
9811            0.0,
9812            &mut fc,
9813        )
9814        .expect("fits");
9815        assert_eq!(right, LogicalPosition::new(200.0, 0.0));
9816        assert_eq!(fc.floats.len(), 2);
9817
9818        // Third float is wider than the 100px gap left between them -> pushed below.
9819        let third = position_floated_child(
9820            2,
9821            size(150.0, 50.0),
9822            LayoutFloat::Left,
9823            &c,
9824            rect(0.0, 0.0, 300.0, 300.0),
9825            0.0,
9826            &mut fc,
9827        )
9828        .expect("drops to the next band");
9829        assert_eq!(third, LogicalPosition::new(0.0, 50.0));
9830    }
9831
9832    #[test]
9833    fn position_floated_child_errors_instead_of_looping_when_nothing_can_fit() {
9834        let mut fc = FloatingContext::default();
9835        let c = constraints(size(300.0, 300.0), HTB);
9836        // Wider than the BFC with no float to drop past -> unrecoverable, must Err.
9837        let out = position_floated_child(
9838            0,
9839            size(500.0, 50.0),
9840            LayoutFloat::Left,
9841            &c,
9842            rect(0.0, 0.0, 300.0, 300.0),
9843            0.0,
9844            &mut fc,
9845        );
9846        assert!(matches!(out, Err(LayoutError::PositioningFailed)));
9847        assert!(fc.floats.is_empty());
9848
9849        // Same for a NaN-sized child: it never "fits", and NaN < float_end is false.
9850        let nan = position_floated_child(
9851            0,
9852            size(f32::NAN, f32::NAN),
9853            LayoutFloat::Left,
9854            &c,
9855            rect(0.0, 0.0, 300.0, 300.0),
9856            0.0,
9857            &mut fc,
9858        );
9859        assert!(matches!(nan, Err(LayoutError::PositioningFailed)));
9860    }
9861
9862    // ==================================================================
9863    // taffy translation (round-trip)
9864    // ==================================================================
9865
9866    #[test]
9867    fn translate_taffy_size_round_trips_through_taffy() {
9868        for s in [
9869            size(0.0, 0.0),
9870            size(-0.0, 1.5),
9871            size(f32::MIN, f32::MAX),
9872            size(-1.0, -2.0),
9873            size(f32::INFINITY, f32::NEG_INFINITY),
9874            size(f32::MIN_POSITIVE, f32::EPSILON),
9875        ] {
9876            let t = translate_taffy_size(s);
9877            let back = translate_taffy_size_back(TaffySize {
9878                width: t.width.unwrap(),
9879                height: t.height.unwrap(),
9880            });
9881            assert_eq!(back.width.to_bits(), s.width.to_bits(), "width for {s:?}");
9882            assert_eq!(back.height.to_bits(), s.height.to_bits(), "height for {s:?}");
9883        }
9884    }
9885
9886    #[test]
9887    fn translate_taffy_size_preserves_nan_without_panicking() {
9888        let t = translate_taffy_size(size(f32::NAN, f32::NAN));
9889        assert!(t.width.unwrap().is_nan());
9890        let back = translate_taffy_size_back(TaffySize {
9891            width: t.width.unwrap(),
9892            height: t.height.unwrap(),
9893        });
9894        assert!(back.width.is_nan() && back.height.is_nan());
9895    }
9896
9897    #[test]
9898    fn translate_taffy_point_back_is_a_field_for_field_copy() {
9899        for (x, y) in [
9900            (0.0_f32, 0.0_f32),
9901            (f32::MIN, f32::MAX),
9902            (-1.5, 2.5),
9903            (f32::INFINITY, f32::NEG_INFINITY),
9904        ] {
9905            let p = translate_taffy_point_back(taffy::Point { x, y });
9906            assert_eq!(p.x.to_bits(), x.to_bits());
9907            assert_eq!(p.y.to_bits(), y.to_bits());
9908        }
9909        let nan = translate_taffy_point_back(taffy::Point {
9910            x: f32::NAN,
9911            y: f32::NAN,
9912        });
9913        assert!(nan.x.is_nan() && nan.y.is_nan());
9914    }
9915
9916    // ==================================================================
9917    // resolve_size_metric (numeric)
9918    // ==================================================================
9919
9920    fn resolve(metric: SizeMetric, value: f32) -> f32 {
9921        resolve_size_metric(metric, value, 200.0, size(1000.0, 500.0), 16.0, 10.0)
9922    }
9923
9924    /// The unit conversions divide before multiplying, so they are only exact to
9925    /// within f32 rounding — compare with a tolerance rather than bit-for-bit.
9926    fn approx(actual: f32, expected: f32) {
9927        assert!(
9928            (actual - expected).abs() < 0.001,
9929            "expected ~{expected}, got {actual}"
9930        );
9931    }
9932
9933    #[test]
9934    fn resolve_size_metric_converts_every_unit() {
9935        assert_eq!(resolve(SizeMetric::Px, 42.0), 42.0);
9936        approx(resolve(SizeMetric::Pt, 72.0), 96.0); // 72pt == 96px
9937        assert_eq!(resolve(SizeMetric::Percent, 50.0), 100.0); // 50% of 200
9938        assert_eq!(resolve(SizeMetric::Em, 2.0), 32.0); // 2 * 16px
9939        assert_eq!(resolve(SizeMetric::Rem, 2.0), 20.0); // 2 * 10px root
9940        approx(resolve(SizeMetric::Vw, 10.0), 100.0); // 10% of 1000
9941        approx(resolve(SizeMetric::Vh, 10.0), 50.0); // 10% of 500
9942        assert_eq!(resolve(SizeMetric::Vmin, 100.0), 500.0); // smaller axis
9943        assert_eq!(resolve(SizeMetric::Vmax, 100.0), 1000.0); // larger axis
9944        assert_eq!(resolve(SizeMetric::In, 1.0), 96.0);
9945        approx(resolve(SizeMetric::Cm, 2.54), 96.0);
9946        approx(resolve(SizeMetric::Mm, 25.4), 96.0);
9947    }
9948
9949    #[test]
9950    fn resolve_size_metric_at_zero_and_negative_values() {
9951        for m in [
9952            SizeMetric::Px,
9953            SizeMetric::Pt,
9954            SizeMetric::Percent,
9955            SizeMetric::Em,
9956            SizeMetric::Rem,
9957            SizeMetric::Vw,
9958            SizeMetric::Vh,
9959            SizeMetric::Vmin,
9960            SizeMetric::Vmax,
9961            SizeMetric::In,
9962            SizeMetric::Cm,
9963            SizeMetric::Mm,
9964        ] {
9965            assert_eq!(resolve(m, 0.0), 0.0, "{m:?} at zero");
9966            assert!(resolve(m, -10.0) <= 0.0, "{m:?} keeps the sign of a negative");
9967        }
9968        // Percentages of a negative containing block stay negative.
9969        assert_eq!(
9970            resolve_size_metric(SizeMetric::Percent, 50.0, -200.0, size(0.0, 0.0), 16.0, 16.0),
9971            -100.0
9972        );
9973    }
9974
9975    #[test]
9976    fn resolve_size_metric_saturates_instead_of_panicking_at_the_limits() {
9977        let huge = resolve_size_metric(
9978            SizeMetric::Em,
9979            f32::MAX,
9980            0.0,
9981            size(0.0, 0.0),
9982            f32::MAX,
9983            16.0,
9984        );
9985        assert!(huge.is_infinite(), "MAX * MAX saturates to +inf");
9986
9987        let neg = resolve_size_metric(
9988            SizeMetric::Percent,
9989            f32::MIN,
9990            f32::MAX,
9991            size(0.0, 0.0),
9992            16.0,
9993            16.0,
9994        );
9995        assert!(neg.is_infinite() && neg.is_sign_negative());
9996    }
9997
9998    #[test]
9999    fn resolve_size_metric_propagates_nan_and_inf_without_panicking() {
10000        assert!(resolve(SizeMetric::Px, f32::NAN).is_nan());
10001        assert!(resolve(SizeMetric::Px, f32::INFINITY).is_infinite());
10002        assert!(resolve(SizeMetric::Percent, f32::NAN).is_nan());
10003        // 0 * inf is the classic NaN trap: it must not panic, it just yields NaN.
10004        assert!(resolve_size_metric(
10005            SizeMetric::Em,
10006            0.0,
10007            0.0,
10008            size(0.0, 0.0),
10009            f32::INFINITY,
10010            16.0
10011        )
10012        .is_nan());
10013        // f32::min/max drop NaN, so a half-NaN viewport still resolves vmin/vmax.
10014        let vp = size(f32::NAN, 400.0);
10015        assert_eq!(
10016            resolve_size_metric(SizeMetric::Vmin, 100.0, 0.0, vp, 16.0, 16.0),
10017            400.0
10018        );
10019        assert_eq!(
10020            resolve_size_metric(SizeMetric::Vmax, 100.0, 0.0, vp, 16.0, 16.0),
10021            400.0
10022        );
10023    }
10024
10025    // ==================================================================
10026    // convert_font_style / convert_font_weight (other)
10027    // ==================================================================
10028
10029    #[test]
10030    fn convert_font_style_maps_every_variant() {
10031        assert_eq!(
10032            convert_font_style(StyleFontStyle::Normal),
10033            crate::font_traits::FontStyle::Normal
10034        );
10035        assert_eq!(
10036            convert_font_style(StyleFontStyle::Italic),
10037            crate::font_traits::FontStyle::Italic
10038        );
10039        assert_eq!(
10040            convert_font_style(StyleFontStyle::Oblique),
10041            crate::font_traits::FontStyle::Oblique
10042        );
10043    }
10044
10045    #[test]
10046    fn convert_font_weight_maps_every_variant_monotonically() {
10047        assert_eq!(convert_font_weight(StyleFontWeight::W100), FcWeight::Thin);
10048        assert_eq!(
10049            convert_font_weight(StyleFontWeight::W200),
10050            FcWeight::ExtraLight
10051        );
10052        assert_eq!(convert_font_weight(StyleFontWeight::W300), FcWeight::Light);
10053        assert_eq!(
10054            convert_font_weight(StyleFontWeight::Lighter),
10055            FcWeight::Light
10056        );
10057        assert_eq!(convert_font_weight(StyleFontWeight::Normal), FcWeight::Normal);
10058        assert_eq!(convert_font_weight(StyleFontWeight::W500), FcWeight::Medium);
10059        assert_eq!(
10060            convert_font_weight(StyleFontWeight::W600),
10061            FcWeight::SemiBold
10062        );
10063        assert_eq!(convert_font_weight(StyleFontWeight::Bold), FcWeight::Bold);
10064        assert_eq!(
10065            convert_font_weight(StyleFontWeight::W800),
10066            FcWeight::ExtraBold
10067        );
10068        assert_eq!(convert_font_weight(StyleFontWeight::W900), FcWeight::Black);
10069        assert_eq!(convert_font_weight(StyleFontWeight::Bolder), FcWeight::Black);
10070    }
10071
10072    // ==================================================================
10073    // BorderInfo (other)
10074    // ==================================================================
10075
10076    fn bi(width: f32, style: BorderStyle, source: BorderSource) -> BorderInfo {
10077        BorderInfo::new(width, style, ColorU::BLACK, source)
10078    }
10079
10080    #[test]
10081    fn border_info_new_stores_its_arguments_verbatim() {
10082        let b = BorderInfo::new(f32::NAN, BorderStyle::Dotted, ColorU::RED, BorderSource::Cell);
10083        assert!(b.width.is_nan());
10084        assert_eq!(b.style, BorderStyle::Dotted);
10085        assert_eq!(b.color, ColorU::RED);
10086        assert_eq!(b.source, BorderSource::Cell);
10087    }
10088
10089    #[test]
10090    fn border_style_priority_follows_the_css_ordering() {
10091        assert_eq!(BorderInfo::style_priority(&BorderStyle::Hidden), 255);
10092        assert_eq!(BorderInfo::style_priority(&BorderStyle::None), 0);
10093        // double > solid > dashed > dotted > ridge > outset > groove > inset
10094        let ordered = [
10095            BorderStyle::Double,
10096            BorderStyle::Solid,
10097            BorderStyle::Dashed,
10098            BorderStyle::Dotted,
10099            BorderStyle::Ridge,
10100            BorderStyle::Outset,
10101            BorderStyle::Groove,
10102            BorderStyle::Inset,
10103        ];
10104        for w in ordered.windows(2) {
10105            assert!(
10106                BorderInfo::style_priority(&w[0]) > BorderInfo::style_priority(&w[1]),
10107                "{:?} must outrank {:?}",
10108                w[0],
10109                w[1]
10110            );
10111        }
10112    }
10113
10114    #[test]
10115    fn resolve_conflict_hidden_suppresses_everything() {
10116        let hidden = bi(1.0, BorderStyle::Hidden, BorderSource::Table);
10117        let fat = bi(99.0, BorderStyle::Solid, BorderSource::Cell);
10118        assert!(BorderInfo::resolve_conflict(&hidden, &fat).is_none());
10119        assert!(BorderInfo::resolve_conflict(&fat, &hidden).is_none());
10120    }
10121
10122    #[test]
10123    fn resolve_conflict_none_always_loses() {
10124        let none = bi(50.0, BorderStyle::None, BorderSource::Cell);
10125        let thin = bi(1.0, BorderStyle::Solid, BorderSource::Table);
10126        assert!(BorderInfo::resolve_conflict(&none, &none).is_none());
10127        // 'none' loses even when it is much wider.
10128        assert_eq!(
10129            BorderInfo::resolve_conflict(&none, &thin).unwrap().style,
10130            BorderStyle::Solid
10131        );
10132        assert_eq!(
10133            BorderInfo::resolve_conflict(&thin, &none).unwrap().style,
10134            BorderStyle::Solid
10135        );
10136    }
10137
10138    #[test]
10139    fn resolve_conflict_falls_through_width_then_style_then_source() {
10140        let wide = bi(5.0, BorderStyle::Dotted, BorderSource::Table);
10141        let narrow = bi(2.0, BorderStyle::Double, BorderSource::Cell);
10142        assert_eq!(BorderInfo::resolve_conflict(&wide, &narrow).unwrap().width, 5.0);
10143
10144        // Same width -> style priority decides (double beats dotted).
10145        let a = bi(3.0, BorderStyle::Dotted, BorderSource::Cell);
10146        let b = bi(3.0, BorderStyle::Double, BorderSource::Table);
10147        assert_eq!(
10148            BorderInfo::resolve_conflict(&a, &b).unwrap().style,
10149            BorderStyle::Double
10150        );
10151
10152        // Same width + style -> source priority decides (cell beats table).
10153        let t = bi(3.0, BorderStyle::Solid, BorderSource::Table);
10154        let c = bi(3.0, BorderStyle::Solid, BorderSource::Cell);
10155        assert_eq!(
10156            BorderInfo::resolve_conflict(&t, &c).unwrap().source,
10157            BorderSource::Cell
10158        );
10159        assert_eq!(
10160            BorderInfo::resolve_conflict(&c, &t).unwrap().source,
10161            BorderSource::Cell
10162        );
10163
10164        // Fully tied -> the first (left/top) wins.
10165        let first = BorderInfo::new(3.0, BorderStyle::Solid, ColorU::RED, BorderSource::Row);
10166        let second = BorderInfo::new(3.0, BorderStyle::Solid, ColorU::GREEN, BorderSource::Row);
10167        assert_eq!(
10168            BorderInfo::resolve_conflict(&first, &second).unwrap().color,
10169            ColorU::RED
10170        );
10171    }
10172
10173    #[test]
10174    fn resolve_conflict_with_nan_widths_is_deterministic() {
10175        // Neither `a.width > b.width` nor `b.width > a.width` holds for NaN, so the
10176        // algorithm must fall through to style/source rather than panic or hang.
10177        let nan = bi(f32::NAN, BorderStyle::Solid, BorderSource::Table);
10178        let ok = bi(1.0, BorderStyle::Solid, BorderSource::Cell);
10179        let win = BorderInfo::resolve_conflict(&nan, &ok).unwrap();
10180        assert_eq!(win.source, BorderSource::Cell); // source breaks the tie
10181        assert_eq!(win.width, 1.0);
10182
10183        // Equal source too -> first argument wins, NaN width and all.
10184        let nan_b = bi(f32::NAN, BorderStyle::Solid, BorderSource::Table);
10185        let other = bi(2.0, BorderStyle::Solid, BorderSource::Table);
10186        assert!(BorderInfo::resolve_conflict(&nan_b, &other)
10187            .unwrap()
10188            .width
10189            .is_nan());
10190    }
10191
10192    #[test]
10193    fn resolve_conflict_infinite_width_wins() {
10194        let inf = bi(f32::INFINITY, BorderStyle::Inset, BorderSource::Table);
10195        let solid = bi(f32::MAX, BorderStyle::Solid, BorderSource::Cell);
10196        assert!(BorderInfo::resolve_conflict(&inf, &solid)
10197            .unwrap()
10198            .width
10199            .is_infinite());
10200    }
10201
10202    // ==================================================================
10203    // distribute_cell_width_across_columns (numeric)
10204    // ==================================================================
10205
10206    fn cols(n: usize, min: f32, max: f32) -> Vec<TableColumnInfo> {
10207        (0..n)
10208            .map(|_| TableColumnInfo {
10209                min_width: min,
10210                max_width: max,
10211                computed_width: None,
10212            })
10213            .collect()
10214    }
10215
10216    #[test]
10217    fn distribute_cell_width_spreads_the_deficit_evenly() {
10218        let mut c = cols(2, 10.0, 20.0);
10219        let collapsed = std::collections::HashSet::new();
10220        distribute_cell_width_across_columns(&mut c, 0, 2, 50.0, 30.0, &collapsed);
10221        // min: 50 needed, 20 present -> +15 each. max: 30 needed, 40 present -> untouched.
10222        assert_eq!(c[0].min_width, 25.0);
10223        assert_eq!(c[1].min_width, 25.0);
10224        assert_eq!(c[0].max_width, 20.0);
10225        assert_eq!(c[1].max_width, 20.0);
10226    }
10227
10228    #[test]
10229    fn distribute_cell_width_is_a_noop_when_the_span_overruns_the_columns() {
10230        let mut c = cols(2, 10.0, 20.0);
10231        let collapsed = std::collections::HashSet::new();
10232        distribute_cell_width_across_columns(&mut c, 1, 5, 500.0, 500.0, &collapsed);
10233        assert_eq!(c[0].min_width, 10.0);
10234        assert_eq!(c[1].min_width, 10.0);
10235
10236        // start_col past the end, and the degenerate usize::MAX start with colspan 0.
10237        distribute_cell_width_across_columns(&mut c, 99, 1, 500.0, 500.0, &collapsed);
10238        distribute_cell_width_across_columns(&mut c, usize::MAX, 0, 500.0, 500.0, &collapsed);
10239        assert_eq!(c[0].min_width, 10.0);
10240    }
10241
10242    #[test]
10243    fn distribute_cell_width_with_zero_colspan_does_not_divide_by_zero() {
10244        let mut c = cols(2, 10.0, 20.0);
10245        let collapsed = std::collections::HashSet::new();
10246        distribute_cell_width_across_columns(&mut c, 0, 0, 1000.0, 1000.0, &collapsed);
10247        assert_eq!(c[0].min_width, 10.0);
10248        assert_eq!(c[1].min_width, 10.0);
10249        assert!(c[0].min_width.is_finite());
10250    }
10251
10252    #[test]
10253    fn distribute_cell_width_skips_fully_collapsed_spans() {
10254        let mut c = cols(2, 10.0, 20.0);
10255        let collapsed: std::collections::HashSet<usize> = [0, 1].into_iter().collect();
10256        distribute_cell_width_across_columns(&mut c, 0, 2, 1000.0, 1000.0, &collapsed);
10257        assert_eq!(c[0].min_width, 10.0);
10258        assert_eq!(c[1].min_width, 10.0);
10259
10260        // A partially collapsed span puts the whole deficit on the visible column.
10261        let collapsed_one: std::collections::HashSet<usize> = [0].into_iter().collect();
10262        distribute_cell_width_across_columns(&mut c, 0, 2, 100.0, 0.0, &collapsed_one);
10263        assert_eq!(c[0].min_width, 10.0, "collapsed column is untouched");
10264        assert_eq!(c[1].min_width, 100.0, "10 + (100 - 10) / 1");
10265    }
10266
10267    #[test]
10268    fn distribute_cell_width_ignores_nan_and_saturates_on_inf() {
10269        let mut c = cols(2, 10.0, 20.0);
10270        let collapsed = std::collections::HashSet::new();
10271        // NaN > total is false -> no distribution, no NaN poisoning of the columns.
10272        distribute_cell_width_across_columns(&mut c, 0, 2, f32::NAN, f32::NAN, &collapsed);
10273        assert_eq!(c[0].min_width, 10.0);
10274        assert_eq!(c[1].max_width, 20.0);
10275
10276        // Infinite demand saturates rather than panicking.
10277        distribute_cell_width_across_columns(
10278            &mut c,
10279            0,
10280            2,
10281            f32::INFINITY,
10282            f32::INFINITY,
10283            &collapsed,
10284        );
10285        assert!(c[0].min_width.is_infinite());
10286        assert!(c[1].max_width.is_infinite());
10287    }
10288
10289    #[test]
10290    fn distribute_cell_width_does_not_shrink_columns() {
10291        let mut c = cols(2, 100.0, 200.0);
10292        let collapsed = std::collections::HashSet::new();
10293        // The cell is narrower than what the columns already provide -> no change.
10294        distribute_cell_width_across_columns(&mut c, 0, 2, 1.0, 1.0, &collapsed);
10295        assert_eq!(c[0].min_width, 100.0);
10296        assert_eq!(c[0].max_width, 200.0);
10297        // Negative demand likewise cannot pull the columns below zero.
10298        distribute_cell_width_across_columns(&mut c, 0, 2, -1000.0, -1000.0, &collapsed);
10299        assert_eq!(c[1].min_width, 100.0);
10300    }
10301
10302    // ==================================================================
10303    // is_cell_empty / is_empty_block / compute_cell_baseline
10304    // ==================================================================
10305
10306    #[test]
10307    fn is_cell_empty_treats_missing_and_childless_cells_as_empty() {
10308        let tree = build_tree(
10309            vec![hot(None, Some(size(10.0, 10.0)), &BoxProps::default())],
10310            vec![LayoutNodeWarm::default()],
10311            &[vec![]],
10312        );
10313        assert!(is_cell_empty(&tree, 0), "no children => empty");
10314        assert!(is_cell_empty(&tree, 1), "out-of-range index => empty");
10315        assert!(is_cell_empty(&tree, usize::MAX), "usize::MAX must not panic");
10316    }
10317
10318    #[test]
10319    fn is_cell_empty_uses_the_inline_layout_when_present() {
10320        let bp = BoxProps::default();
10321        let mut warm = vec![LayoutNodeWarm::default(), LayoutNodeWarm::default()];
10322        // Cell (0) has a child (1) but an inline layout with no items => empty.
10323        warm[0].inline_layout_result = Some(empty_inline_layout());
10324        let tree = build_tree(
10325            vec![
10326                hot(None, Some(size(10.0, 10.0)), &bp),
10327                hot(Some(0), Some(size(10.0, 10.0)), &bp),
10328            ],
10329            warm,
10330            &[vec![1], vec![]],
10331        );
10332        assert!(is_cell_empty(&tree, 0));
10333
10334        // Same tree without the inline layout: children alone mean "not empty".
10335        let tree2 = build_tree(
10336            vec![
10337                hot(None, Some(size(10.0, 10.0)), &bp),
10338                hot(Some(0), Some(size(10.0, 10.0)), &bp),
10339            ],
10340            vec![LayoutNodeWarm::default(), LayoutNodeWarm::default()],
10341            &[vec![1], vec![]],
10342        );
10343        assert!(!is_cell_empty(&tree2, 0));
10344    }
10345
10346    #[test]
10347    fn is_empty_block_requires_no_children_no_inline_content_and_no_height() {
10348        let bp = BoxProps::default();
10349
10350        // Missing node => vacuously empty.
10351        let empty_tree = build_tree(Vec::new(), Vec::new(), &[]);
10352        assert!(is_empty_block(&empty_tree, 0));
10353        assert!(is_empty_block(&empty_tree, usize::MAX));
10354
10355        // No children, no used_size => empty.
10356        let t = build_tree(
10357            vec![hot(None, None, &bp)],
10358            vec![LayoutNodeWarm::default()],
10359            &[vec![]],
10360        );
10361        assert!(is_empty_block(&t, 0));
10362
10363        // Zero and negative heights still count as empty (the check is `> 0.0`).
10364        for h in [0.0, -5.0] {
10365            let t = build_tree(
10366                vec![hot(None, Some(size(100.0, h)), &bp)],
10367                vec![LayoutNodeWarm::default()],
10368                &[vec![]],
10369            );
10370            assert!(is_empty_block(&t, 0), "height {h} must count as empty");
10371        }
10372
10373        // A positive height makes it non-empty.
10374        let t = build_tree(
10375            vec![hot(None, Some(size(100.0, 0.5)), &bp)],
10376            vec![LayoutNodeWarm::default()],
10377            &[vec![]],
10378        );
10379        assert!(!is_empty_block(&t, 0));
10380
10381        // Children make it non-empty.
10382        let t = build_tree(
10383            vec![hot(None, None, &bp), hot(Some(0), None, &bp)],
10384            vec![LayoutNodeWarm::default(), LayoutNodeWarm::default()],
10385            &[vec![1], vec![]],
10386        );
10387        assert!(!is_empty_block(&t, 0));
10388
10389        // An inline layout result makes it non-empty even if it has no items.
10390        let mut warm = vec![LayoutNodeWarm::default()];
10391        warm[0].inline_layout_result = Some(empty_inline_layout());
10392        let t = build_tree(vec![hot(None, None, &bp)], warm, &[vec![]]);
10393        assert!(!is_empty_block(&t, 0));
10394    }
10395
10396    #[test]
10397    fn compute_cell_baseline_falls_back_to_the_content_edge() {
10398        let bp = box_props(
10399            EdgeSizes::default(),
10400            edges(0.0, 0.0, 2.0, 0.0), // border-bottom: 2
10401            edges(0.0, 0.0, 5.0, 0.0), // padding-bottom: 5
10402        );
10403        let tree = build_tree(
10404            vec![hot(None, Some(size(50.0, 100.0)), &bp)],
10405            vec![LayoutNodeWarm::default()],
10406            &[vec![]],
10407        );
10408        // No line box: baseline == bottom of the content edge (100 - 5 - 2).
10409        assert_eq!(compute_cell_baseline(0, &tree), 93.0);
10410
10411        // Missing node => 0.0, never a panic.
10412        assert_eq!(compute_cell_baseline(usize::MAX, &tree), 0.0);
10413    }
10414
10415    #[test]
10416    fn compute_cell_baseline_without_used_size_can_go_negative() {
10417        let bp = box_props(
10418            EdgeSizes::default(),
10419            edges(0.0, 0.0, 2.0, 0.0),
10420            edges(0.0, 0.0, 5.0, 0.0),
10421        );
10422        let tree = build_tree(
10423            vec![hot(None, None, &bp)],
10424            vec![LayoutNodeWarm::default()],
10425            &[vec![]],
10426        );
10427        // used_size defaults to 0x0, so the baseline is -(padding + border).
10428        assert_eq!(compute_cell_baseline(0, &tree), -7.0);
10429    }
10430
10431    // ==================================================================
10432    // check_scrollbar_necessity (numeric)
10433    // ==================================================================
10434
10435    #[test]
10436    fn check_scrollbar_necessity_never_scrolls_for_visible_hidden_or_clip() {
10437        for o in [
10438            OverflowBehavior::Visible,
10439            OverflowBehavior::Hidden,
10440            OverflowBehavior::Clip,
10441        ] {
10442            let r = check_scrollbar_necessity(size(9999.0, 9999.0), size(10.0, 10.0), o, o, 16.0);
10443            assert!(!r.needs_horizontal, "{o:?}");
10444            assert!(!r.needs_vertical, "{o:?}");
10445            assert_eq!(r.scrollbar_width, 0.0);
10446            assert_eq!(r.scrollbar_height, 0.0);
10447        }
10448    }
10449
10450    #[test]
10451    fn check_scrollbar_necessity_always_scrolls_for_scroll_even_with_no_content() {
10452        let r = check_scrollbar_necessity(
10453            size(0.0, 0.0),
10454            size(500.0, 500.0),
10455            OverflowBehavior::Scroll,
10456            OverflowBehavior::Scroll,
10457            16.0,
10458        );
10459        assert!(r.needs_horizontal && r.needs_vertical);
10460        assert_eq!(r.scrollbar_width, 16.0);
10461        assert_eq!(r.scrollbar_height, 16.0);
10462    }
10463
10464    #[test]
10465    fn check_scrollbar_necessity_auto_honours_the_one_pixel_epsilon() {
10466        let auto = OverflowBehavior::Auto;
10467        // Exactly at the epsilon boundary: 301 is NOT > 300 + 1.
10468        let r = check_scrollbar_necessity(size(301.0, 301.0), size(300.0, 300.0), auto, auto, 0.0);
10469        assert!(!r.needs_horizontal);
10470        assert!(!r.needs_vertical);
10471
10472        // One ulp past the boundary triggers both.
10473        let r = check_scrollbar_necessity(size(302.0, 302.0), size(300.0, 300.0), auto, auto, 0.0);
10474        assert!(r.needs_horizontal && r.needs_vertical);
10475        // Overlay scrollbars: needed, but they reserve no layout space.
10476        assert_eq!(r.scrollbar_width, 0.0);
10477        assert_eq!(r.scrollbar_height, 0.0);
10478    }
10479
10480    #[test]
10481    fn check_scrollbar_necessity_two_pass_adds_the_second_scrollbar() {
10482        let auto = OverflowBehavior::Auto;
10483        // Vertically overflowing; horizontally it fits exactly — until the 16px
10484        // vertical scrollbar eats into the width.
10485        let r = check_scrollbar_necessity(size(300.0, 400.0), size(300.0, 300.0), auto, auto, 16.0);
10486        assert!(r.needs_vertical);
10487        assert!(r.needs_horizontal, "vertical scrollbar must force a horizontal one");
10488        assert_eq!(r.scrollbar_width, 16.0);
10489        assert_eq!(r.scrollbar_height, 16.0);
10490
10491        // With overlay scrollbars (0px) the second pass is skipped.
10492        let r = check_scrollbar_necessity(size(300.0, 400.0), size(300.0, 300.0), auto, auto, 0.0);
10493        assert!(r.needs_vertical);
10494        assert!(!r.needs_horizontal);
10495    }
10496
10497    #[test]
10498    fn check_scrollbar_necessity_with_nan_and_negative_sizes_is_deterministic() {
10499        let auto = OverflowBehavior::Auto;
10500        // NaN comparisons are false => no scrollbars, no panic.
10501        let r = check_scrollbar_necessity(
10502            size(f32::NAN, f32::NAN),
10503            size(300.0, 300.0),
10504            auto,
10505            auto,
10506            16.0,
10507        );
10508        assert!(!r.needs_horizontal && !r.needs_vertical);
10509
10510        // A NaN container is equally inert.
10511        let r = check_scrollbar_necessity(
10512            size(500.0, 500.0),
10513            size(f32::NAN, f32::NAN),
10514            auto,
10515            auto,
10516            16.0,
10517        );
10518        assert!(!r.needs_horizontal && !r.needs_vertical);
10519
10520        // Negative container sizes: content trivially overflows, both appear.
10521        let r = check_scrollbar_necessity(
10522            size(0.0, 0.0),
10523            size(-100.0, -100.0),
10524            auto,
10525            auto,
10526            16.0,
10527        );
10528        assert!(r.needs_horizontal && r.needs_vertical);
10529
10530        // Infinite content overflows anything finite.
10531        let r = check_scrollbar_necessity(
10532            size(f32::INFINITY, f32::INFINITY),
10533            size(300.0, 300.0),
10534            auto,
10535            auto,
10536            16.0,
10537        );
10538        assert!(r.needs_horizontal && r.needs_vertical);
10539    }
10540
10541    #[test]
10542    fn check_scrollbar_necessity_with_negative_scrollbar_width_skips_the_second_pass() {
10543        let auto = OverflowBehavior::Auto;
10544        let r =
10545            check_scrollbar_necessity(size(300.0, 400.0), size(300.0, 300.0), auto, auto, -16.0);
10546        assert!(r.needs_vertical);
10547        assert!(!r.needs_horizontal, "the `> 0.0` guard skips the two-pass check");
10548        assert_eq!(r.scrollbar_width, -16.0); // passed through verbatim
10549    }
10550
10551    // ==================================================================
10552    // collapse_margins / advance_pen_with_margin_collapse (numeric)
10553    // ==================================================================
10554
10555    #[test]
10556    fn collapse_margins_follows_css_2_1_section_8_3_1() {
10557        assert_eq!(collapse_margins(10.0, 20.0), 20.0); // both positive -> max
10558        assert_eq!(collapse_margins(-10.0, -20.0), -20.0); // both negative -> min
10559        assert_eq!(collapse_margins(20.0, -5.0), 15.0); // mixed -> sum
10560        assert_eq!(collapse_margins(-5.0, 20.0), 15.0);
10561        assert_eq!(collapse_margins(0.0, 0.0), 0.0);
10562        assert_eq!(collapse_margins(0.0, 10.0), 10.0);
10563        // +0.0 counts as positive, so a zero/negative pair is summed.
10564        assert_eq!(collapse_margins(0.0, -5.0), -5.0);
10565    }
10566
10567    #[test]
10568    fn collapse_margins_is_commutative_for_finite_inputs() {
10569        let vals = [-100.0_f32, -1.0, -0.5, 0.0, 0.5, 1.0, 100.0, f32::MAX, f32::MIN];
10570        for a in vals {
10571            for b in vals {
10572                let ab = collapse_margins(a, b);
10573                let ba = collapse_margins(b, a);
10574                assert_eq!(ab.to_bits(), ba.to_bits(), "collapse_margins({a}, {b})");
10575            }
10576        }
10577    }
10578
10579    #[test]
10580    fn collapse_margins_saturates_and_defines_nan_inf_behaviour() {
10581        assert!(collapse_margins(f32::MAX, -f32::MAX).abs() < 1.0); // sum, no overflow
10582        assert_eq!(collapse_margins(f32::INFINITY, 5.0), f32::INFINITY);
10583        assert_eq!(collapse_margins(f32::NEG_INFINITY, -5.0), f32::NEG_INFINITY);
10584        // +inf and -inf have mixed signs -> summed -> NaN (must not panic).
10585        assert!(collapse_margins(f32::INFINITY, f32::NEG_INFINITY).is_nan());
10586        // f32::max/min discard NaN, so a NaN margin is ignored rather than propagated.
10587        assert_eq!(collapse_margins(f32::NAN, 1.0), 1.0);
10588        assert_eq!(collapse_margins(1.0, f32::NAN), 1.0);
10589        assert_eq!(collapse_margins(-f32::NAN, -1.0), -1.0);
10590    }
10591
10592    #[test]
10593    fn advance_pen_with_margin_collapse_advances_by_exactly_the_collapsed_margin() {
10594        let mut pen = 100.0_f32;
10595        let collapsed = advance_pen_with_margin_collapse(&mut pen, 10.0, 20.0);
10596        assert_eq!(collapsed, 20.0);
10597        assert_eq!(pen, 120.0);
10598
10599        // Mixed signs pull the pen back up.
10600        let mut pen = 100.0_f32;
10601        let collapsed = advance_pen_with_margin_collapse(&mut pen, 30.0, -10.0);
10602        assert_eq!(collapsed, 20.0);
10603        assert_eq!(pen, 120.0);
10604
10605        // Zero margins leave the pen alone.
10606        let mut pen = 7.5_f32;
10607        assert_eq!(advance_pen_with_margin_collapse(&mut pen, 0.0, 0.0), 0.0);
10608        assert_eq!(pen, 7.5);
10609    }
10610
10611    #[test]
10612    fn advance_pen_with_margin_collapse_saturates_at_the_f32_limits() {
10613        let mut pen = f32::MAX;
10614        let collapsed = advance_pen_with_margin_collapse(&mut pen, f32::MAX, f32::MAX);
10615        assert_eq!(collapsed, f32::MAX);
10616        assert!(pen.is_infinite(), "MAX + MAX saturates to +inf, no panic");
10617
10618        let mut pen = f32::NAN;
10619        let collapsed = advance_pen_with_margin_collapse(&mut pen, 1.0, 2.0);
10620        assert_eq!(collapsed, 2.0);
10621        assert!(pen.is_nan(), "a NaN pen stays NaN");
10622    }
10623
10624    // ==================================================================
10625    // has_margin_collapse_blocker (predicate)
10626    // ==================================================================
10627
10628    #[test]
10629    fn has_margin_collapse_blocker_basic_true_false() {
10630        let none = BoxProps::default();
10631        assert!(!has_margin_collapse_blocker(&none, HTB, true));
10632        assert!(!has_margin_collapse_blocker(&none, HTB, false));
10633
10634        let top_border = box_props(
10635            EdgeSizes::default(),
10636            edges(1.0, 0.0, 0.0, 0.0),
10637            EdgeSizes::default(),
10638        );
10639        assert!(has_margin_collapse_blocker(&top_border, HTB, true));
10640        assert!(!has_margin_collapse_blocker(&top_border, HTB, false));
10641
10642        let bottom_padding = box_props(
10643            EdgeSizes::default(),
10644            EdgeSizes::default(),
10645            edges(0.0, 0.0, 0.5, 0.0),
10646        );
10647        assert!(!has_margin_collapse_blocker(&bottom_padding, HTB, true));
10648        assert!(has_margin_collapse_blocker(&bottom_padding, HTB, false));
10649    }
10650
10651    #[test]
10652    fn has_margin_collapse_blocker_maps_edges_per_writing_mode() {
10653        // In vertical writing modes the main axis is horizontal: left/right block.
10654        let left_border = box_props(
10655            EdgeSizes::default(),
10656            edges(0.0, 0.0, 0.0, 3.0),
10657            EdgeSizes::default(),
10658        );
10659        assert!(has_margin_collapse_blocker(&left_border, VRL, true));
10660        assert!(!has_margin_collapse_blocker(&left_border, VRL, false));
10661        // The same box does not block in horizontal-tb (left is a cross edge there).
10662        assert!(!has_margin_collapse_blocker(&left_border, HTB, true));
10663        assert!(!has_margin_collapse_blocker(&left_border, HTB, false));
10664    }
10665
10666    #[test]
10667    fn has_margin_collapse_blocker_ignores_negative_and_nan_edges() {
10668        let weird = box_props(
10669            EdgeSizes::default(),
10670            edges(-5.0, 0.0, f32::NAN, 0.0),
10671            edges(f32::NAN, 0.0, -1.0, 0.0),
10672        );
10673        // `> 0.0` is false for both negatives and NaN -> nothing blocks, no panic.
10674        assert!(!has_margin_collapse_blocker(&weird, HTB, true));
10675        assert!(!has_margin_collapse_blocker(&weird, HTB, false));
10676
10677        // Infinity does block.
10678        let inf = box_props(
10679            EdgeSizes::default(),
10680            edges(f32::INFINITY, 0.0, 0.0, 0.0),
10681            EdgeSizes::default(),
10682        );
10683        assert!(has_margin_collapse_blocker(&inf, HTB, true));
10684    }
10685
10686    // ==================================================================
10687    // is_bk_or_nl_class / is_bidi_control / is_css_document_whitespace
10688    // ==================================================================
10689
10690    #[test]
10691    fn is_bk_or_nl_class_covers_exactly_vt_ff_nel_ls_ps() {
10692        for c in ['\u{000B}', '\u{000C}', '\u{0085}', '\u{2028}', '\u{2029}'] {
10693            assert!(is_bk_or_nl_class(c), "{:04X} must be BK/NL", c as u32);
10694        }
10695        // LF and CR are handled separately by the callers, not by this predicate.
10696        for c in ['\n', '\r', ' ', '\t', 'a', '\0', '\u{200B}', char::MAX] {
10697            assert!(!is_bk_or_nl_class(c), "{:04X} must not be BK/NL", c as u32);
10698        }
10699    }
10700
10701    #[test]
10702    fn is_bidi_control_covers_the_uax9_set_only() {
10703        for c in [
10704            '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}',
10705            '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{061C}',
10706        ] {
10707            assert!(is_bidi_control(c), "{:04X} must be a bidi control", c as u32);
10708        }
10709        for c in ['a', ' ', '\u{200B}', '\u{2028}', '\u{202F}', '\u{2065}', char::MAX] {
10710            assert!(!is_bidi_control(c), "{:04X} must not be a bidi control", c as u32);
10711        }
10712    }
10713
10714    #[test]
10715    fn is_css_document_whitespace_excludes_other_unicode_spaces() {
10716        for c in [' ', '\t', '\n', '\r', '\x0C'] {
10717            assert!(is_css_document_whitespace(c));
10718        }
10719        // NBSP, ideographic space, ZWSP, LS and VT are NOT document white space.
10720        for c in ['\u{00A0}', '\u{3000}', '\u{200B}', '\u{2028}', '\u{000B}', 'a'] {
10721            assert!(
10722                !is_css_document_whitespace(c),
10723                "{:04X} must not be document white space",
10724                c as u32
10725            );
10726        }
10727    }
10728
10729    // ==================================================================
10730    // split_at_forced_breaks / split_at_bk_nl_chars (other)
10731    // ==================================================================
10732
10733    #[test]
10734    fn split_at_forced_breaks_handles_every_newline_flavour() {
10735        assert_eq!(split_at_forced_breaks(""), vec![String::new()]);
10736        assert_eq!(split_at_forced_breaks("abc"), vec!["abc".to_string()]);
10737        assert_eq!(split_at_forced_breaks("a\nb"), vec!["a", "b"]);
10738        assert_eq!(split_at_forced_breaks("a\r\nb"), vec!["a", "b"]);
10739        assert_eq!(split_at_forced_breaks("a\rb"), vec!["a", "b"]);
10740        // A lone \r followed by another \r is two breaks, not one.
10741        assert_eq!(split_at_forced_breaks("a\r\rb"), vec!["a", "", "b"]);
10742        // Leading / trailing breaks produce empty leading / trailing segments.
10743        assert_eq!(split_at_forced_breaks("\na\n"), vec!["", "a", ""]);
10744        // BK/NL class chars break too.
10745        assert_eq!(split_at_forced_breaks("a\u{2028}b"), vec!["a", "b"]);
10746        assert_eq!(split_at_forced_breaks("a\u{000B}b"), vec!["a", "b"]);
10747    }
10748
10749    #[test]
10750    fn split_at_forced_breaks_preserves_every_non_break_char() {
10751        let text = "héllo 🌍\u{0301}\u{200B}x";
10752        let segs = split_at_forced_breaks(text);
10753        assert_eq!(segs.len(), 1);
10754        assert_eq!(segs[0], text, "no char-boundary slicing damage");
10755
10756        // The number of segments is always (number of breaks) + 1.
10757        let many = "a\nb\rc\r\nd\u{2029}e";
10758        assert_eq!(split_at_forced_breaks(many).len(), 5);
10759        assert_eq!(split_at_forced_breaks(many).concat(), "abcde");
10760    }
10761
10762    #[test]
10763    fn split_at_forced_breaks_survives_a_huge_all_break_input() {
10764        let text = "\n".repeat(10_000);
10765        let segs = split_at_forced_breaks(&text);
10766        assert_eq!(segs.len(), 10_001);
10767        assert!(segs.iter().all(String::is_empty));
10768    }
10769
10770    #[test]
10771    fn split_at_bk_nl_chars_ignores_lf_and_cr() {
10772        assert_eq!(split_at_bk_nl_chars(""), vec![String::new()]);
10773        // \n and \r are collapsible in normal/nowrap, so they are NOT split points.
10774        assert_eq!(split_at_bk_nl_chars("a\nb\rc"), vec!["a\nb\rc".to_string()]);
10775        // BK/NL class chars still force a break.
10776        assert_eq!(split_at_bk_nl_chars("a\u{0085}b"), vec!["a", "b"]);
10777        assert_eq!(split_at_bk_nl_chars("\u{2029}"), vec!["", ""]);
10778    }
10779
10780    // ==================================================================
10781    // is_east_asian_wide / is_east_asian_fullwidth_or_wide (predicates)
10782    // ==================================================================
10783
10784    #[test]
10785    fn is_east_asian_wide_matches_cjk_kana_and_hangul() {
10786        for c in ['中', '一', 'あ', 'カ', '。', '가', 'ㄅ', 'A'] {
10787            assert!(is_east_asian_wide(c), "{:04X} must be wide", c as u32);
10788        }
10789        for c in ['a', 'Z', '0', ' ', 'é', '\u{0000}', char::MAX] {
10790            assert!(!is_east_asian_wide(c), "{:04X} must not be wide", c as u32);
10791        }
10792    }
10793
10794    #[test]
10795    fn is_east_asian_wide_range_boundaries_are_inclusive() {
10796        assert!(!is_east_asian_wide('\u{4DFF}')); // just below CJK Unified
10797        assert!(is_east_asian_wide('\u{4E00}')); // first CJK Unified
10798        assert!(is_east_asian_wide('\u{9FFF}')); // last CJK Unified
10799        assert!(!is_east_asian_wide('\u{A000}')); // Yi — not in this list
10800        assert!(!is_east_asian_wide('\u{FF00}')); // just below fullwidth forms
10801        assert!(is_east_asian_wide('\u{FF01}')); // first fullwidth form
10802        assert!(is_east_asian_wide('\u{FF60}')); // last fullwidth form
10803        assert!(!is_east_asian_wide('\u{FF61}')); // halfwidth forms start here
10804    }
10805
10806    #[test]
10807    fn is_east_asian_fullwidth_or_wide_excludes_hangul_but_wide_does_not() {
10808        // The two predicates deliberately disagree on Hangul: the segment-break
10809        // transform must NOT drop breaks between Hangul syllables.
10810        assert!(is_east_asian_wide('가'));
10811        assert!(!is_east_asian_fullwidth_or_wide('가'));
10812        assert!(!is_east_asian_fullwidth_or_wide('\u{1100}')); // Hangul Jamo
10813        assert!(!is_east_asian_fullwidth_or_wide('\u{3130}')); // Compat Jamo
10814        assert!(!is_east_asian_fullwidth_or_wide('\u{A960}')); // Jamo Extended-A
10815        assert!(!is_east_asian_fullwidth_or_wide('\u{D7B0}')); // Jamo Extended-B
10816
10817        // Han / kana still count, and the halfwidth + Yi ranges are added on top.
10818        assert!(is_east_asian_fullwidth_or_wide('中'));
10819        assert!(is_east_asian_fullwidth_or_wide('あ'));
10820        assert!(is_east_asian_fullwidth_or_wide('\u{FF61}'));
10821        assert!(is_east_asian_fullwidth_or_wide('\u{A000}'));
10822        assert!(!is_east_asian_fullwidth_or_wide('a'));
10823        assert!(!is_east_asian_fullwidth_or_wide(char::MAX));
10824    }
10825
10826    // ==================================================================
10827    // apply_segment_break_transform (other)
10828    // ==================================================================
10829
10830    #[test]
10831    fn apply_segment_break_transform_converts_breaks_to_a_single_space() {
10832        assert_eq!(apply_segment_break_transform(""), "");
10833        assert_eq!(apply_segment_break_transform("ab"), "ab");
10834        assert_eq!(apply_segment_break_transform("a\nb"), "a b");
10835        assert_eq!(apply_segment_break_transform("a\r\nb"), "a b");
10836        assert_eq!(apply_segment_break_transform("a\rb"), "a b");
10837        // §4.1.1: white space around the break is removed first.
10838        assert_eq!(apply_segment_break_transform("a \n b"), "a b");
10839        assert_eq!(apply_segment_break_transform("a\t\n\tb"), "a b");
10840        // Consecutive breaks collapse into one space.
10841        assert_eq!(apply_segment_break_transform("a\n\n\nb"), "a b");
10842    }
10843
10844    #[test]
10845    fn apply_segment_break_transform_at_the_string_edges() {
10846        // No char before -> still a space; no char after -> still a space.
10847        assert_eq!(apply_segment_break_transform("\na"), " a");
10848        assert_eq!(apply_segment_break_transform("a\n"), "a ");
10849        assert_eq!(apply_segment_break_transform("\n"), " ");
10850        assert_eq!(apply_segment_break_transform("  \n  "), " ");
10851    }
10852
10853    #[test]
10854    fn apply_segment_break_transform_removes_breaks_around_zwsp_and_cjk() {
10855        // Rule 1: adjacent to U+200B ZERO WIDTH SPACE -> the break disappears.
10856        assert_eq!(apply_segment_break_transform("a\u{200B}\nb"), "a\u{200B}b");
10857        assert_eq!(apply_segment_break_transform("a\n\u{200B}b"), "a\u{200B}b");
10858        // Rule 2: East Asian on both sides -> the break disappears.
10859        assert_eq!(apply_segment_break_transform("中\n文"), "中文");
10860        assert_eq!(apply_segment_break_transform("あ \n い"), "あい");
10861        // Only one side East Asian -> a space is kept.
10862        assert_eq!(apply_segment_break_transform("中\na"), "中 a");
10863        assert_eq!(apply_segment_break_transform("a\n中"), "a 中");
10864        // Hangul is excluded from rule 2, so the break becomes a space.
10865        assert_eq!(apply_segment_break_transform("가\n나"), "가 나");
10866    }
10867
10868    #[test]
10869    fn apply_segment_break_transform_survives_pathological_input() {
10870        let text = "\n".repeat(5_000);
10871        assert_eq!(apply_segment_break_transform(&text), " ");
10872        // Interleaved breaks and multi-byte chars must not slice a char boundary.
10873        let mixed = "🌍\n🌍\n🌍";
10874        assert_eq!(apply_segment_break_transform(mixed), "🌍 🌍 🌍");
10875    }
10876
10877    // ==================================================================
10878    // apply_text_transform (other)
10879    // ==================================================================
10880
10881    #[test]
10882    fn apply_text_transform_none_is_the_identity() {
10883        for s in ["", "abc", "ÄÖÜ", "🌍", " \t\n"] {
10884            assert_eq!(apply_text_transform(s, TextTransform::None), s);
10885        }
10886    }
10887
10888    #[test]
10889    fn apply_text_transform_case_changes_handle_growing_and_multibyte_chars() {
10890        assert_eq!(apply_text_transform("abc", TextTransform::Uppercase), "ABC");
10891        // ß uppercases to two chars — the output is longer than the input.
10892        assert_eq!(apply_text_transform("straße", TextTransform::Uppercase), "STRASSE");
10893        assert_eq!(apply_text_transform("ÄÖÜ", TextTransform::Lowercase), "äöü");
10894        assert_eq!(apply_text_transform("", TextTransform::Uppercase), "");
10895        assert_eq!(apply_text_transform("🌍", TextTransform::Uppercase), "🌍");
10896    }
10897
10898    #[test]
10899    fn apply_text_transform_capitalize_uses_word_boundaries() {
10900        assert_eq!(
10901            apply_text_transform("hello world", TextTransform::Capitalize),
10902            "Hello World"
10903        );
10904        // A leading digit is not alphabetic and is not a boundary either.
10905        assert_eq!(
10906            apply_text_transform("1st place", TextTransform::Capitalize),
10907            "1st Place"
10908        );
10909        // ASCII punctuation opens a new word.
10910        assert_eq!(
10911            apply_text_transform("a-b (c)", TextTransform::Capitalize),
10912            "A-B (C)"
10913        );
10914        assert_eq!(apply_text_transform("élan", TextTransform::Capitalize), "Élan");
10915        assert_eq!(apply_text_transform("", TextTransform::Capitalize), "");
10916    }
10917
10918    #[test]
10919    fn apply_text_transform_fullwidth_maps_the_ascii_block() {
10920        assert_eq!(apply_text_transform("a", TextTransform::FullWidth), "\u{FF41}");
10921        assert_eq!(apply_text_transform("!", TextTransform::FullWidth), "\u{FF01}");
10922        assert_eq!(apply_text_transform("~", TextTransform::FullWidth), "\u{FF5E}");
10923        assert_eq!(apply_text_transform(" ", TextTransform::FullWidth), "\u{3000}");
10924        // Chars outside U+0021..U+007E are passed through untouched.
10925        assert_eq!(apply_text_transform("\t\n", TextTransform::FullWidth), "\t\n");
10926        assert_eq!(apply_text_transform("\u{7F}", TextTransform::FullWidth), "\u{7F}");
10927        assert_eq!(apply_text_transform("中🌍", TextTransform::FullWidth), "中🌍");
10928    }
10929
10930    // ==================================================================
10931    // layout_initial_letter (numeric)
10932    // ==================================================================
10933
10934    #[test]
10935    fn layout_initial_letter_rejects_degenerate_parameters() {
10936        // size <= 0, line_height <= 0 or content width <= 0 => no exclusion at all.
10937        assert_eq!(layout_initial_letter(0.0, 3, 1000.0, 20.0), (0.0, 0.0));
10938        assert_eq!(layout_initial_letter(-1.0, 3, 1000.0, 20.0), (0.0, 0.0));
10939        assert_eq!(layout_initial_letter(3.0, 3, 1000.0, 0.0), (0.0, 0.0));
10940        assert_eq!(layout_initial_letter(3.0, 3, 1000.0, -20.0), (0.0, 0.0));
10941        assert_eq!(layout_initial_letter(3.0, 3, 0.0, 20.0), (0.0, 0.0));
10942        assert_eq!(layout_initial_letter(3.0, 3, -10.0, 20.0), (0.0, 0.0));
10943    }
10944
10945    #[test]
10946    fn layout_initial_letter_computes_a_classic_drop_cap() {
10947        // 3 lines x 20px => 60px tall; 60 * 0.7 + 4px gap => 46px wide.
10948        assert_eq!(layout_initial_letter(3.0, 3, 1000.0, 20.0), (46.0, 60.0));
10949        // The width is clamped to the content box.
10950        assert_eq!(layout_initial_letter(3.0, 3, 10.0, 20.0), (10.0, 60.0));
10951        // sink == 0 (raised cap): the exclusion still covers the whole letter.
10952        assert_eq!(layout_initial_letter(3.0, 0, 1000.0, 20.0), (46.0, 60.0));
10953        // sink > size (sunken cap): the exclusion grows past the letter.
10954        assert_eq!(layout_initial_letter(2.0, 5, 1000.0, 20.0), (32.0, 100.0));
10955    }
10956
10957    #[test]
10958    fn layout_initial_letter_exclusion_is_never_shorter_than_the_letter() {
10959        for size_lines in [0.5_f32, 1.0, 3.0, 100.0] {
10960            for sink in [0_u32, 1, 3, 100] {
10961                let (w, h) = layout_initial_letter(size_lines, sink, 10_000.0, 20.0);
10962                let letter_height = size_lines * 20.0;
10963                assert!(h >= letter_height, "{size_lines}/{sink}: {h} < {letter_height}");
10964                assert!(w > 0.0 && w.is_finite());
10965            }
10966        }
10967    }
10968
10969    #[test]
10970    fn layout_initial_letter_saturates_at_extreme_sinks_and_sizes() {
10971        // u32::MAX lines of sink: a huge but finite exclusion, no overflow panic.
10972        let (w, h) = layout_initial_letter(1.0, u32::MAX, 1000.0, 20.0);
10973        assert!(h.is_finite() && h > 0.0);
10974        assert_eq!(w, 18.0); // 20 * 0.7 + 4
10975
10976        // f32::MAX size: the height saturates to +inf, the width clamps to the box.
10977        let (w, h) = layout_initial_letter(f32::MAX, 1, 1000.0, f32::MAX);
10978        assert_eq!(w, 1000.0);
10979        assert!(h.is_infinite());
10980    }
10981
10982    #[test]
10983    fn layout_initial_letter_with_nan_and_inf_produces_a_defined_result() {
10984        // NaN passes the `<= 0.0` guard (NaN compares false), so the maths runs.
10985        // f32::min/max discard NaN, so the result stays defined: the width falls back
10986        // to the content box and the height to the sink-derived exclusion.
10987        let (w, h) = layout_initial_letter(f32::NAN, 3, 1000.0, 20.0);
10988        assert_eq!(w, 1000.0);
10989        assert_eq!(h, 60.0);
10990
10991        // An infinite line height clamps the width and yields an infinite exclusion.
10992        let (w, h) = layout_initial_letter(3.0, 3, 1000.0, f32::INFINITY);
10993        assert_eq!(w, 1000.0);
10994        assert!(h.is_infinite());
10995    }
10996
10997    // ==================================================================
10998    // get_cell_spans (other)
10999    // ==================================================================
11000
11001    fn cell_dom(attrs: Vec<AttributeType>) -> StyledDom {
11002        let mut cell = Dom::create_div();
11003        for a in attrs {
11004            cell = cell.with_attribute(a);
11005        }
11006        styled(Dom::create_body().with_child(cell), "")
11007    }
11008
11009    #[test]
11010    fn get_cell_spans_defaults_to_one_when_unset() {
11011        let dom = cell_dom(Vec::new());
11012        assert_eq!(get_cell_spans(&dom, DIV_NODE), (1, 1));
11013    }
11014
11015    #[test]
11016    fn get_cell_spans_clamps_hostile_html_values() {
11017        // Zero / negative / i32::MIN spans must clamp up to 1, never wrap or panic.
11018        for n in [0, -1, i32::MIN] {
11019            let dom = cell_dom(vec![AttributeType::ColSpan(n), AttributeType::RowSpan(n)]);
11020            assert_eq!(get_cell_spans(&dom, DIV_NODE), (1, 1), "span {n}");
11021        }
11022        // Absurd spans clamp down to the HTML limits (1000 / 65534) so the column
11023        // and row vectors cannot be grown into an OOM.
11024        let dom = cell_dom(vec![
11025            AttributeType::ColSpan(i32::MAX),
11026            AttributeType::RowSpan(i32::MAX),
11027        ]);
11028        assert_eq!(get_cell_spans(&dom, DIV_NODE), (1000, 65534));
11029
11030        // In-range values pass through.
11031        let dom = cell_dom(vec![
11032            AttributeType::ColSpan(3),
11033            AttributeType::RowSpan(7),
11034        ]);
11035        assert_eq!(get_cell_spans(&dom, DIV_NODE), (3, 7));
11036    }
11037
11038    // ==================================================================
11039    // get_float_property / get_clear_property (other)
11040    // ==================================================================
11041
11042    #[test]
11043    fn get_float_and_clear_properties_default_to_none_for_a_missing_node() {
11044        let dom = text_dom("x", "");
11045        assert_eq!(get_float_property(&dom, None), LayoutFloat::None);
11046        assert_eq!(get_clear_property(&dom, None), LayoutClear::None);
11047        // An unstyled node also reports the initial values.
11048        assert_eq!(get_float_property(&dom, Some(DIV_NODE)), LayoutFloat::None);
11049        assert_eq!(get_clear_property(&dom, Some(DIV_NODE)), LayoutClear::None);
11050    }
11051
11052    #[test]
11053    fn get_float_and_clear_properties_read_the_cascade() {
11054        let dom = text_dom("x", ".p { float: right; clear: both; }");
11055        assert_eq!(get_float_property(&dom, Some(DIV_NODE)), LayoutFloat::Right);
11056        assert_eq!(get_clear_property(&dom, Some(DIV_NODE)), LayoutClear::Both);
11057
11058        let dom = text_dom("x", ".p { float: left; clear: left; }");
11059        assert_eq!(get_float_property(&dom, Some(DIV_NODE)), LayoutFloat::Left);
11060        assert_eq!(get_clear_property(&dom, Some(DIV_NODE)), LayoutClear::Left);
11061    }
11062
11063    // ==================================================================
11064    // split_text_for_whitespace (other)
11065    // ==================================================================
11066
11067    #[test]
11068    fn split_text_for_whitespace_collapses_runs_in_normal_mode() {
11069        let dom = text_dom("  a  b  ", "");
11070        let out = split_text_for_whitespace(&dom, TEXT_NODE, "  a  b  ", &plain_style());
11071        assert_eq!(out.len(), 1);
11072        // Interior runs collapse to one space; the leading/trailing ones are kept
11073        // as a single space each (they are trimmed later, at line-layout time).
11074        assert_eq!(text_of(&out[0]), Some(" a b "));
11075    }
11076
11077    #[test]
11078    fn split_text_for_whitespace_on_empty_and_whitespace_only_text() {
11079        let dom = text_dom("", "");
11080        assert!(split_text_for_whitespace(&dom, TEXT_NODE, "", &plain_style()).is_empty());
11081
11082        // A whitespace-only node collapses to exactly one space.
11083        let out = split_text_for_whitespace(&dom, TEXT_NODE, "   \t  ", &plain_style());
11084        assert_eq!(out.len(), 1);
11085        assert_eq!(text_of(&out[0]), Some(" "));
11086    }
11087
11088    #[test]
11089    fn split_text_for_whitespace_honours_newlines_in_pre() {
11090        let dom = text_dom("a\nb", ".p { white-space: pre; }");
11091        let out = split_text_for_whitespace(&dom, TEXT_NODE, "a\nb", &plain_style());
11092        assert_eq!(out.len(), 3);
11093        assert_eq!(text_of(&out[0]), Some("a"));
11094        assert!(matches!(out[1], InlineContent::LineBreak(_)));
11095        assert_eq!(text_of(&out[2]), Some("b"));
11096
11097        // Tabs become explicit Tab items so the tab-size can be applied later.
11098        let out = split_text_for_whitespace(&dom, TEXT_NODE, "a\tb", &plain_style());
11099        assert_eq!(out.len(), 3);
11100        assert_eq!(text_of(&out[0]), Some("a"));
11101        assert!(matches!(out[1], InlineContent::Tab { .. }));
11102        assert_eq!(text_of(&out[2]), Some("b"));
11103
11104        // Whitespace is preserved verbatim in `pre`.
11105        let out = split_text_for_whitespace(&dom, TEXT_NODE, "  a  ", &plain_style());
11106        assert_eq!(text_of(&out[0]), Some("  a  "));
11107    }
11108
11109    #[test]
11110    fn split_text_for_whitespace_normalises_cr_and_crlf() {
11111        let dom = text_dom("x", ".p { white-space: pre; }");
11112        // \r\n and a bare \r must behave exactly like \n (one forced break each).
11113        for text in ["a\r\nb", "a\rb", "a\nb"] {
11114            let out = split_text_for_whitespace(&dom, TEXT_NODE, text, &plain_style());
11115            assert_eq!(out.len(), 3, "{text:?}");
11116            assert!(matches!(out[1], InlineContent::LineBreak(_)), "{text:?}");
11117        }
11118    }
11119
11120    #[test]
11121    fn split_text_for_whitespace_forces_breaks_on_bk_nl_chars_in_every_mode() {
11122        // U+2028 LINE SEPARATOR is a forced break even in white-space: normal.
11123        let dom = text_dom("x", "");
11124        let out = split_text_for_whitespace(&dom, TEXT_NODE, "a\u{2028}b", &plain_style());
11125        assert_eq!(out.len(), 3);
11126        assert!(matches!(out[1], InlineContent::LineBreak(_)));
11127
11128        let dom = text_dom("x", ".p { white-space: pre-line; }");
11129        let out = split_text_for_whitespace(&dom, TEXT_NODE, "a\u{000C}b", &plain_style());
11130        assert_eq!(out.len(), 3);
11131        assert!(matches!(out[1], InlineContent::LineBreak(_)));
11132    }
11133
11134    #[test]
11135    fn split_text_for_whitespace_strips_bidi_controls_before_collapsing() {
11136        let dom = text_dom("x", "");
11137        // The RLM between the two spaces must not stop them from collapsing.
11138        let out = split_text_for_whitespace(&dom, TEXT_NODE, "a \u{200F} b", &plain_style());
11139        assert_eq!(out.len(), 1);
11140        assert_eq!(text_of(&out[0]), Some("a b"));
11141    }
11142
11143    #[test]
11144    fn split_text_for_whitespace_pre_line_collapses_spaces_but_keeps_breaks() {
11145        let dom = text_dom("x", ".p { white-space: pre-line; }");
11146        let out = split_text_for_whitespace(&dom, TEXT_NODE, "a  b\n  c  ", &plain_style());
11147        assert_eq!(out.len(), 3);
11148        assert_eq!(text_of(&out[0]), Some("a b"));
11149        assert!(matches!(out[1], InlineContent::LineBreak(_)));
11150        assert_eq!(text_of(&out[2]), Some("c"));
11151    }
11152
11153    #[test]
11154    fn split_text_for_whitespace_survives_a_huge_multibyte_input() {
11155        let dom = text_dom("x", ".p { white-space: pre; }");
11156        let text = "🌍\n".repeat(2_000);
11157        let out = split_text_for_whitespace(&dom, TEXT_NODE, &text, &plain_style());
11158        // 2000 globes + 2000 breaks (the trailing empty segment emits no Text item).
11159        assert_eq!(out.len(), 4_000);
11160        assert_eq!(text_of(&out[0]), Some("🌍"));
11161    }
11162}