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,
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::{AvailableSpace as Text3AvailableSpace, TextAlign as Text3TextAlign},
72};
73
74/// Default scrollbar width in pixels (CSS `scrollbar-width: auto`).
75///
76/// This is only used as a fallback when per-node CSS cannot be queried.
77/// Prefer `getters::get_layout_scrollbar_width_px()` for per-node resolution.
78pub const DEFAULT_SCROLLBAR_WIDTH_PX: f32 = 16.0;
79
80// Note: DEFAULT_FONT_SIZE and PT_TO_PX are imported from pixel
81
82/// Result of BFC layout with margin escape information
83#[derive(Debug, Clone)]
84pub(crate) struct BfcLayoutResult {
85    /// Standard layout output (positions, overflow size, baseline)
86    pub output: LayoutOutput,
87    /// Top margin that escaped the BFC (for parent-child collapse)
88    /// If Some, this margin should be used by parent instead of positioning this BFC
89    pub escaped_top_margin: Option<f32>,
90    /// Bottom margin that escaped the BFC (for parent-child collapse)
91    /// If Some, this margin should collapse with next sibling
92    pub escaped_bottom_margin: Option<f32>,
93}
94
95impl BfcLayoutResult {
96    pub(crate) const fn from_output(output: LayoutOutput) -> Self {
97        Self {
98            output,
99            escaped_top_margin: None,
100            escaped_bottom_margin: None,
101        }
102    }
103}
104
105/// The CSS `overflow` property behavior.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum OverflowBehavior {
108    Visible,
109    Hidden,
110    Clip,
111    Scroll,
112    Auto,
113}
114
115impl OverflowBehavior {
116    #[must_use] pub const fn is_clipped(&self) -> bool {
117        matches!(self, Self::Hidden | Self::Clip | Self::Scroll | Self::Auto)
118    }
119
120    #[must_use] pub const fn is_scroll(&self) -> bool {
121        matches!(self, Self::Scroll | Self::Auto)
122    }
123}
124
125/// Input constraints for a layout function.
126#[derive(Debug)]
127pub struct LayoutConstraints<'a> {
128    /// The available space for the content, excluding padding and borders.
129    pub available_size: LogicalSize,
130    /// The CSS writing-mode of the context.
131    pub writing_mode: LayoutWritingMode,
132    /// Full writing mode context (writing-mode + direction + text-orientation).
133    /// Used by writing-mode-aware layout code to correctly map inline/block
134    /// dimensions to physical x/y coordinates.
135    pub writing_mode_ctx: super::geometry::WritingModeContext,
136    /// The state of the parent Block Formatting Context, if applicable.
137    /// This is how state (like floats) is passed down.
138    pub bfc_state: Option<&'a mut BfcState>,
139    // Other properties like text-align would go here.
140    pub text_align: TextAlign,
141    /// The size of the containing block (parent's content box).
142    /// This is used for resolving percentage-based sizes and as `parent_size` for Taffy.
143    pub containing_block_size: LogicalSize,
144    /// The semantic type of the available width constraint.
145    ///
146    /// This field is crucial for correct inline layout caching:
147    /// - `Definite(w)`: Normal layout with a specific available width
148    /// - `MinContent`: Intrinsic minimum width measurement (maximum wrapping)
149    /// - `MaxContent`: Intrinsic maximum width measurement (no wrapping)
150    ///
151    /// When caching inline layouts, we must track which constraint type was used
152    /// to compute the cached result. A layout computed with `MinContent` (width=0)
153    /// must not be reused when the actual available width is known.
154    pub available_width_type: Text3AvailableSpace,
155}
156
157/// Manages all layout state for a single Block Formatting Context.
158/// This struct is created by the BFC root and lives for the duration of its layout.
159#[derive(Debug, Clone)]
160pub struct BfcState {
161    /// The current position for the next in-flow block element.
162    pub pen: LogicalPosition,
163    /// The state of all floated elements within this BFC.
164    pub floats: FloatingContext,
165    /// The state of margin collapsing within this BFC.
166    pub margins: MarginCollapseContext,
167}
168
169impl Default for BfcState {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl BfcState {
176    #[must_use] pub fn new() -> Self {
177        Self {
178            pen: LogicalPosition::zero(),
179            floats: FloatingContext::default(),
180            margins: MarginCollapseContext::default(),
181        }
182    }
183}
184
185/// Manages vertical margin collapsing within a BFC.
186#[derive(Copy, Debug, Default, Clone)]
187pub struct MarginCollapseContext {
188    /// The bottom margin of the last in-flow, block-level element.
189    /// Can be positive or negative.
190    pub last_in_flow_margin_bottom: f32,
191}
192
193/// The result of laying out a formatting context.
194#[derive(Debug, Default, Clone)]
195pub struct LayoutOutput {
196    /// The final positions of child nodes, relative to the container's content-box origin.
197    pub positions: BTreeMap<usize, LogicalPosition>,
198    /// The total size occupied by the content, which may exceed `available_size`.
199    pub overflow_size: LogicalSize,
200    // +spec:inline-formatting-context:f7eebb - baseline along inline axis for glyph alignment
201    /// The baseline of the context, if applicable, measured from the top of its content box.
202    pub baseline: Option<f32>,
203}
204
205/// Text alignment options
206#[derive(Debug, Clone, Copy, Default)]
207pub enum TextAlign {
208    #[default]
209    Start,
210    End,
211    Center,
212    Justify,
213}
214
215/// Represents a single floated element within a BFC.
216#[derive(Debug, Clone, Copy)]
217struct FloatBox {
218    /// The type of float (Left or Right).
219    kind: LayoutFloat,
220    /// The rectangle of the float's content box (origin includes top/left margin offset).
221    rect: LogicalRect,
222    /// The margin sizes (needed to calculate true margin-box bounds).
223    margin: EdgeSizes,
224}
225
226/// Manages the state of all floated elements within a Block Formatting Context.
227// +spec:block-formatting-context:a4e6f9 - float rules reference only elements in the same BFC (scoped via BfcState)
228// +spec:floats:2fa329 - Float positioning (left/right shift), content flow along sides, and clear property
229/// +spec:floats:970b4c - Implements CSS2§9.5 float positioning and flow interaction
230#[derive(Debug, Default, Clone)]
231pub struct FloatingContext {
232    /// All currently positioned floats within the BFC.
233    pub floats: Vec<FloatBox>,
234}
235
236impl FloatingContext {
237    /// Add a newly positioned float to the context
238    pub fn add_float(&mut self, kind: LayoutFloat, rect: LogicalRect, margin: EdgeSizes) {
239        self.floats.push(FloatBox { kind, rect, margin });
240    }
241
242    // +spec:box-model:0c9b13 - line boxes next to floats are shortened to make room
243    // +spec:floats:148fcd - floating boxes reduce available line box width between containing block edges
244    // +spec:floats:49a491 - Line boxes stacked with no separation except float clearance, never overlap
245    // +spec:floats:8974e6 - text flows into vacated space by narrowing line boxes around floats
246    // +spec:floats:af94f2 - content displaced by float: line boxes shrink to avoid float margin boxes
247    // +spec:floats:e5961b - remaining text flows into vacated space via available_line_box_space
248    // +spec:inline-formatting-context:7cbe58 - shortened line boxes due to floats; shift down if too small
249    /// Finds the available space on the cross-axis for a line box at a given main-axis range.
250    // +spec:containing-block:4b0c44 - line boxes shortened by floats resume containing block width after float
251    ///
252    /// Returns a tuple of (`cross_start_offset`, `cross_end_offset`) relative to the
253    /// BFC content box, defining the available space for an in-flow element.
254    // +spec:inline-formatting-context:e70328 - line box width reduced by floats between containing block edges
255    #[must_use] pub fn available_line_box_space(
256        &self,
257        main_start: f32,
258        main_end: f32,
259        bfc_cross_size: f32,
260        wm: LayoutWritingMode,
261    ) -> (f32, f32) {
262        let mut available_cross_start = 0.0_f32;
263        let mut available_cross_end = bfc_cross_size;
264
265        for float in &self.floats {
266            // Get the logical main-axis span of the existing float's MARGIN BOX.
267            let float_main_start = float.rect.origin.main(wm) - float.margin.main_start(wm);
268            let float_main_end = float_main_start + float.rect.size.main(wm)
269                + float.margin.main_start(wm) + float.margin.main_end(wm);
270
271            // Check for overlap on the main axis.
272            if main_end > float_main_start && main_start < float_main_end {
273                // CSS 2.2 § 9.5: border box must not overlap MARGIN BOX of floats,
274                // so we include the float's margins in the cross-axis bounds.
275                let float_cross_start = float.rect.origin.cross(wm) - float.margin.cross_start(wm);
276                let float_cross_end = float_cross_start + float.rect.size.cross(wm)
277                    + float.margin.cross_start(wm) + float.margin.cross_end(wm);
278
279                // +spec:floats:17a63f - float left/right map to line-left/line-right via logical coords
280                // +spec:writing-modes:e55820 - line-relative mappings: left/right interpreted as line-left/line-right per writing mode
281                if float.kind == LayoutFloat::Left {
282                    // "line-left", i.e., cross-start
283                    available_cross_start = available_cross_start.max(float_cross_end);
284                } else {
285                    // Float::Right, i.e., cross-end
286                    available_cross_end = available_cross_end.min(float_cross_start);
287                }
288            }
289        }
290        (available_cross_start, available_cross_end)
291    }
292
293    // +spec:block-formatting-context:d06e6e - clearance computation for clear property on blocks and floats (CSS 2.2 § 9.5.2)
294    // +spec:floats:31a3d5 - Clearance computation: places border edge even with bottom outer edge of lowest float to be cleared
295    // +spec:floats:f9bef1 - clear property moves element below preceding floats
296    /// Returns the main-axis offset needed to be clear of floats of the given type.
297    // +spec:block-formatting-context:7f6bde - CSS 2.2 § 9.5.2 clear property: clearance places border edge below bottom outer edge of cleared floats
298    // +spec:block-formatting-context:ef493f - clearance computation: places border edge even with bottom outer edge of lowest float to be cleared; inhibits margin collapsing
299    // +spec:box-model:b118fe - top border edge must be below bottom outer edge of earlier floats
300    // +spec:floats:415066 - Clear property: top border edge below bottom outer edge of cleared floats
301    // +spec:floats:7e4ad6 - clear property: element box may not be adjacent to earlier floats; only considers floats in same BFC
302    // +spec:floats:32e45d - clear:right causes sibling to flow below right floats
303    // +spec:floats:7f417a - clear property prevents content from flowing next to floats
304    // +spec:floats:d06304 - clear property moves element below floats, leaving blank space
305    // +spec:overflow:1a7aff - clearance calculation (incl. negative clearance) and clear on floats (constraint #10)
306    // +spec:positioning:1c2508 - clearance calculation: places border edge even with bottom outer edge of lowest cleared float (CSS 2.2 § 9.5.2)
307    // +spec:positioning:fe0912 - clearance computation: places border edge below bottom outer edge of cleared floats
308    // (clearance = amount to place border edge even with bottom outer edge of lowest
309    // float to be cleared); clearance can be negative per spec example 2
310    // +spec:floats:054a1e - Clearance computation: positions border edge below bottom outer edge of cleared floats
311    // +spec:floats:cb984c - Clearance can be negative per spec example 2; inhibits margin collapsing
312    #[must_use] pub fn clearance_offset(
313        &self,
314        clear: LayoutClear,
315        current_main_offset: f32,
316        wm: LayoutWritingMode,
317    ) -> f32 {
318        let mut max_end_offset = 0.0_f32;
319
320        let check_left = clear == LayoutClear::Left || clear == LayoutClear::Both;
321        let check_right = clear == LayoutClear::Right || clear == LayoutClear::Both;
322
323        for float in &self.floats {
324            let should_clear_this_float = (check_left && float.kind == LayoutFloat::Left)
325                || (check_right && float.kind == LayoutFloat::Right);
326
327            if should_clear_this_float {
328                // CSS 2.2 § 9.5.2: "the top border edge of the box be below the bottom outer edge"
329                // Outer edge = margin-box boundary (content + padding + border + margin)
330                let float_margin_box_end = float.rect.origin.main(wm)
331                    + float.rect.size.main(wm)
332                    + float.margin.main_end(wm);
333                max_end_offset = max_end_offset.max(float_margin_box_end);
334            }
335        }
336
337        if max_end_offset > current_main_offset {
338            max_end_offset
339        } else {
340            current_main_offset
341        }
342    }
343}
344
345/// Encapsulates all state needed to lay out a single Block Formatting Context.
346struct BfcLayoutState {
347    /// The current position for the next in-flow block element.
348    pen: LogicalPosition,
349    floats: FloatingContext,
350    margins: MarginCollapseContext,
351    /// The writing mode of the BFC root.
352    writing_mode: LayoutWritingMode,
353}
354
355// Entry Point & Dispatcher
356
357/// Main dispatcher for formatting context layout.
358///
359/// Routes layout to the appropriate formatting context handler based on the node's
360/// `formatting_context` property. This is the main entry point for all layout operations.
361///
362/// # CSS Spec References
363/// - CSS 2.2 § 9.4: Formatting contexts
364/// - CSS Flexbox § 3: Flex formatting contexts
365/// - CSS Grid § 5: Grid formatting contexts
366// +spec:block-formatting-context:b04653 - dispatches layout by formatting context type (BFC, IFC, Table, Flex, Grid)
367// +spec:block-formatting-context:e46499 - inner display type determines formatting context (BFC, IFC, table, flex, grid)
368#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
369/// # Errors
370///
371/// Returns a `LayoutError` if laying out the formatting context fails.
372pub fn layout_formatting_context<T: ParsedFontTrait>(
373    ctx: &mut LayoutContext<'_, T>,
374    tree: &mut LayoutTree,
375    text_cache: &mut TextLayoutCache,
376    node_index: usize,
377    constraints: &LayoutConstraints<'_>,
378    float_cache: &mut HashMap<usize, FloatingContext>,
379) -> Result<BfcLayoutResult> {
380    // [g147e az-web-lift DIAG] PURE-CONSTANT entry marker (0x609E0+slot) — fires before any node read,
381    // so it reliably shows whether layout_formatting_context is ENTERED for the nested div nodes 1,2.
382    #[cfg(feature = "web_lift")]
383    unsafe { crate::az_mark(((0x609E0 + (node_index & 7) * 4)) as u32, (0xC0DE0042) as u32); }
384    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
385    // [g147i az-web-lift DIAG] node REFERENCE address (0x60B80+slot) — NOT a field deref, so reliable.
386    // If nodes 0,1,2 aren't spaced by sizeof(LayoutNodeHot) → tree.get(index>0) mis-lifts the Vec stride,
387    // making nodes 1,2 garbage references (which would explain FC reading garbage + reads destabilizing).
388    #[cfg(feature = "web_lift")]
389    unsafe { crate::az_mark(((0x60B80 + (node_index & 7) * 4)) as u32, ((node as *const _ as usize) as u32) as u32); }
390
391    // [g147 az-web-lift] Recompute the IFC decision from the DOM: on the lift, the stored
392    // `node.formatting_context` reads GARBAGE for nested inline divs (2026-06-10 re-test WITHOUT
393    // this bypass: nodes 1/2 dispatch to the `_` arm + determine_formatting_context_for_display's
394    // markers never fire → the FC ASSIGNMENT path itself mis-lifts upstream — NOT fixed by the
395    // repr(C,u8) guard, NOT fixed by the leak-gated SP restore; same family as the enum/jump-table
396    // devirt class). The styled_dom IS reliable, so a block container whose children are all
397    // inline-level establishes an IFC (CSS 2.2 §9.2.1) — semantically valid recomputation, not a
398    // hack on top of garbage. web_lift-gated → native untouched. Remove when the FC-assignment
399    // mis-lift is root-caused (follow-up: bisect LayoutTreeBuilder's determine_/display match).
400    #[cfg(feature = "web_lift")]
401    {
402        let force_ifc = node
403            .dom_node_id
404            .map_or(false, |dom_id| {
405                crate::solver3::layout_tree::has_only_inline_children(ctx.styled_dom, dom_id)
406            });
407        if force_ifc {
408            unsafe { crate::az_mark(((0x60BA0 + (node_index & 7) * 4)) as u32, (0xC0DE1FC0) as u32); }
409            return layout_ifc(ctx, text_cache, tree, node_index, constraints)
410                .map(BfcLayoutResult::from_output);
411        }
412    }
413
414    // [g147b az-web-lift DIAG] per-node FormattingContext discriminant at layout_formatting_context
415    // entry (0x609A0+slot). Pairs with the dispatch-arm marker (0x609C0+slot) inside each match arm:
416    // if a text-div's FC reads Inline(2) but the arm marker shows Block(1) → match dispatch mis-lifts;
417    // if FC reads Block(1) → tree-construction FC assignment is wrong; if 0x609A0 stays unset for the
418    // div node → layout_formatting_context is never called for it (cache-hit short-circuit upstream).
419    #[cfg(feature = "web_lift")]
420    unsafe {
421        let fc_disc = match node.formatting_context {
422            FormattingContext::Block { .. } => 1u32,
423            FormattingContext::Inline => 2,
424            FormattingContext::InlineBlock => 3,
425            FormattingContext::Flex => 4,
426            FormattingContext::Grid => 5,
427            FormattingContext::Table => 6,
428            FormattingContext::TableCell => 7,
429            FormattingContext::TableCaption => 8,
430            _ => 0,
431        };
432        crate::az_mark(((0x609A0 + (node_index & 7) * 4)) as u32, (fc_disc | 0xC0DE0000) as u32);
433    }
434
435    debug_info!(
436        ctx,
437        "[layout_formatting_context] node_index={}, fc={:?}, available_size={:?}",
438        node_index,
439        node.formatting_context,
440        constraints.available_size
441    );
442
443    // +spec:block-formatting-context:06a24f - CSS 2.2 § 9.4: block-level boxes → BFC, inline-level → IFC
444    // +spec:block-formatting-context:9428cf - block container can establish both BFC and IFC simultaneously
445    // +spec:inline-formatting-context:8bfe73 - display:flow generates inline box (Inline) or block container (Block) based on outer display type
446    match node.formatting_context {
447        FormattingContext::Block { .. } => {
448            #[cfg(feature = "web_lift")]
449            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0001) as u32); }
450            layout_bfc(ctx, tree, text_cache, node_index, constraints, float_cache)
451        }
452        // +spec:inline-formatting-context:a180ed - IFC establishment: inline-level boxes fragmented into line boxes with baseline alignment
453        FormattingContext::Inline => {
454            #[cfg(feature = "web_lift")]
455            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0002) as u32); }
456            layout_ifc(ctx, text_cache, tree, node_index, constraints)
457                .map(BfcLayoutResult::from_output)
458        }
459        FormattingContext::InlineBlock => {
460            #[cfg(feature = "web_lift")]
461            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0003) as u32); }
462            // +spec:display-property:1f5ddf - inline-level boxes with non-flow inner display establish new formatting context
463            // +spec:inline-formatting-context:1ad004 - atomic inline (inline-block) establishes new formatting context
464            // CSS 2.2 § 9.4.1: "inline-blocks... establish new block formatting contexts"
465            // +spec:inline-block:8d21f6 - inline-block generates inline-level block container (BFC inside, atomic inline outside)
466            // InlineBlock ALWAYS establishes a BFC for its contents.
467            // The element itself participates as an atomic inline in its parent's IFC,
468            // but its children are laid out in a BFC, not an IFC.
469            let mut temp_float_cache = HashMap::new();
470            layout_bfc(ctx, tree, text_cache, node_index, constraints, &mut temp_float_cache)
471        }
472        // +spec:table-layout:753687 - CSS 2.2 §17.2 table model: display values map to FormattingContext variants and dispatch table layout
473        FormattingContext::Table => {
474            #[cfg(feature = "web_lift")]
475            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0006) as u32); }
476            layout_table_fc(ctx, tree, text_cache, node_index, constraints)
477                .map(BfcLayoutResult::from_output)
478        }
479        // Table-internal flex items are blockified during tree construction
480        // (blockify_flex_item_if_table_internal in layout_tree.rs), so they arrive
481        // here as Block, not TableCell etc.
482        FormattingContext::Flex | FormattingContext::Grid => {
483            #[cfg(feature = "web_lift")]
484            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0004) as u32); }
485            layout_flex_grid(ctx, tree, text_cache, node_index, constraints)
486        }
487        // that are not block boxes, so they establish new BFCs for their contents
488        FormattingContext::TableCell | FormattingContext::TableCaption => {
489            #[cfg(feature = "web_lift")]
490            unsafe { crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0007) as u32); }
491            let mut temp_float_cache = HashMap::new();
492            layout_bfc(ctx, tree, text_cache, node_index, constraints, &mut temp_float_cache)
493        }
494        _ => {
495            // [g147g az-web-lift DIAG] read the RAW discriminant byte (offset 0 under repr(C,u8)) of the
496            // node that fell through to `_`. node 0 won't hit `_`; nodes 1,2 (divs) write their disc to
497            // 0x60B40+slot. disc=1 ⇒ value IS Inline but the dispatch match mis-branched (match/jump-table
498            // lift bug); disc≠1 ⇒ tree-construction stored the wrong/garbage FC for the nested div.
499            #[cfg(feature = "web_lift")]
500            unsafe {
501                crate::az_mark(((0x609C0 + (node_index & 7) * 4)) as u32, (0xC0DE0009) as u32);
502                let disc: u8 = core::ptr::read_volatile((&node.formatting_context) as *const FormattingContext as *const u8);
503                crate::az_mark(((0x60B40 + (node_index & 7) * 4)) as u32, (0xC0DE0000 | (disc as u32)) as u32);
504            }
505            // Unknown formatting context - fall back to BFC
506            let mut temp_float_cache = HashMap::new();
507            layout_bfc(
508                ctx,
509                tree,
510                text_cache,
511                node_index,
512                constraints,
513                &mut temp_float_cache,
514            )
515        }
516    }
517}
518
519// Flex / grid layout (taffy Bridge)
520// containing block determined by grid-placement properties; Taffy handles this internally
521// (grid auto-placement §8.5 and abspos grid items use grid-area CB, not just padding box)
522
523/// Lays out a Flex or Grid formatting context using the Taffy layout engine.
524///
525/// # CSS Spec References
526///
527/// - CSS Flexbox § 9: Flex Layout Algorithm
528/// - CSS Grid § 12: Grid Layout Algorithm
529// gutters on either side of collapsed tracks collapse including distributed alignment space,
530// minimum contribution = outer size from min-width/min-height if specified size is auto else
531// min-content contribution) — all handled by Taffy grid implementation
532///
533/// # Implementation Notes
534///
535/// - Resolves explicit CSS dimensions to pixel values for `known_dimensions`
536/// - Uses `InherentSize` mode when explicit dimensions are set
537/// - Uses `ContentSize` mode for auto-sizing (shrink-to-fit)
538#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
539fn layout_flex_grid<T: ParsedFontTrait>(
540    ctx: &mut LayoutContext<'_, T>,
541    tree: &mut LayoutTree,
542    text_cache: &mut TextLayoutCache,
543    node_index: usize,
544    constraints: &LayoutConstraints<'_>,
545) -> Result<BfcLayoutResult> {
546    // Available space comes directly from constraints - margins are handled by Taffy
547    let available_space = TaffySize {
548        width: AvailableSpace::Definite(constraints.available_size.width),
549        height: AvailableSpace::Definite(constraints.available_size.height),
550    };
551
552    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
553
554    // from flex line's cross size (clamped by min/max) when align-self:stretch, cross-size:auto,
555    // and neither cross-axis margin is auto. Otherwise uses hypothetical cross size.
556    // NOTE: visibility:collapse strut size for flex items is handled internally by Taffy.
557    //
558    // Resolve explicit CSS dimensions to pixel values.
559    // This is CRITICAL for align-items: stretch to work correctly!
560    // Taffy uses known_dimensions to calculate cross_axis_available_space for children.
561    let (explicit_width, has_explicit_width) =
562        resolve_explicit_dimension_width(ctx, node, constraints);
563    let (explicit_height, has_explicit_height) =
564        resolve_explicit_dimension_height(ctx, node, constraints);
565
566    // FIX: For root nodes or nodes where the parent provides a definite size,
567    // use the available_size as known_dimensions if no explicit CSS width/height is set.
568    // This is critical for `align-self: stretch` to work - Taffy needs to know the
569    // cross-axis size of the container to stretch children to fill it.
570    let is_root = node.parent.is_none();
571
572    let bp = node.box_props.unpack();
573    let width_adjustment = bp.border.left
574        + bp.border.right
575        + bp.padding.left
576        + bp.padding.right;
577    let height_adjustment = bp.border.top
578        + bp.border.bottom
579        + bp.padding.top
580        + bp.padding.bottom;
581
582    // `constraints.available_size` is the root's CONTENT-BOX (produced by
583    // `prepare_layout_context::inner_size(final_used_size)`), not the viewport
584    // border-box. Previously, the code used it as if it were border-box,
585    // causing taffy to subtract padding a second time and shrink the content
586    // area by 2x padding. For the root, pull the actual border-box from
587    // `node.used_size` (set by `calculate_used_size_for_node` before this call).
588    let root_border_box = node.used_size;
589
590    let effective_width = if has_explicit_width {
591        explicit_width
592    } else if is_root {
593        root_border_box.as_ref().map(|s| s.width).or_else(|| {
594            if constraints.available_size.width.is_finite() {
595                // Fallback: convert content-box to border-box.
596                Some(constraints.available_size.width + width_adjustment)
597            } else {
598                None
599            }
600        })
601    } else {
602        // Non-root flex/grid container with `width: auto`: for a block-level
603        // child the parent's block layout has ALREADY resolved the used width
604        // (auto → fill containing block) before descending into this FC — pass
605        // it through as the definite border-box width, exactly like the root
606        // branch does with its used_size. Without this, known_dimensions.width
607        // stays None and taffy treats a column container's cross axis as
608        // INDEFINITE, so `align-items: stretch` items get the flex line's
609        // max-content width instead of the container width (live bug: under
610        // the injected Html menubar wrapper, AzulPaint's body laid out its
611        // header AND canvas at 315.776px — the header text's max-content —
612        // instead of the body's 624px).
613        node.used_size.as_ref().map(|s| s.width)
614    };
615    let effective_height = if has_explicit_height {
616        explicit_height
617    } else if is_root {
618        root_border_box.as_ref().map(|s| s.height).or_else(|| {
619            if constraints.available_size.height.is_finite() {
620                Some(constraints.available_size.height + height_adjustment)
621            } else {
622                None
623            }
624        })
625    } else {
626        None
627    };
628    let has_effective_width = effective_width.is_some();
629    let has_effective_height = effective_height.is_some();
630
631    // Taffy interprets known_dimensions as border-box. CSS width/height default
632    // to content-box, so explicit values need +padding+border added. For the
633    // ROOT element, however, we auto-apply box-sizing: border-box — the common
634    // CSS reset pattern — so `height:100%` + padding fits the viewport instead
635    // of overflowing by padding (which the default content-box interpretation
636    // would produce, since 100% of ICB is viewport-sized content, with padding
637    // added outside pushing border-box past the viewport).
638    let adjusted_width = if has_explicit_width && !is_root {
639        explicit_width.map(|w| w + width_adjustment)
640    } else if has_explicit_width && is_root {
641        explicit_width
642    } else {
643        effective_width
644    };
645    let adjusted_height = if has_explicit_height && !is_root {
646        explicit_height.map(|h| h + height_adjustment)
647    } else if has_explicit_height && is_root {
648        explicit_height
649    } else {
650        effective_height
651    };
652
653    // CSS Flexbox § 9.2: Use InherentSize when explicit dimensions are set,
654    // ContentSize for auto-sizing (shrink-to-fit behavior).
655    let sizing_mode = if has_effective_width || has_effective_height {
656        taffy::SizingMode::InherentSize
657    } else {
658        taffy::SizingMode::ContentSize
659    };
660
661    let known_dimensions = TaffySize {
662        width: adjusted_width,
663        height: adjusted_height,
664    };
665
666    // parent_size tells Taffy the size of the container's parent.
667    // For root nodes, the "parent" is the viewport, but since margins are already
668    // handled by calculate_used_size_for_node(), we use containing_block_size directly.
669    // For non-root nodes, containing_block_size is already the parent's content-box.
670    let parent_size = translate_taffy_size(constraints.containing_block_size);
671
672    let taffy_inputs = LayoutInput {
673        known_dimensions,
674        parent_size,
675        available_space,
676        run_mode: taffy::RunMode::PerformLayout,
677        sizing_mode,
678        axis: taffy::RequestedAxis::Both,
679        // Flex and Grid containers establish a new BFC, preventing margin collapse.
680        vertical_margins_are_collapsible: Line::FALSE,
681    };
682
683    debug_info!(
684        ctx,
685        "CALLING LAYOUT_TAFFY FOR FLEX/GRID FC node_index={:?}",
686        node_index
687    );
688
689    // For the root with auto-applied border-box: sync node.used_size so
690    // display-list rendering matches the border-box we handed taffy.
691    // Without this, the root's background/border would paint at the
692    // inflated size from calculate_used_size_for_node while taffy placed
693    // children inside a smaller content-box.
694    if is_root {
695        if let (Some(aw), Some(ah)) = (adjusted_width, adjusted_height) {
696            if let Some(node_mut) = tree.get_mut(node_index) {
697                node_mut.used_size = Some(LogicalSize::new(aw, ah));
698            }
699        }
700    }
701
702    // Cache border values before the mutable borrow in layout_taffy_subtree
703    let border_left = bp.border.left;
704    let border_top = bp.border.top;
705
706    let taffy_output =
707        taffy_bridge::layout_taffy_subtree(ctx, tree, text_cache, node_index, taffy_inputs);
708
709    // Collect child positions from the tree (Taffy stores results directly on nodes).
710    let mut output = LayoutOutput::default();
711    // Use content_size for overflow detection, not container size.
712    // content_size represents the actual size of all children, which may exceed the container.
713    //
714    // Taffy's content_size is measured from (0,0) of the border-box, so it includes
715    // border.top/left as a leading offset.  The scrollbar geometry and scroll clamp
716    // both measure inside the padding-box (border stripped).  Subtract the start
717    // border so that overflow_size is in the same coordinate space as the viewport
718    // (padding-box), preventing extra scroll range equal to the border width.
719    let raw = translate_taffy_size_back(taffy_output.content_size);
720    output.overflow_size = LogicalSize::new(
721        (raw.width - border_left).max(0.0),
722        (raw.height - border_top).max(0.0),
723    );
724
725    let children: Vec<usize> = tree.children(node_index).to_vec();
726    for &child_idx in &children {
727        if let Some(warm_node) = tree.warm(child_idx) {
728            if let Some(pos) = warm_node.relative_position {
729                output.positions.insert(child_idx, pos);
730            }
731        }
732    }
733
734    Ok(BfcLayoutResult::from_output(output))
735}
736
737/// Resolves explicit CSS width to pixel value for Taffy layout.
738fn resolve_explicit_dimension_width<T: ParsedFontTrait>(
739    ctx: &LayoutContext<'_, T>,
740    node: &LayoutNodeHot,
741    constraints: &LayoutConstraints<'_>,
742) -> (Option<f32>, bool) {
743    node.dom_node_id
744        .map_or((None, false), |id| {
745            let width = get_css_width(
746                ctx.styled_dom,
747                id,
748                &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state,
749            );
750            match width.unwrap_or_default() {
751                LayoutWidth::Auto
752                | LayoutWidth::MinContent
753                | LayoutWidth::MaxContent
754                | LayoutWidth::FitContent(_) => (None, false),
755                LayoutWidth::Px(px) => {
756                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
757                    let pixels = resolve_size_metric(
758                        px.metric,
759                        px.number.get(),
760                        constraints.available_size.width,
761                        ctx.viewport_size,
762                        get_element_font_size(ctx.styled_dom, id, node_state),
763                        get_root_font_size(ctx.styled_dom, node_state),
764                    );
765                    (Some(pixels), true)
766                }
767                LayoutWidth::Calc(items) => {
768                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
769                    let em = get_element_font_size(ctx.styled_dom, id, node_state);
770                    let calc_ctx = super::calc::CalcResolveContext {
771                        items, em_size: em, rem_size: DEFAULT_FONT_SIZE,
772                    };
773                    let px = super::calc::evaluate_calc(&calc_ctx, constraints.available_size.width);
774                    (Some(px), true)
775                }
776            }
777        })
778}
779
780/// Resolves explicit CSS height to pixel value for Taffy layout.
781fn resolve_explicit_dimension_height<T: ParsedFontTrait>(
782    ctx: &LayoutContext<'_, T>,
783    node: &LayoutNodeHot,
784    constraints: &LayoutConstraints<'_>,
785) -> (Option<f32>, bool) {
786    node.dom_node_id
787        .map_or((None, false), |id| {
788            let height = get_css_height(
789                ctx.styled_dom,
790                id,
791                &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state,
792            );
793            match height.unwrap_or_default() {
794                LayoutHeight::Auto
795                | LayoutHeight::MinContent
796                | LayoutHeight::MaxContent
797                | LayoutHeight::FitContent(_) => (None, false),
798                LayoutHeight::Px(px) => {
799                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
800                    let pixels = resolve_size_metric(
801                        px.metric,
802                        px.number.get(),
803                        constraints.available_size.height,
804                        ctx.viewport_size,
805                        get_element_font_size(ctx.styled_dom, id, node_state),
806                        get_root_font_size(ctx.styled_dom, node_state),
807                    );
808                    (Some(pixels), true)
809                }
810                LayoutHeight::Calc(items) => {
811                    let node_state = &ctx.styled_dom.styled_nodes.as_container()[id].styled_node_state;
812                    let em = get_element_font_size(ctx.styled_dom, id, node_state);
813                    let calc_ctx = super::calc::CalcResolveContext {
814                        items, em_size: em, rem_size: DEFAULT_FONT_SIZE,
815                    };
816                    let px = super::calc::evaluate_calc(&calc_ctx, constraints.available_size.height);
817                    (Some(px), true)
818                }
819            }
820        })
821}
822
823// +spec:floats:167a2c - Float positioning rules (CSS 2.2 § 9.5.1): left/right/none, precise placement constraints
824// +spec:floats:6a1769 - Float shortens line boxes, margins never collapse, stacking order
825// +spec:floats:15bfd9 - float:right positions element at line-right edge within BFC
826// +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
827/// Position a float within a BFC, considering existing floats.
828/// Returns the `LogicalRect` (margin box) for the float.
829// +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
830// +spec:containing-block:136e45 - Float shifted left/right until outer edge touches containing block edge or another float
831// +spec:containing-block:3ebb4e - Content moves below floats when containing block too narrow
832// +spec:floats:45fce7 - Float positioning: pulled out of flow, line boxes shortened around float
833// +spec:floats:f6c218 - float pulled out of flow, line boxes shorten around it
834// +spec:height-calculation:86142a - CSS 2.2 §9.5 float positioning, clearance, and margin non-collapsing
835// +spec:width-calculation:761677 - float positioning: content flows around floats, line boxes shortened by float presence
836fn position_float(
837    float_ctx: &FloatingContext,
838    float_type: LayoutFloat,
839    size: LogicalSize,
840    margin: &EdgeSizes,
841    current_main_offset: f32,
842    bfc_cross_size: f32,
843    wm: LayoutWritingMode,
844) -> LogicalRect {
845    // Start at the current main-axis position (Y in horizontal-tb)
846    let mut main_start = current_main_offset;
847
848    // Calculate total size including margins
849    let total_main = size.main(wm) + margin.main_start(wm) + margin.main_end(wm);
850    let total_cross = size.cross(wm) + margin.cross_start(wm) + margin.cross_end(wm);
851
852    // +spec:floats:3d89d8 - shift float downward when not enough horizontal room
853    // Find a position where the float fits
854    let cross_start = loop {
855        let (avail_start, avail_end) = float_ctx.available_line_box_space(
856            main_start,
857            main_start + total_main,
858            bfc_cross_size,
859            wm,
860        );
861
862        let available_width = avail_end - avail_start;
863
864        if available_width >= total_cross {
865            // +spec:floats:449158 - left float positioned at line-left, content flows on right
866            // Found space that fits
867            if float_type == LayoutFloat::Left {
868                // +spec:writing-modes:84bcba - floats positioned at line-left / line-right
869                // Position at line-left (avail_start)
870                break avail_start + margin.cross_start(wm);
871            }
872            // Position at line-right (avail_end - size)
873            break avail_end - total_cross + margin.cross_start(wm);
874        }
875
876        // top is moved lower than earlier float's bottom (outer edge / margin box bottom)
877        // Not enough space at this Y, move down past the lowest overlapping float's margin box bottom
878        let next_main = float_ctx
879            .floats
880            .iter()
881            .filter(|f| {
882                let f_main_start = f.rect.origin.main(wm) - f.margin.main_start(wm);
883                let f_main_end = f_main_start + f.rect.size.main(wm)
884                    + f.margin.main_start(wm) + f.margin.main_end(wm);
885                f_main_end > main_start && f_main_start < main_start + total_main
886            })
887            .map(|f| f.rect.origin.main(wm) + f.rect.size.main(wm) + f.margin.main_end(wm))
888            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
889
890        if let Some(next) = next_main {
891            main_start = next;
892        } else {
893            // No overlapping floats found, use current position anyway
894            if float_type == LayoutFloat::Left {
895                break avail_start + margin.cross_start(wm);
896            }
897            break avail_end - total_cross + margin.cross_start(wm);
898        }
899    };
900
901    LogicalRect {
902        origin: LogicalPosition::from_main_cross(
903            main_start + margin.main_start(wm),
904            cross_start,
905            wm,
906        ),
907        size,
908    }
909}
910
911// Block Formatting Context (CSS 2.2 § 9.4.1)
912
913/// Lays out a Block Formatting Context (BFC).
914///
915/// This is the corrected, architecturally-sound implementation. It solves the
916/// "chicken-and-egg" problem by performing its own two-pass layout:
917///
918/// 1. **Sizing Pass:** It first iterates through its children and triggers their layout recursively
919///    by calling `calculate_layout_for_subtree`. This ensures that the `used_size` property of each
920///    child is correctly populated.
921///
922/// 2. **Positioning Pass:** It then iterates through the children again. Now that each child has a
923///    valid size, it can apply the standard block-flow logic: stacking them vertically and
924///    advancing a "pen" by each child's outer height.
925///
926/// # Margin Collapsing Architecture
927///
928/// CSS 2.1 Section 8.3.1 compliant margin collapsing:
929///
930/// ```text
931/// layout_bfc()
932///   ├─ Check parent border/padding blockers
933///   ├─ For each child:
934///   │   ├─ Check child border/padding blockers
935///   │   ├─ is_first_child?
936///   │   │   └─ Check parent-child top collapse
937///   │   ├─ Sibling collapse?
938///   │   │   └─ advance_pen_with_margin_collapse()
939///   │   │       └─ collapse_margins(prev_bottom, curr_top)
940///   │   ├─ Position child
941///   │   ├─ is_empty_block()?
942///   │   │   └─ Collapse own top+bottom margins (collapse through)
943///   │   └─ Save bottom margin for next sibling
944///   └─ Check parent-child bottom collapse
945/// ```
946///
947/// **Collapsing Rules:**
948///
949/// - Sibling margins: Adjacent vertical margins collapse to max (or sum if mixed signs)
950/// - Parent-child: First child's top margin can escape parent (if no border/padding)
951/// - Parent-child: Last child's bottom margin can escape parent (if no border/padding/height)
952/// - Empty blocks: Top+bottom margins collapse with each other, then with siblings
953/// - Blockers: Border, padding, inline content, or new BFC prevents collapsing
954///
955/// This approach is compliant with the CSS visual formatting model and works within
956/// the constraints of the existing layout engine architecture.
957// +spec:display-property:f38f52 - BFC handles normal flow, relative positioning offsets, and float extraction (CSS 2.2 § 9.8)
958#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
959fn layout_bfc<T: ParsedFontTrait>(
960    ctx: &mut LayoutContext<'_, T>,
961    tree: &mut LayoutTree,
962    text_cache: &mut TextLayoutCache,
963    node_index: usize,
964    constraints: &LayoutConstraints<'_>,
965    float_cache: &mut HashMap<usize, FloatingContext>,
966) -> Result<BfcLayoutResult> {
967    let node = tree
968        .get(node_index)
969        .ok_or(LayoutError::InvalidTree)?
970        .clone();
971    // +spec:block-formatting-context:4f4ff6 - writing-mode determines block flow direction (main axis) for ordering block-level boxes in BFC
972    let writing_mode = constraints.writing_mode;
973    let mut output = LayoutOutput::default();
974
975    debug_info!(
976        ctx,
977        "\n[layout_bfc] ENTERED for node_index={}, children.len()={}, incoming_bfc_state={}",
978        node_index,
979        tree.children(node_index).len(),
980        constraints.bfc_state.is_some()
981    );
982
983    // Initialize FloatingContext for this BFC
984    //
985    // We always recalculate float positions in this pass, but we'll store them in the cache
986    // so that subsequent layout passes (for auto-sizing) have access to the positioned floats
987    let mut float_context = FloatingContext::default();
988
989    // +spec:containing-block:42b75f - Block element establishes containing block for inline content (IFC)
990    // Calculate this node's content-box size for use as containing block for children
991    // CSS 2.2 § 10.1: The containing block for in-flow children is formed by the
992    // content edge of the parent's content box.
993    //
994    // We use constraints.available_size directly as this already represents the
995    // content-box available to this node (set by parent). For nodes with explicit
996    // sizes, used_size contains the border-box which we convert to content-box.
997    //
998    // NOTE(writing-modes): The containing block size uses physical width/height.
999    // In vertical writing modes, the block progression direction is horizontal,
1000    // so the "available width" for children maps to the physical height of
1001    // the containing block. The main_pen variable below tracks block progression
1002    // using logical main-axis coordinates; the WritingModeContext in constraints
1003    // determines how main/cross map to physical x/y via from_main_cross().
1004    // +spec:inline-block:17944a - orthogonal flow roots get infinite available inline space here (not yet detected)
1005    // +spec:inline-block:a60e22 - other layout models pass through infinite inline space to contained block containers
1006    let mut children_containing_block_size = node.used_size.map_or_else(
1007        // No used_size yet - use available_size directly (this is already content-box
1008        // when coming from parent's layout constraints)
1009        || constraints.available_size,
1010        |used_size| {
1011            // Node has used_size (border-box) - convert to content-box.
1012            // For auto-height containers, the pre-layout `used_size.height` is a
1013            // placeholder (calculate_used_size_for_node returns 0 for block-level
1014            // auto-height; apply_content_based_height resolves it after children lay
1015            // out). In that window, `constraints.available_size.height` holds the
1016            // containing block's height — the value children should use as their own
1017            // containing block for percentage-height / indefinite-height semantics.
1018            let inner = node.box_props.inner_size(used_size, writing_mode);
1019            let height_is_auto = tree
1020                .warm(node_index)
1021                .is_none_or(|w| w.computed_style.height.is_none());
1022            if height_is_auto {
1023                LogicalSize::new(inner.width, constraints.available_size.height)
1024            } else {
1025                inner
1026            }
1027        },
1028    );
1029
1030    // +spec:overflow:ffe6f7 - scrollbar space subtracted from containing block per spec §11.1.1
1031    // Reserve space for vertical scrollbar when appropriate.
1032    //
1033    // - overflow: scroll  → ALWAYS reserve (CSS spec: scrollbar always shown)
1034    // - overflow: auto    → Reserve ONLY when a previous pass already determined
1035    //   a scrollbar is needed.
1036    //   On the very first pass the node has no scrollbar_info yet, so no space
1037    //   is reserved.  After `compute_scrollbar_info` detects overflow it sets
1038    //   `reflow_needed_for_scrollbars = true`, triggering a second pass where
1039    //   `node.scrollbar_info.needs_vertical == true` and space IS reserved.
1040    //   Each pass replaces `scrollbar_info` with the current state; the outer
1041    //   layout loop's iteration cap handles oscillation safety.
1042    let scrollbar_reservation = node
1043        .dom_node_id
1044        .map_or(0.0, |dom_id| {
1045            let styled_node_state = ctx
1046                .styled_dom
1047                .styled_nodes
1048                .as_container()
1049                .get(dom_id)
1050                .map(|s| s.styled_node_state)
1051                .unwrap_or_default();
1052            let overflow_y =
1053                get_overflow_y(ctx.styled_dom, dom_id, &styled_node_state);
1054            match overflow_y.unwrap_or_default() {
1055                LayoutOverflow::Scroll => {
1056                    crate::solver3::getters::get_layout_scrollbar_width_px(ctx, dom_id, &styled_node_state)
1057                }
1058                LayoutOverflow::Auto => {
1059                    let already_needs = tree.warm(node_index)
1060                        .and_then(|w| w.scrollbar_info.as_ref())
1061                        .is_some_and(|s| s.needs_vertical);
1062                    if already_needs {
1063                        crate::solver3::getters::get_layout_scrollbar_width_px(ctx, dom_id, &styled_node_state)
1064                    } else {
1065                        0.0
1066                    }
1067                }
1068                _ => 0.0,
1069            }
1070        });
1071
1072    if scrollbar_reservation > 0.0 {
1073        children_containing_block_size.width =
1074            (children_containing_block_size.width - scrollbar_reservation).max(0.0);
1075    }
1076
1077    // === Pass 1: Pre-compute child sizes (restored two-pass BFC) ===
1078    //
1079    // Inspired by Taffy's two-pass approach: first measure, then position.
1080    //
1081    // This was removed in commit 1a3e5850 and replaced with a single-pass approach
1082    // that computed sizes just-in-time during positioning. The single-pass approach
1083    // caused regression 8e092a2e because positioning decisions (margin collapsing,
1084    // float clearance, available width after floats) depend on knowing ALL sibling
1085    // sizes upfront, not just the ones visited so far.
1086    //
1087    // With the per-node cache (§9.1-§9.2), the re-added Pass 1 is efficient:
1088    // - Each child subtree is computed once and stored in NodeCache
1089    // - Pass 2 positioning reads sizes from tree nodes (used_size set by Pass 1)
1090    // - When calculate_layout_for_subtree recurses into children after layout_bfc
1091    //   returns, it hits the per-node cache (same available_size) — O(1) per child.
1092    //
1093    // Performance: O(n) for the tree. No double-computation thanks to caching.
1094    {
1095        let mut temp_positions: super::PositionVec = Vec::new();
1096        let mut temp_scrollbar_reflow = false;
1097
1098        let bfc_children = tree.children(node_index).to_vec();
1099        // [g147c az-web-lift DIAG] layout_bfc Pass-1 child-sizing loop: record bfc_children.len per parent
1100        // node (0x60A00+slot). If body shows len=2 but the divs never get the per-child "sized" marker
1101        // (0x60A40+childslot) below → the loop skips them; if they DO get it but layout_formatting_context
1102        // (0x609A0) stays unset → calculate(child,ComputeSize) cache-hit (vs 0x60A60 miss-flag in cache.rs).
1103        #[cfg(feature = "web_lift")]
1104        unsafe { crate::az_mark(((0x60A00 + (node_index & 7) * 4)) as u32, (bfc_children.len() as u32 | 0xC0DE0000) as u32); }
1105        for &child_index in &bfc_children {
1106            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1107            let child_dom_id = child_node.dom_node_id;
1108
1109            // +spec:positioning:447b06 - Absolute positioning pulls element out of flow, skip from normal layout
1110            // +spec:positioning:77a2d2 - Absolutely positioned children are ignored for auto height
1111            // +spec:positioning:b47ac2 - Only normal flow children taken into account for auto height
1112            // Skip absolutely/fixed positioned children — they're laid out separately
1113            // +spec:positioning:c7e5c5 - out-of-flow elements ignored for word boundary / hyphenation
1114            // +spec:positioning:7dd6d1 - Absolutely positioned boxes are taken out of the normal flow (no impact on later siblings, no margin collapsing)
1115            let position_type = get_position_type(ctx.styled_dom, child_dom_id);
1116            if position_type == LayoutPosition::Absolute || position_type == LayoutPosition::Fixed {
1117                continue;
1118            }
1119
1120            // Compute the child's full subtree layout with temporary positions.
1121            // Position (0,0) is intentionally wrong — Pass 1 only cares about sizing.
1122            // The correct positions are determined in Pass 2 below.
1123            // [g147c] this child IS reached by Pass-1 sizing (per-child slot).
1124            #[cfg(feature = "web_lift")]
1125            unsafe { crate::az_mark(((0x60A40 + (child_index & 7) * 4)) as u32, (0xC0DE0000 | (child_index as u32 & 0xffff)) as u32); }
1126            crate::solver3::cache::calculate_layout_for_subtree(
1127                ctx,
1128                tree,
1129                text_cache,
1130                child_index,
1131                LogicalPosition::zero(),
1132                children_containing_block_size,
1133                &mut temp_positions,
1134                &mut temp_scrollbar_reflow,
1135                float_cache,
1136                crate::solver3::cache::ComputeMode::ComputeSize,
1137            )?;
1138        }
1139    }
1140
1141    // +spec:block-formatting-context:98b633 - CSS 2.2 § 9.4.1: boxes laid out vertically, margins collapse
1142    // === Pass 2: Position children using known sizes ===
1143    //
1144    // All children now have used_size set from Pass 1. This pass handles:
1145    // - Margin collapsing (parent-child + sibling-sibling)
1146    // - Float positioning and clearance
1147    // - Normal flow block positioning
1148
1149    let mut main_pen = 0.0f32;
1150    let mut max_cross_size = 0.0f32;
1151
1152    // Track escaped margins separately from content-box height
1153    // CSS 2.2 § 8.3.1: Escaped margins don't contribute to parent's content-box height,
1154    // but DO affect sibling positioning within the parent
1155    let mut total_escaped_top_margin = 0.0f32;
1156    // Track all inter-sibling margins (collapsed) - these are also not part of content height
1157    let mut total_sibling_margins = 0.0f32;
1158
1159    // Margin collapsing state
1160    let mut last_margin_bottom = 0.0f32;
1161    let mut is_first_child = true;
1162    let mut first_child_index: Option<usize> = None;
1163    let mut last_child_index: Option<usize> = None;
1164
1165    // Parent's own margins (for escape calculation)
1166    let node_bp = node.box_props.unpack();
1167    let parent_margin_top = node_bp.margin.main_start(writing_mode);
1168    let parent_margin_bottom = node_bp.margin.main_end(writing_mode);
1169
1170    // margins do not collapse across formatting context boundaries: an independent
1171    // BFC (float, overflow != visible, display: flex/grid, etc.) isolates its
1172    // children's margins. The DOM root is NOT a BFC boundary for this purpose —
1173    // its first child's margin still collapses through it (then gets absorbed at
1174    // the root, since there's no grandparent to escape to).
1175    let establishes_own_bfc = establishes_new_bfc(ctx, &node, tree.cold(node_index));
1176    let is_bfc_root = node.parent.is_none() || establishes_own_bfc;
1177
1178    // parent_has_*_blocker inhibits parent-child margin collapse per CSS 2.2 §8.3.1.
1179    // An explicit border/padding blocks, and an independent BFC blocks, but the
1180    // root on its own does not.
1181    let parent_has_top_blocker = establishes_own_bfc
1182        || has_margin_collapse_blocker(&node_bp, writing_mode, true);
1183    let parent_has_bottom_blocker = establishes_own_bfc
1184        || has_margin_collapse_blocker(&node_bp, writing_mode, false);
1185
1186    // Track accumulated top margin for first-child escape
1187    let mut accumulated_top_margin = 0.0f32;
1188    let mut top_margin_resolved = false;
1189    // Track if first child's margin escaped (for return value)
1190    let mut top_margin_escaped = false;
1191
1192    // Track if we have any actual content (non-empty blocks)
1193    let mut has_content = false;
1194
1195    // +spec:display-property:9f6e18 - BFC dispatches normal flow, floats, and relative positioning (CSS 2.2 §9.8)
1196    let pos_children = tree.children(node_index).to_vec();
1197    for &child_index in &pos_children {
1198        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1199        let child_dom_id = child_node.dom_node_id;
1200
1201        // +spec:floats:2cec1b - 'position' and 'float' determine the positioning algorithm
1202        // +spec:positioning:dccad6 - floats only apply to non-absolutely-positioned boxes
1203        let position_type = get_position_type(ctx.styled_dom, child_dom_id);
1204        if position_type == LayoutPosition::Absolute || position_type == LayoutPosition::Fixed {
1205            continue;
1206        }
1207
1208        // +spec:floats:2cec1b - float property determines positioning algorithm (float path)
1209        // +spec:floats:f6c0b2 - floats only processed in BFC; other formatting contexts (flex/grid) inhibit floating
1210        // Check if this child is a float - if so, position it at current main_pen
1211        if let Some(node_id) = child_dom_id {
1212            let float_type = get_float_property(ctx.styled_dom, Some(node_id));
1213
1214            if float_type != LayoutFloat::None {
1215                // Calculate float size just-in-time if not already computed
1216                let float_size = if let Some(size) = child_node.used_size { size } else {
1217                    let intrinsic = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
1218                    let child_bp = child_node.box_props.unpack();
1219                    let computed_size = crate::solver3::sizing::calculate_used_size_for_node(
1220                        ctx.styled_dom,
1221                        child_dom_id,
1222                        &children_containing_block_size,
1223                        intrinsic,
1224                        &child_bp,
1225                        &ctx.viewport_size,
1226                    )?;
1227                    if let Some(node_mut) = tree.get_mut(child_index) {
1228                        node_mut.used_size = Some(computed_size);
1229                    }
1230                    computed_size
1231                };
1232                // Re-borrow after potential mutation
1233                let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1234                let child_bp2 = child_node.box_props.unpack();
1235                let float_margin = &child_bp2.margin;
1236
1237                // +spec:floats:d0d163 - clear on floats adds constraint #10: float top below cleared floats' bottom
1238                // +spec:floats:7adb9d - Clear on floats: constraint #10, top outer edge must be below earlier cleared floats
1239                let float_clear = get_clear_property(ctx.styled_dom, Some(node_id));
1240                let float_y = if float_clear == LayoutClear::None {
1241                    // +spec:floats:ef96cb - Float margins never collapse with adjacent margins
1242                    // CSS 2.2 § 9.5: Float margins don't collapse with any other margins.
1243                    main_pen + last_margin_bottom
1244                } else {
1245                    float_context.clearance_offset(float_clear, main_pen + last_margin_bottom, writing_mode)
1246                };
1247
1248                debug_info!(
1249                    ctx,
1250                    "[layout_bfc] Positioning float: index={}, type={:?}, size={:?}, at Y={} \
1251                     (main_pen={} + last_margin={})",
1252                    child_index,
1253                    float_type,
1254                    float_size,
1255                    float_y,
1256                    main_pen,
1257                    last_margin_bottom
1258                );
1259
1260                // Position the float at the CURRENT main_pen + last margin (respects DOM order!)
1261                let float_rect = position_float(
1262                    &float_context,
1263                    float_type,
1264                    float_size,
1265                    float_margin,
1266                    // Include last_margin_bottom since float margins don't collapse!
1267                    float_y,
1268                    constraints.available_size.cross(writing_mode),
1269                    writing_mode,
1270                );
1271
1272                debug_info!(ctx, "[layout_bfc] Float positioned at: {:?}", float_rect);
1273
1274                // Add to float context BEFORE positioning next element
1275                float_context.add_float(float_type, float_rect, *float_margin);
1276
1277                // Store position in output
1278                output.positions.insert(child_index, float_rect.origin);
1279
1280                debug_info!(
1281                    ctx,
1282                    "[layout_bfc] *** FLOAT POSITIONED: child={}, main_pen={} (unchanged - floats \
1283                     don't advance pen)",
1284                    child_index,
1285                    main_pen
1286                );
1287
1288                // Floats are taken out of normal flow - DON'T advance main_pen
1289                // Continue to next child
1290                continue;
1291            }
1292        }
1293
1294        // Floats `continue` above; everything reaching here is normal-flow
1295        // (non-float) content.
1296
1297        // From here: normal flow (non-float) children only
1298
1299        // Track first and last in-flow children for parent-child collapse
1300        if first_child_index.is_none() {
1301            first_child_index = Some(child_index);
1302        }
1303        last_child_index = Some(child_index);
1304
1305        // Calculate child's used_size just-in-time if not already computed
1306        // This replaces the old "Pass 1" that recursively laid out grandchildren with wrong positions
1307        let child_size = if let Some(size) = child_node.used_size { size } else {
1308            // Calculate size without recursive layout
1309            let intrinsic = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
1310            let child_used_size = crate::solver3::sizing::calculate_used_size_for_node(
1311                ctx.styled_dom,
1312                child_dom_id,
1313                &children_containing_block_size,
1314                intrinsic,
1315                &child_node.box_props.unpack(),
1316                &ctx.viewport_size,
1317            )?;
1318            // Update the node with computed size (we need to re-borrow mutably)
1319            if let Some(node_mut) = tree.get_mut(child_index) {
1320                node_mut.used_size = Some(child_used_size);
1321            }
1322            child_used_size
1323        };
1324        // Re-borrow child_node after potential mutation
1325        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1326        let child_bp = child_node.box_props.unpack();
1327        let child_margin = &child_bp.margin;
1328
1329        debug_info!(
1330            ctx,
1331            "[layout_bfc] Child {} margin from box_props: top={}, right={}, bottom={}, left={}",
1332            child_index,
1333            child_margin.top,
1334            child_margin.right,
1335            child_margin.bottom,
1336            child_margin.left
1337        );
1338
1339        // +spec:block-formatting-context:0f802c - margins use containing block's writing mode for collapsing/auto expansion in orthogonal flows
1340        let child_own_margin_top = child_margin.main_start(writing_mode);
1341        let child_own_margin_bottom = child_margin.main_end(writing_mode);
1342
1343        // CSS 2.2 § 8.3.1: If a child has no top blocker (no padding/border) and its
1344        // own BFC layout produced an escaped_top_margin, that margin represents the
1345        // collapsed value of (child's margin, child's first child's margin, ...).
1346        // Use it for sibling collapse instead of the child's own margin.
1347        let child_escaped_top = if has_margin_collapse_blocker(&child_bp, writing_mode, true) { None } else {
1348            tree.warm(child_index).and_then(|w| w.escaped_top_margin)
1349        };
1350        let child_escaped_bottom = if has_margin_collapse_blocker(&child_bp, writing_mode, false) { None } else {
1351            tree.warm(child_index).and_then(|w| w.escaped_bottom_margin)
1352        };
1353
1354        let child_margin_top = child_escaped_top.unwrap_or(child_own_margin_top);
1355        let child_margin_bottom = child_escaped_bottom.unwrap_or(child_own_margin_bottom);
1356
1357        debug_info!(
1358            ctx,
1359            "[layout_bfc] Child {} final margins: margin_top={}, margin_bottom={}",
1360            child_index,
1361            child_margin_top,
1362            child_margin_bottom
1363        );
1364
1365        // Check if this child has border/padding that prevents margin collapsing
1366        let child_has_top_blocker =
1367            has_margin_collapse_blocker(&child_bp, writing_mode, true);
1368        let child_has_bottom_blocker =
1369            has_margin_collapse_blocker(&child_bp, writing_mode, false);
1370
1371        // +spec:floats:dc195a - Clear property only applies to block-level elements (CSS 2.2 § 9.5.2)
1372        // Check for clear property FIRST - clearance affects whether element is considered empty
1373        // CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing"
1374        // An element with clearance is NOT empty even if it has no content
1375        let child_clear = if let Some(node_id) = child_dom_id {
1376            get_clear_property(ctx.styled_dom, Some(node_id))
1377        } else {
1378            LayoutClear::None
1379        };
1380        debug_info!(
1381            ctx,
1382            "[layout_bfc] Child {} clear property: {:?}",
1383            child_index,
1384            child_clear
1385        );
1386
1387        // PHASE 1: Empty Block Detection & Self-Collapse
1388        let is_empty = is_empty_block(tree, child_index);
1389
1390        // Handle empty blocks FIRST (they collapse through and don't participate in layout)
1391        // EXCEPTION: Elements with clear property are NOT skipped even if empty!
1392        // CSS 2.2 § 9.5.2: Clear property affects positioning even for empty elements
1393        if is_empty
1394            && !child_has_top_blocker
1395            && !child_has_bottom_blocker
1396            && child_clear == LayoutClear::None
1397        {
1398            // Empty block: collapse its own top and bottom margins FIRST
1399            let self_collapsed = collapse_margins(child_margin_top, child_margin_bottom);
1400
1401            // Then collapse with previous margin (sibling or parent)
1402            if is_first_child {
1403                is_first_child = false;
1404                // Empty first child: its collapsed margin can escape with parent's
1405                if parent_has_top_blocker {
1406                    // Parent has blocker: add margins
1407                    if accumulated_top_margin == 0.0 {
1408                        accumulated_top_margin = parent_margin_top;
1409                    }
1410                    main_pen += accumulated_top_margin + self_collapsed;
1411                    top_margin_resolved = true;
1412                    accumulated_top_margin = 0.0;
1413                } else {
1414                    accumulated_top_margin = collapse_margins(parent_margin_top, self_collapsed);
1415                }
1416                last_margin_bottom = self_collapsed;
1417            } else {
1418                // Empty sibling: collapse with previous sibling's bottom margin
1419                last_margin_bottom = collapse_margins(last_margin_bottom, self_collapsed);
1420            }
1421
1422            // Skip positioning and pen advance (empty has no visual presence)
1423            continue;
1424        }
1425
1426        // From here on: non-empty blocks only (or empty blocks with clear property)
1427
1428        // Apply clearance if needed
1429        // +spec:floats:148ee6 - clear:left pushes element below float; clearance added above top margin
1430        // CSS 2.2 § 9.5.2: Clearance inhibits margin collapsing.
1431        //
1432        // Per CSS 2.2 § 9.5.2, the clearance computation works as follows:
1433        // 1. Compute the "hypothetical position" — where the border edge would be
1434        //    with normal margin collapsing (as if clear:none).
1435        // 2. If the hypothetical position is NOT past the relevant floats,
1436        //    clearance is introduced and the border edge is placed at float bottom.
1437        // 3. The final border edge = max(float_bottom, hypothetical_position).
1438        //
1439        // This means child_margin_top is already accounted for in the hypothetical
1440        // position and must NOT be added again after clearance positions main_pen.
1441        let clearance_applied = if child_clear == LayoutClear::None {
1442            false
1443        } else {
1444            let hypothetical = main_pen + collapse_margins(last_margin_bottom, child_margin_top);
1445            let cleared_position =
1446                float_context.clearance_offset(child_clear, hypothetical, writing_mode);
1447            debug_info!(
1448                ctx,
1449                "[layout_bfc] Child {} clearance check: cleared_position={}, hypothetical={} (main_pen={} + collapse({}, {}))",
1450                child_index,
1451                cleared_position,
1452                hypothetical,
1453                main_pen,
1454                last_margin_bottom,
1455                child_margin_top
1456            );
1457            if cleared_position > hypothetical {
1458                debug_info!(
1459                    ctx,
1460                    "[layout_bfc] Applying clearance: child={}, clear={:?}, old_pen={}, new_pen={}",
1461                    child_index,
1462                    child_clear,
1463                    main_pen,
1464                    cleared_position
1465                );
1466                main_pen = cleared_position;
1467                true // Signal that clearance was applied
1468            } else {
1469                false
1470            }
1471        };
1472
1473        // PHASE 2: Parent-Child Top Margin Escape (First Child)
1474        //
1475        // CSS 2.2 § 8.3.1: "The top margin of a box is adjacent to the top margin of its first
1476        // in-flow child if the box has no top border, no top padding, and the child has no
1477        // clearance." CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing"
1478
1479        if is_first_child {
1480            is_first_child = false;
1481
1482            // Clearance prevents collapse (acts as invisible blocker)
1483            if clearance_applied {
1484                // Clearance inhibits all margin collapsing for this element
1485                // The clearance has already positioned main_pen at the correct
1486                // border-edge position (= max(float_bottom, hypothetical)).
1487                // The hypothetical already includes child_margin_top via
1488                // collapse_margins, so we must NOT add it again here.
1489                debug_info!(
1490                    ctx,
1491                    "[layout_bfc] First child {} with CLEARANCE: no collapse, child_margin={}, \
1492                     main_pen={}",
1493                    child_index,
1494                    child_margin_top,
1495                    main_pen
1496                );
1497            } else if !parent_has_top_blocker {
1498                // Margin Escape Case
1499                //
1500                // CSS 2.2 § 8.3.1: "The top margin of an in-flow block element collapses with
1501                // its first in-flow block-level child's top margin if the element has no top
1502                // border, no top padding, and the child has no clearance."
1503                //
1504                // When margins collapse, they "escape" upward through the parent to be resolved
1505                // in the grandparent's coordinate space. This is critical for understanding the
1506                // coordinate system separation:
1507                //
1508                // Example:
1509                // <body padding=20>
1510                //  <div margin=0>
1511                //      <div margin=30></div>
1512                //  </div>
1513                // </body>
1514                //
1515                //   - Middle div (our parent) has no padding → margins can escape
1516                //   - Inner div's 30px margin collapses with middle div's 0px margin = 30px
1517                //   - This 30px margin "escapes" to be handled by body's BFC
1518                //   - Body positions middle div at Y=30 (relative to body's content-box)
1519                //   - Middle div's content-box height does NOT include the escaped 30px
1520                //   - Inner div is positioned at Y=0 in middle div's content-box
1521                //
1522                // **NOTE**: This is a subtle but critical distinction in coordinate systems:
1523                //
1524                //   - Parent's margin belongs to grandparent's coordinate space
1525                //   - Child's margin (when escaped) also belongs to grandparent's coordinate space
1526                //   - They collapse BEFORE entering this BFC's coordinate space
1527                //   - We return the collapsed margin so grandparent can position parent correctly
1528                //
1529                // **NOTE**: Child's own blocker status (padding/border) is IRRELEVANT for
1530                // parent-child  collapse. The child may have padding that prevents
1531                // collapse with ITS OWN  children, but this doesn't prevent its
1532                // margin from escaping  through its parent.
1533                //
1534                // **NOTE**: Previously, we incorrectly added parent_margin_top to main_pen in
1535                //  the blocked case, which double-counted the margin by mixing
1536                //  coordinate systems. The parent's margin is NEVER in our (the
1537                //  parent's content-box) coordinate system!
1538                //
1539                // We collapse the parent's margin with the child's margin.
1540                // This combined margin is what "escapes" to the grandparent.
1541                // The grandparent uses this to position the parent.
1542                //
1543                // Effectively, we are saying "The parent starts here, but its effective
1544                // top margin is now max(parent_margin, child_margin)".
1545
1546                accumulated_top_margin = collapse_margins(parent_margin_top, child_margin_top);
1547                top_margin_resolved = true;
1548                top_margin_escaped = true;
1549
1550                // Track escaped margin so it gets subtracted from content-box height
1551                // The escaped margin is NOT part of our content-box - it belongs to our
1552                // parent's parent
1553                total_escaped_top_margin = accumulated_top_margin;
1554
1555                // Position child at pen (no margin applied - it escaped!)
1556                debug_info!(
1557                    ctx,
1558                    "[layout_bfc] First child {} margin ESCAPES: parent_margin={}, \
1559                     child_margin={}, collapsed={}, total_escaped={}",
1560                    child_index,
1561                    parent_margin_top,
1562                    child_margin_top,
1563                    accumulated_top_margin,
1564                    total_escaped_top_margin
1565                );
1566            } else {
1567                // Margin Blocked Case
1568                //
1569                // CSS 2.2 § 8.3.1: "no top padding and no top border" required for collapse.
1570                // When padding or border exists, margins do NOT collapse and exist in different
1571                // coordinate spaces.
1572                //
1573                // CRITICAL COORDINATE SYSTEM SEPARATION:
1574                //
1575                //   This is where the architecture becomes subtle. When layout_bfc() is called:
1576                //   1. We are INSIDE the parent's content-box coordinate space (main_pen starts at
1577                //      0)
1578                //   2. The parent's own margin was ALREADY RESOLVED by the grandparent's BFC
1579                //   3. The parent's margin is in the grandparent's coordinate space, not ours
1580                //   4. We NEVER reference the parent's margin in this BFC - it's outside our scope
1581                //
1582                // Example:
1583                //
1584                // <body padding=20>
1585                //   <div margin=30 padding=20>
1586                //      <div margin=30></div>
1587                //   </div>
1588                // </body>
1589                //
1590                //   - Middle div has padding=20 → blocker exists, margins don't collapse
1591                //   - Body's BFC positions middle div at Y=30 (middle div's margin, in body's
1592                //     space)
1593                //   - Middle div's BFC starts at its content-box (after the padding)
1594                //   - main_pen=0 at the top of middle div's content-box
1595                //   - Inner div has margin=30 → we add 30 to main_pen (in OUR coordinate space)
1596                //   - Inner div positioned at Y=30 (relative to middle div's content-box)
1597                //   - Absolute position: 20 (body padding) + 30 (middle margin) + 20 (middle
1598                //     padding) + 30 (inner margin) = 100px
1599                //
1600                // **NOTE**: Previous code incorrectly added parent_margin_top to main_pen here:
1601                //
1602                //     - main_pen += parent_margin_top;  // WRONG! Mixes coordinate systems
1603                //     - main_pen += child_margin_top;
1604                //
1605                //   This caused the "double margin" bug where margins were applied twice:
1606                //
1607                //   - Once by grandparent positioning parent (correct)
1608                //   - Again inside parent's BFC (INCORRECT - wrong coordinate system)
1609                //
1610                //   The parent's margin belongs to GRANDPARENT's coordinate space and was already
1611                //   used to position the parent. Adding it again here is like adding feet to
1612                //   meters.
1613                //
1614                //   We ONLY add the child's margin in our (parent's content-box) coordinate space.
1615                //   The parent's margin is irrelevant to us - it's outside our scope.
1616
1617                main_pen += child_margin_top;
1618                debug_info!(
1619                    ctx,
1620                    "[layout_bfc] First child {} BLOCKED: parent_has_blocker={}, advanced by \
1621                     child_margin={}, main_pen={}",
1622                    child_index,
1623                    parent_has_top_blocker,
1624                    child_margin_top,
1625                    main_pen
1626                );
1627            }
1628        } else {
1629            // Not first child: handle sibling collapse
1630            // CSS 2.2 § 8.3.1 Rule 1: "Vertical margins of adjacent block boxes in the normal flow
1631            // collapse" CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing"
1632
1633            // Resolve accumulated top margin if not yet done (for parent's first in-flow child)
1634            if !top_margin_resolved {
1635                main_pen += accumulated_top_margin;
1636                top_margin_resolved = true;
1637                debug_info!(
1638                    ctx,
1639                    "[layout_bfc] RESOLVED top margin for node {} at sibling {}: accumulated={}, \
1640                     main_pen={}",
1641                    node_index,
1642                    child_index,
1643                    accumulated_top_margin,
1644                    main_pen
1645                );
1646            }
1647
1648            if clearance_applied {
1649                // Clearance has already positioned main_pen at the correct
1650                // border-edge = max(float_bottom, hypothetical). The hypothetical
1651                // already includes collapse_margins(last_margin_bottom, child_margin_top),
1652                // so we must NOT add child_margin_top again here.
1653                debug_info!(
1654                    ctx,
1655                    "[layout_bfc] Child {} with CLEARANCE: no collapse with sibling, \
1656                     child_margin_top={}, main_pen={}",
1657                    child_index,
1658                    child_margin_top,
1659                    main_pen
1660                );
1661            } else {
1662                // Sibling Margin Collapse
1663                //
1664                // CSS 2.2 § 8.3.1: "Vertical margins of adjacent block boxes in the normal
1665                // flow collapse." The collapsed margin is the maximum of the two margins.
1666                //
1667                // IMPORTANT: Sibling margins ARE part of the parent's content-box height!
1668                //
1669                // Unlike escaped margins (which belong to grandparent's space), sibling margins
1670                // are the space BETWEEN children within our content-box.
1671                //
1672                // Example:
1673                //
1674                // <div>
1675                //  <div margin-bottom=30></div>
1676                //  <div margin-top=40></div>
1677                // </div>
1678                //
1679                //   - First child ends at Y=100 (including its content + margins)
1680                //   - Collapsed margin = max(30, 40) = 40px
1681                //   - Second child starts at Y=140 (100 + 40)
1682                //   - Parent's content-box height includes this 40px gap
1683                //
1684                // We track total_sibling_margins for debugging, but NOTE: we do **not**
1685                // subtract these from content-box height! They are part of the layout space.
1686                //
1687                // Previously we subtracted total_sibling_margins from content-box height:
1688                //
1689                //   content_box_height = main_pen - total_escaped_top_margin -
1690                // total_sibling_margins;
1691                //
1692                // This was wrong because sibling margins are between boxes (part of content),
1693                // not outside boxes (like escaped margins).
1694
1695                let collapsed = collapse_margins(last_margin_bottom, child_margin_top);
1696                main_pen += collapsed;
1697                total_sibling_margins += collapsed;
1698                debug_info!(
1699                    ctx,
1700                    "[layout_bfc] Sibling collapse for child {}: last_margin_bottom={}, \
1701                     child_margin_top={}, collapsed={}, main_pen={}, total_sibling_margins={}",
1702                    child_index,
1703                    last_margin_bottom,
1704                    child_margin_top,
1705                    collapsed,
1706                    main_pen,
1707                    total_sibling_margins
1708                );
1709            }
1710        }
1711
1712        // Position child (non-empty blocks only reach here)
1713        //
1714        // +spec:block-formatting-context:1dada5 - Normal flow boxes in BFC touch containing block edge
1715        // +spec:block-formatting-context:9f56cb - each box's left outer edge touches containing block left edge; new BFC may shrink due to floats
1716        // CSS 2.2 § 9.4.1: "In a block formatting context, each box's left outer edge touches
1717        // the left edge of the containing block (for right-to-left formatting, right edges touch).
1718        // This is true even in the presence of floats (although a box's line boxes may shrink
1719        // due to the floats), unless the box establishes a new block formatting context
1720        // (in which case the box itself may become narrower due to the floats)."
1721        //
1722        // +spec:block-formatting-context:3d2811 - Float overlap with normal flow element borders
1723        // +spec:display-property:796059 - BFC/replaced/table border box must not overlap float margin boxes; line boxes shorten around floats
1724        // +spec:floats:5214a6 - BFC/replaced/table border box must not overlap float margin boxes; shrink or clear below
1725        // CSS 2.2 § 9.5: "The border box of a table, a block-level replaced element, or an element
1726        // in the normal flow that establishes a new block formatting context (such as an element
1727        // with 'overflow' other than 'visible') must not overlap any floats in the same block
1728        // formatting context as the element itself."
1729
1730        // +spec:floats:a29f70 - BFC roots, tables, and block-level replaced elements must not overlap float margin boxes
1731        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1732        let avoids_floats = establishes_new_bfc(ctx, child_node, tree.cold(child_index))
1733            || is_block_level_replaced(ctx, child_node);
1734
1735        // Query available space considering floats ONLY if child avoids floats
1736        let (cross_start, cross_end, available_cross) = if avoids_floats {
1737            // New BFC / replaced / table: Must shrink or move down to avoid overlapping floats
1738            let child_cross_needed = child_size.cross(writing_mode);
1739            let bfc_cross = constraints.available_size.cross(writing_mode);
1740
1741            let (mut start, mut end) = float_context.available_line_box_space(
1742                main_pen,
1743                main_pen + child_size.main(writing_mode),
1744                bfc_cross,
1745                writing_mode,
1746            );
1747            let mut available = end - start;
1748
1749            // CSS 2.2 § 9.5: "If necessary, implementations should clear the said element
1750            // by placing it below any preceding floats, but may place it adjacent to such
1751            // floats if there is sufficient space."
1752            if available < child_cross_needed && !float_context.floats.is_empty() {
1753                let clear_to = float_context.floats.iter()
1754                    .filter(|f| {
1755                        let f_main_start = f.rect.origin.main(writing_mode) - f.margin.main_start(writing_mode);
1756                        let f_main_end = f_main_start + f.rect.size.main(writing_mode)
1757                            + f.margin.main_start(writing_mode) + f.margin.main_end(writing_mode);
1758                        f_main_end > main_pen && f_main_start < main_pen + child_size.main(writing_mode)
1759                    })
1760                    .map(|f| {
1761                        f.rect.origin.main(writing_mode) + f.rect.size.main(writing_mode)
1762                            + f.margin.main_end(writing_mode)
1763                    })
1764                    .fold(main_pen, f32::max);
1765
1766                if clear_to > main_pen {
1767                    main_pen = clear_to;
1768                    let (s, e) = float_context.available_line_box_space(
1769                        main_pen,
1770                        main_pen + child_size.main(writing_mode),
1771                        bfc_cross,
1772                        writing_mode,
1773                    );
1774                    start = s;
1775                    end = e;
1776                    available = end - start;
1777                }
1778            }
1779
1780            debug_info!(
1781                ctx,
1782                "[layout_bfc] Child {} avoids floats: shrinking to avoid floats, \
1783                 cross_range={}..{}, available_cross={}",
1784                child_index,
1785                start,
1786                end,
1787                available
1788            );
1789
1790            (start, end, available)
1791        } else {
1792            // Normal flow: Overlaps floats, positioned at full width
1793            // Only the child's INLINE CONTENT (if any) wraps around floats
1794            let start = 0.0;
1795            let end = constraints.available_size.cross(writing_mode);
1796            let available = end - start;
1797
1798            debug_info!(
1799                ctx,
1800                "[layout_bfc] Child {} is normal flow: overlapping floats at full width, \
1801                 available_cross={}",
1802                child_index,
1803                available
1804            );
1805
1806            (start, end, available)
1807        };
1808
1809        // Get child's margin, margin_auto, size, and formatting context
1810        let (child_margin_cloned, child_margin_auto, child_used_size, is_inline_fc, child_dom_id_for_debug) = {
1811            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1812            let cbp = child_node.box_props.unpack();
1813            (
1814                cbp.margin,
1815                cbp.margin_auto,
1816                child_node.used_size.unwrap_or_default(),
1817                child_node.formatting_context == FormattingContext::Inline,
1818                child_node.dom_node_id,
1819            )
1820        };
1821        let child_margin = &child_margin_cloned;
1822
1823        debug_info!(
1824            ctx,
1825            "[layout_bfc] Child {} margin_auto: left={}, right={}, top={}, bottom={}",
1826            child_index,
1827            child_margin_auto.left,
1828            child_margin_auto.right,
1829            child_margin_auto.top,
1830            child_margin_auto.bottom
1831        );
1832        debug_info!(
1833            ctx,
1834            "[layout_bfc] Child {} used_size: width={}, height={}",
1835            child_index,
1836            child_used_size.width,
1837            child_used_size.height
1838        );
1839
1840        // Position child
1841        // For normal flow blocks (including IFCs): position at full width (cross_start = 0)
1842        // For BFC-establishing blocks: position in available space between floats
1843        //
1844        // CSS 2.2 § 10.3.3: If margin-left and margin-right are both auto,
1845        // their used values are equal, centering the element horizontally.
1846        
1847        let (child_cross_pos, mut child_main_pos) = if avoids_floats {
1848            // BFC: Position in float-free space, but also check margin:auto centering.
1849            // A flex container or overflow:hidden box establishes a BFC (must avoid floats)
1850            // but can still be centered via margin:auto — these are independent concepts.
1851            let cross_pos = if child_margin_auto.left && child_margin_auto.right {
1852                let remaining = (available_cross - child_used_size.cross(writing_mode)).max(0.0);
1853                debug_info!(
1854                    ctx,
1855                    "[layout_bfc] Child {} BFC + margin:auto centering: available={}, size={}, offset={}",
1856                    child_index, available_cross, child_used_size.cross(writing_mode), remaining / 2.0
1857                );
1858                cross_start + remaining / 2.0
1859            } else if child_margin_auto.left {
1860                let remaining = (available_cross - child_used_size.cross(writing_mode) - child_margin.right).max(0.0);
1861                cross_start + remaining
1862            } else {
1863                cross_start + child_margin.cross_start(writing_mode)
1864            };
1865            (cross_pos, main_pen)
1866        } else {
1867            // Normal flow: Check for margin: auto centering
1868            let available_cross = constraints.available_size.cross(writing_mode);
1869            let child_cross_size = child_used_size.cross(writing_mode);
1870            
1871            debug_info!(
1872                ctx,
1873                "[layout_bfc] Child {} centering check: available_cross={}, child_cross_size={}, margin_auto.left={}, margin_auto.right={}",
1874                child_index,
1875                available_cross,
1876                child_cross_size,
1877                child_margin_auto.left,
1878                child_margin_auto.right
1879            );
1880            
1881            // +spec:block-formatting-context:d52ce5 - auto margins resolved per containing block's writing mode for centering
1882            // +spec:width-calculation:0c5044 - auto margins center element on cross axis (respects writing mode)
1883            // +spec:width-calculation:25c2fc - §10.3.3: block-level margin auto centering and over-constrained resolution
1884            // +spec:width-calculation:ba691f - auto margins treated as zero when element overflows containing block (via .max(0.0) on remaining_space)
1885            // +spec:width-calculation:324e7e - both margin-left and margin-right auto => equal used values (centering)
1886            // CSS 2.2 § 10.3.3: If both margin-left and margin-right are auto,
1887            // center the element within the available space
1888            let cross_pos = if child_margin_auto.left && child_margin_auto.right {
1889                // Center: (available - child_width) / 2
1890                let remaining_space = (available_cross - child_cross_size).max(0.0);
1891                debug_info!(
1892                    ctx,
1893                    "[layout_bfc] Child {} CENTERING: remaining_space={}, cross_pos={}",
1894                    child_index,
1895                    remaining_space,
1896                    remaining_space / 2.0
1897                );
1898                remaining_space / 2.0
1899            } else if child_margin_auto.left {
1900                // Only left is auto: push element to the right
1901                let remaining_space = (available_cross - child_cross_size - child_margin.right).max(0.0);
1902                debug_info!(
1903                    ctx,
1904                    "[layout_bfc] Child {} margin-left:auto only, pushing right: remaining_space={}",
1905                    child_index,
1906                    remaining_space
1907                );
1908                remaining_space
1909            } else if child_margin_auto.right {
1910                // Only right is auto: element stays at left with its margin
1911                debug_info!(
1912                    ctx,
1913                    "[layout_bfc] Child {} margin-right:auto only, using left margin={}",
1914                    child_index,
1915                    child_margin.cross_start(writing_mode)
1916                );
1917                child_margin.cross_start(writing_mode)
1918            } else {
1919                // +spec:box-model:218643 - over-constrained: drop end margin per containing block writing mode
1920                // +spec:width-calculation:d172a4 - over-constrained: LTR ignores margin-right, RTL ignores margin-left
1921                // in LTR, margin-right is ignored (element positioned at margin-left);
1922                // in RTL, margin-left is ignored (element positioned from right edge)
1923                let is_rtl = tree.get(node_index)
1924                    .and_then(|n| n.dom_node_id)
1925                    .is_some_and(|cb_dom_id| {
1926                        let node_state = ctx.styled_dom.styled_nodes.as_container()
1927                            .get(cb_dom_id)
1928                            .map(|s| s.styled_node_state)
1929                            .unwrap_or_default();
1930                        matches!(
1931                            get_direction_property(ctx.styled_dom, cb_dom_id, &node_state),
1932                            MultiValue::Exact(StyleDirection::Rtl)
1933                        )
1934                    });
1935                let cross_pos = if is_rtl {
1936                    // RTL: ignore margin-left, position from right edge
1937                    available_cross - child_cross_size - child_margin.cross_end(writing_mode)
1938                } else {
1939                    // LTR (default): ignore margin-right, position at margin-left
1940                    child_margin.cross_start(writing_mode)
1941                };
1942                debug_info!(
1943                    ctx,
1944                    "[layout_bfc] Child {} NO auto margins (over-constrained), is_rtl={}, cross_pos={}",
1945                    child_index,
1946                    is_rtl,
1947                    cross_pos
1948                );
1949                cross_pos
1950            };
1951            
1952            (cross_pos, main_pen)
1953        };
1954
1955        // NOTE: We do NOT adjust child_main_pos based on child's escaped_top_margin here!
1956        // The escaped_top_margin represents margins that escaped FROM the child's own children.
1957        // The child's position in THIS BFC is determined by main_pen and the child's own margin
1958        // (which was already handled in the margin collapse logic above).
1959        //
1960        // Previously, this code incorrectly added child_escaped_margin to child_main_pos,
1961        // which caused double-application of margins because:
1962        // 1. The child's margin was used to calculate its position in THIS BFC
1963        // 2. Then its escaped_top_margin (which included its own margin) was added again
1964        //
1965        // The correct behavior per CSS 2.2 § 8.3.1 is:
1966        // - The child's escaped_top_margin is used by THIS node's parent to position THIS node
1967        // - It does NOT affect how we position the child within our content-box
1968
1969        // final_pos is [CoordinateSpace::Parent] - relative to this BFC's content-box
1970        let final_pos =
1971            LogicalPosition::from_main_cross(child_main_pos, child_cross_pos, writing_mode);
1972
1973        debug_info!(
1974            ctx,
1975            "[layout_bfc] *** NORMAL FLOW BLOCK POSITIONED: child={}, final_pos={:?}, \
1976             main_pen={}, avoids_floats={}",
1977            child_index,
1978            final_pos,
1979            main_pen,
1980            avoids_floats
1981        );
1982
1983        // Re-layout IFC children with float context for correct text wrapping
1984        // Normal flow blocks WITH inline content need float context propagated
1985        if is_inline_fc && !avoids_floats {
1986            // Use cached floats if available (from previous layout passes),
1987            // otherwise use the floats positioned in this pass
1988            let floats_for_ifc = float_cache.get(&node_index).unwrap_or(&float_context);
1989
1990            debug_info!(
1991                ctx,
1992                "[layout_bfc] Re-layouting IFC child {} (normal flow) with parent's float context \
1993                 at Y={}, child_cross_pos={}",
1994                child_index,
1995                main_pen,
1996                child_cross_pos
1997            );
1998            debug_info!(
1999                ctx,
2000                "[layout_bfc]   Using {} floats (from cache: {})",
2001                floats_for_ifc.floats.len(),
2002                float_cache.contains_key(&node_index)
2003            );
2004
2005            // Translate float coordinates from BFC-relative to IFC-relative
2006            // The IFC child is positioned at (child_cross_pos, main_pen) in BFC coordinates
2007            // Floats need to be relative to the IFC's CONTENT-BOX origin (inside padding/border)
2008            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
2009            let cbp = child_node.box_props.unpack();
2010            let padding_border_cross = cbp.padding.cross_start(writing_mode)
2011                + cbp.border.cross_start(writing_mode);
2012            let padding_border_main = cbp.padding.main_start(writing_mode)
2013                + cbp.border.main_start(writing_mode);
2014
2015            // Content-box origin in BFC coordinates
2016            let content_box_cross = child_cross_pos + padding_border_cross;
2017            let content_box_main = main_pen + padding_border_main;
2018
2019            debug_info!(
2020                ctx,
2021                "[layout_bfc]   Border-box at ({}, {}), Content-box at ({}, {}), \
2022                 padding+border=({}, {})",
2023                child_cross_pos,
2024                main_pen,
2025                content_box_cross,
2026                content_box_main,
2027                padding_border_cross,
2028                padding_border_main
2029            );
2030
2031            let mut ifc_floats = FloatingContext::default();
2032            for float_box in &floats_for_ifc.floats {
2033                // Convert float position from BFC coords to IFC CONTENT-BOX relative coords
2034                let float_rel_to_ifc = LogicalRect {
2035                    origin: LogicalPosition {
2036                        x: float_box.rect.origin.x - content_box_cross,
2037                        y: float_box.rect.origin.y - content_box_main,
2038                    },
2039                    size: float_box.rect.size,
2040                };
2041
2042                debug_info!(
2043                    ctx,
2044                    "[layout_bfc] Float {:?}: BFC coords = {:?}, IFC-content-relative = {:?}",
2045                    float_box.kind,
2046                    float_box.rect,
2047                    float_rel_to_ifc
2048                );
2049
2050                ifc_floats.add_float(float_box.kind, float_rel_to_ifc, float_box.margin);
2051            }
2052
2053            // Create a BfcState with IFC-relative float coordinates
2054            let mut bfc_state = BfcState {
2055                pen: LogicalPosition::zero(), // IFC starts at its own origin
2056                floats: ifc_floats.clone(),
2057                margins: MarginCollapseContext::default(),
2058            };
2059
2060            debug_info!(
2061                ctx,
2062                "[layout_bfc]   Created IFC-relative FloatingContext with {} floats",
2063                ifc_floats.floats.len()
2064            );
2065
2066            // Get the IFC child's content-box size (after padding/border)
2067            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
2068            let child_dom_id = child_node.dom_node_id;
2069
2070            // +spec:containing-block:a8ada9 - line box width determined by containing block and floats
2071            // For inline elements (display: inline), use containing block width as available
2072            // width. Inline elements flow within the containing block and wrap at its width.
2073            // CSS 2.2 § 10.3.1: For inline elements, available width = containing block width.
2074            let display = get_display_property(ctx.styled_dom, child_dom_id).unwrap_or_default();
2075            let child_content_size = if display == LayoutDisplay::Inline {
2076                // Inline elements use the containing block's content-box width
2077                LogicalSize::new(
2078                    children_containing_block_size.width,
2079                    children_containing_block_size.height,
2080                )
2081            } else {
2082                // Block-level elements use their own content-box
2083                child_node.box_props.inner_size(child_size, writing_mode)
2084            };
2085
2086            debug_info!(
2087                ctx,
2088                "[layout_bfc]   IFC child size: border-box={:?}, content-box={:?}",
2089                child_size,
2090                child_content_size
2091            );
2092
2093            // Create new constraints with float context
2094            // IMPORTANT: Use the child's CONTENT-BOX width, not the BFC width!
2095            let ifc_constraints = LayoutConstraints {
2096                available_size: child_content_size,
2097                bfc_state: Some(&mut bfc_state),
2098                writing_mode,
2099                writing_mode_ctx: constraints.writing_mode_ctx,
2100                text_align: constraints.text_align,
2101                containing_block_size: constraints.containing_block_size,
2102                available_width_type: Text3AvailableSpace::Definite(child_content_size.width),
2103            };
2104
2105            // Re-layout the IFC with float awareness
2106            // This will pass floats as exclusion zones to text3 for line wrapping
2107            let ifc_result = layout_formatting_context(
2108                ctx,
2109                tree,
2110                text_cache,
2111                child_index,
2112                &ifc_constraints,
2113                float_cache,
2114            )?;
2115
2116            // DON'T update used_size - the box keeps its full width!
2117            // Only the text layout inside changes to wrap around floats
2118
2119            debug_info!(
2120                ctx,
2121                "[layout_bfc] IFC child {} re-layouted with float context (text will wrap, box \
2122                 stays full width)",
2123                child_index
2124            );
2125
2126            // NOTE: We do NOT merge inline-block positions from the IFC's output.positions here!
2127            // The IFC's inline-block children will be correctly positioned when 
2128            // calculate_layout_for_subtree recursively processes the IFC node (child_index).
2129            // At that point, layout_ifc will be called again, and the inline-block positions
2130            // will be relative to the IFC's content-box, which is what we want.
2131            //
2132            // Merging them here would cause them to be processed by process_inflow_child
2133            // with the BFC's content-box position (self_content_box_pos of the BFC), 
2134            // resulting in incorrect absolute positions.
2135        }
2136
2137        output.positions.insert(child_index, final_pos);
2138
2139        // CSS margin collapse: escaped margins are handled via accumulated_top_margin
2140        // at the START of layout, not by adjusting positions after layout.
2141        // We simply advance by the child's actual size.
2142        main_pen += child_size.main(writing_mode);
2143        has_content = true;
2144
2145        // Update last margin for next sibling
2146        // CSS 2.2 § 8.3.1: The bottom margin of this box will collapse with the top margin
2147        // of the next sibling (if no clearance or blockers intervene)
2148        // element (between prev sibling's bottom and this element's top margin). The cleared
2149        // element's bottom margin is still available for normal collapsing with the next sibling.
2150        // CSS 2.2 § 9.5.2: "Clearance inhibits margin collapsing and acts as spacing above
2151        // the margin-top of an element."
2152        last_margin_bottom = child_margin_bottom;
2153
2154        debug_info!(
2155            ctx,
2156            "[layout_bfc] Child {} positioned at final_pos={:?}, size={:?}, advanced main_pen to \
2157             {}, last_margin_bottom={}, clearance_applied={}",
2158            child_index,
2159            final_pos,
2160            child_size,
2161            main_pen,
2162            last_margin_bottom,
2163            clearance_applied
2164        );
2165
2166        // Track the maximum cross-axis size to determine the BFC's overflow size.
2167        let child_cross_extent =
2168            child_cross_pos + child_size.cross(writing_mode) + child_margin.cross_end(writing_mode);
2169        max_cross_size = max_cross_size.max(child_cross_extent);
2170    }
2171
2172    // Store the float context in cache for future layout passes
2173    // This happens after ALL children (floats and normal) have been positioned
2174    debug_info!(
2175        ctx,
2176        "[layout_bfc] Storing {} floats in cache for node {}",
2177        float_context.floats.len(),
2178        node_index
2179    );
2180    float_cache.insert(node_index, float_context.clone());
2181
2182    // PHASE 3: Parent-Child Bottom Margin Escape
2183    let mut escaped_top_margin = None;
2184    let mut escaped_bottom_margin = None;
2185
2186    // Handle top margin escape
2187    if top_margin_escaped {
2188        // First child's margin escaped through parent
2189        escaped_top_margin = Some(accumulated_top_margin);
2190        debug_info!(
2191            ctx,
2192            "[layout_bfc] Returning escaped top margin: accumulated={}, node={}",
2193            accumulated_top_margin,
2194            node_index
2195        );
2196    } else if !top_margin_resolved && accumulated_top_margin > 0.0 {
2197        // No content was positioned, all margins accumulated (empty blocks)
2198        escaped_top_margin = Some(accumulated_top_margin);
2199        debug_info!(
2200            ctx,
2201            "[layout_bfc] Escaping top margin (no content): accumulated={}, node={}",
2202            accumulated_top_margin,
2203            node_index
2204        );
2205    } else {
2206        // Don't set escaped_top_margin = Some(0) — that would override the child's
2207        // own margin (e.g., 30px) with 0 during sibling collapse.
2208        debug_info!(
2209            ctx,
2210            "[layout_bfc] NOT escaping top margin: top_margin_resolved={}, escaped={}, \
2211             accumulated={}, node={}",
2212            top_margin_resolved,
2213            top_margin_escaped,
2214            accumulated_top_margin,
2215            node_index
2216        );
2217    }
2218
2219    // Handle bottom margin escape
2220    if let Some(last_idx) = last_child_index {
2221        let last_child = tree.get(last_idx).ok_or(LayoutError::InvalidTree)?;
2222        let last_child_bp = last_child.box_props.unpack();
2223        let last_has_bottom_blocker =
2224            has_margin_collapse_blocker(&last_child_bp, writing_mode, false);
2225
2226        debug_info!(
2227            ctx,
2228            "[layout_bfc] Bottom margin for node {}: parent_has_bottom_blocker={}, \
2229             last_has_bottom_blocker={}, last_margin_bottom={}, main_pen_before={}",
2230            node_index,
2231            parent_has_bottom_blocker,
2232            last_has_bottom_blocker,
2233            last_margin_bottom,
2234            main_pen
2235        );
2236
2237        if !parent_has_bottom_blocker && !last_has_bottom_blocker && has_content {
2238            // Last child's bottom margin can escape
2239            let collapsed_bottom = collapse_margins(parent_margin_bottom, last_margin_bottom);
2240            escaped_bottom_margin = Some(collapsed_bottom);
2241            debug_info!(
2242                ctx,
2243                "[layout_bfc] Bottom margin ESCAPED for node {}: collapsed={}",
2244                node_index,
2245                collapsed_bottom
2246            );
2247            // Don't add last_margin_bottom to pen (it escaped)
2248        } else {
2249            // Can't escape: add to pen
2250            main_pen += last_margin_bottom;
2251            // NOTE: We do NOT add parent_margin_bottom to main_pen here!
2252            // parent_margin_bottom is added OUTSIDE the content-box (in the margin-box)
2253            // The content-box height should only include children's content and margins
2254            debug_info!(
2255                ctx,
2256                "[layout_bfc] Bottom margin BLOCKED for node {}: added last_margin_bottom={}, \
2257                 main_pen_after={}",
2258                node_index,
2259                last_margin_bottom,
2260                main_pen
2261            );
2262        }
2263    } else {
2264        // No children: just use parent's margins
2265        if !top_margin_resolved {
2266            main_pen += parent_margin_top;
2267        }
2268        main_pen += parent_margin_bottom;
2269    }
2270
2271    // CRITICAL: If this is a root node (no parent), apply escaped margins directly
2272    // instead of propagating them upward (since there's no parent to receive them)
2273    let is_root_node = node.parent.is_none();
2274    if is_root_node {
2275        if let Some(top) = escaped_top_margin {
2276            // Adjust all child positions downward by the escaped top margin
2277            for pos in output.positions.values_mut() {
2278                let current_main = pos.main(writing_mode);
2279                *pos = LogicalPosition::from_main_cross(
2280                    current_main + top,
2281                    pos.cross(writing_mode),
2282                    writing_mode,
2283                );
2284            }
2285            main_pen += top;
2286        }
2287        if let Some(bottom) = escaped_bottom_margin {
2288            main_pen += bottom;
2289        }
2290        // For root nodes, don't propagate margins further
2291        escaped_top_margin = None;
2292        escaped_bottom_margin = None;
2293    }
2294
2295    // CSS 2.2 § 9.5: Floats don't contribute to container height with overflow:visible
2296    //
2297    // However, browsers DO expand containers to contain floats in specific cases:
2298    //
2299    // 1. If there's NO in-flow content (main_pen == 0), floats determine height
2300    // 2. If container establishes a BFC (overflow != visible)
2301    //
2302    // In this case, we have in-flow content (main_pen > 0) and overflow:visible,
2303    // so floats should NOT expand the container. Their margins can "bleed" beyond
2304    // the container boundaries into the parent.
2305    //
2306    // This matches Chrome/Firefox behavior where float margins escape through
2307    // the container's padding when there's existing in-flow content.
2308
2309    // +spec:block-formatting-context:7954a2 - 10.6.3: auto height for block-level non-replaced elements in normal flow
2310    // Content-box Height Calculation
2311    //
2312    // CSS 2.2 § 8.3.1: "The top border edge of the box is defined to coincide with
2313    // the top border edge of the [first] child" when margins collapse/escape.
2314    //
2315    // This means escaped margins do NOT contribute to the parent's content-box height.
2316    //
2317    // Calculation:
2318    //
2319    //   main_pen = total vertical space used by all children and margins
2320    //
2321    //   Components of main_pen:
2322    //
2323    //   1. Children's border-boxes (always included)
2324    //   2. Sibling collapsed margins (space BETWEEN children - part of content)
2325    //   3. First child's position (0 if margin escaped, margin_top if blocked)
2326    //
2327    //   What to subtract:
2328    //
2329    //   - total_escaped_top_margin: First child's margin that went to grandparent's space This
2330    //     margin is OUTSIDE our content-box, so we must subtract it.
2331    //
2332    //   What NOT to subtract:
2333    //
2334    //   - total_sibling_margins: These are the gaps BETWEEN children, which are
2335    //    legitimately part of our content area's layout space.
2336    //
2337    // Example with escaped margin:
2338    //   <div class="parent" padding=0>              <!-- Node 2 -->
2339    //     <div class="child1" margin=30></div>      <!-- Node 3, margin escapes -->
2340    //     <div class="child2" margin=40></div>      <!-- Node 5 -->
2341    //   </div>
2342    //
2343    //   Layout process:
2344    //
2345    //   - Node 3 positioned at main_pen=0 (margin escaped)
2346    //   - Node 3 size=140px → main_pen advances to 140
2347    //   - Sibling collapse: max(30 child1 bottom, 40 child2 top) = 40px
2348    //   - main_pen advances to 180
2349    //   - Node 5 size=130px → main_pen advances to 310
2350    //   - total_escaped_top_margin = 30
2351    //   - total_sibling_margins = 40 (tracked but NOT subtracted)
2352    //   - content_box_height = 310 - 30 = 280px ✓
2353    //
2354    // Previously, we calculated:
2355    //
2356    //   content_box_height = main_pen - total_escaped_top_margin - total_sibling_margins
2357    //
2358    // This incorrectly subtracted sibling margins, making parent too small.
2359    // Sibling margins are *between* boxes (part of layout), not *outside* boxes
2360    // (like escaped margins).
2361
2362    // +spec:box-model:4eebed - auto height for BFC = top margin-edge of topmost child to bottom margin-edge of bottommost child
2363    // +spec:box-model:4eebed - auto height = top margin-edge of topmost child to bottom margin-edge of bottommost child
2364    // +spec:height-calculation:d65226 - §10.6.7 auto heights for BFC roots: block children use
2365    // margin-edge of topmost/bottommost, floats extend height if below content edge
2366    // +spec:positioning:1a05bb - 10.6.7 auto height for BFC roots: block children use margin edges,
2367    // abspos ignored (skipped in Pass 1/2), relative considered without offset (applied after layout),
2368    // floats whose bottom margin edge exceeds content edge expand height (below)
2369    // +spec:positioning:e6712c - Auto height for BFC: distance between top/bottom margin-edges of
2370    // block children (minus escaped margins), ignoring absolutely positioned children (skipped at
2371    // line ~966), considering relatively positioned boxes without offset (applied after layout),
2372    // and extending to include floats whose bottom margin edge exceeds content edge
2373    // +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)
2374    // CSS 2.2 §8.3.1: escaped margins (both top and bottom) don't contribute to parent height
2375    let mut content_box_height = main_pen - total_escaped_top_margin
2376        - escaped_bottom_margin.unwrap_or(0.0);
2377
2378    // +spec:block-formatting-context:f73d3e - BFC root grows to fully contain its floats; floats from outside cannot protrude in
2379    // whose bottom margin edge exceeds bottom content edge; only floats participating
2380    // in this BFC are counted (not floats inside abspos descendants or nested BFCs)
2381    // +spec:box-model:1d4798 - auto height includes floats whose bottom margin edge exceeds content edge
2382    // only floats participating in this BFC are counted (not floats inside abspos descendants or nested BFCs)
2383    if is_bfc_root {
2384        for float_box in &float_context.floats {
2385            let float_bottom_margin_edge = float_box.rect.origin.main(writing_mode)
2386                + float_box.rect.size.main(writing_mode)
2387                + float_box.margin.main_end(writing_mode);
2388            if float_bottom_margin_edge > content_box_height {
2389                content_box_height = float_bottom_margin_edge;
2390            }
2391        }
2392    }
2393
2394    // +spec:display-contents:f6de1a - content height overflow tracked via overflow_size
2395    // +spec:overflow:043182 - overflow computed from box bounds + children overflow
2396    output.overflow_size =
2397        LogicalSize::from_main_cross(content_box_height, max_cross_size, writing_mode);
2398
2399    debug_info!(
2400        ctx,
2401        "[layout_bfc] FINAL for node {}: main_pen={}, total_escaped_top={}, \
2402         total_sibling_margins={}, content_box_height={}",
2403        node_index,
2404        main_pen,
2405        total_escaped_top_margin,
2406        total_sibling_margins,
2407        content_box_height
2408    );
2409
2410    // +spec:inline-formatting-context:2227a4 - atomic inline baseline for inline-block/inline-table
2411    // Baseline calculation would happen here in a full implementation.
2412    // CSS2 §10.8.1: For inline-block, baseline is the baseline of the last
2413    // line box in normal flow, or the bottom margin edge if no line boxes.
2414    output.baseline = None;
2415
2416    // Store escaped margins in the LayoutNode for use by parent
2417    if let Some(warm_mut) = tree.warm_mut(node_index) {
2418        warm_mut.escaped_top_margin = escaped_top_margin;
2419        warm_mut.escaped_bottom_margin = escaped_bottom_margin;
2420    }
2421
2422    if let Some(warm_mut) = tree.warm_mut(node_index) {
2423        warm_mut.baseline = output.baseline;
2424    }
2425
2426    Ok(BfcLayoutResult {
2427        output,
2428        escaped_top_margin,
2429        escaped_bottom_margin,
2430    })
2431}
2432
2433// Inline Formatting Context (CSS 2.2 § 9.4.2)
2434// +spec:display-property:ede6f4 - inline layout: mixed stream of text and inline-level boxes
2435
2436/// Lays out an Inline Formatting Context (IFC) by delegating to the `text3` engine.
2437///
2438/// This function acts as a bridge between the box-tree world of `solver3` and the
2439/// rich text layout world of `text3`. Its responsibilities are:
2440///
2441/// 1. **Collect Content**: Traverse the direct children of the IFC root and convert them into a
2442///    `Vec<InlineContent>`, the input format for `text3`. This involves:
2443///
2444///     - Recursively laying out `inline-block` children to determine their final size and baseline,
2445///       which are then passed to `text3` as opaque objects.
2446///     - Extracting raw text runs from inline text nodes.
2447///
2448/// 2. **Translate Constraints**: Convert the `LayoutConstraints` (available space, floats) from
2449///    `solver3` into the more detailed `UnifiedConstraints` that `text3` requires.
2450///
2451/// 3. **Invoke Text Layout**: Call the `text3` cache's `layout_flow` method to perform the complex
2452///    tasks of BIDI analysis, shaping, line breaking, justification, and vertical alignment.
2453///    +spec:display-property:e96c82 - inline formatting context: flow of elements/text wrapped into lines
2454///
2455/// 4. **Integrate Results**: Process the `UnifiedLayout` returned by `text3`:
2456///
2457///     - Store the rich layout result on the IFC root `LayoutNode` for the display list generation
2458///       pass.
2459///     - Update the `positions` map for all `inline-block` children based on the positions
2460///       calculated by `text3`.
2461///     - Extract the final overflow size and baseline for the IFC root itself
2462// NOTE(writing-modes): The IFC currently assumes inline direction = horizontal
2463// and block direction = vertical. In vertical writing modes, line boxes would
2464// stack horizontally and inline content would flow vertically. The writing mode
2465// is now available via constraints.writing_mode_ctx for agents to use when
2466// implementing vertical text layout in the text3 engine.
2467// +spec:display-property:574e7b - text-box-trim for inline boxes trims block-end to content edge (TODO: implement trimming per text-box-edge metric)
2468// +spec:display-property:da284a - IFC: flow inline-level boxes into line boxes, size/position each fragment
2469// +spec:inline-formatting-context:275f64 - IFC: boxes laid out horizontally into line boxes, respecting margins/borders/padding
2470#[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
2471#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2472fn layout_ifc<T: ParsedFontTrait>(
2473    ctx: &mut LayoutContext<'_, T>,
2474    text_cache: &mut TextLayoutCache,
2475    tree: &mut LayoutTree,
2476    node_index: usize,
2477    constraints: &LayoutConstraints<'_>,
2478) -> Result<LayoutOutput> {
2479    unsafe { crate::az_mark(0x60704_u32, (0x20u32)); }
2480    // [g147 az-web-lift DIAG] CALLER-side tree validity at layout_ifc entry, indexed by node_index
2481    // (0x60900+ = nodes.len, 0x60920+ = tree ptr) to dodge marker-overwrite across multiple IFCs.
2482    // Compare vs _impl's CALLEE-side (0x60940+/0x60960+): ptr differs ⇒ &mut tree mis-passes across
2483    // the call; ptr same but len differs ⇒ the tree's `nodes` Vec is emptied in place.
2484    #[cfg(feature = "web_lift")]
2485    unsafe {
2486        let slot = (node_index & 7) * 4;
2487        crate::az_mark(((0x60900 + slot)) as u32, (tree.nodes.len() as u32) as u32);
2488        crate::az_mark(((0x60920 + slot)) as u32, ((&*tree as *const LayoutTree as usize) as u32) as u32);
2489    }
2490    let float_count = constraints
2491        .bfc_state
2492        .as_ref()
2493        .map_or(0, |s| s.floats.floats.len());
2494    debug_info!(
2495        ctx,
2496        "[layout_ifc] ENTRY: node_index={}, has_bfc_state={}, float_count={}",
2497        node_index,
2498        constraints.bfc_state.is_some(),
2499        float_count
2500    );
2501    debug_ifc_layout!(ctx, "CALLED for node_index={}", node_index);
2502
2503    // +spec:display-property:7f3c1d - Anonymous inline boxes: text directly in block containers treated as anonymous inline elements in IFC
2504    // +spec:display-property:5a795c - root inline box: block container generates anonymous inline box holding all inline-level contents, inheriting from parent
2505    // For anonymous boxes, we need to find the DOM ID from a parent or child
2506    // CSS 2.2 § 9.2.1.1: Anonymous boxes inherit properties from their enclosing box
2507    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2508    let ifc_root_dom_id = if let Some(id) = node.dom_node_id { id } else {
2509        // Anonymous box - get DOM ID from parent or first child with DOM ID
2510        let parent_dom_id = node
2511            .parent
2512            .and_then(|p| tree.get(p))
2513            .and_then(|n| n.dom_node_id);
2514
2515        if let Some(id) = parent_dom_id {
2516            id
2517        } else {
2518            // Try to find DOM ID from first child
2519            tree.children(node_index)
2520                .iter()
2521                .filter_map(|&child_idx| tree.get(child_idx)).find_map(|n| n.dom_node_id)
2522                .ok_or(LayoutError::InvalidTree)?
2523        }
2524    };
2525
2526    debug_ifc_layout!(ctx, "ifc_root_dom_id={:?}", ifc_root_dom_id);
2527
2528    // +spec:display-property:a469a6 - line boxes created as needed for inline-level content in IFC
2529    // +spec:display-property:f3c875 - calculate layout bounds (size contributions) of each inline-level box
2530    // Phase 1: Collect and measure all inline-level children.
2531    let collect_result = collect_and_measure_inline_content(
2532        ctx,
2533        text_cache,
2534        tree,
2535        node_index,
2536        constraints,
2537    );
2538    // [g133 az-web-lift DIAG] which early-return fires in POSITIONING's layout_ifc.
2539    #[cfg(feature = "web_lift")]
2540    unsafe {
2541        crate::az_mark((0x60680) as u32, (collect_result.as_ref().map(|(c, _)| c.len()).unwrap_or(0) as u32) as u32);
2542        crate::az_mark((0x60684) as u32, (if collect_result.is_ok() { 0xC0DE0680u32 } else { 0x000000EEu32 }) as u32);
2543    }
2544    let (inline_content, child_map) = collect_result?;
2545
2546    // #11 fix: hash the inline content once. Used to (a) skip stale Phase 2d
2547    // fast-path reuse and (b) force a cache REPLACE when content changed even
2548    // though available width is unchanged — the display-list generator paints
2549    // text from the cached `inline_layout_result` (display_list.rs), so a
2550    // content change at a same-width constraint MUST overwrite it or the old
2551    // glyphs keep rendering (#11 stale display list).
2552    // Phase 2 (translate early): resolve the container-level (IFC) constraints now,
2553    // so the Phase 2d cache-reuse decision below can key on them too. Reuse was keyed
2554    // on available width + per-run content hash only; a change to a container-level
2555    // property (text-align, text-align-last, text-indent, direction, line-height,
2556    // white-space, columns) — which is NOT covered by the per-run content hash — would
2557    // otherwise silently reuse a stale, differently-aligned/indented cached layout.
2558    let text3_constraints =
2559        translate_to_text3_constraints(ctx, constraints, ctx.styled_dom, ifc_root_dom_id);
2560
2561    let current_content_hash = {
2562        use std::hash::{Hash, Hasher};
2563        let mut h = std::collections::hash_map::DefaultHasher::new();
2564        inline_content.hash(&mut h);
2565        // Fold the constraint-relevant container properties into the validity key.
2566        text3_constraints.text_align.hash(&mut h);
2567        text3_constraints.text_align_last.hash(&mut h);
2568        text3_constraints.white_space_mode.hash(&mut h);
2569        text3_constraints.direction.hash(&mut h);
2570        text3_constraints.columns.hash(&mut h);
2571        text3_constraints.text_indent.to_bits().hash(&mut h);
2572        match text3_constraints.line_height {
2573            text3::cache::LineHeight::Normal => 0u64.hash(&mut h),
2574            text3::cache::LineHeight::Px(v) => {
2575                1u64.hash(&mut h);
2576                v.to_bits().hash(&mut h);
2577            }
2578        }
2579        h.finish()
2580    };
2581
2582    debug_info!(
2583        ctx,
2584        "[layout_ifc] Collected {} inline content items for node {}",
2585        inline_content.len(),
2586        node_index
2587    );
2588    for (i, item) in inline_content.iter().enumerate() {
2589        match item {
2590            InlineContent::Text(run) => debug_info!(ctx, "  [{}] Text: '{}'", i, run.text),
2591            InlineContent::Marker {
2592                run,
2593                position_outside,
2594            } => debug_info!(
2595                ctx,
2596                "  [{}] Marker: '{}' (outside={})",
2597                i,
2598                run.text,
2599                position_outside
2600            ),
2601            InlineContent::Shape(_) => debug_info!(ctx, "  [{}] Shape", i),
2602            InlineContent::Image(_) => debug_info!(ctx, "  [{}] Image", i),
2603            _ => debug_info!(ctx, "  [{}] Other", i),
2604        }
2605    }
2606
2607    debug_ifc_layout!(
2608        ctx,
2609        "Collected {} inline content items",
2610        inline_content.len()
2611    );
2612
2613    if inline_content.is_empty() {
2614        debug_warning!(ctx, "inline_content is empty, returning default output!");
2615        // The node has no inline-level content this pass (e.g. its only
2616        // inline child — a text run or an inline image — was removed by a
2617        // relayout). Any `inline_layout_result` left over from a previous
2618        // frame is now stale: the display-list generator paints inline
2619        // objects (images, inline-block shapes) straight out of this cached
2620        // layout (see display_list.rs `paint_inline_*`), so a leftover entry
2621        // would re-emit the removed content AND index `styled_nodes` with a
2622        // `source_node_id` that no longer exists in the new DOM (OOB panic).
2623        // Clear it so the empty IFC renders nothing.
2624        if let Some(warm_node) = tree.warm_mut(node_index) {
2625            warm_node.inline_layout_result = None;
2626        }
2627        return Ok(LayoutOutput::default());
2628    }
2629
2630    // === Phase 2d: IFC incremental relayout decision tree ===
2631    //
2632    // Check if a cached layout exists with matching constraints. If so,
2633    // try incremental relayout (GlyphSwap or LineShift) before falling
2634    // back to full layout_flow().
2635    {
2636        let cached_ifc = tree
2637            .warm(node_index)
2638            .and_then(|n| n.inline_layout_result.as_ref());
2639
2640        // Only reuse the cached inline layout when the available WIDTH is unchanged.
2641        // This fast path was built for text edits (content changes, width constant); on a
2642        // viewport/container resize the width differs and the text must RE-WRAP, so the
2643        // cached old-width layout must NOT be reused — fall through to full layout_flow()
2644        // below. Without this guard, resizing kept the stale line breaks (#45). Real
2645        // text-edit incremental relayout (with dirty items) lives in
2646        // LayoutWindow::try_incremental_text_relayout.
2647        let resize_has_floats = constraints
2648            .bfc_state
2649            .as_ref()
2650            .is_some_and(|s| !s.floats.floats.is_empty());
2651        // #11 fix: cache validity is keyed on WIDTH only, so a same-width
2652        // RefreshDom whose text CHANGED would otherwise reuse the stale shaped
2653        // layout. Require the inline content hash to match too.
2654        let cached_ifc = cached_ifc
2655            .filter(|c| c.is_valid_for(constraints.available_width_type, resize_has_floats))
2656            .filter(|c| c.inline_content_hash == current_content_hash);
2657
2658        if let Some(cached) = cached_ifc {
2659            if let Some(ref line_breaks) = cached.line_breaks {
2660                // Collect per-item advance widths from cached metrics
2661                let old_advances: Vec<f32> = cached.item_metrics.iter()
2662                    .map(|m| m.advance_width)
2663                    .collect();
2664
2665                // Cache-reuse fast path. Real incremental relayout for text
2666                // edits lives in LayoutWindow::try_incremental_text_relayout
2667                // (window.rs) — it has the newly-shaped items and the edited
2668                // node id, so it can compute real dirty_item_indices and
2669                // take the GlyphSwap / LineShift branches. Here we only
2670                // know the IFC is being re-entered (e.g. viewport resize on
2671                // a static IFC); with nothing re-shaped yet, the best we can
2672                // do is "no items changed at this level" → trivial GlyphSwap
2673                // to return the cached layout unchanged.
2674                let result = text3::cache::try_incremental_relayout(
2675                    &[], // empty = no dirty items detected at this level
2676                    &old_advances,
2677                    &old_advances, // same advances since we haven't reshaped yet
2678                    line_breaks,
2679                );
2680
2681                if matches!(result, text3::cache::IncrementalRelayoutResult::GlyphSwap) {
2682                    // No items changed — return cached layout directly
2683                    debug_info!(ctx, "[layout_ifc] Phase 2d: GlyphSwap — reusing cached layout");
2684                    let main_frag = &cached.layout;
2685                    let frag_bounds = main_frag.bounds();
2686                    let mut output = LayoutOutput::default();
2687                    output.overflow_size = LogicalSize::new(frag_bounds.width, frag_bounds.height);
2688                    output.baseline = main_frag.last_baseline();
2689                    // Re-position inline-block children from cached layout
2690                    for positioned_item in &main_frag.items {
2691                        if let ShapedItem::Object { source, .. } = &positioned_item.item {
2692                            if let Some(&child_node_index) = child_map.get(source) {
2693                                output.positions.insert(child_node_index, LogicalPosition {
2694                                    x: positioned_item.position.x,
2695                                    y: positioned_item.position.y,
2696                                });
2697                            }
2698                        }
2699                    }
2700                    return Ok(output);
2701                }
2702                // Fall through to full layout_flow
2703            }
2704        }
2705    }
2706
2707    // Phase 2: text3_constraints was resolved early (above) so the cache-reuse key
2708    // could include container-level properties.
2709    // Clone constraints for caching (before they're moved into fragments)
2710    let cached_constraints = text3_constraints.clone();
2711
2712    debug_info!(
2713        ctx,
2714        "[layout_ifc] CALLING text_cache.layout_flow for node {} with {} exclusions",
2715        node_index,
2716        text3_constraints.shape_exclusions.len()
2717    );
2718
2719    let fragments = vec![LayoutFragment {
2720        id: "main".to_string(),
2721        constraints: text3_constraints,
2722    }];
2723
2724    // Phase 3: Invoke the text layout engine.
2725    // Get pre-loaded fonts from font manager (fonts should be loaded before layout)
2726    let loaded_fonts = ctx.font_manager.get_loaded_fonts();
2727    let text_layout_result = match text_cache.layout_flow(
2728        &inline_content,
2729        &[],
2730        &fragments,
2731        &ctx.font_manager.font_chain_cache,
2732        &ctx.font_manager.fc_cache,
2733        &loaded_fonts,
2734        ctx.debug_messages,
2735    ) {
2736        Ok(result) => {
2737            // [g133 az-web-lift DIAG] layout_flow returned Ok.
2738            #[cfg(feature = "web_lift")]
2739            unsafe { crate::az_mark((0x60688) as u32, (0xC0DE0688u32) as u32); }
2740            result
2741        }
2742        Err(e) => {
2743            // [g133 az-web-lift DIAG] layout_flow returned Err → zero-sized (text not positioned).
2744            #[cfg(feature = "web_lift")]
2745            unsafe {
2746                crate::az_mark((0x60688) as u32, (0x000000EEu32) as u32);
2747                // Read the error's first byte (discriminant) for the marker — a
2748                // `*const u8` read is always aligned + in-bounds; the old
2749                // `*const u32` read was UB on a 1-aligned / <4-byte enum.
2750                crate::az_mark((0x6068C) as u32, (*(&e as *const _ as *const u8)) as u32);
2751            }
2752            // Font errors should not stop layout of other elements.
2753            // Log the error and return a zero-sized layout.
2754            debug_warning!(ctx, "Text layout failed: {:?}", e);
2755            debug_warning!(
2756                ctx,
2757                "Continuing with zero-sized layout for node {}",
2758                node_index
2759            );
2760
2761            let mut output = LayoutOutput::default();
2762            output.overflow_size = LogicalSize::new(0.0, 0.0);
2763            return Ok(output);
2764        }
2765    };
2766    // Phase 4: Integrate results back into the solver3 layout tree.
2767    let mut output = LayoutOutput::default();
2768
2769    debug_ifc_layout!(
2770        ctx,
2771        "text_layout_result has {} fragment_layouts",
2772        text_layout_result.fragment_layouts.len()
2773    );
2774
2775    if let Some(main_frag) = text_layout_result.fragment_layouts.get("main") {
2776        let frag_bounds = main_frag.bounds();
2777        debug_ifc_layout!(
2778            ctx,
2779            "Found 'main' fragment with {} items, bounds={}x{}",
2780            main_frag.items.len(),
2781            frag_bounds.width,
2782            frag_bounds.height
2783        );
2784        debug_ifc_layout!(ctx, "Storing inline_layout_result on node {}", node_index);
2785
2786        // Determine if we should store this layout result using the new
2787        // CachedInlineLayout system. The key insight is that inline layouts
2788        // depend on available width:
2789        //
2790        // - Min-content measurement uses width ≈ 0 (maximum line wrapping)
2791        // - Max-content measurement uses width = ∞ (no line wrapping)
2792        // - Final layout uses the actual column/container width
2793        //
2794        // We must track which constraint type was used, otherwise a min-content
2795        // measurement would incorrectly be reused for final rendering.
2796        let has_floats = constraints
2797            .bfc_state
2798            .as_ref()
2799            .is_some_and(|s| !s.floats.floats.is_empty());
2800        let current_width_type = constraints.available_width_type;
2801
2802        let warm_node = tree.warm_mut(node_index).ok_or(LayoutError::InvalidTree)?;
2803
2804        let should_store = match &warm_node.inline_layout_result {
2805            None => {
2806                // No cached result - always store
2807                debug_info!(
2808                    ctx,
2809                    "[layout_ifc] Storing NEW inline_layout_result for node {} (width_type={:?}, \
2810                     has_floats={})",
2811                    node_index,
2812                    current_width_type,
2813                    has_floats
2814                );
2815                true
2816            }
2817            Some(cached) => {
2818                // Check if the new result should replace the cached one
2819                if cached.should_replace_with(current_width_type, has_floats)
2820                    || cached.inline_content_hash != current_content_hash
2821                {
2822                    // #11 fix: the cached layout is what the display-list
2823                    // generator paints from; replace it when the inline content
2824                    // changed, even if the width constraint is unchanged.
2825                    debug_info!(
2826                        ctx,
2827                        "[layout_ifc] REPLACING inline_layout_result for node {} (old: \
2828                         width={:?}, floats={}) with (new: width={:?}, floats={})",
2829                        node_index,
2830                        cached.available_width,
2831                        cached.has_floats,
2832                        current_width_type,
2833                        has_floats
2834                    );
2835                    true
2836                } else {
2837                    debug_info!(
2838                        ctx,
2839                        "[layout_ifc] KEEPING cached inline_layout_result for node {} (cached: \
2840                         width={:?}, floats={}, new: width={:?}, floats={})",
2841                        node_index,
2842                        cached.available_width,
2843                        cached.has_floats,
2844                        current_width_type,
2845                        has_floats
2846                    );
2847                    false
2848                }
2849            }
2850        };
2851
2852        if should_store {
2853            let mut cil = CachedInlineLayout::new_with_constraints(
2854                main_frag.clone(),
2855                current_width_type,
2856                has_floats,
2857                cached_constraints.clone(),
2858            );
2859            // #11 fix: record the content hash so Phase 2d only fast-path-reuses
2860            // this layout when the inline content is genuinely unchanged, and so
2861            // the store decision above can detect content changes.
2862            cil.inline_content_hash = current_content_hash;
2863            warm_node.inline_layout_result = Some(cil);
2864        }
2865
2866        // Extract the overall size and baseline for the IFC root.
2867        // +spec:display-property:a0d0ab - IFC height = top of topmost line box to bottom of bottommost line box
2868        // +spec:display-property:a63b8f - baseline-source defaults to auto (last baseline for inline-block/IFC)
2869        output.overflow_size = LogicalSize::new(frag_bounds.width, frag_bounds.height);
2870        output.baseline = main_frag.last_baseline();
2871        warm_node.baseline = output.baseline;
2872
2873        // +spec:box-model:929f42 - text-box-trim: trim half-leading from first/last formatted line
2874        // +spec:box-model:02e0f9 - text-box-trim: trim-end and trim-both, no effect with non-zero padding/border
2875        //
2876        // CSS Inline 3 § 6.2: For block containers, trim the block-start/block-end side
2877        // of the first/last formatted line. If there is intervening non-zero padding or
2878        // borders, there is no effect. Does not apply to flex, grid, or table contexts.
2879        let ifc_node_state = &ctx.styled_dom.styled_nodes.as_container()[ifc_root_dom_id].styled_node_state;
2880        // Fast path: if no node in the DOM declared text-box-trim, the cascade
2881        // walk would always return None → skip it.
2882        let text_box_trim = {
2883            let skip = ctx.styled_dom
2884                .css_property_cache
2885                .ptr
2886                .compact_cache
2887                .as_ref()
2888                .is_some_and(|cc| cc.dom_declared_flags & azul_css::compact_cache::DOM_HAS_TEXT_BOX_TRIM == 0);
2889            if skip {
2890                StyleTextBoxTrim::None
2891            } else {
2892                get_text_box_trim_property(ctx.styled_dom, ifc_root_dom_id, ifc_node_state)
2893                    .unwrap_or(StyleTextBoxTrim::None)
2894            }
2895        };
2896
2897        if text_box_trim != StyleTextBoxTrim::None && !main_frag.items.is_empty() {
2898            // Half-leading = (line-height - (ascent + descent)) / 2
2899            let half_leading = (cached_constraints.resolved_line_height()
2900                - (cached_constraints.strut_ascent + cached_constraints.strut_descent))
2901                / 2.0;
2902            let half_leading = half_leading.max(0.0);
2903
2904            // Check for intervening non-zero padding/border on block-start (top)
2905            let has_pad_or_border_top = match get_css_padding_top(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
2906                MultiValue::Exact(pv) => pv.number.get() != 0.0,
2907                _ => false,
2908            } || match get_css_border_top_width(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
2909                MultiValue::Exact(pv) => pv.number.get() != 0.0,
2910                _ => false,
2911            };
2912
2913            // Check for intervening non-zero padding/border on block-end (bottom)
2914            let has_pad_or_border_bottom = match get_css_padding_bottom(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
2915                MultiValue::Exact(pv) => pv.number.get() != 0.0,
2916                _ => false,
2917            } || match get_css_border_bottom_width(ctx.styled_dom, ifc_root_dom_id, ifc_node_state) {
2918                MultiValue::Exact(pv) => pv.number.get() != 0.0,
2919                _ => false,
2920            };
2921
2922            let trim_start = matches!(text_box_trim, StyleTextBoxTrim::TrimStart | StyleTextBoxTrim::TrimBoth)
2923                && !has_pad_or_border_top;
2924            let trim_end = matches!(text_box_trim, StyleTextBoxTrim::TrimEnd | StyleTextBoxTrim::TrimBoth)
2925                && !has_pad_or_border_bottom;
2926
2927            let mut height_reduction = 0.0;
2928            if trim_start && half_leading > 0.0 {
2929                height_reduction += half_leading;
2930            }
2931            if trim_end && half_leading > 0.0 {
2932                height_reduction += half_leading;
2933            }
2934
2935            if height_reduction > 0.0 {
2936                output.overflow_size.height = (output.overflow_size.height - height_reduction).max(0.0);
2937            }
2938        }
2939
2940        // Position all the inline-block children based on text3's calculations.
2941        // [CoordinateSpace::Parent] - positions are relative to IFC's content-box (0,0)
2942        for positioned_item in &main_frag.items {
2943            if let ShapedItem::Object { source, content, .. } = &positioned_item.item {
2944                if let Some(&child_node_index) = child_map.get(source) {
2945                    // new_relative_pos is [CoordinateSpace::Parent] - relative to this IFC's content-box
2946                    let new_relative_pos = LogicalPosition {
2947                        x: positioned_item.position.x,
2948                        y: positioned_item.position.y,
2949                    };
2950                    output.positions.insert(child_node_index, new_relative_pos);
2951                }
2952            }
2953        }
2954    }
2955
2956    // [g132 az-web-lift VERIFY] Capture the IFC content geometry (the line-box bounds from
2957    // main_frag.bounds(), set above as output.overflow_size). height>0 proves the text LAID OUT
2958    // (not just shaped). Free-band addrs, f32 bits. REVERT at cleanup.
2959    #[cfg(feature = "web_lift")]
2960    unsafe {
2961        crate::az_mark((0x60670) as u32, (output.overflow_size.width.to_bits()) as u32);
2962        crate::az_mark((0x60674) as u32, (output.overflow_size.height.to_bits()) as u32);
2963        crate::az_mark((0x60678) as u32, (output.positions.len() as u32) as u32);
2964        crate::az_mark((0x6067C) as u32, (0xC0DE0132u32) as u32);
2965    }
2966
2967    Ok(output)
2968}
2969
2970const fn translate_taffy_size(size: LogicalSize) -> TaffySize<Option<f32>> {
2971    TaffySize {
2972        width: Some(size.width),
2973        height: Some(size.height),
2974    }
2975}
2976
2977/// Helper: Convert `StyleFontStyle` to `text3::cache::FontStyle`
2978#[must_use] pub const fn convert_font_style(style: StyleFontStyle) -> crate::font_traits::FontStyle {
2979    match style {
2980        StyleFontStyle::Normal => crate::font_traits::FontStyle::Normal,
2981        StyleFontStyle::Italic => crate::font_traits::FontStyle::Italic,
2982        StyleFontStyle::Oblique => crate::font_traits::FontStyle::Oblique,
2983    }
2984}
2985
2986/// Helper: Convert `StyleFontWeight` to `FcWeight`
2987#[must_use] pub const fn convert_font_weight(weight: StyleFontWeight) -> FcWeight {
2988    match weight {
2989        StyleFontWeight::W100 => FcWeight::Thin,
2990        StyleFontWeight::W200 => FcWeight::ExtraLight,
2991        StyleFontWeight::W300 | StyleFontWeight::Lighter => FcWeight::Light,
2992        StyleFontWeight::Normal => FcWeight::Normal,
2993        StyleFontWeight::W500 => FcWeight::Medium,
2994        StyleFontWeight::W600 => FcWeight::SemiBold,
2995        StyleFontWeight::Bold => FcWeight::Bold,
2996        StyleFontWeight::W800 => FcWeight::ExtraBold,
2997        StyleFontWeight::W900 | StyleFontWeight::Bolder => FcWeight::Black,
2998    }
2999}
3000
3001/// Resolves a CSS size metric to pixels.
3002///
3003/// - `metric`: The CSS unit (px, pt, em, vw, etc.)
3004/// - `value`: The numeric value
3005/// - `containing_block_size`: Size of containing block (for percentage)
3006/// - `viewport_size`: Viewport dimensions (for vw, vh, vmin, vmax)
3007/// - `element_font_size`: The element's own computed font-size (for `em`)
3008/// - `root_font_size`: The root element's computed font-size (for `rem`)
3009#[inline]
3010fn resolve_size_metric(
3011    metric: SizeMetric,
3012    value: f32,
3013    containing_block_size: f32,
3014    viewport_size: LogicalSize,
3015    element_font_size: f32,
3016    root_font_size: f32,
3017) -> f32 {
3018    match metric {
3019        SizeMetric::Px => value,
3020        SizeMetric::Pt => value * PT_TO_PX,
3021        SizeMetric::Percent => value / 100.0 * containing_block_size,
3022        SizeMetric::Em => value * element_font_size,
3023        SizeMetric::Rem => value * root_font_size,
3024        SizeMetric::Vw => value / 100.0 * viewport_size.width,
3025        SizeMetric::Vh => value / 100.0 * viewport_size.height,
3026        SizeMetric::Vmin => value / 100.0 * viewport_size.width.min(viewport_size.height),
3027        SizeMetric::Vmax => value / 100.0 * viewport_size.width.max(viewport_size.height),
3028        SizeMetric::In => value * super::calc::PX_PER_INCH,
3029        SizeMetric::Cm => value * super::calc::PX_PER_INCH / super::calc::CM_PER_INCH,
3030        SizeMetric::Mm => value * super::calc::PX_PER_INCH / super::calc::MM_PER_INCH,
3031    }
3032}
3033
3034#[must_use] pub const fn translate_taffy_size_back(size: TaffySize<f32>) -> LogicalSize {
3035    LogicalSize {
3036        width: size.width,
3037        height: size.height,
3038    }
3039}
3040
3041#[must_use] pub const fn translate_taffy_point_back(point: taffy::Point<f32>) -> LogicalPosition {
3042    LogicalPosition {
3043        x: point.x,
3044        y: point.y,
3045    }
3046}
3047
3048// +spec:block-formatting-context:40e03e - BFC root: block container establishing new BFC (contains floats, excludes external floats, suppresses margin collapsing)
3049/// Checks if a node establishes a new Block Formatting Context (BFC).
3050///
3051/// Per CSS 2.2 § 9.4.1, a BFC is established by:
3052/// - Floats (elements with float other than 'none')
3053/// - Absolutely positioned elements (position: absolute or fixed)
3054/// - Block containers that are not block boxes (e.g., inline-blocks, table-cells)
3055/// - Block boxes with 'overflow' other than 'visible' and 'clip'
3056/// - Elements with 'display: flow-root'
3057/// - Table cells, table captions, and inline-blocks
3058///
3059/// Normal flow block-level boxes do NOT establish a new BFC.
3060///
3061/// This is critical for correct float interaction: normal blocks should overlap floats
3062/// (not shrink around them), while their inline content wraps around floats.
3063// +spec:block-formatting-context:241d22 - block container establishes new BFC or continues parent's, based on overflow/position/float/display
3064// +spec:block-formatting-context:9fe441 - BFC establishment based on position, float, overflow, and display properties
3065// +spec:display-property:3c7369 - block boxes establishing independent FC create new BFC; flex containers already do; non-replaced inlines cannot
3066// +spec:positioning:1e94f6 - floats, abspos, inline-blocks/table-cells/table-captions, overflow!=visible establish new BFC
3067fn establishes_new_bfc<T: ParsedFontTrait>(ctx: &LayoutContext<'_, T>, node: &LayoutNodeHot, cold: Option<&LayoutNodeCold>) -> bool {
3068    // +spec:block-formatting-context:f39cd3 - table wrapper box establishes a BFC (CSS 2.2 §17.4)
3069    // Anonymous table wrapper boxes have no dom_node_id but must still establish BFC
3070    // +spec:height-calculation:e20498 - table wrapper box establishes BFC (CSS 2.2 §17.4)
3071    // +spec:positioning:b780d3 - Table wrapper box establishes BFC (CSS 2.2 § 17.4)
3072    if cold.and_then(|c| c.anonymous_type) == Some(AnonymousBoxType::TableWrapper) {
3073        return true;
3074    }
3075    let Some(dom_id) = node.dom_node_id else {
3076        return false;
3077    };
3078
3079    let node_state = &ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
3080
3081    // 1. Floats establish BFC
3082    let float_val = get_float(ctx.styled_dom, dom_id, node_state);
3083    if matches!(
3084        float_val,
3085        MultiValue::Exact(LayoutFloat::Left | LayoutFloat::Right)
3086    ) {
3087        return true;
3088    }
3089
3090    // +spec:positioning:69468c - absolute/fixed forces independent formatting context
3091    let position = get_position_type(ctx.styled_dom, Some(dom_id));
3092    if matches!(position, LayoutPosition::Absolute | LayoutPosition::Fixed) {
3093        return true;
3094    }
3095
3096    // 3. Inline-blocks, table-cells, table-captions establish BFC
3097    let display = get_display_property(ctx.styled_dom, Some(dom_id));
3098    if matches!(
3099        display,
3100        MultiValue::Exact(
3101            LayoutDisplay::InlineBlock | LayoutDisplay::TableCell | LayoutDisplay::TableCaption
3102        )
3103    ) {
3104        return true;
3105    }
3106
3107    // 4. display: flow-root establishes BFC
3108    // +spec:display-property:14bae6 - flow-root establishes a formatting context that contains/excludes floats
3109    if matches!(display, MultiValue::Exact(LayoutDisplay::FlowRoot)) {
3110        return true;
3111    }
3112
3113    // +spec:overflow:0a944d - clip does NOT establish BFC; hidden/scroll/auto do establish BFC
3114    // +spec:overflow:631a4c - scroll containers establish independent formatting context (BFC)
3115    // +spec:overflow:f6a186 - overflow:clip does NOT establish BFC; use display:flow-root for that
3116    // +spec:overflow:717de1 - overflow != visible/clip establishes BFC per CSS 2.2 §9.4.1
3117    // +spec:positioning:6feb32 - overflow:clip does NOT establish new formatting context; hidden/scroll/auto do
3118    // 5. Block boxes with overflow other than 'visible' or 'clip' establish BFC
3119    // +spec:overflow:b34aef - Block boxes with overflow other than 'visible' or 'clip' establish BFC
3120    // Note: 'clip' does NOT establish BFC per CSS Overflow Module Level 3
3121    let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, node_state);
3122    let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, node_state);
3123
3124    let creates_bfc_via_overflow = |ov: &MultiValue<LayoutOverflow>| {
3125        matches!(
3126            ov,
3127            &MultiValue::Exact(
3128                LayoutOverflow::Hidden | LayoutOverflow::Scroll | LayoutOverflow::Auto
3129            )
3130        )
3131    };
3132
3133    if creates_bfc_via_overflow(&overflow_x) || creates_bfc_via_overflow(&overflow_y) {
3134        return true;
3135    }
3136
3137    // 6. Table, Flex, and Grid containers establish BFC (via FormattingContext)
3138    // +spec:block-formatting-context:f15b87 - display:table participates in a BFC
3139    if matches!(
3140        node.formatting_context,
3141        FormattingContext::Table | FormattingContext::Flex | FormattingContext::Grid
3142    ) {
3143        return true;
3144    }
3145
3146    // +spec:block-formatting-context:33e6cd - block container with different writing-mode than parent establishes independent BFC
3147    // CSS Writing Modes 4 § 3.2: if a block container has a different writing-mode
3148    // than its parent, its inner display type computes to flow-root (i.e., it establishes BFC).
3149    {
3150        let hierarchy = ctx.styled_dom.node_hierarchy.as_container();
3151        if let Some(parent_dom_id) = hierarchy[dom_id].parent_id() {
3152            let parent_state = &ctx.styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
3153            let child_wm = get_writing_mode(ctx.styled_dom, dom_id, node_state).unwrap_or_default();
3154            let parent_wm = get_writing_mode(ctx.styled_dom, parent_dom_id, parent_state).unwrap_or_default();
3155            if child_wm != parent_wm {
3156                return true;
3157            }
3158        }
3159    }
3160
3161    // Normal flow block boxes do NOT establish BFC
3162    // NOTE: align-content != normal should also establish BFC per CSS-DISPLAY-3, but align-content is not yet implemented for block containers
3163    false
3164}
3165
3166// +spec:display-property:5e5420 - replaced element identification (glossary: replaced elements have natural dimensions, establish independent formatting context)
3167/// CSS 2.2 § 9.5: "The border box of a table, a block-level replaced element, or an element
3168/// in the normal flow that establishes a new block formatting context [...] must not overlap
3169/// the margin box of any floats in the same block formatting context as the element itself."
3170fn is_block_level_replaced<T: ParsedFontTrait>(ctx: &LayoutContext<'_, T>, node: &LayoutNodeHot) -> bool {
3171    let Some(dom_id) = node.dom_node_id else {
3172        return false;
3173    };
3174
3175    // Check display is block-level
3176    let display = get_display_property(ctx.styled_dom, Some(dom_id));
3177    let is_block_level = matches!(
3178        display,
3179        MultiValue::Exact(LayoutDisplay::Block | LayoutDisplay::ListItem | LayoutDisplay::FlowRoot)
3180    );
3181
3182    if !is_block_level {
3183        return false;
3184    }
3185
3186    // Check if the element is a replaced element (image, video, etc.)
3187    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
3188    matches!(
3189        node_data.get_node_type(),
3190        NodeType::Image(_)
3191    )
3192}
3193
3194/// Translates solver3 layout constraints into the text3 engine's unified constraints.
3195#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
3196#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3197fn translate_to_text3_constraints<'a, T: ParsedFontTrait>(
3198    ctx: &mut LayoutContext<'_, T>,
3199    constraints: &'a LayoutConstraints<'a>,
3200    styled_dom: &StyledDom,
3201    dom_id: NodeId,
3202) -> UnifiedConstraints {
3203    use azul_css::compact_cache::{
3204        DOM_HAS_SHAPE_INSIDE, DOM_HAS_SHAPE_OUTSIDE, DOM_HAS_TEXT_JUSTIFY,
3205        DOM_HAS_TEXT_INDENT, DOM_HAS_COLUMN_COUNT, DOM_HAS_COLUMN_GAP,
3206        DOM_HAS_COLUMN_WIDTH,
3207        DOM_HAS_INITIAL_LETTER, DOM_HAS_INITIAL_LETTER_ALIGN,
3208        DOM_HAS_LINE_CLAMP, DOM_HAS_HANGING_PUNCTUATION,
3209        DOM_HAS_TEXT_COMBINE_UPRIGHT, DOM_HAS_EXCLUSION_MARGIN,
3210        DOM_HAS_SHAPE_MARGIN,
3211        DOM_HAS_HYPHENATION_LANGUAGE, DOM_HAS_UNICODE_BIDI,
3212        DOM_HAS_HYPHENS, DOM_HAS_WORD_BREAK, DOM_HAS_OVERFLOW_WRAP,
3213        DOM_HAS_LINE_BREAK, DOM_HAS_TEXT_ALIGN_LAST, DOM_HAS_LINE_HEIGHT,
3214    };
3215    unsafe { crate::az_mark(0x60704_u32, (0x30u32)); }
3216    // DOM-level declared flags: if a bit is clear, no node in this DOM
3217    // declared the corresponding property → cascade walks always return
3218    // None, and we use the default value directly. All flags default to
3219    // "set" when there is no compact cache (paranoid fallback).
3220    let dom_declared = styled_dom
3221        .css_property_cache
3222        .ptr
3223        .compact_cache
3224        .as_ref()
3225        .map_or(!0u32, |cc| cc.dom_declared_flags);
3226
3227    // Convert floats into exclusion zones for text3 to flow around.
3228    let mut shape_exclusions = if let Some(ref bfc_state) = constraints.bfc_state {
3229        debug_info!(
3230            ctx,
3231            "[translate_to_text3] dom_id={:?}, converting {} floats to exclusions",
3232            dom_id,
3233            bfc_state.floats.floats.len()
3234        );
3235        bfc_state
3236            .floats
3237            .floats
3238            .iter()
3239            .enumerate()
3240            .map(|(i, float_box)| {
3241                let rect = text3::cache::Rect {
3242                    x: float_box.rect.origin.x,
3243                    y: float_box.rect.origin.y,
3244                    width: float_box.rect.size.width,
3245                    height: float_box.rect.size.height,
3246                };
3247                debug_info!(
3248                    ctx,
3249                    "[translate_to_text3]   Exclusion #{}: {:?} at ({}, {}) size {}x{}",
3250                    i,
3251                    float_box.kind,
3252                    rect.x,
3253                    rect.y,
3254                    rect.width,
3255                    rect.height
3256                );
3257                ShapeBoundary::Rectangle(rect)
3258            })
3259            .collect()
3260    } else {
3261        debug_info!(
3262            ctx,
3263            "[translate_to_text3] dom_id={:?}, NO bfc_state - no float exclusions",
3264            dom_id
3265        );
3266        Vec::new()
3267    };
3268
3269    debug_info!(
3270        ctx,
3271        "[translate_to_text3] dom_id={:?}, available_size={}x{}, shape_exclusions.len()={}",
3272        dom_id,
3273        constraints.available_size.width,
3274        constraints.available_size.height,
3275        shape_exclusions.len()
3276    );
3277
3278    // Map text-align and justify-content from CSS to text3 enums.
3279    let id = dom_id;
3280    let node_data = &styled_dom.node_data.as_container()[id];
3281    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
3282
3283    // Read CSS Shapes properties
3284    // For reference box, use the element's CSS height if available, otherwise available_size
3285    // This is important because available_size.height might be infinite during auto height
3286    // calculation
3287    let ref_box_height = if constraints.available_size.height.is_finite() {
3288        constraints.available_size.height
3289    } else {
3290        // Try to get explicit CSS height
3291        // NOTE: If height is infinite, we can't properly resolve % heights
3292        // This is a limitation - shape-inside with % heights requires finite containing block
3293        styled_dom
3294            .css_property_cache
3295            .ptr
3296            .get_height(node_data, &id, node_state)
3297            .and_then(|v| v.get_property())
3298            .and_then(|h| match h {
3299                LayoutHeight::Px(v) => {
3300                    // Only accept absolute units (px, pt, in, cm, mm) - no %, em, rem
3301                    // since we can't resolve relative units without proper context
3302                    match v.metric {
3303                        SizeMetric::Px => Some(v.number.get()),
3304                        SizeMetric::Pt => Some(v.number.get() * PT_TO_PX),
3305                        SizeMetric::In => Some(v.number.get() * super::calc::PX_PER_INCH),
3306                        SizeMetric::Cm => Some(v.number.get() * super::calc::PX_PER_INCH / super::calc::CM_PER_INCH),
3307                        SizeMetric::Mm => Some(v.number.get() * super::calc::PX_PER_INCH / super::calc::MM_PER_INCH),
3308                        _ => None, // Ignore %, em, rem
3309                    }
3310                }
3311                _ => None,
3312            })
3313            .unwrap_or(constraints.available_size.width) // Fallback: use width as height (square)
3314    };
3315
3316    let reference_box = text3::cache::Rect {
3317        x: 0.0,
3318        y: 0.0,
3319        width: constraints.available_size.width,
3320        height: ref_box_height,
3321    };
3322
3323    // shape-inside: Text flows within the shape boundary
3324    debug_info!(ctx, "Checking shape-inside for node {:?}", id);
3325    debug_info!(
3326        ctx,
3327        "Reference box: {:?} (available_size height was: {})",
3328        reference_box,
3329        constraints.available_size.height
3330    );
3331
3332    let shape_boundaries = if dom_declared & DOM_HAS_SHAPE_INSIDE != 0 {
3333        styled_dom
3334            .css_property_cache
3335            .ptr
3336            .get_shape_inside(node_data, &id, node_state)
3337            .and_then(|v| {
3338                debug_info!(ctx, "Got shape-inside value: {:?}", v);
3339                v.get_property()
3340            })
3341            .and_then(|shape_inside| {
3342                debug_info!(ctx, "shape-inside property: {:?}", shape_inside);
3343                if let ShapeInside::Shape(css_shape) = shape_inside {
3344                    debug_info!(
3345                        ctx,
3346                        "Converting CSS shape to ShapeBoundary: {:?}",
3347                        css_shape
3348                    );
3349                    let boundary =
3350                        ShapeBoundary::from_css_shape(css_shape, reference_box, ctx.debug_messages);
3351                    debug_info!(ctx, "Created ShapeBoundary: {:?}", boundary);
3352                    Some(vec![boundary])
3353                } else {
3354                    debug_info!(ctx, "shape-inside is None");
3355                    None
3356                }
3357            })
3358            .unwrap_or_default()
3359    } else {
3360        Vec::new()
3361    };
3362
3363    debug_info!(
3364        ctx,
3365        "Final shape_boundaries count: {}",
3366        shape_boundaries.len()
3367    );
3368
3369    // shape-outside: Text wraps around the shape (adds to exclusions)
3370    debug_info!(ctx, "Checking shape-outside for node {:?}", id);
3371    if dom_declared & DOM_HAS_SHAPE_OUTSIDE != 0 {
3372        if let Some(shape_outside_value) = styled_dom
3373            .css_property_cache
3374            .ptr
3375            .get_shape_outside(node_data, &id, node_state)
3376        {
3377            debug_info!(ctx, "Got shape-outside value: {:?}", shape_outside_value);
3378            if let Some(shape_outside) = shape_outside_value.get_property() {
3379                debug_info!(ctx, "shape-outside property: {:?}", shape_outside);
3380                if let ShapeOutside::Shape(css_shape) = shape_outside {
3381                    debug_info!(
3382                        ctx,
3383                        "Converting CSS shape-outside to ShapeBoundary: {:?}",
3384                        css_shape
3385                    );
3386                    let boundary =
3387                        ShapeBoundary::from_css_shape(css_shape, reference_box, ctx.debug_messages);
3388                    debug_info!(ctx, "Created ShapeBoundary (exclusion): {:?}", boundary);
3389                    shape_exclusions.push(boundary);
3390                }
3391            }
3392        } else {
3393            debug_info!(ctx, "No shape-outside value found");
3394        }
3395    }
3396
3397    // TODO: clip-path will be used for rendering clipping (not text layout)
3398
3399    let writing_mode = get_writing_mode(styled_dom, id, node_state).unwrap_or_default();
3400
3401    let text_align = get_text_align(styled_dom, id, node_state).unwrap_or_default();
3402
3403    let text_justify = if dom_declared & DOM_HAS_TEXT_JUSTIFY != 0 {
3404        styled_dom
3405            .css_property_cache
3406            .ptr
3407            .get_text_justify(node_data, &id, node_state)
3408            .and_then(|s| s.get_property().copied())
3409            .unwrap_or_default()
3410    } else {
3411        LayoutTextJustify::default()
3412    };
3413
3414    // Get font-size for resolving line-height
3415    // Use helper function which checks dependency chain first
3416    let font_size = get_element_font_size(styled_dom, id, node_state);
3417
3418    let line_height_value = if dom_declared & DOM_HAS_LINE_HEIGHT != 0 {
3419        styled_dom
3420            .css_property_cache
3421            .ptr
3422            .get_line_height(node_data, &id, node_state)
3423            .and_then(|s| s.get_property().copied())
3424            .unwrap_or_default()
3425    } else {
3426        azul_css::props::style::text::StyleLineHeight::default()
3427    };
3428
3429    let hyphenation = if dom_declared & DOM_HAS_HYPHENS != 0 {
3430        styled_dom
3431            .css_property_cache
3432            .ptr
3433            .get_hyphens(node_data, &id, node_state)
3434            .and_then(|s| s.get_property().copied())
3435            .unwrap_or_default()
3436    } else {
3437        StyleHyphens::default()
3438    };
3439
3440    let word_break_css = if dom_declared & DOM_HAS_WORD_BREAK != 0 {
3441        styled_dom
3442            .css_property_cache
3443            .ptr
3444            .get_word_break(node_data, &id, node_state)
3445            .and_then(|s| s.get_property().copied())
3446            .unwrap_or_default()
3447    } else {
3448        StyleWordBreak::default()
3449    };
3450
3451    let overflow_wrap_css = if dom_declared & DOM_HAS_OVERFLOW_WRAP != 0 {
3452        styled_dom
3453            .css_property_cache
3454            .ptr
3455            .get_overflow_wrap(node_data, &id, node_state)
3456            .and_then(|s| s.get_property().copied())
3457            .unwrap_or_default()
3458    } else {
3459        StyleOverflowWrap::default()
3460    };
3461
3462    let line_break_css = if dom_declared & DOM_HAS_LINE_BREAK != 0 {
3463        styled_dom
3464            .css_property_cache
3465            .ptr
3466            .get_line_break(node_data, &id, node_state)
3467            .and_then(|s| s.get_property().copied())
3468            .unwrap_or_default()
3469    } else {
3470        StyleLineBreak::default()
3471    };
3472
3473    let text_align_last_css = if dom_declared & DOM_HAS_TEXT_ALIGN_LAST != 0 {
3474        styled_dom
3475            .css_property_cache
3476            .ptr
3477            .get_text_align_last(node_data, &id, node_state)
3478            .and_then(|s| s.get_property().copied())
3479            .unwrap_or_default()
3480    } else {
3481        StyleTextAlignLast::default()
3482    };
3483
3484    let overflow_behaviour = get_overflow_x(styled_dom, id, node_state).unwrap_or_default();
3485
3486    // +spec:display-property:21f728 - vertical-align shorthand resolves inline-level box alignment
3487    // +spec:display-property:98fa8e - alignment-baseline values for inline-level boxes in IFC (implemented via vertical-align shorthand)
3488    // +spec:display-property:1f71ad - baseline-shift + alignment-baseline longhands mapped through vertical-align
3489    // +spec:display-property:89dd7b - line-relative shift values (top/center/bottom) and aligned subtree alignment
3490    // +spec:inline-formatting-context:21da06 - vertical-align uses line-over/line-under sides via writing_mode logical mapping
3491    // +spec:inline-formatting-context:295603 - baseline alignment: vertical-align determines how inline boxes align (baseline, super, sub, etc.)
3492    // +spec:inline-formatting-context:7351bf - default alignment baseline is alphabetic in horizontal typographic mode
3493    // +spec:inline-formatting-context:85de3d - vertical-align shorthand: alignment within line box
3494    // +spec:inline-formatting-context:aa8af0 - alignment baseline chosen by vertical-align, defaults to parent's dominant baseline
3495    // +spec:inline-formatting-context:e475d2 - baseline and vertical-align control transverse alignment of inline content on line boxes
3496    // +spec:overflow:d44eac - vertical-align inline box alignment (CSS 2.2 model covers baseline/top/middle/bottom/sub/super/text-top/text-bottom)
3497    // +spec:writing-modes:313575 - alignment-baseline: inline-level boxes align baselines within parent inline box's alignment context along inline axis
3498    // +spec:writing-modes:60ad67 - inline layout aligns boxes in block axis via baselines
3499    // +spec:writing-modes:0127e5 - line-relative directions: line-over/under map to vertical-align top/bottom
3500    // Get vertical-align from CSS property cache (defaults to Baseline per CSS spec)
3501    // +spec:inline-formatting-context:686f8b - vertical-align shorthand: alignment-baseline + baseline-shift for inline boxes
3502    // +spec:inline-formatting-context:e579b6 - vertical-align / baseline alignment in inline context
3503    // +spec:inline-formatting-context:a01a75 - dominant baseline alignment for atomic inlines
3504    let vertical_align = match get_vertical_align_property(styled_dom, id, node_state) {
3505        MultiValue::Exact(v) => v,
3506        _ => StyleVerticalAlign::default(),
3507    };
3508
3509    // +spec:display-property:c03a6b - baseline-shift (sub/super/length/percentage) and line-relative (top/center/bottom) shifts handled via vertical-align
3510    let vertical_align = match vertical_align {
3511        StyleVerticalAlign::Baseline => text3::cache::VerticalAlign::Baseline,
3512        StyleVerticalAlign::Top => text3::cache::VerticalAlign::Top,
3513        StyleVerticalAlign::Middle => text3::cache::VerticalAlign::Middle,
3514        StyleVerticalAlign::Bottom => text3::cache::VerticalAlign::Bottom,
3515        StyleVerticalAlign::Sub => text3::cache::VerticalAlign::Sub,
3516        // +spec:inline-formatting-context:fe563c - vertical-align: super shifts inline to superscript position
3517        // +spec:inline-formatting-context:fe563c - vertical-align:super shifts child to superscript position
3518        StyleVerticalAlign::Superscript => text3::cache::VerticalAlign::Super,
3519        StyleVerticalAlign::TextTop => text3::cache::VerticalAlign::TextTop,
3520        StyleVerticalAlign::TextBottom => text3::cache::VerticalAlign::TextBottom,
3521        // §10.8.1: <percentage> refers to line-height of the element itself
3522        StyleVerticalAlign::Percentage(p) => {
3523            let lh_n = line_height_value.inner.normalized();
3524            let resolved_lh = if lh_n < 0.0 { -lh_n } else { lh_n * font_size };
3525            let offset = p.normalized() * resolved_lh;
3526            text3::cache::VerticalAlign::Offset(offset)
3527        }
3528        // §10.8.1: <length> is absolute offset from baseline
3529        StyleVerticalAlign::Length(l) => {
3530            // Resolve viewport units (vw/vh/vmin/vmax) against the real viewport
3531            // instead of falling through `resolve_pixel_value`'s "treat 50vw as 50px".
3532            let offset = super::calc::resolve_pixel_value_with_viewport(
3533                &l,
3534                0.0,
3535                font_size,
3536                font_size,
3537                ctx.viewport_size.width,
3538                ctx.viewport_size.height,
3539            );
3540            text3::cache::VerticalAlign::Offset(offset)
3541        }
3542    };
3543    // +spec:block-formatting-context:987746 - text-orientation property (mixed/upright/sideways) for vertical writing modes
3544    // +spec:inline-formatting-context:cbe738 - text-orientation (mixed/upright/sideways) bi-orientational transform for vertical text
3545    // +spec:writing-modes:09a1bb - vertical typesetting orientation (upright/sideways) for vertical-rl/vertical-lr
3546    // +spec:writing-modes:2eb1b2 - text-orientation (mixed/upright/sideways) applied to vertical text layout
3547    let text_orientation = match get_text_orientation_property(styled_dom, id, node_state) {
3548        MultiValue::Exact(o) => match o {
3549            StyleTextOrientation::Mixed => text3::cache::TextOrientation::Mixed,
3550            StyleTextOrientation::Upright => text3::cache::TextOrientation::Upright,
3551            // +spec:block-formatting-context:a606e6 - sideways text typeset rotated 90° CW in vertical modes
3552            StyleTextOrientation::Sideways => text3::cache::TextOrientation::Sideways,
3553        },
3554        _ => text3::cache::TextOrientation::default(),
3555    };
3556
3557    // +spec:display-property:8364c0 - direction property (ltr/rtl) sets paragraph embedding level for bidi algorithm
3558    // +spec:text-alignment-spacing:97b93a - direction property affects text-align:justify last-line alignment
3559    // +spec:writing-modes:73aaff - block elements inherit base direction from parent via CSS direction property
3560    // +spec:writing-modes:8a888b - line box inline base direction from containing block's direction
3561    // Get the direction property from the CSS cache (defaults to LTR if not set)
3562    // +spec:display-property:da3b59 - direction property specifies inline base direction for ordering inline-level content
3563    // +spec:inline-formatting-context:97af40 - direction property sets inline base direction for bidi, text alignment, overflow
3564    // +spec:writing-modes:2deb38 - bidirectional reordering via CSS direction property
3565    // +spec:writing-modes:fbb332 - in vertical writing modes, text-orientation:upright forces used direction to ltr
3566    let direction = match constraints.writing_mode {
3567        LayoutWritingMode::VerticalRl | LayoutWritingMode::VerticalLr
3568            if matches!(text_orientation, text3::cache::TextOrientation::Upright) =>
3569        {
3570            Some(text3::cache::BidiDirection::Ltr)
3571        }
3572        _ => match get_direction_property(styled_dom, id, node_state) {
3573            MultiValue::Exact(d) => Some(match d {
3574                StyleDirection::Ltr => text3::cache::BidiDirection::Ltr,
3575                StyleDirection::Rtl => text3::cache::BidiDirection::Rtl,
3576            }),
3577            _ => None,
3578        },
3579    };
3580
3581    // Get unicode-bidi property for bidi algorithm configuration
3582    // +spec:containing-block:0d4914 - unicode-bidi: plaintext causes P2/P3 heuristics instead of HL1 override
3583    let unicode_bidi_val = if dom_declared & DOM_HAS_UNICODE_BIDI != 0 {
3584        match get_unicode_bidi_property(styled_dom, id, node_state) {
3585            MultiValue::Exact(u) => match u {
3586                StyleUnicodeBidi::Normal => text3::cache::UnicodeBidi::Normal,
3587                StyleUnicodeBidi::Embed => text3::cache::UnicodeBidi::Embed,
3588                StyleUnicodeBidi::Isolate => text3::cache::UnicodeBidi::Isolate,
3589                StyleUnicodeBidi::BidiOverride => text3::cache::UnicodeBidi::BidiOverride,
3590                StyleUnicodeBidi::IsolateOverride => text3::cache::UnicodeBidi::IsolateOverride,
3591                StyleUnicodeBidi::Plaintext => text3::cache::UnicodeBidi::Plaintext,
3592            },
3593            _ => text3::cache::UnicodeBidi::Normal,
3594        }
3595    } else {
3596        text3::cache::UnicodeBidi::Normal
3597    };
3598
3599    debug_info!(
3600        ctx,
3601        "dom_id={:?}, available_size={}x{}, setting available_width={}",
3602        dom_id,
3603        constraints.available_size.width,
3604        constraints.available_size.height,
3605        constraints.available_size.width
3606    );
3607
3608    // +spec:box-model:8113d7 - text-indent treated as margin on start edge of line box
3609    // +spec:display-contents:5f95ac - text-indent: percentage=0 for intrinsic sizing, each-line and hanging keywords
3610    // +spec:floats:17c74a - text-indent applied to first line (5em indentation with no floats)
3611    // +spec:positioning:1e32b1 - text-indent with hanging/each-line keywords resolved and passed to text layout
3612    let text_indent_prop = if dom_declared & DOM_HAS_TEXT_INDENT != 0 {
3613        styled_dom
3614            .css_property_cache
3615            .ptr
3616            .get_text_indent(node_data, &id, node_state)
3617            .and_then(|s| s.get_property().copied())
3618    } else {
3619        None
3620    };
3621    let is_intrinsic_sizing = matches!(
3622        constraints.available_width_type,
3623        Text3AvailableSpace::MinContent | Text3AvailableSpace::MaxContent
3624    );
3625    // +spec:intrinsic-sizing:0e8625 - percentage text-indent treated as 0 for intrinsic size contributions
3626    let text_indent = text_indent_prop
3627        .map_or(0.0, |ti| {
3628            // CSS Text 3 §8.1: "Percentages must be treated as 0 for the purpose
3629            // of calculating intrinsic size contributions"
3630            if is_intrinsic_sizing && ti.inner.to_percent().is_some() {
3631                return 0.0;
3632            }
3633            let context = ResolutionContext {
3634                element_font_size: get_element_font_size(styled_dom, id, node_state),
3635                parent_font_size: get_parent_font_size(styled_dom, id, node_state),
3636                root_font_size: get_root_font_size(styled_dom, node_state),
3637                containing_block_size: PhysicalSize::new(constraints.available_size.width, 0.0),
3638                element_size: None,
3639                viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
3640            };
3641            ti.inner
3642                .resolve_with_context(&context, PropertyContext::Other)
3643        });
3644    let text_indent_each_line = text_indent_prop.is_some_and(|ti| ti.each_line);
3645    let text_indent_hanging = text_indent_prop.is_some_and(|ti| ti.hanging);
3646
3647    // ResolutionContext shared by column-gap and column-width (both resolve
3648    // lengths against the same font/viewport, with no containing-block size).
3649    let column_resolve_ctx = ResolutionContext {
3650        element_font_size: get_element_font_size(styled_dom, id, node_state),
3651        parent_font_size: get_parent_font_size(styled_dom, id, node_state),
3652        root_font_size: get_root_font_size(styled_dom, node_state),
3653        containing_block_size: PhysicalSize::new(0.0, 0.0),
3654        element_size: None,
3655        viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
3656    };
3657
3658    // Read a declared CSS property from the cache, returning None when the
3659    // DOM-level declared bit is clear (no node sets the property).
3660    macro_rules! declared_prop {
3661        ($bit:expr, $getter:ident) => {
3662            if dom_declared & $bit != 0 {
3663                styled_dom
3664                    .css_property_cache
3665                    .ptr
3666                    .$getter(node_data, &id, node_state)
3667                    .and_then(|s| s.get_property())
3668            } else {
3669                None
3670            }
3671        };
3672    }
3673
3674    // Get column-gap for multi-column layout (default: normal = 1em)
3675    let column_gap = declared_prop!(DOM_HAS_COLUMN_GAP, get_column_gap)
3676        .map(|cg| {
3677            cg.inner
3678                .resolve_with_context(&column_resolve_ctx, PropertyContext::Other)
3679        })
3680        .unwrap_or_else(|| get_element_font_size(styled_dom, id, node_state));
3681
3682    // Get column-width for multi-column layout (None = auto)
3683    let column_width =
3684        declared_prop!(DOM_HAS_COLUMN_WIDTH, get_column_width).and_then(|cw| match cw {
3685            ColumnWidth::Auto => None,
3686            ColumnWidth::Length(px) => {
3687                Some(px.resolve_with_context(&column_resolve_ctx, PropertyContext::Other))
3688            }
3689        });
3690
3691    // Get column-count for multi-column layout (default: 1 = no columns)
3692    let explicit_column_count =
3693        declared_prop!(DOM_HAS_COLUMN_COUNT, get_column_count).copied();
3694
3695    // CSS multi-column: derive column count from column-width when column-count is auto.
3696    // Per spec: N = max(1, floor((available-width + column-gap) / (column-width + column-gap)))
3697    let columns = match (explicit_column_count, column_width) {
3698        (Some(ColumnCount::Integer(n)), _) => n,
3699        (_, Some(cw)) if cw > 0.0 => {
3700            let avail = constraints.available_size.width;
3701            ((avail + column_gap) / (cw + column_gap)).floor().max(1.0) as u32
3702        }
3703        _ => 1,
3704    };
3705
3706    // +spec:line-breaking:b4928e - white-space values mapped to wrap/whitespace processing rules
3707    // Map white-space CSS property to TextWrap
3708    let resolved_ws = match get_white_space_property(styled_dom, id, node_state) {
3709        MultiValue::Exact(ws) => ws,
3710        _ => StyleWhiteSpace::Normal,
3711    };
3712    let text_wrap = match resolved_ws {
3713        StyleWhiteSpace::Normal
3714        | StyleWhiteSpace::PreWrap
3715        | StyleWhiteSpace::PreLine
3716        | StyleWhiteSpace::BreakSpaces => text3::cache::TextWrap::Wrap,
3717        StyleWhiteSpace::Nowrap | StyleWhiteSpace::Pre => text3::cache::TextWrap::NoWrap,
3718    };
3719    let white_space_mode = match resolved_ws {
3720        StyleWhiteSpace::Normal => text3::cache::WhiteSpaceMode::Normal,
3721        StyleWhiteSpace::Nowrap => text3::cache::WhiteSpaceMode::Nowrap,
3722        StyleWhiteSpace::Pre => text3::cache::WhiteSpaceMode::Pre,
3723        StyleWhiteSpace::PreWrap => text3::cache::WhiteSpaceMode::PreWrap,
3724        StyleWhiteSpace::PreLine => text3::cache::WhiteSpaceMode::PreLine,
3725        StyleWhiteSpace::BreakSpaces => text3::cache::WhiteSpaceMode::BreakSpaces,
3726    };
3727
3728    // +spec:block-formatting-context:fd60a8 - initial letter box is in-flow in its BFC, originating line box
3729    // +spec:block-formatting-context:c5ba02 - initial letter inline flow layout (alignment, white space collapsing)
3730    // +spec:block-formatting-context:83f8a7 - initial letter wrapping modes (none, all, first)
3731    // +spec:block-formatting-context:fef28d - initial letter box is in-flow in its BFC, part of originating line box
3732    // +spec:box-model:c3ce58 - initial letter block-start margin edge must be below containing block content edge
3733    // +spec:display-contents:568fe2 - initial letter participates in same IFC as its line
3734    // +spec:display-property:a89adb - initial letter boxes from non-replaced inline boxes and atomic inlines
3735    // +spec:display-property:4b59ce - initial-letter applies to inline-level boxes at start of first line
3736    // +spec:display-property:756cad - initial-letter sizing: drop/raise/sunken initial computation
3737    // +spec:display-property:8b08f4 - initial-letter applied to first inline-level child of block container
3738    // +spec:display-property:8c1dce - initial-letter property: size/sink for drop caps on inline-level boxes
3739    // +spec:display-property:b453a3 - initial-letter applies to inline-level boxes in IFC
3740    // +spec:display-property:b5e149 - initial letters are in-flow inline-level content, not floats
3741    // +spec:display-property:fa044e - initial-letter applies to first-child inline-level boxes
3742    // +spec:line-height:306d87 - initial-letter sizing must use containing block's line-height, not spanned lines' heights
3743    // +spec:writing-modes:903310 - atomic initial letters use normal sizing; only positioning is special
3744    // Get initial-letter for drop caps
3745    // +spec:display-property:4c69bf - read initial-letter-align for alignment points
3746    let initial_letter_align = if dom_declared & DOM_HAS_INITIAL_LETTER_ALIGN != 0 {
3747        styled_dom
3748            .css_property_cache
3749            .ptr
3750            .get_initial_letter_align(node_data, &id, node_state)
3751            .and_then(|s| s.get_property())
3752            .map_or(text3::cache::InitialLetterAlign::Auto, |a| match a {
3753                azul_css::props::style::text::StyleInitialLetterAlign::Auto => text3::cache::InitialLetterAlign::Auto,
3754                azul_css::props::style::text::StyleInitialLetterAlign::Alphabetic => text3::cache::InitialLetterAlign::Alphabetic,
3755                azul_css::props::style::text::StyleInitialLetterAlign::Hanging => text3::cache::InitialLetterAlign::Hanging,
3756                azul_css::props::style::text::StyleInitialLetterAlign::Ideographic => text3::cache::InitialLetterAlign::Ideographic,
3757            })
3758    } else {
3759        text3::cache::InitialLetterAlign::Auto
3760    };
3761    // +spec:display-property:5af252 - initial-letter on inline-level box not at line start uses normal
3762    // +spec:text-alignment-spacing:a17609 - sunken initial letters suppress letter-spacing and justification (not word-spacing) with adjacent content
3763    // +spec:display-property:68ab22 - initial-letter only applies in IFC (inline-level);
3764    // float!=none or position!=static causes display to compute to block (BFC), so
3765    // initial-letter naturally does not apply to those elements
3766    // +spec:writing-modes:c89d19 - initial-letter block-axis positioning: sink determines block offset
3767    // +spec:display-property:b67500 - initial-letter size/sink: values other than normal make box an initial letter box (inline-level, in-flow)
3768    // +spec:display-property:416f27 - initial-letter sink defaults to "drop" (sink = size floored) when omitted
3769    let initial_letter = if dom_declared & DOM_HAS_INITIAL_LETTER != 0 {
3770        styled_dom
3771            .css_property_cache
3772            .ptr
3773            .get_initial_letter(node_data, &id, node_state)
3774            .and_then(|s| s.get_property())
3775            .map(|il| {
3776                use std::num::NonZeroUsize;
3777                let sink = match il.sink {
3778                    azul_css::corety::OptionU32::Some(s) => s,
3779                    azul_css::corety::OptionU32::None => il.size, // "drop" assumed: sink = size
3780                };
3781                text3::cache::InitialLetter {
3782                    size: il.size as f32,
3783                    sink,
3784                    count: NonZeroUsize::new(1).unwrap(),
3785                    align: initial_letter_align,
3786                }
3787            })
3788    } else {
3789        None
3790    };
3791
3792    // If initial-letter is set, compute the drop cap exclusion area and add it
3793    // to the shape exclusions so that text wraps around the enlarged letter.
3794    // +spec:box-model:d4adf6 - ancestor inline boundaries excluded via geometric exclusion
3795    // +spec:floats:c5e23f - floats in subsequent lines adjacent to a sunk initial letter must clear it
3796    if let Some(ref il) = initial_letter {
3797        let lh_n = line_height_value.inner.normalized();
3798        let computed_line_height = if lh_n < 0.0 { -lh_n } else { lh_n * font_size };
3799        let (letter_w, letter_h) = layout_initial_letter(
3800            il.size,
3801            il.sink,
3802            constraints.available_size.width,
3803            computed_line_height,
3804        );
3805        if letter_w > 0.0 && letter_h > 0.0 {
3806            // Place the exclusion at the inline-start (x=0, y=0 relative to the IFC).
3807            // This creates a rectangular exclusion that text flows around.
3808            shape_exclusions.push(ShapeBoundary::Rectangle(text3::cache::Rect {
3809                x: 0.0,
3810                y: 0.0,
3811                width: letter_w,
3812                height: letter_h,
3813            }));
3814        }
3815    }
3816
3817    // Get line-clamp for limiting visible lines
3818    let line_clamp = if dom_declared & DOM_HAS_LINE_CLAMP != 0 {
3819        styled_dom
3820            .css_property_cache
3821            .ptr
3822            .get_line_clamp(node_data, &id, node_state)
3823            .and_then(|s| s.get_property())
3824            .and_then(|lc| std::num::NonZeroUsize::new(lc.max_lines))
3825    } else {
3826        None
3827    };
3828
3829    // Get hanging-punctuation for hanging punctuation marks
3830    let hanging_punctuation = if dom_declared & DOM_HAS_HANGING_PUNCTUATION != 0 {
3831        styled_dom
3832            .css_property_cache
3833            .ptr
3834            .get_hanging_punctuation(node_data, &id, node_state)
3835            .and_then(|s| s.get_property())
3836            .is_some_and(azul_css::props::style::StyleHangingPunctuation::is_enabled)
3837    } else {
3838        false
3839    };
3840
3841    // Get text-combine-upright for vertical text combination
3842    // +spec:line-breaking:9f150a - text-combine-upright:all composes glyphs horizontally, ignoring letter-spacing and forced line breaks
3843    // +spec:line-breaking:1b88cd - text-combine-upright:all layout: inline-block with 1em square, ignoring forced line breaks
3844    // +spec:inline-formatting-context:c8d8d9 - text-combine-upright compression passed to text shaping engine
3845    // +spec:inline-formatting-context:f4ef7d - text-combine-upright layout rules (1em square composition)
3846    let text_combine_upright = if dom_declared & DOM_HAS_TEXT_COMBINE_UPRIGHT != 0 {
3847        styled_dom
3848            .css_property_cache
3849            .ptr
3850            .get_text_combine_upright(node_data, &id, node_state)
3851            .and_then(|s| s.get_property())
3852            // +spec:display-property:6f174d - text-combine-upright horizontal-in-vertical composition
3853            .map(|tcu| match tcu {
3854                StyleTextCombineUpright::None => text3::cache::TextCombineUpright::None,
3855                StyleTextCombineUpright::All => text3::cache::TextCombineUpright::All,
3856                StyleTextCombineUpright::Digits(n) => text3::cache::TextCombineUpright::Digits(*n),
3857            })
3858    } else {
3859        None
3860    };
3861
3862    // Get exclusion-margin (CSS Exclusions L1) and shape-margin (CSS Shapes L1)
3863    // for shape exclusions. We sum both into a single margin knob — strictly,
3864    // they apply to different sources (exclusion-margin → CSS Exclusions,
3865    // shape-margin → shape-outside), but the layout solver currently keeps
3866    // a single per-IFC margin value, so the two get added.
3867    let exclusion_margin_base = if dom_declared & DOM_HAS_EXCLUSION_MARGIN != 0 {
3868        styled_dom
3869            .css_property_cache
3870            .ptr
3871            .get_exclusion_margin(node_data, &id, node_state)
3872            .and_then(|s| s.get_property())
3873            .map_or(0.0, |em| em.inner.get())
3874    } else {
3875        0.0
3876    };
3877
3878    let shape_margin = if dom_declared & DOM_HAS_SHAPE_MARGIN != 0 {
3879        styled_dom
3880            .css_property_cache
3881            .ptr
3882            .get_shape_margin(node_data, &id, node_state)
3883            .and_then(|s| s.get_property())
3884            .map_or(0.0, |sm| sm.inner.number.get())
3885    } else {
3886        0.0
3887    };
3888
3889    let exclusion_margin = exclusion_margin_base + shape_margin;
3890
3891    // Get hyphenation-language for language-specific hyphenation
3892    let hyphenation_language = if dom_declared & DOM_HAS_HYPHENATION_LANGUAGE != 0 {
3893        styled_dom
3894            .css_property_cache
3895            .ptr
3896            .get_hyphenation_language(node_data, &id, node_state)
3897            .and_then(|s| s.get_property())
3898            .and_then(|hl| {
3899                #[cfg(feature = "text_layout_hyphenation")]
3900                {
3901                    use hyphenation::{Language, Load};
3902                    // Parse BCP 47 language code to hyphenation::Language
3903                    match hl.inner.as_str() {
3904                        "en-US" | "en" => Some(Language::EnglishUS),
3905                        "de-DE" | "de" => Some(Language::German1996),
3906                        "fr-FR" | "fr" => Some(Language::French),
3907                        "es-ES" | "es" => Some(Language::Spanish),
3908                        "it-IT" | "it" => Some(Language::Italian),
3909                        "pt-PT" | "pt" => Some(Language::Portuguese),
3910                        "nl-NL" | "nl" => Some(Language::Dutch),
3911                        "pl-PL" | "pl" => Some(Language::Polish),
3912                        "ru-RU" | "ru" => Some(Language::Russian),
3913                        "zh-CN" | "zh" => Some(Language::Chinese),
3914                        _ => None, // Unsupported language
3915                    }
3916                }
3917                #[cfg(not(feature = "text_layout_hyphenation"))]
3918                {
3919                    None::<crate::text3::script::Language>
3920                }
3921            })
3922    } else {
3923        None
3924    };
3925
3926    UnifiedConstraints {
3927        exclusion_margin,
3928        hyphenation_language,
3929        text_indent,
3930        text_indent_each_line,
3931        text_indent_hanging,
3932        initial_letter,
3933        line_clamp,
3934        columns,
3935        column_gap,
3936        hanging_punctuation,
3937        text_wrap,
3938        white_space_mode,
3939        text_combine_upright,
3940        segment_alignment: SegmentAlignment::Total,
3941        overflow: match overflow_behaviour {
3942            LayoutOverflow::Visible => text3::cache::OverflowBehavior::Visible,
3943            LayoutOverflow::Hidden | LayoutOverflow::Clip => text3::cache::OverflowBehavior::Hidden,
3944            LayoutOverflow::Scroll => text3::cache::OverflowBehavior::Scroll,
3945            LayoutOverflow::Auto => text3::cache::OverflowBehavior::Auto,
3946        },
3947        // Use the semantic available_width_type directly instead of converting from float.
3948        // This preserves MinContent/MaxContent semantics for intrinsic sizing.
3949        available_width: constraints.available_width_type,
3950        // For scrollable containers (overflow: scroll/auto), don't constrain height
3951        // so that the full content is laid out and content_size is calculated correctly.
3952        available_height: match overflow_behaviour {
3953            LayoutOverflow::Scroll | LayoutOverflow::Auto => None,
3954            _ => Some(constraints.available_size.height),
3955        },
3956        shape_boundaries, // CSS shape-inside: text flows within shape
3957        shape_exclusions, // CSS shape-outside + floats: text wraps around shapes
3958        writing_mode: Some(match writing_mode {
3959            LayoutWritingMode::HorizontalTb => text3::cache::WritingMode::HorizontalTb,
3960            LayoutWritingMode::VerticalRl => text3::cache::WritingMode::VerticalRl,
3961            LayoutWritingMode::VerticalLr => text3::cache::WritingMode::VerticalLr,
3962        }),
3963        direction, // Use the CSS direction property (currently defaulting to LTR)
3964        unicode_bidi: unicode_bidi_val,
3965        // +spec:overflow:7ff7d1 - hyphens property: none/manual/auto hyphenation control
3966        hyphenation: match hyphenation {
3967            StyleHyphens::None => text3::cache::Hyphens::None,
3968            StyleHyphens::Manual => text3::cache::Hyphens::Manual,
3969            StyleHyphens::Auto => text3::cache::Hyphens::Auto,
3970        },
3971        text_orientation,
3972        // +spec:text-alignment-spacing:6cb965 - text-align shorthand sets text-align-all (mapped here from computed value)
3973        // +spec:text-alignment-spacing:838967 - map text-align values (start/end/left/right/center/justify) to inline alignment
3974        // +spec:text-alignment-spacing:d9ea45 - property index: text-align, text-justify, letter-spacing mapped to layout
3975        // +spec:text-alignment-spacing:600fda - text-align values (left/right/center/justify) mapped per CSS Text §6.1
3976        text_align: match text_align {
3977            StyleTextAlign::Start => text3::cache::TextAlign::Start,
3978            StyleTextAlign::End => text3::cache::TextAlign::End,
3979            StyleTextAlign::Left => text3::cache::TextAlign::Left,
3980            StyleTextAlign::Right => text3::cache::TextAlign::Right,
3981            StyleTextAlign::Center => text3::cache::TextAlign::Center,
3982            StyleTextAlign::Justify => text3::cache::TextAlign::Justify,
3983        },
3984        // +spec:text-alignment-spacing:0ea31d - text-justify inter-word/inter-character/distribute mapped per §6.4
3985        // +spec:text-alignment-spacing:01244f - text-justify: none disables justification, auto uses inter-word as universal default
3986        text_justify: match text_justify {
3987            LayoutTextJustify::None => text3::cache::JustifyContent::None,
3988            LayoutTextJustify::Auto | LayoutTextJustify::InterWord => {
3989                text3::cache::JustifyContent::InterWord
3990            }
3991            // distribute computes to inter-character
3992            LayoutTextJustify::InterCharacter | LayoutTextJustify::Distribute => {
3993                text3::cache::JustifyContent::InterCharacter
3994            }
3995        },
3996        // +spec:line-height:79f3aa - line-height resolved: `normal` uses the font's real
3997        // metrics (ascent - descent + line_gap), <number>/<percentage> × font-size.
3998        // When line-height is NOT declared the computed value is `normal`; pass
3999        // LineHeight::Normal through so text3 resolves it against the run's actual
4000        // font metrics (CoreText/Chrome parity) instead of a synthetic 1.2 ratio.
4001        // Negative normalized() = absolute px value (convention from parser for "50px" etc.)
4002        line_height: if dom_declared & DOM_HAS_LINE_HEIGHT == 0 {
4003            text3::cache::LineHeight::Normal
4004        } else {
4005            text3::cache::LineHeight::Px({
4006                let n = line_height_value.inner.normalized();
4007                if n < 0.0 { -n } else { n * font_size }
4008            })
4009        },
4010        // Strut metrics for the container's first available font, approximated as
4011        // 80%/20%/50% of font_size (typical Latin ratios).
4012        // TODO(superplan): use the resolved primary font's real OS/2 metrics
4013        // (`ParsedFontTrait::get_font_metrics` → ascent/descent/x_height scaled by
4014        // units_per_em) and `get_space_width` for `ch_width`. The font is not
4015        // resolved here: picking the element's primary `ParsedFont` requires the
4016        // font-chain machinery in `getters::resolve_font_chains` (font-family →
4017        // fc_cache → loaded font), which isn't threaded into this function. The
4018        // strut only sizes empty / whitespace-only lines — non-empty runs already
4019        // use each run's real font metrics during shaping in text3.
4020        strut_ascent: font_size * 0.8,
4021        strut_descent: font_size * 0.2,
4022        strut_x_height: font_size * 0.5, // 0.5em fallback per CSS Inline 3 Appendix A
4023        ch_width: font_size * 0.5,
4024        vertical_align,
4025        // +spec:inline-formatting-context:48ce44 - overflow-wrap property: break at otherwise disallowed points to prevent overflow
4026        // +spec:line-breaking:bbb5f7 - overflow-wrap: anywhere vs break-word distinction for min-content
4027        overflow_wrap: if word_break_css == StyleWordBreak::BreakWord {
4028            // +spec:line-breaking:815882 - break-word forces overflow-wrap: anywhere
4029            text3::cache::OverflowWrap::Anywhere
4030        } else {
4031            match overflow_wrap_css {
4032                StyleOverflowWrap::Normal => text3::cache::OverflowWrap::Normal,
4033                StyleOverflowWrap::Anywhere => text3::cache::OverflowWrap::Anywhere,
4034                StyleOverflowWrap::BreakWord => text3::cache::OverflowWrap::BreakWord,
4035            }
4036        },
4037        text_align_last: match text_align_last_css {
4038            StyleTextAlignLast::Auto => text3::cache::TextAlign::default(),
4039            StyleTextAlignLast::Start => text3::cache::TextAlign::Start,
4040            StyleTextAlignLast::End => text3::cache::TextAlign::End,
4041            StyleTextAlignLast::Left => text3::cache::TextAlign::Left,
4042            StyleTextAlignLast::Right => text3::cache::TextAlign::Right,
4043            StyleTextAlignLast::Center => text3::cache::TextAlign::Center,
4044            StyleTextAlignLast::Justify => text3::cache::TextAlign::Justify,
4045        },
4046        // +spec:line-breaking:815882 - word-break: break-word => normal + overflow-wrap: anywhere
4047        word_break: match word_break_css {
4048            StyleWordBreak::Normal | StyleWordBreak::BreakWord => text3::cache::WordBreak::Normal,
4049            StyleWordBreak::BreakAll => text3::cache::WordBreak::BreakAll,
4050            StyleWordBreak::KeepAll => text3::cache::WordBreak::KeepAll,
4051        },
4052        // +spec:white-space-processing:bc5f7b - line-break with break-spaces allows breaking before first space
4053        // CSS Text Level 3 §5.3: The line-break property affects preserved white space behavior:
4054        // - normal/pre-line: preserved white space at end/start of line is discarded
4055        // - nowrap/pre: wrapping is forbidden altogether
4056        // - pre-wrap: preserved white space hangs
4057        // - break-spaces: allows breaking before first space of a sequence
4058        // break-spaces allows wrapping preserved spaces to next line; for other white-space values,
4059        // preserved spaces at line ends are either discarded (normal, pre-line), wrapping is
4060        // forbidden (nowrap, pre), or they hang (pre-wrap).
4061        line_break: match line_break_css {
4062            StyleLineBreak::Auto => text3::cache::LineBreakStrictness::Auto,
4063            StyleLineBreak::Loose => text3::cache::LineBreakStrictness::Loose,
4064            StyleLineBreak::Normal => text3::cache::LineBreakStrictness::Normal,
4065            StyleLineBreak::Strict => text3::cache::LineBreakStrictness::Strict,
4066            StyleLineBreak::Anywhere => text3::cache::LineBreakStrictness::Anywhere,
4067        },
4068    }
4069}
4070
4071// Table Formatting Context (CSS 2.2 § 17)
4072// +spec:display-property:d887c0 - Table wrapper box BFC, caption-side, table grid layout (§17.4-17.5)
4073// +spec:positioning:930891 - Table formatting context implementation (CSS 2.2 § 17 introduction)
4074
4075// +spec:inline-formatting-context:9c272d - CSS table model: row-primary structure, display-to-table-element mapping, visual formatting as rectangular grid
4076/// Lays out a Table Formatting Context.
4077/// Table column information for layout calculations
4078#[derive(Copy, Debug, Clone)]
4079pub struct TableColumnInfo {
4080    /// Minimum width required for this column
4081    pub min_width: f32,
4082    /// Maximum width desired for this column
4083    pub max_width: f32,
4084    /// Computed final width for this column
4085    pub computed_width: Option<f32>,
4086}
4087
4088/// Information about a table cell for layout
4089#[derive(Copy, Debug, Clone)]
4090pub struct TableCellInfo {
4091    /// Node index in the layout tree
4092    pub node_index: usize,
4093    /// Column index (0-based)
4094    pub column: usize,
4095    /// Number of columns this cell spans
4096    pub colspan: usize,
4097    /// Row index (0-based)
4098    pub row: usize,
4099    /// Number of rows this cell spans
4100    pub rowspan: usize,
4101}
4102
4103/// Table layout context - holds all information needed for table layout
4104#[derive(Debug)]
4105struct TableLayoutContext {
4106    /// Information about each column
4107    columns: Vec<TableColumnInfo>,
4108    /// Information about each cell
4109    cells: Vec<TableCellInfo>,
4110    /// Number of rows in the table
4111    num_rows: usize,
4112    /// Whether to use fixed or auto layout algorithm
4113    use_fixed_layout: bool,
4114    /// Computed height for each row
4115    row_heights: Vec<f32>,
4116    /// Computed baseline offset for each row (distance from row top to row baseline)
4117    row_baselines: Vec<f32>,
4118    // +spec:inline-formatting-context:440ca9 - border-collapse/border-spacing/visibility:collapse table properties (CSS 2.2 §17.5-17.6)
4119    /// Border collapse mode
4120    border_collapse: StyleBorderCollapse,
4121    /// Border spacing (only used when `border_collapse` is Separate)
4122    border_spacing: LayoutBorderSpacing,
4123    /// CSS 2.2 Section 17.4: Index of table-caption child, if any
4124    caption_index: Option<usize>,
4125    //   from display without forcing table re-layout
4126    /// CSS 2.2 Section 17.6: Rows with visibility:collapse (dynamic effects)
4127    /// Set of row indices that have visibility:collapse
4128    collapsed_rows: std::collections::HashSet<usize>,
4129    /// CSS 2.2 Section 17.6: Columns with visibility:collapse (dynamic effects)
4130    /// Set of column indices that have visibility:collapse
4131    collapsed_columns: std::collections::HashSet<usize>,
4132    /// Rows that are hidden-empty (zero height, border-spacing on only one side)
4133    hidden_empty_rows: std::collections::HashSet<usize>,
4134    /// Layout tree indices for each row (row index → layout node index)
4135    row_node_indices: Vec<usize>,
4136}
4137
4138impl TableLayoutContext {
4139    fn new() -> Self {
4140        Self {
4141            columns: Vec::new(),
4142            cells: Vec::new(),
4143            num_rows: 0,
4144            use_fixed_layout: false,
4145            row_heights: Vec::new(),
4146            row_baselines: Vec::new(),
4147            border_collapse: StyleBorderCollapse::Separate,
4148            border_spacing: LayoutBorderSpacing::default(),
4149            caption_index: None,
4150            collapsed_rows: std::collections::HashSet::new(),
4151            collapsed_columns: std::collections::HashSet::new(),
4152            hidden_empty_rows: std::collections::HashSet::new(),
4153            row_node_indices: Vec::new(),
4154        }
4155    }
4156}
4157
4158// +spec:table-layout:485791 - Six superimposed table layers: table, column-group, column, row-group, row, cell (bottom to top)
4159// +spec:table-layout:dcdf1b - Collapsing border model: border conflict resolution uses layer priority (cell > row > row-group > column > column-group > table)
4160/// Source of a border in the border conflict resolution algorithm
4161#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
4162pub enum BorderSource {
4163    Table = 0,
4164    ColumnGroup = 1,
4165    Column = 2,
4166    RowGroup = 3,
4167    Row = 4,
4168    Cell = 5,
4169}
4170
4171/// Information about a border for conflict resolution
4172#[derive(Copy, Debug, Clone)]
4173pub struct BorderInfo {
4174    pub width: f32,
4175    pub style: BorderStyle,
4176    pub color: ColorU,
4177    pub source: BorderSource,
4178}
4179
4180impl BorderInfo {
4181    #[must_use] pub const fn new(width: f32, style: BorderStyle, color: ColorU, source: BorderSource) -> Self {
4182        Self {
4183            width,
4184            style,
4185            color,
4186            source,
4187        }
4188    }
4189
4190    // +spec:block-formatting-context:f772ae - border style priority for table border conflict resolution
4191    /// Get the priority of a border style for conflict resolution
4192    /// Higher number = higher priority
4193    #[must_use] pub const fn style_priority(style: &BorderStyle) -> u8 {
4194        match style {
4195            BorderStyle::Hidden => 255, // Highest - suppresses all borders
4196            BorderStyle::None => 0,     // Lowest - loses to everything
4197            BorderStyle::Double => 8,
4198            BorderStyle::Solid => 7,
4199            BorderStyle::Dashed => 6,
4200            BorderStyle::Dotted => 5,
4201            BorderStyle::Ridge => 4,
4202            BorderStyle::Outset => 3,
4203            BorderStyle::Groove => 2,
4204            BorderStyle::Inset => 1,
4205        }
4206    }
4207
4208    // +spec:box-model:2255c2 - Collapsing border conflict resolution (hidden wins, then none loses, then wider wins, then style priority)
4209    // +spec:box-model:b42c79 - border conflict resolution: hidden wins, then wider, then style priority, then source
4210    // +spec:box-model:503e9e - border conflict resolution: hidden wins, then wider, then style priority, then source priority
4211    // +spec:box-model:7eb217 - Border conflict resolution: hidden > none < wider > style priority > source priority > left/top
4212    // +spec:overflow:1fb482 - Border conflict resolution per CSS 2.2 §17.6.2.1 (hidden wins, then wider, then style priority, then source priority)
4213    // +spec:table-layout:882560 - Border conflict resolution (17.6.2.1): hidden wins, none loses, wider wins, style priority, source priority
4214    /// Compare two borders for conflict resolution per CSS 2.2 Section 17.6.2.1
4215    /// Returns the winning border
4216    // +spec:table-layout:21053b - border conflict resolution: hidden suppresses all, style priorities
4217    // +spec:table-layout:076617 - border conflict resolution algorithm and border style semantics in collapsing model
4218    #[must_use] pub fn resolve_conflict(a: &Self, b: &Self) -> Option<Self> {
4219        // 1. 'hidden' wins and suppresses all borders
4220        if a.style == BorderStyle::Hidden || b.style == BorderStyle::Hidden {
4221            return None;
4222        }
4223
4224        // 2. Filter out 'none' - if both are none, no border
4225        let a_is_none = a.style == BorderStyle::None;
4226        let b_is_none = b.style == BorderStyle::None;
4227
4228        if a_is_none && b_is_none {
4229            return None;
4230        }
4231        if a_is_none {
4232            return Some(*b);
4233        }
4234        if b_is_none {
4235            return Some(*a);
4236        }
4237
4238        // 3. Wider border wins
4239        if a.width > b.width {
4240            return Some(*a);
4241        }
4242        if b.width > a.width {
4243            return Some(*b);
4244        }
4245
4246        // 4. If same width, compare style priority
4247        let a_priority = Self::style_priority(&a.style);
4248        let b_priority = Self::style_priority(&b.style);
4249
4250        if a_priority > b_priority {
4251            return Some(*a);
4252        }
4253        if b_priority > a_priority {
4254            return Some(*b);
4255        }
4256
4257        // 5. If same style, source priority:
4258        // Cell > Row > RowGroup > Column > ColumnGroup > Table
4259        if a.source > b.source {
4260            return Some(*a);
4261        }
4262        if b.source > a.source {
4263            return Some(*b);
4264        }
4265
4266        // 6. Same priority - prefer first one (left/top in LTR)
4267        Some(*a)
4268    }
4269}
4270
4271/// Get border information for a node
4272#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
4273#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4274fn get_border_info<T: ParsedFontTrait>(
4275    ctx: &LayoutContext<'_, T>,
4276    node: &LayoutNodeHot,
4277    source: BorderSource,
4278) -> (BorderInfo, BorderInfo, BorderInfo, BorderInfo) {
4279    use azul_css::props::{
4280        basic::{
4281            pixel::{PhysicalSize, PropertyContext, ResolutionContext},
4282            ColorU,
4283        },
4284        style::BorderStyle,
4285    };
4286    use get_element_font_size;
4287    use get_parent_font_size;
4288    use get_root_font_size;
4289
4290    let default_border = BorderInfo::new(
4291        0.0,
4292        BorderStyle::None,
4293        ColorU {
4294            r: 0,
4295            g: 0,
4296            b: 0,
4297            a: 0,
4298        },
4299        source,
4300    );
4301
4302    let Some(dom_id) = node.dom_node_id else {
4303        return (
4304            default_border,
4305            default_border,
4306            default_border,
4307            default_border,
4308        );
4309    };
4310
4311    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4312    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4313    let cache = &ctx.styled_dom.css_property_cache.ptr;
4314
4315    // FAST PATH: compact cache for normal state
4316    if let Some(ref cc) = cache.compact_cache {
4317        let idx = dom_id.index();
4318
4319        // Border styles from packed u16
4320        let bts = cc.get_border_top_style(idx);
4321        let brs = cc.get_border_right_style(idx);
4322        let bbs = cc.get_border_bottom_style(idx);
4323        let bls = cc.get_border_left_style(idx);
4324
4325        // Border colors from u32 RGBA
4326        let make_color = |raw: u32| -> ColorU {
4327            if raw == 0 {
4328                ColorU { r: 0, g: 0, b: 0, a: 0 }
4329            } else {
4330                ColorU {
4331                    r: ((raw >> 24) & 0xFF) as u8,
4332                    g: ((raw >> 16) & 0xFF) as u8,
4333                    b: ((raw >> 8) & 0xFF) as u8,
4334                    a: (raw & 0xFF) as u8,
4335                }
4336            }
4337        };
4338
4339        let btc = make_color(cc.get_border_top_color_raw(idx));
4340        let brc = make_color(cc.get_border_right_color_raw(idx));
4341        let bbc = make_color(cc.get_border_bottom_color_raw(idx));
4342        let blc = make_color(cc.get_border_left_color_raw(idx));
4343
4344        // Border widths from i16 × 10
4345        let decode_width = |raw: i16| -> f32 {
4346            if raw >= azul_css::compact_cache::I16_SENTINEL_THRESHOLD {
4347                0.0 // sentinel → fall back to 0
4348            } else {
4349                f32::from(raw) / 10.0
4350            }
4351        };
4352
4353        let btw = decode_width(cc.get_border_top_width_raw(idx));
4354        let brw = decode_width(cc.get_border_right_width_raw(idx));
4355        let bbw = decode_width(cc.get_border_bottom_width_raw(idx));
4356        let blw = decode_width(cc.get_border_left_width_raw(idx));
4357
4358        let top = if bts == BorderStyle::None { default_border }
4359            else { BorderInfo::new(btw, bts, btc, source) };
4360        let right = if brs == BorderStyle::None { default_border }
4361            else { BorderInfo::new(brw, brs, brc, source) };
4362        let bottom = if bbs == BorderStyle::None { default_border }
4363            else { BorderInfo::new(bbw, bbs, bbc, source) };
4364        let left = if bls == BorderStyle::None { default_border }
4365            else { BorderInfo::new(blw, bls, blc, source) };
4366
4367        return (top, right, bottom, left);
4368    }
4369
4370    // SLOW PATH: full cascade resolution
4371    let cache = &ctx.styled_dom.css_property_cache.ptr;
4372
4373    // Create resolution context for border-width (em/rem support, no % support)
4374    let element_font_size = get_element_font_size(ctx.styled_dom, dom_id, &node_state);
4375    let parent_font_size = get_parent_font_size(ctx.styled_dom, dom_id, &node_state);
4376    let root_font_size = get_root_font_size(ctx.styled_dom, &node_state);
4377
4378    let resolution_context = ResolutionContext {
4379        element_font_size,
4380        parent_font_size,
4381        root_font_size,
4382        // Not used for border-width
4383        containing_block_size: PhysicalSize::new(0.0, 0.0),
4384        // Not used for border-width
4385        element_size: None,
4386        viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
4387    };
4388
4389    // Top border
4390    let top = cache
4391        .get_border_top_style(node_data, &dom_id, &node_state)
4392        .and_then(|s| s.get_property())
4393        .map_or_else(|| default_border, |style_val| {
4394            let width = cache
4395                .get_border_top_width(node_data, &dom_id, &node_state)
4396                .and_then(|w| w.get_property())
4397                .map_or(0.0, |w| {
4398                    w.inner
4399                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4400                });
4401            let color = cache
4402                .get_border_top_color(node_data, &dom_id, &node_state)
4403                .and_then(|c| c.get_property())
4404                .map_or(ColorU {
4405                    r: 0,
4406                    g: 0,
4407                    b: 0,
4408                    a: 255,
4409                }, |c| c.inner);
4410            BorderInfo::new(width, style_val.inner, color, source)
4411        });
4412
4413    // Right border
4414    let right = cache
4415        .get_border_right_style(node_data, &dom_id, &node_state)
4416        .and_then(|s| s.get_property())
4417        .map_or_else(|| default_border, |style_val| {
4418            let width = cache
4419                .get_border_right_width(node_data, &dom_id, &node_state)
4420                .and_then(|w| w.get_property())
4421                .map_or(0.0, |w| {
4422                    w.inner
4423                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4424                });
4425            let color = cache
4426                .get_border_right_color(node_data, &dom_id, &node_state)
4427                .and_then(|c| c.get_property())
4428                .map_or(ColorU {
4429                    r: 0,
4430                    g: 0,
4431                    b: 0,
4432                    a: 255,
4433                }, |c| c.inner);
4434            BorderInfo::new(width, style_val.inner, color, source)
4435        });
4436
4437    // Bottom border
4438    let bottom = cache
4439        .get_border_bottom_style(node_data, &dom_id, &node_state)
4440        .and_then(|s| s.get_property())
4441        .map_or_else(|| default_border, |style_val| {
4442            let width = cache
4443                .get_border_bottom_width(node_data, &dom_id, &node_state)
4444                .and_then(|w| w.get_property())
4445                .map_or(0.0, |w| {
4446                    w.inner
4447                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4448                });
4449            let color = cache
4450                .get_border_bottom_color(node_data, &dom_id, &node_state)
4451                .and_then(|c| c.get_property())
4452                .map_or(ColorU {
4453                    r: 0,
4454                    g: 0,
4455                    b: 0,
4456                    a: 255,
4457                }, |c| c.inner);
4458            BorderInfo::new(width, style_val.inner, color, source)
4459        });
4460
4461    // Left border
4462    let left = cache
4463        .get_border_left_style(node_data, &dom_id, &node_state)
4464        .and_then(|s| s.get_property())
4465        .map_or_else(|| default_border, |style_val| {
4466            let width = cache
4467                .get_border_left_width(node_data, &dom_id, &node_state)
4468                .and_then(|w| w.get_property())
4469                .map_or(0.0, |w| {
4470                    w.inner
4471                        .resolve_with_context(&resolution_context, PropertyContext::BorderWidth)
4472                });
4473            let color = cache
4474                .get_border_left_color(node_data, &dom_id, &node_state)
4475                .and_then(|c| c.get_property())
4476                .map_or(ColorU {
4477                    r: 0,
4478                    g: 0,
4479                    b: 0,
4480                    a: 255,
4481                }, |c| c.inner);
4482            BorderInfo::new(width, style_val.inner, color, source)
4483        });
4484
4485    (top, right, bottom, left)
4486}
4487
4488// +spec:table-layout:c5e446 - table-layout property (auto|fixed) controls layout algorithm selection
4489/// Get the table-layout property for a table node
4490fn get_table_layout_property<T: ParsedFontTrait>(
4491    ctx: &LayoutContext<'_, T>,
4492    node: &LayoutNodeHot,
4493) -> LayoutTableLayout {
4494    let Some(dom_id) = node.dom_node_id else {
4495        return LayoutTableLayout::Auto;
4496    };
4497
4498    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4499    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4500
4501    ctx.styled_dom
4502        .css_property_cache
4503        .ptr
4504        .get_table_layout(node_data, &dom_id, &node_state)
4505        .and_then(|prop| prop.get_property().copied())
4506        .unwrap_or(LayoutTableLayout::Auto)
4507}
4508
4509/// Get the border-collapse property for a table node
4510fn get_border_collapse_property<T: ParsedFontTrait>(
4511    ctx: &LayoutContext<'_, T>,
4512    node: &LayoutNodeHot,
4513) -> StyleBorderCollapse {
4514    let Some(dom_id) = node.dom_node_id else {
4515        return StyleBorderCollapse::Separate;
4516    };
4517
4518    // FAST PATH: compact cache
4519    if let Some(ref cc) = ctx.styled_dom.css_property_cache.ptr.compact_cache {
4520        return cc.get_border_collapse(dom_id.index());
4521    }
4522
4523    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4524    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4525
4526    ctx.styled_dom
4527        .css_property_cache
4528        .ptr
4529        .get_border_collapse(node_data, &dom_id, &node_state)
4530        .and_then(|prop| prop.get_property().copied())
4531        .unwrap_or(StyleBorderCollapse::Separate)
4532}
4533
4534/// Get the border-spacing property for a table node
4535fn get_border_spacing_property<T: ParsedFontTrait>(
4536    ctx: &LayoutContext<'_, T>,
4537    node: &LayoutNodeHot,
4538) -> LayoutBorderSpacing {
4539    if let Some(dom_id) = node.dom_node_id {
4540        // FAST PATH: compact cache
4541        if let Some(ref cc) = ctx.styled_dom.css_property_cache.ptr.compact_cache {
4542            let idx = dom_id.index();
4543            let h_raw = cc.get_border_spacing_h_raw(idx);
4544            let v_raw = cc.get_border_spacing_v_raw(idx);
4545            // If both are non-sentinel, use compact values
4546            if h_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
4547                && v_raw < azul_css::compact_cache::I16_SENTINEL_THRESHOLD
4548            {
4549                return LayoutBorderSpacing::new_separate(
4550                    azul_css::props::basic::pixel::PixelValue::px(f32::from(h_raw) / 10.0),
4551                    azul_css::props::basic::pixel::PixelValue::px(f32::from(v_raw) / 10.0),
4552                );
4553            }
4554            // sentinel → fall through to slow path
4555        }
4556
4557        let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4558        let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4559
4560        if let Some(prop) = ctx.styled_dom.css_property_cache.ptr.get_border_spacing(
4561            node_data,
4562            &dom_id,
4563            &node_state,
4564        ) {
4565            if let Some(value) = prop.get_property() {
4566                return *value;
4567            }
4568        }
4569    }
4570
4571    LayoutBorderSpacing::default() // Default: 0
4572}
4573
4574/// Get the empty-cells property for a table-cell node.
4575/// Returns Show (default) or Hide.
4576fn get_empty_cells_property<T: ParsedFontTrait>(
4577    ctx: &LayoutContext<'_, T>,
4578    node: &LayoutNodeHot,
4579) -> StyleEmptyCells {
4580    let Some(dom_id) = node.dom_node_id else {
4581        return StyleEmptyCells::Show;
4582    };
4583
4584    let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4585    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4586
4587    ctx.styled_dom
4588        .css_property_cache
4589        .ptr
4590        .get_empty_cells(node_data, &dom_id, &node_state)
4591        .and_then(|prop| prop.get_property().copied())
4592        .unwrap_or(StyleEmptyCells::Show)
4593}
4594
4595/// CSS 2.2 Section 17.4 - Tables in the visual formatting model:
4596///
4597/// "The caption box is a block box that retains its own content, padding,
4598/// border, and margin areas. The caption-side property specifies the position
4599/// of the caption box with respect to the table box."
4600///
4601/// Get the caption-side property for a table node.
4602/// Returns Top (default) or Bottom.
4603fn get_caption_side_property<T: ParsedFontTrait>(
4604    ctx: &LayoutContext<'_, T>,
4605    node: &LayoutNodeHot,
4606) -> StyleCaptionSide {
4607    if let Some(dom_id) = node.dom_node_id {
4608        let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
4609        let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4610
4611        if let Some(prop) =
4612            ctx.styled_dom
4613                .css_property_cache
4614                .ptr
4615                .get_caption_side(node_data, &dom_id, &node_state)
4616        {
4617            if let Some(value) = prop.get_property() {
4618                return *value;
4619            }
4620        }
4621    }
4622
4623    StyleCaptionSide::Top // Default per CSS 2.2
4624}
4625
4626//   removes entire row or column from display; space made available for other content;
4627//   spanned content clipped; does not otherwise affect table layout
4628// +spec:inline-formatting-context:9f5f31 - visibility:collapse for table rows/columns, border-collapse and border-spacing
4629/// CSS 2.2 Section 17.6 - Dynamic row and column effects:
4630///
4631// +spec:box-model:547563 - visibility:collapse removes table rows/columns; elsewhere same as hidden
4632/// "The 'visibility' value 'collapse' removes a row or column from display,
4633/// but it has a different effect than 'visibility: hidden' on other elements.
4634/// When a row or column is collapsed, the space normally occupied by the row
4635/// or column is removed."
4636///
4637/// Check if a node has visibility:collapse set.
4638///
4639/// This is used for table rows and columns to optimize dynamic hiding.
4640/// // +spec:overflow:ebb1f9 - For non-table elements, collapse == hidden (no special handling needed)
4641fn is_visibility_collapsed<T: ParsedFontTrait>(
4642    ctx: &LayoutContext<'_, T>,
4643    node: &LayoutNodeHot,
4644) -> bool {
4645    if let Some(dom_id) = node.dom_node_id {
4646        let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
4647
4648        if let MultiValue::Exact(value) = get_visibility(ctx.styled_dom, dom_id, &node_state) {
4649            return matches!(value, StyleVisibility::Collapse);
4650        }
4651    }
4652
4653    false
4654}
4655
4656// +spec:overflow:af97a8 - empty-cells in separated borders model; collapsing border overflow
4657// +spec:table-layout:dcdf1b - empty-cells property controls rendering of borders/backgrounds around empty cells in separated borders model
4658/// CSS 2.2 Section 17.6.1.1 - Borders and Backgrounds around empty cells
4659///
4660/// In the separated borders model, the 'empty-cells' property controls the rendering of
4661/// borders and backgrounds around cells that have no visible content. Empty means it has no
4662/// children, or has children that are only collapsed whitespace."
4663///
4664/// Check if a table cell is empty (has no visible content).
4665///
4666/// This is used by the rendering pipeline to decide whether to paint borders/backgrounds
4667/// when empty-cells: hide is set in separated border model.
4668///
4669//   in-flow content (including empty elements) other than collapsed whitespace
4670/// A cell is considered empty if:
4671///
4672/// - It has no children, OR
4673/// - It has children but no `inline_layout_result` (no rendered content)
4674///
4675/// Note: Full whitespace detection would require checking text content during rendering.
4676/// This function provides a basic check suitable for layout phase.
4677fn is_cell_empty(tree: &LayoutTree, cell_index: usize) -> bool {
4678    if tree.get(cell_index).is_none() {
4679        return true; // Invalid cell is considered empty
4680    }
4681
4682    // No children = empty
4683    if tree.children(cell_index).is_empty() {
4684        return true;
4685    }
4686
4687    // If cell has an inline layout result, check if it's empty
4688    if let Some(warm_node) = tree.warm(cell_index) {
4689        if let Some(ref cached_layout) = warm_node.inline_layout_result {
4690            // Check if inline layout has any rendered content
4691            // Empty inline layouts have no items (glyphs/fragments)
4692            // Note: This is a heuristic - full detection requires text content analysis
4693            return cached_layout.layout.items.is_empty();
4694        }
4695    }
4696
4697    // Check if all children have no content
4698    // A more thorough check would recursively examine all descendants
4699    //
4700    // For now, we use a simple heuristic: if there are children, assume not empty
4701    // unless proven otherwise by inline_layout_result
4702
4703    // Cell with children but no inline layout = likely has block-level content = not empty
4704    false
4705}
4706
4707/// Main function to layout a table formatting context
4708// +spec:table-layout:235e8e - CSS 2.2 §17.1-17.2 table model: fixed/auto algorithms, row/column/cell/caption structure
4709// +spec:table-layout:a6422d - CSS table model: table structure analysis, row/column/cell layout, caption, border-collapse
4710#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
4711#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4712/// # Panics
4713///
4714/// Panics if the table's root node has no associated DOM node id.
4715/// # Errors
4716///
4717/// Returns a `LayoutError` if laying out the table fails.
4718pub fn layout_table_fc<T: ParsedFontTrait>(
4719    ctx: &mut LayoutContext<'_, T>,
4720    tree: &mut LayoutTree,
4721    text_cache: &mut TextLayoutCache,
4722    node_index: usize,
4723    constraints: &LayoutConstraints<'_>,
4724) -> Result<LayoutOutput> {
4725    debug_log!(ctx, "Laying out table");
4726
4727    debug_table_layout!(
4728        ctx,
4729        "node_index={}, available_size={:?}, writing_mode={:?}",
4730        node_index,
4731        constraints.available_size,
4732        constraints.writing_mode
4733    );
4734
4735    // Multi-pass table layout algorithm:
4736    //
4737    // 1. Analyze table structure - identify rows, cells, columns
4738    // 2. Determine table-layout property (fixed vs auto)
4739    // 3. Calculate column widths
4740    // 4. Layout cells and calculate row heights
4741    // 5. Position cells in final grid
4742
4743    // Get the table node to read CSS properties
4744    let table_node = tree
4745        .get(node_index)
4746        .ok_or(LayoutError::InvalidTree)?
4747        .clone();
4748
4749    // Calculate the table's border-box width for column distribution
4750    // This accounts for the table's own width property (e.g., width: 100%)
4751    let table_border_box_width = if let Some(dom_id) = table_node.dom_node_id {
4752        // Use calculate_used_size_for_node to resolve table width (respects width:100%)
4753        let intrinsic = tree.warm(node_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
4754        let containing_block_size = LogicalSize {
4755            width: constraints.available_size.width,
4756            height: constraints.available_size.height,
4757        };
4758
4759        let table_bp = table_node.box_props.unpack();
4760        let table_size = crate::solver3::sizing::calculate_used_size_for_node(
4761            ctx.styled_dom,
4762            Some(dom_id),
4763            &containing_block_size,
4764            intrinsic,
4765            &table_bp,
4766            &ctx.viewport_size,
4767        )?;
4768
4769        table_size.width
4770    } else {
4771        constraints.available_size.width
4772    };
4773
4774    // Subtract padding and border to get content-box width for column distribution
4775    let tbp = table_node.box_props.unpack();
4776    let table_content_box_width = {
4777        let padding_width = tbp.padding.left + tbp.padding.right;
4778        let border_width = tbp.border.left + tbp.border.right;
4779        (table_border_box_width - padding_width - border_width).max(0.0)
4780    };
4781
4782    debug_table_layout!(ctx, "Table Layout Debug");
4783    debug_table_layout!(ctx, "Node index: {}", node_index);
4784    debug_table_layout!(
4785        ctx,
4786        "Available size from parent: {:.2} x {:.2}",
4787        constraints.available_size.width,
4788        constraints.available_size.height
4789    );
4790    debug_table_layout!(ctx, "Table border-box width: {:.2}", table_border_box_width);
4791    debug_table_layout!(
4792        ctx,
4793        "Table content-box width: {:.2}",
4794        table_content_box_width
4795    );
4796    debug_table_layout!(
4797        ctx,
4798        "Table padding: L={:.2} R={:.2}",
4799        tbp.padding.left,
4800        tbp.padding.right
4801    );
4802    debug_table_layout!(
4803        ctx,
4804        "Table border: L={:.2} R={:.2}",
4805        tbp.border.left,
4806        tbp.border.right
4807    );
4808    debug_table_layout!(ctx, "=");
4809
4810    // Phase 1: Analyze table structure
4811    let mut table_ctx = analyze_table_structure(tree, node_index, ctx)?;
4812
4813    // +spec:table-layout:ff5671 - table-layout property (fixed vs auto) controls column width algorithm
4814    // +spec:width-calculation:7a5b23 - table-layout property determines fixed vs auto algorithm (CSS 2.2 §17.5.2)
4815    // Phase 2: Read CSS properties and determine layout algorithm
4816    let table_layout = get_table_layout_property(ctx, &table_node);
4817    table_ctx.use_fixed_layout = matches!(table_layout, LayoutTableLayout::Fixed);
4818
4819    // +spec:containing-block:cc1453 - collapsing border model: border-collapse property drives table border handling
4820    // Read border properties
4821    table_ctx.border_collapse = get_border_collapse_property(ctx, &table_node);
4822    table_ctx.border_spacing = get_border_spacing_property(ctx, &table_node);
4823
4824    debug_log!(
4825        ctx,
4826        "Table layout: {:?}, border-collapse: {:?}, border-spacing: {:?}",
4827        table_layout,
4828        table_ctx.border_collapse,
4829        table_ctx.border_spacing
4830    );
4831
4832    // +spec:width-calculation:431d60 - fixed vs auto table layout column width algorithms (CSS 2.2 §17.5.2.1, §17.5.2.2)
4833    // Phase 3: Calculate column widths
4834    if table_ctx.use_fixed_layout {
4835        // DEBUG: Log available width passed into fixed column calculation
4836        debug_table_layout!(
4837            ctx,
4838            "FIXED layout: table_content_box_width={:.2}",
4839            table_content_box_width
4840        );
4841        calculate_column_widths_fixed(ctx, tree, &mut table_ctx, table_content_box_width);
4842    } else {
4843        // Pass table_content_box_width for column distribution in auto layout
4844        calculate_column_widths_auto_with_width(
4845            &mut table_ctx,
4846            tree,
4847            text_cache,
4848            ctx,
4849            constraints,
4850            table_content_box_width,
4851        )?;
4852    }
4853
4854    debug_table_layout!(ctx, "After column width calculation:");
4855    debug_table_layout!(ctx, "  Number of columns: {}", table_ctx.columns.len());
4856    for (i, col) in table_ctx.columns.iter().enumerate() {
4857        debug_table_layout!(
4858            ctx,
4859            "  Column {}: width={:.2}",
4860            i,
4861            col.computed_width.unwrap_or(0.0)
4862        );
4863    }
4864    let total_col_width: f32 = table_ctx
4865        .columns
4866        .iter()
4867        .filter_map(|c| c.computed_width)
4868        .sum();
4869    debug_table_layout!(ctx, "  Total column width: {:.2}", total_col_width);
4870
4871    // Phase 4: Calculate row heights based on cell content
4872    calculate_row_heights(&mut table_ctx, tree, text_cache, ctx, constraints)?;
4873
4874    // Phase 5: Position cells in final grid and collect positions
4875    let mut cell_positions =
4876        position_table_cells(&table_ctx, tree, ctx, node_index, constraints)?;
4877
4878    // Calculate final table size including border-spacing
4879    let mut table_width: f32 = table_ctx
4880        .columns
4881        .iter()
4882        .filter_map(|col| col.computed_width)
4883        .sum();
4884    let mut table_height: f32 = table_ctx.row_heights.iter().sum();
4885
4886    debug_table_layout!(
4887        ctx,
4888        "After calculate_row_heights: table_height={:.2}, row_heights={:?}",
4889        table_height,
4890        table_ctx.row_heights
4891    );
4892
4893    // +spec:box-model:494f6b - collapsing border model: row-width formula and table border width computation
4894    // +spec:box-model:e7d0a3 - Separated borders model: border-spacing, empty-cells, collapsing border width calculation
4895    // +spec:box-sizing:ee702c - separated borders model: border-spacing between adjoining cells
4896    // Add border-spacing to table size if border-collapse is separate
4897    // +spec:box-model:acb81f - separated borders model: border-spacing between adjoining cell borders
4898    // +spec:box-model:e480b1 - table width = left inner padding edge to right inner padding edge (including border-spacing)
4899    if table_ctx.border_collapse == StyleBorderCollapse::Separate {
4900        use get_element_font_size;
4901        use get_parent_font_size;
4902        use get_root_font_size;
4903        use PhysicalSize;
4904        use PropertyContext;
4905        use ResolutionContext;
4906
4907        let styled_dom = ctx.styled_dom;
4908        // Anonymous table wrapper boxes have no dom_node_id; without a styled
4909        // node we cannot resolve font-relative border-spacing units, so fall
4910        // back to zero spacing rather than panicking.
4911        let (h_spacing, v_spacing) = if let Some(table_id) = tree.nodes[node_index].dom_node_id {
4912            let table_state = &styled_dom.styled_nodes.as_container()[table_id].styled_node_state;
4913
4914            let spacing_context = ResolutionContext {
4915                element_font_size: get_element_font_size(styled_dom, table_id, table_state),
4916                parent_font_size: get_parent_font_size(styled_dom, table_id, table_state),
4917                root_font_size: get_root_font_size(styled_dom, table_state),
4918                containing_block_size: PhysicalSize::new(0.0, 0.0),
4919                element_size: None,
4920                viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
4921            };
4922
4923            let h_spacing = table_ctx
4924                .border_spacing
4925                .horizontal
4926                .resolve_with_context(&spacing_context, PropertyContext::Other)
4927                .max(0.0);
4928            let v_spacing = table_ctx
4929                .border_spacing
4930                .vertical
4931                .resolve_with_context(&spacing_context, PropertyContext::Other)
4932                .max(0.0);
4933            (h_spacing, v_spacing)
4934        } else {
4935            (0.0f32, 0.0f32)
4936        };
4937
4938        // Add spacing: left + (n-1 between columns) + right = n+1 spacings
4939        let num_cols = table_ctx.columns.len();
4940        if num_cols > 0 {
4941            table_width += h_spacing * (num_cols + 1) as f32;
4942        }
4943
4944        // Add spacing: top + (n-1 between rows) + bottom = n+1 spacings
4945        if table_ctx.num_rows > 0 {
4946            let full_spacings = (table_ctx.num_rows + 1) as f32;
4947            // Each hidden-empty row loses one side of border-spacing
4948            let hidden_empty_count = table_ctx.hidden_empty_rows.len() as f32;
4949            table_height += v_spacing * (full_spacings - hidden_empty_count);
4950        }
4951    }
4952
4953    // +spec:table-layout:24dbf9 - §17.4 table wrapper box model: caption positioning, BFC establishment
4954    // +spec:width-calculation:600f98 - caption-side positions caption above/below table box (CSS 2.2 §17.4)
4955    // CSS 2.2 Section 17.4: Layout and position the caption if present
4956    //
4957    // "The caption box is a block box that retains its own content,
4958    // padding, border, and margin areas."
4959    let caption_side = get_caption_side_property(ctx, &table_node);
4960    let mut caption_height = 0.0;
4961    let mut table_y_offset = 0.0;
4962
4963    if let Some(caption_idx) = table_ctx.caption_index {
4964        debug_log!(
4965            ctx,
4966            "Laying out caption with caption-side: {:?}",
4967            caption_side
4968        );
4969
4970        // Layout caption as a block with the table's width as available width
4971        let caption_constraints = LayoutConstraints {
4972            available_size: LogicalSize {
4973                width: table_width,
4974                height: constraints.available_size.height,
4975            },
4976            writing_mode: constraints.writing_mode,
4977            writing_mode_ctx: constraints.writing_mode_ctx,
4978            bfc_state: None, // Caption creates its own BFC
4979            text_align: constraints.text_align,
4980            containing_block_size: constraints.containing_block_size,
4981            available_width_type: Text3AvailableSpace::Definite(table_width),
4982        };
4983
4984        // Layout the caption node
4985        let mut empty_float_cache = HashMap::new();
4986        let caption_result = layout_formatting_context(
4987            ctx,
4988            tree,
4989            text_cache,
4990            caption_idx,
4991            &caption_constraints,
4992            &mut empty_float_cache,
4993        )?;
4994        caption_height = caption_result.output.overflow_size.height;
4995
4996        let caption_position = match caption_side {
4997            StyleCaptionSide::Top => {
4998                // Caption on top: position at y=0, table starts below caption
4999                table_y_offset = caption_height;
5000                LogicalPosition { x: 0.0, y: 0.0 }
5001            }
5002            StyleCaptionSide::Bottom => {
5003                // Caption on bottom: table starts at y=0, caption below table
5004                LogicalPosition {
5005                    x: 0.0,
5006                    y: table_height,
5007                }
5008            }
5009        };
5010
5011        // Add caption position to the positions map
5012        cell_positions.insert(caption_idx, caption_position);
5013
5014        debug_log!(
5015            ctx,
5016            "Caption positioned at x={:.2}, y={:.2}, height={:.2}",
5017            caption_position.x,
5018            caption_position.y,
5019            caption_height
5020        );
5021    }
5022
5023    // Adjust all table cell positions if caption is on top
5024    if table_y_offset > 0.0 {
5025        debug_log!(
5026            ctx,
5027            "Adjusting table cells by y offset: {:.2}",
5028            table_y_offset
5029        );
5030
5031        // Adjust cell positions in the map
5032        for cell_info in &table_ctx.cells {
5033            if let Some(pos) = cell_positions.get_mut(&cell_info.node_index) {
5034                pos.y += table_y_offset;
5035            }
5036        }
5037    }
5038
5039    let total_height = table_height + caption_height;
5040
5041    debug_table_layout!(ctx, "Final table dimensions:");
5042    debug_table_layout!(ctx, "  Content width (columns): {:.2}", table_width);
5043    debug_table_layout!(ctx, "  Content height (rows): {:.2}", table_height);
5044    debug_table_layout!(ctx, "  Caption height: {:.2}", caption_height);
5045    debug_table_layout!(ctx, "  Total height: {:.2}", total_height);
5046    debug_table_layout!(ctx, "End Table Debug");
5047
5048    // CSS 2.2 §10.8.1: the baseline of a table is the baseline of its first
5049    // in-flow row — used when the table is an `inline-table` aligned on a line.
5050    // `row_baselines[0]` is that row's baseline measured from the row's top;
5051    // every row-0 cell shares the row top, so add any row-0 cell's (already
5052    // caption-adjusted) y position. Falls back to `None` for an empty table,
5053    // where the caller treats the bottom content edge as the baseline.
5054    // TODO(superplan): a rowspan cell that *starts* in row 0 but whose content
5055    // baseline sits in a later row is approximated by `row_baselines[0]` here.
5056    let table_baseline = table_ctx
5057        .row_baselines
5058        .first()
5059        .copied()
5060        .and_then(|row0_baseline| {
5061            table_ctx
5062                .cells
5063                .iter()
5064                .find(|c| c.row == 0)
5065                .and_then(|c| cell_positions.get(&c.node_index))
5066                .map(|pos| pos.y + row0_baseline)
5067        });
5068
5069    // Create output with the table's final size and cell positions
5070    // +spec:box-model:52fcfe - overflow_size must include borders that spill into margin in collapsing border model
5071    let output = LayoutOutput {
5072        overflow_size: LogicalSize {
5073            width: table_width,
5074            height: total_height,
5075        },
5076        // Cell positions calculated in position_table_cells
5077        positions: cell_positions,
5078        // First in-flow row's baseline (CSS 2.2 §10.8.1); None ⇒ bottom edge.
5079        baseline: table_baseline,
5080    };
5081
5082    Ok(output)
5083}
5084
5085// +spec:display-property:f47f8a - Table structure analysis: caption positioning, row/column/row-group traversal per CSS 2.2 §17.4-17.5
5086/// Analyze the table structure to identify rows, cells, and columns
5087fn analyze_table_structure<T: ParsedFontTrait>(
5088    tree: &LayoutTree,
5089    table_index: usize,
5090    ctx: &mut LayoutContext<'_, T>,
5091) -> Result<TableLayoutContext> {
5092    let mut table_ctx = TableLayoutContext::new();
5093
5094    let table_node = tree.get(table_index).ok_or(LayoutError::InvalidTree)?;
5095
5096    // +spec:width-calculation:0a2766 - table internal elements form rectangular grid of rows/columns (CSS 2.2 §17.5)
5097    // CSS 2.2 Section 17.4: A table may have one table-caption child.
5098    // Traverse children to find caption, columns/colgroups, rows, and row groups
5099    for &child_idx in tree.children(table_index) {
5100        if let Some(child) = tree.get(child_idx) {
5101            // Check if this is a table caption
5102            if matches!(child.formatting_context, FormattingContext::TableCaption) {
5103                debug_log!(ctx, "Found table caption at index {}", child_idx);
5104                table_ctx.caption_index = Some(child_idx);
5105                continue;
5106            }
5107
5108            // CSS 2.2 Section 17.2: Check for column groups
5109            if matches!(
5110                child.formatting_context,
5111                FormattingContext::TableColumnGroup
5112            ) {
5113                analyze_table_colgroup(tree, child_idx, &table_ctx, ctx)?;
5114                continue;
5115            }
5116
5117            // Check if this is a table row or row group
5118            match child.formatting_context {
5119                FormattingContext::TableRow => {
5120                    analyze_table_row(tree, child_idx, &mut table_ctx, ctx)?;
5121                }
5122                FormattingContext::TableRowGroup => {
5123                    // Process rows within the row group
5124                    for &row_idx in tree.children(child_idx) {
5125                        if let Some(row) = tree.get(row_idx) {
5126                            if matches!(row.formatting_context, FormattingContext::TableRow) {
5127                                analyze_table_row(tree, row_idx, &mut table_ctx, ctx)?;
5128                            }
5129                        }
5130                    }
5131                }
5132                _ => {}
5133            }
5134        }
5135    }
5136
5137    debug_log!(
5138        ctx,
5139        "Table structure: {} rows, {} columns, {} cells{}",
5140        table_ctx.num_rows,
5141        table_ctx.columns.len(),
5142        table_ctx.cells.len(),
5143        if table_ctx.caption_index.is_some() {
5144            ", has caption"
5145        } else {
5146            ""
5147        }
5148    );
5149
5150    Ok(table_ctx)
5151}
5152
5153/// Analyze a table column group to identify columns and track collapsed columns
5154///
5155/// - CSS 2.2 Section 17.2: Column groups contain columns
5156/// - CSS 2.2 Section 17.6: Columns can have visibility:collapse
5157fn analyze_table_colgroup<T: ParsedFontTrait>(
5158    tree: &LayoutTree,
5159    colgroup_index: usize,
5160    table_ctx: &TableLayoutContext,
5161    ctx: &mut LayoutContext<'_, T>,
5162) -> Result<()> {
5163    let colgroup_node = tree.get(colgroup_index).ok_or(LayoutError::InvalidTree)?;
5164
5165    // Check if the colgroup itself has visibility:collapse
5166    if is_visibility_collapsed(ctx, colgroup_node) {
5167        // All columns in this group should be collapsed
5168        // TODO: For now, just mark the group (actual column indices will be determined later)
5169        debug_log!(
5170            ctx,
5171            "Column group at index {} has visibility:collapse",
5172            colgroup_index
5173        );
5174    }
5175
5176    // Check for individual column elements within the group
5177    for &col_idx in tree.children(colgroup_index) {
5178        if let Some(col_node) = tree.get(col_idx) {
5179            // Note: Individual columns don't have a FormattingContext::TableColumn
5180            // They are represented as children of TableColumnGroup
5181            // Check visibility:collapse on each column
5182            if is_visibility_collapsed(ctx, col_node) {
5183                // We need to determine the actual column index this represents
5184                // For now, we'll track it during cell analysis
5185                debug_log!(ctx, "Column at index {} has visibility:collapse", col_idx);
5186            }
5187        }
5188    }
5189
5190    Ok(())
5191}
5192
5193/// Read the HTML `colspan` / `rowspan` of a table cell from its DOM node.
5194///
5195/// These are HTML presentational attributes (`AttributeType::ColSpan`/`RowSpan`
5196/// on `NodeData`), not CSS properties. Missing or non-positive values default to
5197/// 1 per the HTML parsing rules.
5198#[allow(clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5199fn get_cell_spans(styled_dom: &StyledDom, dom_id: NodeId) -> (usize, usize) {
5200    let mut colspan = 1usize;
5201    let mut rowspan = 1usize;
5202    let node_data = &styled_dom.node_data.as_container()[dom_id];
5203    for attr in node_data.attributes().as_ref() {
5204        match attr {
5205            // Clamp to the HTML limits (colspan 1000, rowspan 65534): an
5206            // unclamped span grows the column/row vectors unboundedly -> OOM/hang.
5207            azul_core::dom::AttributeType::ColSpan(n) => colspan = (*n).clamp(1, 1000) as usize,
5208            azul_core::dom::AttributeType::RowSpan(n) => rowspan = (*n).clamp(1, 65534) as usize,
5209            _ => {}
5210        }
5211    }
5212    (colspan, rowspan)
5213}
5214
5215// +spec:display-property:7f167c - Table grid cell placement: rows fill table top-to-bottom, cells placed left-to-right with colspan/rowspan
5216/// Analyze a table row to identify cells and update column count
5217fn analyze_table_row<T: ParsedFontTrait>(
5218    tree: &LayoutTree,
5219    row_index: usize,
5220    table_ctx: &mut TableLayoutContext,
5221    ctx: &mut LayoutContext<'_, T>,
5222) -> Result<()> {
5223    // +spec:inline-formatting-context:3f8091 - table visual layout: cells occupy grid cells, row/column spanning
5224    let row_node = tree.get(row_index).ok_or(LayoutError::InvalidTree)?;
5225    let row_num = table_ctx.num_rows;
5226    table_ctx.num_rows += 1;
5227    // Track the layout tree index for this row (for positioning/painting)
5228    if table_ctx.row_node_indices.len() <= row_num {
5229        table_ctx.row_node_indices.resize(row_num + 1, 0);
5230    }
5231    table_ctx.row_node_indices[row_num] = row_index;
5232
5233    // CSS 2.2 Section 17.6: Check if this row has visibility:collapse
5234    if is_visibility_collapsed(ctx, row_node) {
5235        debug_log!(ctx, "Row {} has visibility:collapse", row_num);
5236        table_ctx.collapsed_rows.insert(row_num);
5237    }
5238
5239    let mut col_index = 0;
5240
5241    for &cell_idx in tree.children(row_index) {
5242        if let Some(cell) = tree.get(cell_idx) {
5243            if matches!(cell.formatting_context, FormattingContext::TableCell) {
5244                // Read colspan/rowspan from the cell's HTML attributes (default 1).
5245                let (colspan, rowspan) = cell
5246                    .dom_node_id
5247                    .map_or((1, 1), |dom_id| get_cell_spans(ctx.styled_dom, dom_id));
5248
5249                let cell_info = TableCellInfo {
5250                    node_index: cell_idx,
5251                    column: col_index,
5252                    colspan,
5253                    row: row_num,
5254                    rowspan,
5255                };
5256
5257                table_ctx.cells.push(cell_info);
5258
5259                // Update column count
5260                let max_col = col_index + colspan;
5261                while table_ctx.columns.len() < max_col {
5262                    table_ctx.columns.push(TableColumnInfo {
5263                        min_width: 0.0,
5264                        max_width: 0.0,
5265                        computed_width: None,
5266                    });
5267                }
5268
5269                col_index += colspan;
5270            }
5271        }
5272    }
5273
5274    Ok(())
5275}
5276
5277// +spec:overflow:66f584 - Fixed table layout: cells use overflow property to clip overflowing content
5278// +spec:positioning:46070a - Fixed table layout (17.5.2.1) and auto table layout (17.5.2.2) column width algorithms
5279// +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)
5280/// Calculate column widths using the fixed table layout algorithm
5281/// // +spec:overflow:de613c - Fixed table layout algorithm (CSS 2.2 Section 17.5.2.1)
5282// +spec:table-layout:8b72b3 - fixed table layout: column width from column elements/first-row cells, remaining columns equal division
5283///
5284/// CSS 2.2 Section 17.5.2.1: In fixed table layout, the horizontal layout
5285/// does not depend on cell contents. Column widths are determined by:
5286/// 1. Column elements with explicit (non-auto) width
5287/// 2. First-row cells with explicit (non-auto) width
5288/// 3. Remaining columns equally divide remaining horizontal space
5289///
5290/// CSS 2.2 Section 17.6: Columns with visibility:collapse are excluded
5291/// from width calculations
5292// +spec:table-layout:c5e446 - Fixed table layout algorithm: column widths from col elements or first-row cells, remaining columns divide equally
5293/// +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)
5294#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5295#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5296fn calculate_column_widths_fixed<T: ParsedFontTrait>(
5297    ctx: &mut LayoutContext<'_, T>,
5298    tree: &LayoutTree,
5299    table_ctx: &mut TableLayoutContext,
5300    available_width: f32,
5301) {
5302    debug_table_layout!(
5303        ctx,
5304        "calculate_column_widths_fixed: num_cols={}, available_width={:.2}",
5305        table_ctx.columns.len(),
5306        available_width
5307    );
5308
5309    let num_cols = table_ctx.columns.len();
5310    if num_cols == 0 {
5311        return;
5312    }
5313
5314    let num_visible_cols = num_cols - table_ctx.collapsed_columns.len();
5315    if num_visible_cols == 0 {
5316        for col in &mut table_ctx.columns {
5317            col.computed_width = Some(0.0);
5318        }
5319        return;
5320    }
5321
5322    // Step 1 (column elements) is skipped because column elements don't store
5323    // explicit widths in the current table structure analysis.
5324    // Step 2: Check first-row cells for explicit width properties.
5325    let mut col_has_width = vec![false; num_cols];
5326
5327    for cell_info in &table_ctx.cells {
5328        if cell_info.row != 0 {
5329            continue; // Only consider cells in the first row
5330        }
5331        if table_ctx.collapsed_columns.contains(&cell_info.column) {
5332            continue;
5333        }
5334
5335        // Look up the cell's CSS width via its dom_node_id
5336        let Some(dom_id) = tree.get(cell_info.node_index).and_then(|n| n.dom_node_id) else {
5337            continue;
5338        };
5339
5340        let node_state = &ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
5341        let css_width = get_css_width(ctx.styled_dom, dom_id, node_state);
5342
5343        let explicit_px = match css_width.unwrap_or_default() {
5344            LayoutWidth::Px(px) => {
5345                resolve_size_metric(
5346                    px.metric,
5347                    px.number.get(),
5348                    available_width,
5349                    ctx.viewport_size,
5350                    get_element_font_size(ctx.styled_dom, dom_id, node_state),
5351                    get_root_font_size(ctx.styled_dom, node_state),
5352                )
5353            }
5354            LayoutWidth::Auto | LayoutWidth::MinContent | LayoutWidth::MaxContent
5355            | LayoutWidth::Calc(_) | LayoutWidth::FitContent(_) => continue,
5356        };
5357
5358        if cell_info.colspan == 1 {
5359            table_ctx.columns[cell_info.column].computed_width = Some(explicit_px);
5360            col_has_width[cell_info.column] = true;
5361        } else {
5362            let mut visible_span_count = 0;
5363            for offset in 0..cell_info.colspan {
5364                let col_idx = cell_info.column + offset;
5365                if col_idx < num_cols && !table_ctx.collapsed_columns.contains(&col_idx) {
5366                    visible_span_count += 1;
5367                }
5368            }
5369            if visible_span_count > 0 {
5370                let per_col = explicit_px / visible_span_count as f32;
5371                for offset in 0..cell_info.colspan {
5372                    let col_idx = cell_info.column + offset;
5373                    if col_idx < num_cols
5374                        && !table_ctx.collapsed_columns.contains(&col_idx)
5375                        && !col_has_width[col_idx]
5376                    {
5377                        table_ctx.columns[col_idx].computed_width = Some(per_col);
5378                        col_has_width[col_idx] = true;
5379                    }
5380                }
5381            }
5382        }
5383    }
5384
5385    let used_width: f32 = table_ctx.columns.iter().enumerate()
5386        .filter(|(idx, _)| col_has_width[*idx] && !table_ctx.collapsed_columns.contains(idx))
5387        .filter_map(|(_, c)| c.computed_width)
5388        .sum();
5389    let remaining_width = (available_width - used_width).max(0.0);
5390    let num_remaining = table_ctx.columns.iter().enumerate()
5391        .filter(|(idx, _)| !col_has_width[*idx] && !table_ctx.collapsed_columns.contains(idx))
5392        .count();
5393
5394    if num_remaining > 0 {
5395        let width_per_remaining = remaining_width / num_remaining as f32;
5396        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5397            if table_ctx.collapsed_columns.contains(&col_idx) {
5398                col.computed_width = Some(0.0);
5399            } else if !col_has_width[col_idx] {
5400                col.computed_width = Some(width_per_remaining);
5401            }
5402        }
5403    }
5404
5405    // Set collapsed columns to zero width
5406    for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5407        if table_ctx.collapsed_columns.contains(&col_idx) {
5408            col.computed_width = Some(0.0);
5409        }
5410    }
5411
5412    let total_col_width: f32 = table_ctx.columns.iter()
5413        .filter_map(|c| c.computed_width)
5414        .sum();
5415    if available_width > total_col_width && num_visible_cols > 0 {
5416        let extra = available_width - total_col_width;
5417        let extra_per_col = extra / num_visible_cols as f32;
5418        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5419            if !table_ctx.collapsed_columns.contains(&col_idx) {
5420                if let Some(ref mut w) = col.computed_width {
5421                    *w += extra_per_col;
5422                }
5423            }
5424        }
5425    }
5426}
5427
5428/// Recursively clear the layout cache for every node in a subtree.
5429///
5430/// A fixed-depth walk is not enough: a table cell like
5431/// `<td><span><a>text</a></span></td>` has 4+ levels once the anonymous IFC
5432/// wrapper is inserted, and any stale cache below that level would feed a
5433/// narrow intrinsic width back into `measure_cell_content_width`.
5434fn clear_subtree_cache(
5435    tree: &LayoutTree,
5436    cache_map: &mut crate::solver3::cache::LayoutCacheMap,
5437    root: usize,
5438) {
5439    if root < cache_map.entries.len() {
5440        cache_map.entries[root].clear();
5441    }
5442    let child_ids: Vec<usize> = tree.children(root).to_vec();
5443    for child in child_ids {
5444        clear_subtree_cache(tree, cache_map, child);
5445    }
5446}
5447
5448/// Measure a cell's content width for a given intrinsic sizing mode.
5449///
5450/// CSS 2.2 Section 17.5.2.2: shared helper for min-content and max-content
5451/// width measurement. Lays out the cell subtree in `ComputeSize` mode and
5452/// returns the border-box width (content + padding + border).
5453fn measure_cell_content_width<T: ParsedFontTrait>(
5454    ctx: &mut LayoutContext<'_, T>,
5455    tree: &mut LayoutTree,
5456    text_cache: &mut TextLayoutCache,
5457    cell_index: usize,
5458    constraints: &LayoutConstraints<'_>,
5459    sizing_mode: text3::cache::AvailableSpace,
5460) -> Result<f32> {
5461    let width_type = match sizing_mode {
5462        text3::cache::AvailableSpace::MinContent => Text3AvailableSpace::MinContent,
5463        text3::cache::AvailableSpace::MaxContent => Text3AvailableSpace::MaxContent,
5464        text3::cache::AvailableSpace::Definite(w) => Text3AvailableSpace::Definite(w),
5465    };
5466    let cell_constraints = LayoutConstraints {
5467        available_size: LogicalSize {
5468            width: sizing_mode.to_f32_for_layout(),
5469            height: f32::INFINITY,
5470        },
5471        writing_mode: constraints.writing_mode,
5472        writing_mode_ctx: constraints.writing_mode_ctx,
5473        bfc_state: None,
5474        text_align: constraints.text_align,
5475        containing_block_size: constraints.containing_block_size,
5476        available_width_type: width_type,
5477    };
5478
5479    let mut temp_positions: super::PositionVec = Vec::new();
5480    let mut temp_scrollbar_reflow = false;
5481    let mut temp_float_cache = HashMap::new();
5482
5483    // Clear cached layout for this cell and ALL its descendants so that
5484    // min/max-content measurement uses unconstrained width, not a stale
5485    // result from a previous pass with narrower constraints. Deeply nested
5486    // inlines (`<td><span><a>text</a></span></td>`) need recursion; a fixed
5487    // 2-level walk left the `<a>` at level 3 with a stale cached 0-width.
5488    clear_subtree_cache(tree, &mut ctx.cache_map, cell_index);
5489
5490    crate::solver3::cache::calculate_layout_for_subtree(
5491        ctx,
5492        tree,
5493        text_cache,
5494        cell_index,
5495        LogicalPosition::zero(),
5496        cell_constraints.available_size,
5497        &mut temp_positions,
5498        &mut temp_scrollbar_reflow,
5499        &mut temp_float_cache,
5500        crate::solver3::cache::ComputeMode::ComputeSize,
5501    )?;
5502
5503    let cell_bp = tree.get(cell_index)
5504        .ok_or(LayoutError::InvalidTree)?
5505        .box_props.unpack();
5506    let padding = &cell_bp.padding;
5507    let border = &cell_bp.border;
5508    let wm = constraints.writing_mode;
5509
5510    // For min/max-content measurement, use the overflow content size (actual
5511    // content width) rather than used_size. used_size for auto-width blocks
5512    // fills the containing block, which is huge (f32::MAX/2) during
5513    // intrinsic sizing — that would make every column appear infinitely wide.
5514    let content_width = tree.warm(cell_index)
5515        .and_then(|w| w.overflow_content_size)
5516        .map_or_else(|| {
5517            tree.get(cell_index)
5518                .and_then(|n| n.used_size)
5519                .map_or(0.0, |s| s.width)
5520        }, |s| s.width);
5521
5522    Ok(content_width
5523        + padding.cross_start(wm) + padding.cross_end(wm)
5524        + border.cross_start(wm) + border.cross_end(wm))
5525}
5526
5527/// Measure a cell's minimum content width (with maximum wrapping)
5528fn measure_cell_min_content_width<T: ParsedFontTrait>(
5529    ctx: &mut LayoutContext<'_, T>,
5530    tree: &mut LayoutTree,
5531    text_cache: &mut TextLayoutCache,
5532    cell_index: usize,
5533    constraints: &LayoutConstraints<'_>,
5534) -> Result<f32> {
5535    measure_cell_content_width(
5536        ctx, tree, text_cache, cell_index, constraints,
5537        text3::cache::AvailableSpace::MinContent,
5538    )
5539}
5540
5541/// Measure a cell's maximum content width (without wrapping)
5542fn measure_cell_max_content_width<T: ParsedFontTrait>(
5543    ctx: &mut LayoutContext<'_, T>,
5544    tree: &mut LayoutTree,
5545    text_cache: &mut TextLayoutCache,
5546    cell_index: usize,
5547    constraints: &LayoutConstraints<'_>,
5548) -> Result<f32> {
5549    measure_cell_content_width(
5550        ctx, tree, text_cache, cell_index, constraints,
5551        text3::cache::AvailableSpace::MaxContent,
5552    )
5553}
5554
5555/// Calculate column widths using the auto table layout algorithm
5556fn calculate_column_widths_auto<T: ParsedFontTrait>(
5557    table_ctx: &mut TableLayoutContext,
5558    tree: &mut LayoutTree,
5559    text_cache: &mut TextLayoutCache,
5560    ctx: &mut LayoutContext<'_, T>,
5561    constraints: &LayoutConstraints<'_>,
5562) -> Result<()> {
5563    calculate_column_widths_auto_with_width(
5564        table_ctx,
5565        tree,
5566        text_cache,
5567        ctx,
5568        constraints,
5569        constraints.available_size.width,
5570    )
5571}
5572
5573/// Calculate column widths using the auto table layout algorithm with explicit table width
5574// +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
5575/// +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
5576// +spec:table-layout:23a215 - automatic table layout: MCW/max cell widths, column min/max, colspan distribution, table width from MAX/MIN/CAPMIN
5577// +spec:table-layout:5e1145 - Automatic table layout: MCW/max-content per cell, column min/max, colspan distribution, final width from MIN/MAX
5578// +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
5579/// +spec:width-calculation:335ef1 - Automatic table layout: width given by column widths and borders (CSS 2.2 §17.5.2.2)
5580#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
5581#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5582#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5583fn calculate_column_widths_auto_with_width<T: ParsedFontTrait>(
5584    table_ctx: &mut TableLayoutContext,
5585    tree: &mut LayoutTree,
5586    text_cache: &mut TextLayoutCache,
5587    ctx: &mut LayoutContext<'_, T>,
5588    constraints: &LayoutConstraints<'_>,
5589    table_width: f32,
5590) -> Result<()> {
5591    // Auto layout: calculate min/max content width for each cell
5592    let num_cols = table_ctx.columns.len();
5593    if num_cols == 0 {
5594        return Ok(());
5595    }
5596
5597    // Step 1: Measure all cells to determine column min/max widths
5598    // CSS 2.2 Section 17.6: Skip cells in collapsed columns
5599    for cell_info in &table_ctx.cells {
5600        // Skip cells in collapsed columns
5601        if table_ctx.collapsed_columns.contains(&cell_info.column) {
5602            continue;
5603        }
5604
5605        // Skip cells that span into collapsed columns
5606        let mut spans_collapsed = false;
5607        for col_offset in 0..cell_info.colspan {
5608            if table_ctx
5609                .collapsed_columns
5610                .contains(&(cell_info.column + col_offset))
5611            {
5612                spans_collapsed = true;
5613                break;
5614            }
5615        }
5616        if spans_collapsed {
5617            continue;
5618        }
5619
5620        let min_width = measure_cell_min_content_width(
5621            ctx,
5622            tree,
5623            text_cache,
5624            cell_info.node_index,
5625            constraints,
5626        )?;
5627
5628        let max_width = measure_cell_max_content_width(
5629            ctx,
5630            tree,
5631            text_cache,
5632            cell_info.node_index,
5633            constraints,
5634        )?;
5635
5636        // Handle single-column cells
5637        if cell_info.colspan == 1 {
5638            let col = &mut table_ctx.columns[cell_info.column];
5639            col.min_width = col.min_width.max(min_width);
5640            col.max_width = col.max_width.max(max_width);
5641        } else {
5642            // Handle multi-column cells (colspan > 1)
5643            // Distribute the cell's min/max width across the spanned columns
5644            distribute_cell_width_across_columns(
5645                &mut table_ctx.columns,
5646                cell_info.column,
5647                cell_info.colspan,
5648                min_width,
5649                max_width,
5650                &table_ctx.collapsed_columns,
5651            );
5652        }
5653    }
5654
5655    // Step 2: Calculate final column widths based on available space
5656    // Exclude collapsed columns from total width calculations
5657    let total_min_width: f32 = table_ctx
5658        .columns
5659        .iter()
5660        .enumerate()
5661        .filter(|(idx, _)| !table_ctx.collapsed_columns.contains(idx))
5662        .map(|(_, c)| c.min_width)
5663        .sum();
5664    let total_max_width: f32 = table_ctx
5665        .columns
5666        .iter()
5667        .enumerate()
5668        .filter(|(idx, _)| !table_ctx.collapsed_columns.contains(idx))
5669        .map(|(_, c)| c.max_width)
5670        .sum();
5671    let available_width = table_width; // Use table's content-box width, not constraints
5672
5673    debug_table_layout!(
5674        ctx,
5675        "calculate_column_widths_auto: min={:.2}, max={:.2}, table_width={:.2}",
5676        total_min_width,
5677        total_max_width,
5678        table_width
5679    );
5680
5681    // Handle infinity and NaN cases
5682    if !total_max_width.is_finite() || !available_width.is_finite() {
5683        // If max_width is infinite or unavailable, distribute available width equally
5684        let num_non_collapsed = table_ctx.columns.len() - table_ctx.collapsed_columns.len();
5685        let width_per_column = if num_non_collapsed > 0 {
5686            available_width / num_non_collapsed as f32
5687        } else {
5688            0.0
5689        };
5690
5691        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5692            if table_ctx.collapsed_columns.contains(&col_idx) {
5693                col.computed_width = Some(0.0);
5694            } else {
5695                // Use the larger of min_width and equal distribution
5696                col.computed_width = Some(col.min_width.max(width_per_column));
5697            }
5698        }
5699    } else if available_width >= total_max_width {
5700        // Case 1: More space than max-content - distribute excess proportionally
5701        //
5702        // CSS 2.1 Section 17.5.2.2: Distribute extra space proportionally to
5703        // max-content widths
5704        let excess_width = available_width - total_max_width;
5705
5706        // First pass: collect column info (max_width) to avoid borrowing issues
5707        let column_info: Vec<(usize, f32, bool)> = table_ctx
5708            .columns
5709            .iter()
5710            .enumerate()
5711            .map(|(idx, c)| (idx, c.max_width, table_ctx.collapsed_columns.contains(&idx)))
5712            .collect();
5713
5714        // Calculate total weight for proportional distribution (use max_width as weight)
5715        let total_weight: f32 = column_info.iter()
5716            .filter(|(_, _, is_collapsed)| !is_collapsed)
5717            .map(|(_, max_w, _)| max_w.max(1.0)) // Avoid division by zero
5718            .sum();
5719
5720        let num_non_collapsed = column_info
5721            .iter()
5722            .filter(|(_, _, is_collapsed)| !is_collapsed)
5723            .count();
5724
5725        // Second pass: set computed widths
5726        for (col_idx, max_width, is_collapsed) in column_info {
5727            let col = &mut table_ctx.columns[col_idx];
5728            if is_collapsed {
5729                col.computed_width = Some(0.0);
5730            } else {
5731                // Start with max-content width, then add proportional share of excess
5732                let weight_factor = if total_weight > 0.0 {
5733                    max_width.max(1.0) / total_weight
5734                } else {
5735                    // If all columns have 0 max_width, distribute equally
5736                    1.0 / num_non_collapsed.max(1) as f32
5737                };
5738
5739                let final_width = max_width + (excess_width * weight_factor);
5740                col.computed_width = Some(final_width);
5741            }
5742        }
5743    } else if available_width >= total_min_width {
5744        // Case 2: Between min and max - interpolate proportionally
5745        // Avoid division by zero if min == max
5746        let scale = if total_max_width > total_min_width {
5747            (available_width - total_min_width) / (total_max_width - total_min_width)
5748        } else {
5749            0.0 // If min == max, just use min width
5750        };
5751        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5752            if table_ctx.collapsed_columns.contains(&col_idx) {
5753                col.computed_width = Some(0.0);
5754            } else {
5755                let interpolated = col.min_width + (col.max_width - col.min_width) * scale;
5756                col.computed_width = Some(interpolated);
5757            }
5758        }
5759    } else {
5760        // Case 3: Not enough space - columns must not shrink below their
5761        // min-content width (CSS 2.1 §17.5.2). Floor each column at min_width;
5762        // the table overflows its containing block instead of squeezing content.
5763        for (col_idx, col) in table_ctx.columns.iter_mut().enumerate() {
5764            if table_ctx.collapsed_columns.contains(&col_idx) {
5765                col.computed_width = Some(0.0);
5766            } else {
5767                col.computed_width = Some(col.min_width);
5768            }
5769        }
5770    }
5771
5772    Ok(())
5773}
5774
5775/// Distribute a multi-column cell's width across the columns it spans
5776#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
5777fn distribute_cell_width_across_columns(
5778    columns: &mut [TableColumnInfo],
5779    start_col: usize,
5780    colspan: usize,
5781    cell_min_width: f32,
5782    cell_max_width: f32,
5783    collapsed_columns: &std::collections::HashSet<usize>,
5784) {
5785    let end_col = start_col + colspan;
5786    if end_col > columns.len() {
5787        return;
5788    }
5789
5790    // Calculate current total of spanned non-collapsed columns
5791    let current_min_total: f32 = columns[start_col..end_col]
5792        .iter()
5793        .enumerate()
5794        .filter(|(idx, _)| !collapsed_columns.contains(&(start_col + idx)))
5795        .map(|(_, c)| c.min_width)
5796        .sum();
5797    let current_max_total: f32 = columns[start_col..end_col]
5798        .iter()
5799        .enumerate()
5800        .filter(|(idx, _)| !collapsed_columns.contains(&(start_col + idx)))
5801        .map(|(_, c)| c.max_width)
5802        .sum();
5803
5804    // Count non-collapsed columns in the span
5805    let num_visible_cols = (start_col..end_col)
5806        .filter(|idx| !collapsed_columns.contains(idx))
5807        .count();
5808
5809    if num_visible_cols == 0 {
5810        return; // All spanned columns are collapsed
5811    }
5812
5813    // Only distribute if the cell needs more space than currently available
5814    if cell_min_width > current_min_total {
5815        let extra_min = cell_min_width - current_min_total;
5816        let per_col = extra_min / num_visible_cols as f32;
5817        for (idx, col) in columns[start_col..end_col].iter_mut().enumerate() {
5818            if !collapsed_columns.contains(&(start_col + idx)) {
5819                col.min_width += per_col;
5820            }
5821        }
5822    }
5823
5824    if cell_max_width > current_max_total {
5825        let extra_max = cell_max_width - current_max_total;
5826        let per_col = extra_max / num_visible_cols as f32;
5827        for (idx, col) in columns[start_col..end_col].iter_mut().enumerate() {
5828            if !collapsed_columns.contains(&(start_col + idx)) {
5829                col.max_width += per_col;
5830            }
5831        }
5832    }
5833}
5834
5835/// Layout a cell with its computed column width to determine its content height
5836#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5837fn layout_cell_for_height<T: ParsedFontTrait>(
5838    ctx: &mut LayoutContext<'_, T>,
5839    tree: &mut LayoutTree,
5840    text_cache: &mut TextLayoutCache,
5841    cell_index: usize,
5842    cell_width: f32,
5843    constraints: &LayoutConstraints<'_>,
5844) -> Result<f32> {
5845    let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
5846    let cell_dom_id = cell_node.dom_node_id.ok_or(LayoutError::InvalidTree)?;
5847
5848    // Check if cell has text content directly in DOM (not in LayoutTree)
5849    // Text nodes are intentionally not included in LayoutTree per CSS spec,
5850    // but we need to measure them for table cell height calculation.
5851    let has_text_children = cell_dom_id
5852        .az_children(&ctx.styled_dom.node_hierarchy.as_container())
5853        .any(|child_id| {
5854            let node_data = &ctx.styled_dom.node_data.as_container()[child_id];
5855            matches!(node_data.get_node_type(), NodeType::Text(_))
5856        });
5857
5858    debug_table_layout!(
5859        ctx,
5860        "layout_cell_for_height: cell_index={}, has_text_children={}",
5861        cell_index,
5862        has_text_children
5863    );
5864
5865    // Get padding and border to calculate content width
5866    let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
5867    let cell_bp = cell_node.box_props.unpack();
5868    let padding = &cell_bp.padding;
5869    let border = &cell_bp.border;
5870    let writing_mode = constraints.writing_mode;
5871
5872    // cell_width is the border-box width (includes padding/border from column
5873    // width calculation) but layout functions need content-box width
5874    let content_width = cell_width
5875        - padding.cross_start(writing_mode)
5876        - padding.cross_end(writing_mode)
5877        - border.cross_start(writing_mode)
5878        - border.cross_end(writing_mode);
5879
5880    debug_table_layout!(
5881        ctx,
5882        "Cell width: border_box={:.2}, content_box={:.2}",
5883        cell_width,
5884        content_width
5885    );
5886
5887    let content_height = if has_text_children {
5888        // Cell contains text - use IFC to measure it
5889        debug_table_layout!(ctx, "Using IFC to measure text content");
5890
5891        let cell_constraints = LayoutConstraints {
5892            available_size: LogicalSize {
5893                width: content_width, // Use content width, not border-box width
5894                height: f32::INFINITY,
5895            },
5896            writing_mode: constraints.writing_mode,
5897            writing_mode_ctx: constraints.writing_mode_ctx,
5898            bfc_state: None,
5899            text_align: constraints.text_align,
5900            containing_block_size: constraints.containing_block_size,
5901            // Use definite width for final cell layout!
5902            // This replaces any previous MinContent/MaxContent measurement.
5903            available_width_type: Text3AvailableSpace::Definite(content_width),
5904        };
5905
5906        let output = layout_ifc(ctx, text_cache, tree, cell_index, &cell_constraints)?;
5907
5908        // The cell now owns the authoritative IFC result. Clear any duplicate
5909        // inline_layout_result from text children that was set during the cell's
5910        // prior BFC Pass 1 (which ran before layout_cell_for_height).
5911        let cell_children: Vec<usize> = tree.children(cell_index).to_vec();
5912        for child_idx in cell_children {
5913            if let Some(warm) = tree.warm_mut(child_idx) {
5914                warm.inline_layout_result = None;
5915            }
5916        }
5917
5918        debug_table_layout!(
5919            ctx,
5920            "IFC returned height={:.2}",
5921            output.overflow_size.height
5922        );
5923
5924        output.overflow_size.height
5925    } else {
5926        // Cell contains block-level children or is empty - use regular layout
5927        debug_table_layout!(ctx, "Using regular layout for block children");
5928
5929        let cell_constraints = LayoutConstraints {
5930            available_size: LogicalSize {
5931                width: content_width, // Use content width, not border-box width
5932                height: f32::INFINITY,
5933            },
5934            writing_mode: constraints.writing_mode,
5935            writing_mode_ctx: constraints.writing_mode_ctx,
5936            bfc_state: None,
5937            text_align: constraints.text_align,
5938            containing_block_size: constraints.containing_block_size,
5939            // Use Definite width for final cell layout!
5940            available_width_type: Text3AvailableSpace::Definite(content_width),
5941        };
5942
5943        let mut temp_positions: super::PositionVec = Vec::new();
5944        let mut temp_scrollbar_reflow = false;
5945        let mut temp_float_cache = HashMap::new();
5946
5947        crate::solver3::cache::calculate_layout_for_subtree(
5948            ctx,
5949            tree,
5950            text_cache,
5951            cell_index,
5952            LogicalPosition::zero(),
5953            cell_constraints.available_size,
5954            &mut temp_positions,
5955            &mut temp_scrollbar_reflow,
5956            &mut temp_float_cache,
5957            // PerformLayout: final table cell layout with definite width
5958            crate::solver3::cache::ComputeMode::PerformLayout,
5959        )?;
5960
5961        let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
5962        cell_node.used_size.unwrap_or_default().height
5963    };
5964
5965    // Add padding and border to get the total height
5966    let cell_node = tree.get(cell_index).ok_or(LayoutError::InvalidTree)?;
5967    let cell_bp = cell_node.box_props.unpack();
5968    let padding = &cell_bp.padding;
5969    let border = &cell_bp.border;
5970    let writing_mode = constraints.writing_mode;
5971
5972    let total_height = content_height
5973        + padding.main_start(writing_mode)
5974        + padding.main_end(writing_mode)
5975        + border.main_start(writing_mode)
5976        + border.main_end(writing_mode);
5977
5978    debug_table_layout!(
5979        ctx,
5980        "Cell total height: cell_index={}, content={:.2}, padding/border={:.2}, total={:.2}",
5981        cell_index,
5982        content_height,
5983        padding.main_start(writing_mode)
5984            + padding.main_end(writing_mode)
5985            + border.main_start(writing_mode)
5986            + border.main_end(writing_mode),
5987        total_height
5988    );
5989
5990    Ok(total_height)
5991}
5992
5993// or bottom of content edge if no such line box exists
5994// +spec:box-model:b64fa0 - Cell baseline is first in-flow line box or bottom of content edge
5995// +spec:overflow:3fa86f - Table cell baseline: first in-flow line box or bottom of content edge; scrolling boxes treated as at origin
5996// +spec:inline-formatting-context:c4a20d - cell baseline: first in-flow line box or bottom of content edge
5997// +spec:inline-formatting-context:17a9c1 - vertical-align baseline/top/bottom/middle for table cells
5998fn compute_cell_baseline(cell_index: usize, tree: &LayoutTree) -> f32 {
5999    let Some(cell_node) = tree.get(cell_index) else {
6000        return 0.0;
6001    };
6002
6003    let cell_bp = cell_node.box_props.unpack();
6004
6005    // +spec:inline-formatting-context:27be38 - cell baseline is first in-flow line box or bottom of content edge
6006    // Check if the cell has inline layout (first in-flow line box)
6007    if let Some(warm_node) = tree.warm(cell_index) {
6008        if let Some(ref cached_layout) = warm_node.inline_layout_result {
6009            let inline_result = &cached_layout.layout;
6010            // The baseline is the ascent of the first item from the top of the cell
6011            if let Some(first_item) = inline_result.items.first() {
6012                let (item_ascent, _) = text3::cache::get_item_vertical_metrics_approx(&first_item.item);
6013                let padding_top = cell_bp.padding.top;
6014                let border_top = cell_bp.border.top;
6015                return padding_top + border_top + first_item.position.y + item_ascent;
6016            }
6017        }
6018    }
6019
6020    // Check children for first in-flow line box
6021    let children = tree.children(cell_index);
6022    for &child_idx in children {
6023        if child_idx < tree.nodes.len() {
6024            if let Some(child_warm) = tree.warm(child_idx) {
6025                if child_warm.inline_layout_result.is_some() {
6026                    let child_baseline = compute_cell_baseline(child_idx, tree);
6027                    let padding_top = cell_bp.padding.top;
6028                    let border_top = cell_bp.border.top;
6029                    return padding_top + border_top + child_baseline;
6030                }
6031            }
6032        }
6033    }
6034
6035    // No line box found: baseline is the bottom of the content edge
6036    let used_size = cell_node.used_size.unwrap_or_default();
6037    let padding_bottom = cell_bp.padding.bottom;
6038    let border_bottom = cell_bp.border.bottom;
6039    used_size.height - padding_bottom - border_bottom
6040}
6041
6042/// +spec:box-model:72b495 - Table row height = max of computed height and MIN required by cells; baseline alignment
6043// +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
6044// +spec:positioning:3eaadd - Table height algorithms (§17.5.3): row height = max of cell heights/MIN,
6045//   rowspan distribution, vertical-align in table cells, cell baseline definition
6046/// Calculate row heights based on cell content after column widths are determined
6047// +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)
6048#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
6049#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6050fn calculate_row_heights<T: ParsedFontTrait>(
6051    table_ctx: &mut TableLayoutContext,
6052    tree: &mut LayoutTree,
6053    text_cache: &mut TextLayoutCache,
6054    ctx: &mut LayoutContext<'_, T>,
6055    constraints: &LayoutConstraints<'_>,
6056) -> Result<()> {
6057    debug_table_layout!(
6058        ctx,
6059        "calculate_row_heights: num_rows={}, available_size={:?}",
6060        table_ctx.num_rows,
6061        constraints.available_size
6062    );
6063
6064    // +spec:inline-formatting-context:a7c7a0 - row height = max of computed height, cell heights, and MIN; vertical-align per cell
6065    // Initialize row heights and baselines
6066    table_ctx.row_heights = vec![0.0; table_ctx.num_rows];
6067    table_ctx.row_baselines = vec![0.0; table_ctx.num_rows];
6068
6069    // CSS 2.2 Section 17.6: Set collapsed rows to height 0
6070    for &row_idx in &table_ctx.collapsed_rows {
6071        if row_idx < table_ctx.row_heights.len() {
6072            table_ctx.row_heights[row_idx] = 0.0;
6073        }
6074    }
6075
6076    // required by content; 'height' property can influence row height but does not
6077    // increase cell box height
6078    // First pass: Calculate heights for cells that don't span multiple rows
6079    for cell_info in &table_ctx.cells {
6080        // Skip cells in collapsed rows
6081        if table_ctx.collapsed_rows.contains(&cell_info.row) {
6082            continue;
6083        }
6084
6085        // Get the cell's width (sum of column widths if colspan > 1)
6086        let mut cell_width = 0.0;
6087        for col_idx in cell_info.column..(cell_info.column + cell_info.colspan) {
6088            if let Some(col) = table_ctx.columns.get(col_idx) {
6089                if let Some(width) = col.computed_width {
6090                    cell_width += width;
6091                }
6092            }
6093        }
6094
6095        debug_table_layout!(
6096            ctx,
6097            "Cell layout: node_index={}, row={}, col={}, width={:.2}",
6098            cell_info.node_index,
6099            cell_info.row,
6100            cell_info.column,
6101            cell_width
6102        );
6103
6104        // Layout the cell to get its height
6105        let cell_height = layout_cell_for_height(
6106            ctx,
6107            tree,
6108            text_cache,
6109            cell_info.node_index,
6110            cell_width,
6111            constraints,
6112        )?;
6113
6114        debug_table_layout!(
6115            ctx,
6116            "Cell height calculated: node_index={}, height={:.2}",
6117            cell_info.node_index,
6118            cell_height
6119        );
6120
6121        //   row height = max of all single-span cell heights in the row
6122        if cell_info.rowspan == 1 {
6123            let current_height = table_ctx.row_heights[cell_info.row];
6124            table_ctx.row_heights[cell_info.row] = current_height.max(cell_height);
6125        }
6126
6127        // +spec:box-model:073652 - Table height: baseline-aligned cells establish row baseline, then top/bottom/middle cells positioned
6128        // The baseline of a cell is the baseline of its first line box (from inline layout)
6129        // or the bottom of the content box if no inline content.
6130        if cell_info.rowspan == 1 {
6131            let cell_baseline = compute_cell_baseline(cell_info.node_index, tree);
6132            let current_baseline = table_ctx.row_baselines[cell_info.row];
6133            table_ctx.row_baselines[cell_info.row] = current_baseline.max(cell_baseline);
6134        }
6135    }
6136
6137    // involved must be great enough to encompass the cell spanning the rows
6138    // Second pass: Handle cells that span multiple rows (rowspan > 1)
6139    for cell_info in &table_ctx.cells {
6140        // Skip cells that start in collapsed rows
6141        if table_ctx.collapsed_rows.contains(&cell_info.row) {
6142            continue;
6143        }
6144
6145        if cell_info.rowspan > 1 {
6146            // Get the cell's width
6147            let mut cell_width = 0.0;
6148            for col_idx in cell_info.column..(cell_info.column + cell_info.colspan) {
6149                if let Some(col) = table_ctx.columns.get(col_idx) {
6150                    if let Some(width) = col.computed_width {
6151                        cell_width += width;
6152                    }
6153                }
6154            }
6155
6156            // Layout the cell to get its height
6157            let cell_height = layout_cell_for_height(
6158                ctx,
6159                tree,
6160                text_cache,
6161                cell_info.node_index,
6162                cell_width,
6163                constraints,
6164            )?;
6165
6166            // Calculate the current total height of spanned rows (excluding collapsed rows)
6167            // Clamp to the actual row count: a rowspan extending past the last
6168            // row would slice row_heights out of bounds (panic on e.g. a
6169            // rowspan="2" cell in a single-row table).
6170            let end_row = (cell_info.row + cell_info.rowspan).min(table_ctx.row_heights.len());
6171            let current_total: f32 = table_ctx.row_heights[cell_info.row..end_row]
6172                .iter()
6173                .enumerate()
6174                .filter(|(idx, _)| !table_ctx.collapsed_rows.contains(&(cell_info.row + idx)))
6175                .map(|(_, height)| height)
6176                .sum();
6177
6178            // If the cell needs more height, distribute extra height across
6179            // non-collapsed spanned rows
6180            if cell_height > current_total {
6181                let extra_height = cell_height - current_total;
6182
6183                // Count non-collapsed rows in span
6184                let non_collapsed_rows = (cell_info.row..end_row)
6185                    .filter(|row_idx| !table_ctx.collapsed_rows.contains(row_idx))
6186                    .count();
6187
6188                if non_collapsed_rows > 0 {
6189                    let per_row = extra_height / non_collapsed_rows as f32;
6190
6191                    for row_idx in cell_info.row..end_row {
6192                        if !table_ctx.collapsed_rows.contains(&row_idx) {
6193                            table_ctx.row_heights[row_idx] += per_row;
6194                        }
6195                    }
6196                }
6197            }
6198        }
6199    }
6200
6201    // CSS 2.2 Section 17.6: Final pass - ensure collapsed rows have height 0
6202    for &row_idx in &table_ctx.collapsed_rows {
6203        if row_idx < table_ctx.row_heights.len() {
6204            table_ctx.row_heights[row_idx] = 0.0;
6205        }
6206    }
6207
6208    //   visible content, the row has zero height and v-spacing on only one side
6209    // +spec:table-layout:7370dc - empty-cells:hide in separated borders model
6210    // +spec:box-model:1e9cf1 - empty-cells:hide rows get zero height with v-spacing on only one side
6211    // +spec:overflow:a44925 - CSS 2.2 §17.6.1.1: empty-cells:hide suppresses borders/backgrounds; all-hidden rows get zero height
6212    // +spec:table-layout:dc8bc3 - separated borders model: border-spacing, empty-cells, row zero-height
6213    if table_ctx.border_collapse == StyleBorderCollapse::Separate {
6214        for row_idx in 0..table_ctx.num_rows {
6215            if table_ctx.collapsed_rows.contains(&row_idx) {
6216                continue;
6217            }
6218            // Collect cells in this row
6219            let row_cells: Vec<usize> = table_ctx
6220                .cells
6221                .iter()
6222                .filter(|c| c.row == row_idx && c.rowspan == 1)
6223                .map(|c| c.node_index)
6224                .collect();
6225            if row_cells.is_empty() {
6226                continue;
6227            }
6228            // +spec:box-model:0ab9b0 - empty-cells:hide suppresses borders/backgrounds, row gets zero height if all cells hidden+empty
6229            // Check if ALL cells in this row have empty-cells:hide and are empty
6230            let all_hidden_empty = row_cells.iter().all(|&cell_idx| {
6231                tree.get(cell_idx).is_none_or(|cell_node| {
6232                    let ec = get_empty_cells_property(ctx, cell_node);
6233                    ec == StyleEmptyCells::Hide && is_cell_empty(tree, cell_idx)
6234                })
6235            });
6236            if all_hidden_empty {
6237                table_ctx.row_heights[row_idx] = 0.0;
6238                table_ctx.hidden_empty_rows.insert(row_idx);
6239            }
6240        }
6241    }
6242
6243    Ok(())
6244}
6245
6246/// Position all cells in the table grid with calculated widths and heights
6247#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
6248#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
6249#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6250fn position_table_cells<T: ParsedFontTrait>(
6251    table_ctx: &TableLayoutContext,
6252    tree: &mut LayoutTree,
6253    ctx: &mut LayoutContext<'_, T>,
6254    table_index: usize,
6255    constraints: &LayoutConstraints<'_>,
6256) -> Result<BTreeMap<usize, LogicalPosition>> {
6257    debug_log!(ctx, "Positioning table cells in grid");
6258
6259    let mut positions = BTreeMap::new();
6260
6261    // +spec:box-model:54e86a - Separated borders model: individual cell borders, border-spacing between cells, empty-cells handling
6262    //   rows, columns, row groups, column groups cannot have borders (UA must ignore border props);
6263    //   row/column/rowgroup/colgroup backgrounds are invisible in border-spacing area (table bg shows through);
6264    //   distance from table edge to edge-cell border = table padding + border-spacing
6265    //   (table padding is already accounted for by the containing block; h_spacing is the border-spacing)
6266    // Get border spacing values if border-collapse is separate
6267    let (h_spacing, v_spacing) = if table_ctx.border_collapse == StyleBorderCollapse::Separate {
6268        let styled_dom = ctx.styled_dom;
6269        // Anonymous table wrapper boxes have no dom_node_id; without a styled
6270        // node we cannot resolve font-relative border-spacing units, so fall
6271        // back to zero spacing rather than panicking.
6272        if let Some(table_id) = tree.nodes[table_index].dom_node_id {
6273            let table_state = &styled_dom.styled_nodes.as_container()[table_id].styled_node_state;
6274
6275            let spacing_context = ResolutionContext {
6276                element_font_size: get_element_font_size(styled_dom, table_id, table_state),
6277                parent_font_size: get_parent_font_size(styled_dom, table_id, table_state),
6278                root_font_size: get_root_font_size(styled_dom, table_state),
6279                containing_block_size: PhysicalSize::new(0.0, 0.0),
6280                element_size: None,
6281                viewport_size: PhysicalSize::new(ctx.viewport_size.width, ctx.viewport_size.height),
6282            };
6283
6284            let h = table_ctx
6285                .border_spacing
6286                .horizontal
6287                .resolve_with_context(&spacing_context, PropertyContext::Other)
6288                .max(0.0);
6289
6290            let v = table_ctx
6291                .border_spacing
6292                .vertical
6293                .resolve_with_context(&spacing_context, PropertyContext::Other)
6294                .max(0.0);
6295
6296            (h, v)
6297        } else {
6298            (0.0, 0.0)
6299        }
6300    } else {
6301        (0.0, 0.0)
6302    };
6303
6304    debug_log!(
6305        ctx,
6306        "Border spacing: h={:.2}, v={:.2}",
6307        h_spacing,
6308        v_spacing
6309    );
6310
6311    // Calculate cumulative column positions (x-offsets) with spacing
6312    let mut col_positions = vec![0.0; table_ctx.columns.len()];
6313    let mut x_offset = h_spacing; // Start with spacing on the left
6314    for (i, col) in table_ctx.columns.iter().enumerate() {
6315        col_positions[i] = x_offset;
6316        if let Some(width) = col.computed_width {
6317            // Collapsed columns: gutters on either side collapse (width is 0, skip spacing)
6318            if table_ctx.collapsed_columns.contains(&i) {
6319                // No width, no gutter added
6320            } else {
6321                x_offset += width + h_spacing; // Add spacing between columns
6322            }
6323        }
6324    }
6325
6326    // Calculate cumulative row positions (y-offsets) with spacing
6327    let mut row_positions = vec![0.0; table_ctx.num_rows];
6328    let mut y_offset = v_spacing; // Start with spacing on the top
6329    for (i, &height) in table_ctx.row_heights.iter().enumerate() {
6330        row_positions[i] = y_offset;
6331        // Collapsed rows: gutters on either side collapse (height is 0, skip spacing)
6332        if table_ctx.collapsed_rows.contains(&i) {
6333            // No height, no gutter added
6334        } else if table_ctx.hidden_empty_rows.contains(&i) {
6335            // Hidden-empty row: zero height, only one side of spacing
6336            // (we already added spacing before this row, so skip the spacing after)
6337            y_offset += height; // height is 0.0
6338        } else {
6339            y_offset += height + v_spacing; // Add spacing between rows
6340        }
6341    }
6342
6343    // Store row positions and sizes so paint_element_background can paint row backgrounds.
6344    // Row width = sum of column widths + spacing. Row height from row_heights.
6345    {
6346        let total_col_width: f32 = table_ctx.columns.iter().map(|c| c.computed_width.unwrap_or(0.0)).sum::<f32>()
6347            + h_spacing * (table_ctx.columns.len().max(1) - 1) as f32
6348            + h_spacing * 2.0; // border-spacing on left+right edges
6349        for (i, &row_y) in row_positions.iter().enumerate() {
6350            if let Some(&row_node_idx) = table_ctx.row_node_indices.get(i) {
6351                let row_height = table_ctx.row_heights.get(i).copied().unwrap_or(0.0);
6352                if let Some(row_node) = tree.get_mut(row_node_idx) {
6353                    row_node.used_size = Some(LogicalSize {
6354                        width: total_col_width,
6355                        height: row_height,
6356                    });
6357                }
6358                // Don't add to `positions` map (feeds position_bfc_child_descendants,
6359                // would double-offset cells). The display list computes row paint
6360                // rects from the row's cell children.
6361            }
6362        }
6363    }
6364
6365    // Position each cell
6366    for cell_info in &table_ctx.cells {
6367        let precomputed_cell_baseline = compute_cell_baseline(cell_info.node_index, tree);
6368
6369        let cell_node = tree
6370            .get_mut(cell_info.node_index)
6371            .ok_or(LayoutError::InvalidTree)?;
6372
6373        // Calculate cell position
6374        let x = col_positions.get(cell_info.column).copied().unwrap_or(0.0);
6375        let y = row_positions.get(cell_info.row).copied().unwrap_or(0.0);
6376
6377        // Calculate cell size (sum of spanned columns/rows)
6378        let mut width = 0.0;
6379        debug_info!(
6380            ctx,
6381            "[position_table_cells] Cell {}: calculating width from cols {}..{}",
6382            cell_info.node_index,
6383            cell_info.column,
6384            cell_info.column + cell_info.colspan
6385        );
6386        for col_idx in cell_info.column..(cell_info.column + cell_info.colspan) {
6387            if let Some(col) = table_ctx.columns.get(col_idx) {
6388                debug_info!(
6389                    ctx,
6390                    "[position_table_cells]   Col {}: computed_width={:?}",
6391                    col_idx,
6392                    col.computed_width
6393                );
6394                if let Some(col_width) = col.computed_width {
6395                    width += col_width;
6396                    // Add spacing between spanned columns (but not after the last one)
6397                    if col_idx < cell_info.column + cell_info.colspan - 1 {
6398                        width += h_spacing;
6399                    }
6400                } else {
6401                    debug_info!(
6402                        ctx,
6403                        "[position_table_cells]   WARN:  Col {} has NO computed_width!",
6404                        col_idx
6405                    );
6406                }
6407            } else {
6408                debug_info!(
6409                    ctx,
6410                    "[position_table_cells]   WARN:  Col {} not found in table_ctx.columns!",
6411                    col_idx
6412                );
6413            }
6414        }
6415
6416        let mut height = 0.0;
6417        let end_row = cell_info.row + cell_info.rowspan;
6418        for row_idx in cell_info.row..end_row {
6419            if let Some(&row_height) = table_ctx.row_heights.get(row_idx) {
6420                height += row_height;
6421                // Add spacing between spanned rows (but not after the last one)
6422                if row_idx < end_row - 1 {
6423                    height += v_spacing;
6424                }
6425            }
6426        }
6427
6428        // Update cell's used size and position
6429        let writing_mode = constraints.writing_mode;
6430        // Table layout works in main/cross axes, must convert back to logical width/height
6431
6432        debug_info!(
6433            ctx,
6434            "[position_table_cells] Cell {}: BEFORE from_main_cross: width={}, height={}, \
6435             writing_mode={:?}",
6436            cell_info.node_index,
6437            width,
6438            height,
6439            writing_mode
6440        );
6441
6442        cell_node.used_size = Some(LogicalSize::from_main_cross(height, width, writing_mode));
6443
6444        debug_info!(
6445            ctx,
6446            "[position_table_cells] Cell {}: AFTER from_main_cross: used_size={:?}",
6447            cell_info.node_index,
6448            cell_node.used_size
6449        );
6450
6451        debug_info!(
6452            ctx,
6453            "[position_table_cells] Cell {}: setting used_size to {}x{} (row_heights={:?})",
6454            cell_info.node_index,
6455            width,
6456            height,
6457            table_ctx.row_heights
6458        );
6459
6460        // Save hot fields needed for vertical alignment before dropping the mutable borrow
6461        let cell_dom_node_id = cell_node.dom_node_id;
6462        let cell_box_props = cell_node.box_props.unpack();
6463        drop(cell_node);
6464
6465        // +spec:inline-formatting-context:20e8e8 - table cell vertical-align alignment order (baseline first, then top, then bottom/middle)
6466        // receive extra top or bottom padding; vertical-align determines alignment
6467        // +spec:inline-formatting-context:4545e8 - vertical-align on table cells maps to align-content: top→start, bottom→end, middle→center
6468        // +spec:inline-formatting-context:e216be - vertical-align on table cells (baseline, middle, top, bottom)
6469        // +spec:positioning:156e49 - table cell vertical-align ordering and extra padding per CSS 2.2 §17.5.3
6470        // Apply vertical-align to cell content if it has inline layout
6471        // We need to compute the y_offset using immutable borrows first, then apply it mutably.
6472        let vertical_align_adjustment = if let Some(warm_node) = tree.warm(cell_info.node_index) {
6473            if let Some(ref cached_layout) = warm_node.inline_layout_result {
6474                let inline_result = &cached_layout.layout;
6475
6476                // Get vertical-align property from styled_dom
6477                let vertical_align = if let Some(dom_id) = cell_dom_node_id {
6478                    let node_state = ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
6479
6480                    match get_vertical_align_property(ctx.styled_dom, dom_id, &node_state) {
6481                        MultiValue::Exact(v) => v,
6482                        _ => StyleVerticalAlign::Baseline,
6483                    }
6484                } else {
6485                    StyleVerticalAlign::Baseline
6486                };
6487
6488                // Calculate content height from inline layout bounds
6489                let content_bounds = inline_result.bounds();
6490                let content_height = content_bounds.height;
6491
6492                // Get padding and border to calculate content-box height
6493                // height is border-box, but vertical alignment should be within content-box
6494                let padding = &cell_box_props.padding;
6495                let border = &cell_box_props.border;
6496                let content_box_height = height
6497                    - padding.main_start(writing_mode)
6498                    - padding.main_end(writing_mode)
6499                    - border.main_start(writing_mode)
6500                    - border.main_end(writing_mode);
6501
6502                // top: top of cell box aligned with top of first row it spans
6503                // bottom: bottom of cell box aligned with bottom of last row it spans
6504                // middle: center of cell aligned with center of rows it spans
6505                //   the cell is aligned at the baseline instead
6506                let y_offset = match vertical_align {
6507                    StyleVerticalAlign::Top => 0.0,
6508                    StyleVerticalAlign::Middle => (content_box_height - content_height) * 0.5,
6509                    StyleVerticalAlign::Bottom => content_box_height - content_height,
6510                    // align with the row baseline. cell_baseline = distance from top of cell box
6511                    // to cell's baseline; row_baseline = distance from top of row to row's baseline
6512                    StyleVerticalAlign::Baseline
6513                    | StyleVerticalAlign::Sub
6514                    | StyleVerticalAlign::Superscript
6515                    | StyleVerticalAlign::TextTop
6516                    | StyleVerticalAlign::TextBottom
6517                    | StyleVerticalAlign::Percentage(_)
6518                    | StyleVerticalAlign::Length(_) => {
6519                        let row_baseline = table_ctx.row_baselines.get(cell_info.row).copied().unwrap_or(0.0);
6520                        (row_baseline - precomputed_cell_baseline).max(0.0)
6521                    }
6522                };
6523
6524                debug_info!(
6525                    ctx,
6526                    "[position_table_cells] Cell {}: vertical-align={:?}, border_box_height={}, \
6527                     content_box_height={}, content_height={}, y_offset={}",
6528                    cell_info.node_index,
6529                    vertical_align,
6530                    height,
6531                    content_box_height,
6532                    content_height,
6533                    y_offset
6534                );
6535
6536                if y_offset.abs() > 0.01 {
6537                    Some((y_offset, cached_layout.available_width, cached_layout.has_floats))
6538                } else {
6539                    None
6540                }
6541            } else {
6542                None
6543            }
6544        } else {
6545            None
6546        };
6547
6548        // Apply the vertical alignment adjustment (requires mutable borrow)
6549        if let Some((y_offset, available_width, has_floats)) = vertical_align_adjustment {
6550            if let Some(warm_mut) = tree.warm_mut(cell_info.node_index) {
6551                if let Some(ref cached_layout) = warm_mut.inline_layout_result {
6552                    use std::sync::Arc;
6553                    use crate::text3::cache::{PositionedItem, UnifiedLayout};
6554
6555                    let adjusted_items: Vec<PositionedItem> = cached_layout.layout
6556                        .items
6557                        .iter()
6558                        .map(|item| PositionedItem {
6559                            item: item.item.clone(),
6560                            position: text3::cache::Point {
6561                                x: item.position.x,
6562                                y: item.position.y + y_offset,
6563                            },
6564                            line_index: item.line_index,
6565                        })
6566                        .collect();
6567
6568                    let adjusted_layout = UnifiedLayout {
6569                        items: adjusted_items,
6570                        overflow: cached_layout.layout.overflow.clone(),
6571                    };
6572
6573                    // Keep the same constraint type from the cached layout
6574                    let mut cil = CachedInlineLayout::new(
6575                        Arc::new(adjusted_layout),
6576                        available_width,
6577                        has_floats,
6578                    );
6579                    // LineShift preserves content; carry the hash so Phase 2d
6580                    // can still validly fast-path this layout (#11).
6581                    cil.inline_content_hash = cached_layout.inline_content_hash;
6582                    warm_mut.inline_layout_result = Some(cil);
6583                }
6584            }
6585        }
6586
6587        // Store position relative to table origin
6588        let position = LogicalPosition::from_main_cross(y, x, writing_mode);
6589
6590        // Insert position into map so cache module can position the cell
6591        positions.insert(cell_info.node_index, position);
6592
6593        debug_log!(
6594            ctx,
6595            "Cell at row={}, col={}: pos=({:.2}, {:.2}), size=({:.2}x{:.2})",
6596            cell_info.row,
6597            cell_info.column,
6598            x,
6599            y,
6600            width,
6601            height
6602        );
6603    }
6604
6605    Ok(positions)
6606}
6607
6608/// Gathers all inline content for `text3`, recursively laying out `inline-block` children
6609/// to determine their size and baseline before passing them to the text engine.
6610///
6611/// This function also assigns IFC membership to all participating nodes:
6612/// - The IFC root gets an `ifc_id` assigned
6613/// - Each text/inline child gets `ifc_membership` set with a reference back to the IFC root
6614///
6615/// This mapping enables efficient cursor hit-testing: when a text node is clicked,
6616/// we can find its parent IFC's `inline_layout_result` via `ifc_membership.ifc_root_layout_index`.
6617// +spec:display-property:63a38b - inline box boundaries and out-of-flow elements are ignored for text adjacency (white space, line-breaking, text-transform)
6618fn collect_and_measure_inline_content<T: ParsedFontTrait>(
6619    ctx: &mut LayoutContext<'_, T>,
6620    text_cache: &mut TextLayoutCache,
6621    tree: &mut LayoutTree,
6622    ifc_root_index: usize,
6623    constraints: &LayoutConstraints<'_>,
6624) -> Result<(Vec<InlineContent>, HashMap<ContentIndex, usize>)> {
6625    use crate::solver3::layout_tree::{IfcId, IfcMembership};
6626    use crate::text3::cache::InlineContent;
6627
6628    let mut content = Vec::new();
6629    let mut child_map = HashMap::new();
6630    collect_and_measure_inline_content_impl(
6631        ctx,
6632        text_cache,
6633        tree,
6634        ifc_root_index,
6635        constraints,
6636        &mut content,
6637        &mut child_map,
6638    )?;
6639    Ok((content, child_map))
6640}
6641
6642#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
6643#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6644fn collect_and_measure_inline_content_impl<T: ParsedFontTrait>(
6645    ctx: &mut LayoutContext<'_, T>,
6646    text_cache: &mut TextLayoutCache,
6647    tree: &mut LayoutTree,
6648    ifc_root_index: usize,
6649    constraints: &LayoutConstraints<'_>,
6650    content: &mut Vec<InlineContent>,
6651    child_map: &mut HashMap<ContentIndex, usize>,
6652) -> Result<()> {
6653    use crate::solver3::layout_tree::{IfcId, IfcMembership};
6654
6655    debug_ifc_layout!(
6656        ctx,
6657        "collect_and_measure_inline_content: node_index={}",
6658        ifc_root_index
6659    );
6660
6661    // Generate a unique IFC ID for this inline formatting context
6662    let ifc_id = IfcId::unique();
6663
6664    // Store IFC ID on the IFC root node
6665    if let Some(cold_node) = tree.cold_mut(ifc_root_index) {
6666        cold_node.ifc_id = Some(ifc_id);
6667    }
6668
6669    // [g129/g130 az-web-lift] `content` and `child_map` are now caller-provided out-params
6670    // (the by-value `(Vec, HashMap)` return mis-lifted its len on the web backend). The caller
6671    // passes them in EMPTY; this body fills them exactly as before. `child_map` maps the
6672    // `ContentIndex` used by text3 back to the `LayoutNode` index.
6673    // Track the current run index for IFC membership assignment
6674    let mut current_run_index: u32 = 0;
6675
6676    // [g134/g135 az-web-lift DIAG] out-param pointer + tree validity at _impl entry. The early Err is
6677    // the `tree.get(ifc_root_index).ok_or(InvalidTree)?` at 6449/6706 (no other `?` before the first
6678    // content push) — capture whether `tree` is valid (nodes.len) and tree.get(idx) actually works.
6679    #[cfg(feature = "web_lift")]
6680    unsafe {
6681        crate::az_mark((0x60690) as u32, (content as *const _ as usize as u32) as u32);
6682        crate::az_mark((0x60694) as u32, (ifc_root_index as u32 | 0xC0DE0000u32) as u32);
6683        crate::az_mark((0x606A8) as u32, (tree.nodes.len() as u32) as u32);
6684        crate::az_mark((0x606AC) as u32, (tree.get(ifc_root_index).is_some() as u32 | 0xC0DE0000u32) as u32);
6685        crate::az_mark((0x606B0) as u32, (tree.root as u32) as u32);
6686        // [g147 az-web-lift DIAG] CALLEE-side tree ptr + nodes.len indexed by ifc_root_index
6687        // (0x60940+ = nodes.len, 0x60960+ = tree ptr). Pair with layout_ifc's 0x60900+/0x60920+.
6688        let slot = (ifc_root_index & 7) * 4;
6689        crate::az_mark(((0x60940 + slot)) as u32, (tree.nodes.len() as u32) as u32);
6690        crate::az_mark(((0x60960 + slot)) as u32, ((&*tree as *const LayoutTree as usize) as u32) as u32);
6691    }
6692
6693    let ifc_root_node = tree.get(ifc_root_index).ok_or(LayoutError::InvalidTree)?;
6694    // [g135] reached past the 6449 tree.get.
6695    #[cfg(feature = "web_lift")]
6696    unsafe { crate::az_mark((0x606A4) as u32, (0x0000_6449u32) as u32); }
6697
6698    // Check if this is an anonymous IFC wrapper (has no DOM ID)
6699    let is_anonymous = ifc_root_node.dom_node_id.is_none();
6700
6701    // Get the DOM node ID of the IFC root, or find it from parent/children for anonymous boxes
6702    // CSS 2.2 § 9.2.1.1: Anonymous boxes inherit properties from their enclosing box
6703    let ifc_root_dom_id = if let Some(id) = ifc_root_node.dom_node_id { id } else {
6704        // Anonymous box - get DOM ID from parent or first child with DOM ID
6705        let parent_dom_id = ifc_root_node
6706            .parent
6707            .and_then(|p| tree.get(p))
6708            .and_then(|n| n.dom_node_id);
6709
6710        if let Some(id) = parent_dom_id {
6711            id
6712        } else {
6713            // Try to find DOM ID from first child
6714            if let Some(id) = tree.children(ifc_root_index)
6715                .iter()
6716                .filter_map(|&child_idx| tree.get(child_idx)).find_map(|n| n.dom_node_id) { id } else {
6717                debug_warning!(ctx, "IFC root and all ancestors/children have no DOM ID");
6718                return Ok(());
6719            }
6720        }
6721    };
6722
6723    // Collect children to avoid holding an immutable borrow during iteration
6724    let children: Vec<_> = tree.children(ifc_root_index).to_vec();
6725    drop(ifc_root_node);
6726
6727    debug_ifc_layout!(
6728        ctx,
6729        "Node {} has {} layout children, is_anonymous={}",
6730        ifc_root_index,
6731        children.len(),
6732        is_anonymous
6733    );
6734
6735    // For anonymous IFC wrappers, we collect content from layout tree children
6736    // For regular IFC roots, we also check DOM children for text nodes
6737    if is_anonymous {
6738        // Anonymous IFC wrapper - iterate over layout tree children and collect their content
6739        for (item_idx, &child_index) in children.iter().enumerate() {
6740            let content_index = ContentIndex {
6741                run_index: ifc_root_index as u32,
6742                item_index: item_idx as u32,
6743            };
6744
6745            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
6746            let Some(dom_id) = child_node.dom_node_id else {
6747                debug_warning!(
6748                    ctx,
6749                    "Anonymous IFC child at index {} has no DOM ID",
6750                    child_index
6751                );
6752                continue;
6753            };
6754
6755            let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
6756
6757            // Check if this is a text node
6758            if let NodeType::Text(ref text_content) = node_data.get_node_type() {
6759                debug_info!(
6760                    ctx,
6761                    "[collect_and_measure_inline_content] OK: Found text node (DOM {:?}) in anonymous wrapper: '{}'",
6762                    dom_id,
6763                    text_content.as_str()
6764                );
6765                // Get style from the TEXT NODE itself (dom_id), not the IFC root
6766                // This ensures inline styles like color: #666666 are applied to the text
6767                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)));
6768                let text_items = split_text_for_whitespace(
6769                    ctx.styled_dom,
6770                    dom_id,
6771                    text_content.as_str(),
6772                    &style,
6773                );
6774                content.extend(text_items);
6775                child_map.insert(content_index, child_index);
6776                
6777                // Set IFC membership on the text node - drop child_node borrow first
6778                drop(child_node);
6779                if let Some(warm_mut) = tree.warm_mut(child_index) {
6780                    warm_mut.ifc_membership = Some(IfcMembership {
6781                        ifc_id,
6782                        ifc_root_layout_index: ifc_root_index,
6783                        run_index: current_run_index,
6784                    });
6785                }
6786                current_run_index += 1;
6787
6788                continue;
6789            }
6790
6791            // Non-text inline child - add as shape for inline-block
6792            let display = get_display_property(ctx.styled_dom, Some(dom_id)).unwrap_or_default();
6793
6794            if display == LayoutDisplay::Inline {
6795                // Regular inline element - collect its text children
6796                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));
6797                collect_inline_span_recursive(
6798                    ctx,
6799                    tree,
6800                    dom_id,
6801                    &span_style,
6802                    content,
6803                    &children,
6804                    constraints,
6805                )?;
6806            } else {
6807                // +spec:display-property:a37a9a - atomic inline-level boxes treated as neutral characters in bidi reordering
6808                // This is an atomic inline-level box (e.g., inline-block, image).
6809                // We must determine its size and baseline before passing it to text3.
6810
6811                // The intrinsic sizing pass has already calculated its preferred size.
6812                let intrinsic_size = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
6813                let box_props = child_node.box_props.unpack();
6814
6815                let styled_node_state = ctx
6816                    .styled_dom
6817                    .styled_nodes
6818                    .as_container()
6819                    .get(dom_id)
6820                    .map(|n| n.styled_node_state)
6821                    .unwrap_or_default();
6822
6823                // Calculate tentative border-box size based on CSS properties
6824                let tentative_size = crate::solver3::sizing::calculate_used_size_for_node(
6825                    ctx.styled_dom,
6826                    Some(dom_id),
6827                    &constraints.containing_block_size,
6828                    intrinsic_size,
6829                    &box_props,
6830                    &ctx.viewport_size,
6831                )?;
6832
6833                let writing_mode = get_writing_mode(ctx.styled_dom, dom_id, &styled_node_state)
6834                    .unwrap_or_default();
6835
6836                // Determine content-box size for laying out children
6837                let content_box_size = box_props.inner_size(tentative_size, writing_mode);
6838
6839                // To find its height and baseline, we must lay out its contents.
6840                let child_wm_ctx = super::geometry::WritingModeContext::new(
6841                    writing_mode,
6842                    get_direction_property(ctx.styled_dom, dom_id, &styled_node_state)
6843                        .unwrap_or_default(),
6844                    get_text_orientation_property(ctx.styled_dom, dom_id, &styled_node_state)
6845                        .unwrap_or_default(),
6846                );
6847                let child_constraints = LayoutConstraints {
6848                    available_size: LogicalSize::new(content_box_size.width, f32::INFINITY),
6849                    writing_mode,
6850                    writing_mode_ctx: child_wm_ctx,
6851                    bfc_state: None,
6852                    text_align: TextAlign::Start,
6853                    containing_block_size: constraints.containing_block_size,
6854                    available_width_type: Text3AvailableSpace::Definite(content_box_size.width),
6855                };
6856
6857                // Drop the immutable borrow before calling layout_formatting_context
6858                drop(child_node);
6859
6860                // Recursively lay out the inline-block to get its final height and baseline.
6861                let mut empty_float_cache = HashMap::new();
6862                let layout_result = layout_formatting_context(
6863                    ctx,
6864                    tree,
6865                    text_cache,
6866                    child_index,
6867                    &child_constraints,
6868                    &mut empty_float_cache,
6869                )?;
6870
6871                let css_height = get_css_height(ctx.styled_dom, dom_id, &styled_node_state);
6872
6873                // Replaced elements (image / VirtualView) have no flow content, so the
6874                // measured content_height is 0 — treat their auto height like an
6875                // explicit height (CSS/intrinsic-resolved tentative_size).
6876                let is_replaced_atomic = {
6877                    let nd = &ctx.styled_dom.node_data.as_container()[dom_id];
6878                    matches!(nd.get_node_type(), NodeType::Image(_)) || nd.is_virtual_view_node()
6879                };
6880                // Determine final border-box height
6881                let final_height = match css_height.unwrap_or_default() {
6882                    LayoutHeight::Auto if !is_replaced_atomic => {
6883                        let content_height = layout_result.output.overflow_size.height;
6884                        content_height
6885                            + box_props.padding.main_sum(writing_mode)
6886                            + box_props.border.main_sum(writing_mode)
6887                    }
6888                    _ => tentative_size.height,
6889                };
6890
6891                let final_size = LogicalSize::new(tentative_size.width, final_height);
6892
6893                // Update the node in the tree with its now-known used size.
6894                tree.get_mut(child_index).unwrap().used_size = Some(final_size);
6895
6896                // CSS 2.2 § 10.8.1: inline-block baseline fallback
6897                // If overflow is not 'visible', use bottom margin edge as baseline
6898                let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
6899                let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
6900                let overflow_is_visible = matches!(
6901                    (overflow_x, overflow_y),
6902                    (LayoutOverflow::Visible, LayoutOverflow::Visible)
6903                );
6904                let baseline_offset = if overflow_is_visible {
6905                    layout_result.output.baseline.unwrap_or(final_height)
6906                } else {
6907                    final_height
6908                };
6909
6910                // +spec:box-model:66ad24 - inline-axis margins, borders, padding respected for inline-level boxes (no collapsing)
6911                // The margin-box size is used so text3 positions inline-blocks with proper spacing
6912                let margin = &box_props.margin;
6913                let margin_box_width = final_size.width + margin.left + margin.right;
6914                let margin_box_height = final_size.height + margin.top + margin.bottom;
6915
6916                // For inline-block shapes, text3 uses the content array index as run_index
6917                // and always item_index=0 for objects. We must match this when inserting into child_map.
6918                let shape_content_index = ContentIndex {
6919                    run_index: content.len() as u32,
6920                    item_index: 0,
6921                };
6922                content.push(InlineContent::Shape(InlineShape {
6923                    shape_def: ShapeDefinition::Rectangle {
6924                        size: crate::text3::cache::Size {
6925                            // Use margin-box size for positioning in inline flow
6926                            width: margin_box_width,
6927                            height: margin_box_height,
6928                        },
6929                        corner_radius: None,
6930                    },
6931                    fill: None,
6932                    stroke: None,
6933                    // Adjust baseline offset by top margin
6934                    baseline_offset: baseline_offset + margin.top,
6935                    alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, dom_id),
6936                    source_node_id: Some(dom_id),
6937                }));
6938                child_map.insert(shape_content_index, child_index);
6939            }
6940        }
6941
6942        return Ok(());
6943    }
6944
6945    // Regular (non-anonymous) IFC root - check for list markers and use DOM traversal
6946
6947    // Check if this IFC root OR its parent is a list-item and needs a marker
6948    // Case 1: IFC root itself is list-item (e.g., <li> with display: list-item)
6949    // Case 2: IFC root's parent is list-item (e.g., <li><text>...</text></li>)
6950    let ifc_root_node = tree.get(ifc_root_index).ok_or(LayoutError::InvalidTree)?;
6951    // [g135] reached past the 6706 tree.get.
6952    #[cfg(feature = "web_lift")]
6953    unsafe { crate::az_mark((0x606A4) as u32, (0x0000_6706u32) as u32); }
6954    let mut list_item_dom_id: Option<NodeId> = None;
6955
6956    // Check IFC root itself
6957    if let Some(dom_id) = ifc_root_node.dom_node_id {
6958        use crate::solver3::getters::get_display_property;
6959        if let MultiValue::Exact(display) = get_display_property(ctx.styled_dom, Some(dom_id)) {
6960            use LayoutDisplay;
6961            if display == LayoutDisplay::ListItem {
6962                debug_ifc_layout!(ctx, "IFC root NodeId({:?}) is list-item", dom_id);
6963                list_item_dom_id = Some(dom_id);
6964            }
6965        }
6966    }
6967
6968    // Check IFC root's parent
6969    if list_item_dom_id.is_none() {
6970        if let Some(parent_idx) = ifc_root_node.parent {
6971            if let Some(parent_node) = tree.get(parent_idx) {
6972                if let Some(parent_dom_id) = parent_node.dom_node_id {
6973                    use crate::solver3::getters::get_display_property;
6974                    if let MultiValue::Exact(display) = get_display_property(ctx.styled_dom, Some(parent_dom_id)) {
6975                        use LayoutDisplay;
6976                        if display == LayoutDisplay::ListItem {
6977                            debug_ifc_layout!(
6978                                ctx,
6979                                "IFC root parent NodeId({:?}) is list-item",
6980                                parent_dom_id
6981                            );
6982                            list_item_dom_id = Some(parent_dom_id);
6983                        }
6984                    }
6985                }
6986            }
6987        }
6988    }
6989
6990    // If we found a list-item, generate markers
6991    if let Some(list_dom_id) = list_item_dom_id {
6992        debug_ifc_layout!(
6993            ctx,
6994            "Found list-item (NodeId({:?})), generating marker",
6995            list_dom_id
6996        );
6997
6998        // Find the layout node index for the list-item DOM node
6999        let list_item_layout_idx = tree
7000            .nodes
7001            .iter()
7002            .enumerate()
7003            .find(|(idx, node)| {
7004                node.dom_node_id == Some(list_dom_id) && tree.warm(*idx).and_then(|w| w.pseudo_element).is_none()
7005            })
7006            .map(|(idx, _)| idx);
7007
7008        if let Some(list_idx) = list_item_layout_idx {
7009            // Per CSS spec, the ::marker pseudo-element is the first child of the list-item
7010            // Find the ::marker pseudo-element in the list-item's children
7011            let marker_idx = tree.children(list_idx)
7012                .iter()
7013                .find(|&&child_idx| {
7014                    tree.warm(child_idx)
7015                        .is_some_and(|w| w.pseudo_element == Some(PseudoElement::Marker))
7016                })
7017                .copied();
7018
7019            if let Some(marker_idx) = marker_idx {
7020                debug_ifc_layout!(ctx, "Found ::marker pseudo-element at index {}", marker_idx);
7021
7022                // Get the DOM ID for style resolution (marker references the same DOM node as
7023                // list-item)
7024                let list_dom_id_for_style = tree
7025                    .get(marker_idx)
7026                    .and_then(|n| n.dom_node_id)
7027                    .unwrap_or(list_dom_id);
7028
7029                // Get list-style-position to determine marker positioning
7030                // Default is 'outside' per CSS Lists Module Level 3
7031
7032                let list_style_position =
7033                    get_list_style_position(ctx.styled_dom, Some(list_dom_id));
7034                let position_outside =
7035                    matches!(list_style_position, StyleListStylePosition::Outside);
7036
7037                debug_ifc_layout!(
7038                    ctx,
7039                    "List marker list-style-position: {:?} (outside={})",
7040                    list_style_position,
7041                    position_outside
7042                );
7043
7044                // Generate marker text segments - font fallback happens during shaping
7045                let base_style =
7046                    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)));
7047                let marker_segments = generate_list_marker_segments(
7048                    tree,
7049                    ctx.styled_dom,
7050                    marker_idx, // Pass the marker index, not the list-item index
7051                    ctx.counters,
7052                    base_style,
7053                    ctx.debug_messages,
7054                );
7055
7056                debug_ifc_layout!(
7057                    ctx,
7058                    "Generated {} list marker segments",
7059                    marker_segments.len()
7060                );
7061
7062                // Add markers as InlineContent::Marker with position information
7063                // Outside markers will be positioned in the padding gutter by the layout engine
7064                for segment in marker_segments {
7065                    content.push(InlineContent::Marker {
7066                        run: segment,
7067                        position_outside,
7068                    });
7069                }
7070            } else {
7071                debug_ifc_layout!(
7072                    ctx,
7073                    "WARNING: List-item at index {} has no ::marker pseudo-element",
7074                    list_idx
7075                );
7076            }
7077        }
7078    }
7079
7080    drop(ifc_root_node);
7081
7082    // IMPORTANT: We need to traverse the DOM, not just the layout tree!
7083    //
7084    // According to CSS spec, a block container with inline-level children establishes
7085    // an IFC and should collect ALL inline content, including text nodes.
7086    // Text nodes exist in the DOM but might not have their own layout tree nodes.
7087
7088    // Debug: Check what the node_hierarchy says about this node
7089    let node_hier_item = &ctx.styled_dom.node_hierarchy.as_container()[ifc_root_dom_id];
7090    debug_info!(
7091        ctx,
7092        "[collect_and_measure_inline_content] DEBUG: node_hier_item.first_child={:?}, \
7093         last_child={:?}",
7094        node_hier_item.first_child_id(ifc_root_dom_id),
7095        node_hier_item.last_child_id()
7096    );
7097
7098    let ifc_root_node_data = &ctx.styled_dom.node_data.as_container()[ifc_root_dom_id];
7099
7100    // SPECIAL CASE: If the IFC root itself is a text node (leaf node),
7101    // add its text content directly instead of iterating over children
7102    if let NodeType::Text(ref text_content) = ifc_root_node_data.get_node_type() {
7103        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)));
7104        let text_items = split_text_for_whitespace(
7105            ctx.styled_dom,
7106            ifc_root_dom_id,
7107            text_content.as_str(),
7108            &style,
7109        );
7110        content.extend(text_items);
7111        return Ok(());
7112    }
7113
7114    let _ifc_root_node_type = match ifc_root_node_data.get_node_type() {
7115        NodeType::Div => "Div",
7116        NodeType::Text(_) => "Text",
7117        NodeType::Body => "Body",
7118        _ => "Other",
7119    };
7120
7121    // [g138 az-web-lift] Collect `dom_children` HERE — immediately before the loop, AFTER the
7122    // get_node_type() calls above. Those calls were corrupting the `dom_children` Vec's stack-slot
7123    // header (g137 PROVED: `dom_children.len()` reads 1 right after `.collect()` but 0 in the loop's
7124    // `0..len` range a few calls later — the recurring SP-leak / stack-address mis-lift). With NO call
7125    // between this `.collect()` and the loop, the header survives the range evaluation + first index.
7126    let dom_children: Vec<NodeId> = ifc_root_dom_id
7127        .az_children(&ctx.styled_dom.node_hierarchy.as_container())
7128        .collect();
7129    // [g139 az-web-lift] The loop's `dom_children.len()` read MIS-LIFTS to 0 even though the in-memory
7130    // value is 1 (g138: the volatile marker reads 1 but the loop's `0..len` range reads 0 with NOTHING
7131    // between — the optimizer's SROA'd len read is mis-tracked by the lift; only a FORCED/volatile read is
7132    // correct; same Vec-len mis-lift class as the original sret bug, here on std `collect()` which can't be
7133    // out-param'd). Read len via a volatile round-trip (guaranteed-correct, like the marker) and index via
7134    // get_unchecked (the index's bounds-check len read mis-lifts the same way; len is valid → sound).
7135    // [g195 — collect_and_measure_inline_content_impl is DEAD on the web lift (NOT lifted for hello-world
7136    // OR web-nested-text; both lay out via measure_intrinsic_widths + layout_flow instead). So this g139
7137    // Vec-len workaround never executes on the web lift → it's irrelevant/deletable (kept: harmless, and
7138    // unverified-dead for other layouts). The cron's "collect_and_measure Vec-len" target is a DEAD PATH.]
7139    #[cfg(feature = "web_lift")]
7140    let dom_children_len = unsafe {
7141        crate::az_mark((0x606B4) as u32, (dom_children.len() as u32) as u32);
7142        crate::az_mark((0x606A4) as u32, (0x0000_6863u32) as u32);
7143        crate::az_mark_read(0x606B4) as usize
7144    };
7145    #[cfg(not(feature = "web_lift"))]
7146    let dom_children_len = dom_children.len();
7147
7148    for item_idx in 0..dom_children_len {
7149        let dom_child_id = unsafe { *dom_children.get_unchecked(item_idx) };
7150        let content_index = ContentIndex {
7151            run_index: ifc_root_index as u32,
7152            item_index: item_idx as u32,
7153        };
7154
7155        let node_data = &ctx.styled_dom.node_data.as_container()[dom_child_id];
7156        // [g136] loop body entered; capture the FIRST child's node_type (does it read as Text?).
7157        #[cfg(feature = "web_lift")]
7158        unsafe {
7159            if item_idx == 0 {
7160                crate::az_mark((0x606B8) as u32, (match node_data.get_node_type() {
7161                    NodeType::Text(_) => 0xC0DE_7E70u32,
7162                    NodeType::Div => 0xC0DE_D11Fu32,
7163                    NodeType::Body => 0xC0DE_B0D1u32,
7164                    _ => 0xC0DE_0000u32,
7165                }) as u32);
7166            }
7167            crate::az_mark((0x606A4) as u32, (0x0000_6896u32) as u32);
7168        }
7169
7170        // Check if this is a text node
7171        if let NodeType::Text(ref text_content) = node_data.get_node_type() {
7172            debug_info!(
7173                ctx,
7174                "[collect_and_measure_inline_content] OK: Found text node (DOM child {:?}): '{}'",
7175                dom_child_id,
7176                text_content.as_str()
7177            );
7178
7179            // Get style from the TEXT NODE itself (dom_child_id), not the IFC root
7180            // This ensures inline styles like color: #666666 are applied to the text
7181            // Uses split_text_for_whitespace to correctly handle white-space: pre with \n
7182            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)));
7183            let text_items = split_text_for_whitespace(
7184                ctx.styled_dom,
7185                dom_child_id,
7186                text_content.as_str(),
7187                &style,
7188            );
7189            content.extend(text_items);
7190            // [g136] TEXT branch taken + pushed; content.len now.
7191            #[cfg(feature = "web_lift")]
7192            unsafe {
7193                crate::az_mark((0x606A4) as u32, (0x0000_6905u32) as u32);
7194                crate::az_mark((0x606BC) as u32, (content.len() as u32) as u32);
7195            }
7196
7197            // Set IFC membership on the text node's layout node (if it exists)
7198            // Text nodes may or may not have their own layout tree entry depending on
7199            // whether they're wrapped in an anonymous IFC wrapper
7200            if let Some(&layout_idx) = tree.dom_to_layout.get(&dom_child_id).and_then(|v| v.first()) {
7201                if let Some(warm_mut) = tree.warm_mut(layout_idx) {
7202                    warm_mut.ifc_membership = Some(IfcMembership {
7203                        ifc_id,
7204                        ifc_root_layout_index: ifc_root_index,
7205                        run_index: current_run_index,
7206                    });
7207                }
7208            }
7209            current_run_index += 1;
7210            
7211            continue;
7212        }
7213
7214        // For non-text nodes, find their corresponding layout tree node
7215        let child_index = children
7216            .iter()
7217            .find(|&&idx| {
7218                tree.get(idx)
7219                    .and_then(|n| n.dom_node_id)
7220                    .is_some_and(|id| id == dom_child_id)
7221            })
7222            .copied();
7223
7224        let Some(child_index) = child_index else {
7225            debug_info!(
7226                ctx,
7227                "[collect_and_measure_inline_content] WARN: DOM child {:?} has no layout node",
7228                dom_child_id
7229            );
7230            continue;
7231        };
7232
7233        // [g136] NON-TEXT branch taken (text child mis-classified?) — reached tree.get(child_index).
7234        #[cfg(feature = "web_lift")]
7235        unsafe { crate::az_mark((0x606A4) as u32, (0x0000_6942u32) as u32); }
7236        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
7237        // At this point we have a non-text DOM child with a layout node
7238        let dom_id = child_node.dom_node_id.unwrap();
7239
7240        let display = get_display_property(ctx.styled_dom, Some(dom_id)).unwrap_or_default();
7241        if display != LayoutDisplay::Inline {
7242            // This is an atomic inline-level box (e.g., inline-block, image).
7243            // We must determine its size and baseline before passing it to text3.
7244
7245            // The intrinsic sizing pass has already calculated its preferred size.
7246            let intrinsic_size = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
7247            let box_props = child_node.box_props.unpack();
7248
7249            let styled_node_state = ctx
7250                .styled_dom
7251                .styled_nodes
7252                .as_container()
7253                .get(dom_id)
7254                .map(|n| n.styled_node_state)
7255                .unwrap_or_default();
7256
7257            // Calculate tentative border-box size based on CSS properties
7258            // This correctly handles explicit width/height, box-sizing, and constraints
7259            let tentative_size = crate::solver3::sizing::calculate_used_size_for_node(
7260                ctx.styled_dom,
7261                Some(dom_id),
7262                &constraints.containing_block_size,
7263                intrinsic_size,
7264                &box_props,
7265                &ctx.viewport_size,
7266            )?;
7267
7268            let writing_mode =
7269                get_writing_mode(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7270
7271            // Determine content-box size for laying out children
7272            let content_box_size = box_props.inner_size(tentative_size, writing_mode);
7273
7274            debug_info!(
7275                ctx,
7276                "[collect_and_measure_inline_content] Inline-block NodeId({:?}): \
7277                 tentative_border_box={:?}, content_box={:?}",
7278                dom_id,
7279                tentative_size,
7280                content_box_size
7281            );
7282
7283            // To find its height and baseline, we must lay out its contents.
7284            let child_wm_ctx = super::geometry::WritingModeContext::new(
7285                writing_mode,
7286                get_direction_property(ctx.styled_dom, dom_id, &styled_node_state)
7287                    .unwrap_or_default(),
7288                get_text_orientation_property(ctx.styled_dom, dom_id, &styled_node_state)
7289                    .unwrap_or_default(),
7290            );
7291            let child_constraints = LayoutConstraints {
7292                available_size: LogicalSize::new(content_box_size.width, f32::INFINITY),
7293                writing_mode,
7294                writing_mode_ctx: child_wm_ctx,
7295                // Inline-blocks establish a new BFC, so no state is passed in.
7296                bfc_state: None,
7297                // Does not affect size/baseline of the container.
7298                text_align: TextAlign::Start,
7299                containing_block_size: constraints.containing_block_size,
7300                available_width_type: Text3AvailableSpace::Definite(content_box_size.width),
7301            };
7302
7303            // Drop the immutable borrow before calling layout_formatting_context
7304            drop(child_node);
7305
7306            // Recursively lay out the inline-block to get its final height and baseline.
7307            // Note: This does not affect its final position, only its dimensions.
7308            let mut empty_float_cache = HashMap::new();
7309            let layout_result = layout_formatting_context(
7310                ctx,
7311                tree,
7312                text_cache,
7313                child_index,
7314                &child_constraints,
7315                &mut empty_float_cache,
7316            )?;
7317
7318            let css_height = get_css_height(ctx.styled_dom, dom_id, &styled_node_state);
7319
7320            // Replaced elements (image / VirtualView) have no flow content, so the
7321            // measured content_height is 0 — treat their auto height like an explicit
7322            // height (use the CSS/intrinsic-resolved tentative_size). Fixes 0-height
7323            // images / VirtualViews laid out as atomic inline-blocks.
7324            let is_replaced_atomic = {
7325                let nd = &ctx.styled_dom.node_data.as_container()[dom_id];
7326                matches!(nd.get_node_type(), NodeType::Image(_)) || nd.is_virtual_view_node()
7327            };
7328            // Determine final border-box height
7329            let final_height = match css_height.clone().unwrap_or_default() {
7330                LayoutHeight::Auto if !is_replaced_atomic => {
7331                    // For auto height, add padding and border to the content height
7332                    let content_height = layout_result.output.overflow_size.height;
7333                    content_height
7334                        + box_props.padding.main_sum(writing_mode)
7335                        + box_props.border.main_sum(writing_mode)
7336                }
7337                // Explicit height (calculate_used_size_for_node gave the border-box
7338                // height), OR a replaced element's auto height (intrinsic/CSS-resolved).
7339                _ => tentative_size.height,
7340            };
7341
7342            debug_info!(
7343                ctx,
7344                "[collect_and_measure_inline_content] Inline-block NodeId({:?}): \
7345                 layout_content_height={}, css_height={:?}, final_border_box_height={}",
7346                dom_id,
7347                layout_result.output.overflow_size.height,
7348                css_height,
7349                final_height
7350            );
7351
7352            let final_size = LogicalSize::new(tentative_size.width, final_height);
7353
7354            // Update the node in the tree with its now-known used size.
7355            tree.get_mut(child_index).unwrap().used_size = Some(final_size);
7356
7357            // CSS 2.2 § 10.8.1: For inline-block elements, the baseline is the baseline of the
7358            // last line box in the normal flow, unless it has either no in-flow line boxes or
7359            // if its 'overflow' property has a computed value other than 'visible', in which
7360            // case the baseline is the bottom margin edge.
7361            //
7362            // `layout_result.output.baseline` returns the Y-position of the baseline measured
7363            // from the TOP of the content box. But `get_item_vertical_metrics` expects
7364            // `baseline_offset` to be the distance from the BOTTOM to the baseline.
7365            //
7366            // Conversion: baseline_offset_from_bottom = height - baseline_from_top
7367            //
7368            // If no baseline is found (e.g., the inline-block has no text), or if
7369            // overflow is not 'visible', we fall back to the bottom margin edge
7370            // (baseline_offset = 0, meaning baseline at bottom).
7371            let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7372            let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, &styled_node_state).unwrap_or_default();
7373            let overflow_is_visible = matches!(
7374                (overflow_x, overflow_y),
7375                (LayoutOverflow::Visible, LayoutOverflow::Visible)
7376            );
7377            let baseline_from_top = layout_result.output.baseline;
7378            let baseline_offset = match baseline_from_top {
7379                Some(baseline_y) if overflow_is_visible => {
7380                    // baseline_y is measured from top of content box
7381                    // We need to add padding and border to get the position within the border-box
7382                    let content_box_top = box_props.padding.top + box_props.border.top;
7383                    let baseline_from_border_box_top = baseline_y + content_box_top;
7384                    // Convert to distance from bottom
7385                    (final_height - baseline_from_border_box_top).max(0.0)
7386                }
7387                _ => {
7388                    // No baseline found or overflow != visible - use bottom margin edge
7389                    0.0
7390                }
7391            };
7392            
7393            debug_info!(
7394                ctx,
7395                "[collect_and_measure_inline_content] Inline-block NodeId({:?}): \
7396                 baseline_from_top={:?}, final_height={}, baseline_offset_from_bottom={}",
7397                dom_id,
7398                baseline_from_top,
7399                final_height,
7400                baseline_offset
7401            );
7402
7403            // Get margins for inline-block positioning
7404            // For inline-blocks, we need to include margins in the shape size
7405            // so that text3 positions them correctly with spacing
7406            let margin = &box_props.margin;
7407            let margin_box_width = final_size.width + margin.left + margin.right;
7408            let margin_box_height = final_size.height + margin.top + margin.bottom;
7409
7410            // For inline-block shapes, text3 uses the content array index as run_index
7411            // and always item_index=0 for objects. We must match this when inserting into child_map.
7412            let shape_content_index = ContentIndex {
7413                run_index: content.len() as u32,
7414                item_index: 0,
7415            };
7416            // the box used for alignment is the margin box" - using margin_box_width/height here
7417            content.push(InlineContent::Shape(InlineShape {
7418                shape_def: ShapeDefinition::Rectangle {
7419                    size: crate::text3::cache::Size {
7420                        // Use margin-box size for positioning in inline flow
7421                        width: margin_box_width,
7422                        height: margin_box_height,
7423                    },
7424                    corner_radius: None,
7425                },
7426                fill: None,
7427                stroke: None,
7428                // Adjust baseline offset by top margin
7429                baseline_offset: baseline_offset + margin.top,
7430                alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, dom_id),
7431                source_node_id: Some(dom_id),
7432            }));
7433            child_map.insert(shape_content_index, child_index);
7434        } else if let NodeType::Image(image_ref) =
7435            ctx.styled_dom.node_data.as_container()[dom_id].get_node_type()
7436        {
7437            // +spec:replaced-elements:31a782 - replaced elements (img) not rendered purely by CSS box concepts
7438            // Images are replaced elements - they have intrinsic dimensions
7439            // and CSS width/height can constrain them
7440            
7441            // Re-get child_node since we dropped it earlier for the inline-block case
7442            let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
7443            let box_props = child_node.box_props.unpack();
7444
7445            // Get intrinsic size from the image data or fall back to layout node
7446            let intrinsic_size = tree.warm(child_index)
7447                .and_then(|w| w.intrinsic_sizes)
7448                .unwrap_or_else(|| IntrinsicSizes {
7449                    max_content_width: 50.0,
7450                    max_content_height: 50.0,
7451                    ..Default::default()
7452                });
7453            
7454            // Get styled node state for CSS property lookup
7455            let styled_node_state = ctx
7456                .styled_dom
7457                .styled_nodes
7458                .as_container()
7459                .get(dom_id)
7460                .map(|n| n.styled_node_state)
7461                .unwrap_or_default();
7462            
7463            // Calculate the used size respecting CSS width/height constraints
7464            let tentative_size = crate::solver3::sizing::calculate_used_size_for_node(
7465                ctx.styled_dom,
7466                Some(dom_id),
7467                &constraints.containing_block_size,
7468                intrinsic_size,
7469                &box_props,
7470                &ctx.viewport_size,
7471            )?;
7472            
7473            // Drop immutable borrow before mutable access
7474            drop(child_node);
7475            
7476            // Set the used_size on the layout node so paint_rect works correctly
7477            let final_size = LogicalSize::new(tentative_size.width, tentative_size.height);
7478            tree.get_mut(child_index).unwrap().used_size = Some(final_size);
7479            
7480            // Calculate display size for text3 (this is what text3 uses for positioning)
7481            let display_width = if final_size.width > 0.0 { 
7482                Some(final_size.width) 
7483            } else { 
7484                None 
7485            };
7486            let display_height = if final_size.height > 0.0 { 
7487                Some(final_size.height) 
7488            } else { 
7489                None 
7490            };
7491            
7492            content.push(InlineContent::Image(InlineImage {
7493                source: ImageSource::Ref(image_ref.as_ref().clone()),
7494                intrinsic_size: crate::text3::cache::Size {
7495                    width: intrinsic_size.max_content_width,
7496                    height: intrinsic_size.max_content_height,
7497                },
7498                display_size: if display_width.is_some() || display_height.is_some() {
7499                    Some(crate::text3::cache::Size {
7500                        width: display_width.unwrap_or(intrinsic_size.max_content_width),
7501                        height: display_height.unwrap_or(intrinsic_size.max_content_height),
7502                    })
7503                } else {
7504                    None
7505                },
7506                // Images are bottom-aligned with the baseline by default
7507                baseline_offset: 0.0,
7508                alignment: text3::cache::VerticalAlign::Baseline,
7509                object_fit: ObjectFit::Fill,
7510            }));
7511            // For images, text3 uses the content array index as run_index
7512            // and always item_index=0 for objects. We must match this.
7513            let image_content_index = ContentIndex {
7514                run_index: (content.len() - 1) as u32,  // -1 because we just pushed
7515                item_index: 0,
7516            };
7517            child_map.insert(image_content_index, child_index);
7518        } else {
7519            // This is a regular inline box (display: inline) - e.g., <span>, <em>, <strong>
7520            //
7521            // According to CSS Inline-3 spec §2, inline boxes are "transparent" wrappers
7522            // We must recursively collect their text children with inherited style
7523            debug_info!(
7524                ctx,
7525                "[collect_and_measure_inline_content] Found inline span (DOM {:?}), recursing",
7526                dom_id
7527            );
7528
7529            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));
7530            collect_inline_span_recursive(
7531                ctx,
7532                tree,
7533                dom_id,
7534                &span_style,
7535                content,
7536                &children,
7537                constraints,
7538            )?;
7539        }
7540    }
7541    // [g134 az-web-lift DIAG] _impl reached its FINAL return; content.len as _impl sees it.
7542    #[cfg(feature = "web_lift")]
7543    unsafe {
7544        crate::az_mark((0x60698) as u32, (content.len() as u32) as u32);
7545        crate::az_mark((0x6069C) as u32, (0xC0DE069Cu32) as u32);
7546    }
7547    Ok(())
7548}
7549
7550// +spec:display-property:c05c53 - inlinifying boxes can't contain block-level boxes; children are recursively inlinified
7551// it recursively inlinifies all of its in-flow children, so that no block-level descendants
7552// break up the inline formatting context in which it participates.
7553// +spec:display-property:aee879 - recursively inlinifies in-flow children of inline boxes
7554/// Recursively collects inline content from an inline span (display: inline) element.
7555///
7556/// According to CSS Inline Layout Module Level 3 §2:
7557///
7558/// "Inline boxes are transparent wrappers that wrap their content."
7559///
7560/// They don't create a new formatting context - their children participate in the
7561/// same IFC as the parent. This function processes:
7562///
7563/// - Text nodes: collected with the span's inherited style
7564/// - Nested inline spans: recursively descended
7565/// - Inline-blocks, images: measured and added as shapes
7566#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7567fn collect_inline_span_recursive<T: ParsedFontTrait>(
7568    ctx: &mut LayoutContext<'_, T>,
7569    tree: &mut LayoutTree,
7570    span_dom_id: NodeId,
7571    span_style: &StyleProperties,
7572    content: &mut Vec<InlineContent>,
7573    parent_children: &[usize], // Layout tree children of parent IFC
7574    constraints: &LayoutConstraints<'_>,
7575) -> Result<()> {
7576    debug_info!(
7577        ctx,
7578        "[collect_inline_span_recursive] Processing inline span {:?}",
7579        span_dom_id
7580    );
7581
7582    // Get DOM children of this span
7583    let span_dom_children: Vec<NodeId> = span_dom_id
7584        .az_children(&ctx.styled_dom.node_hierarchy.as_container())
7585        .collect();
7586
7587    debug_info!(
7588        ctx,
7589        "[collect_inline_span_recursive] Span has {} DOM children",
7590        span_dom_children.len()
7591    );
7592
7593    // +spec:box-model:b7428d - empty inline boxes still have margins, padding, borders, line-height
7594    // +spec:box-model:cc79a4 - empty inline elements still have margins, padding, borders and line height
7595    if span_dom_children.is_empty() {
7596        let node_state = &ctx.styled_dom.styled_nodes.as_container()[span_dom_id].styled_node_state;
7597        let font_size = get_element_font_size(ctx.styled_dom, span_dom_id, node_state);
7598
7599        let line_height_value = crate::solver3::getters::get_line_height_value(
7600            ctx.styled_dom, span_dom_id, node_state
7601        );
7602        let line_height = line_height_value
7603            .map_or(text3::cache::LineHeight::Normal, |v| {
7604                // Absolute px line-heights are stored as a negative normalized
7605                // value; a positive value is a unitless multiplier of font-size.
7606                let n = v.inner.normalized();
7607                let px = if n < 0.0 { -n } else { n * font_size };
7608                text3::cache::LineHeight::Px(px)
7609            });
7610
7611        let cb_width = constraints.containing_block_size.main(constraints.writing_mode);
7612        let padding_top = get_css_padding_top(ctx.styled_dom, span_dom_id, node_state)
7613            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7614        let padding_bottom = get_css_padding_bottom(ctx.styled_dom, span_dom_id, node_state)
7615            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7616        let padding_left = crate::solver3::getters::get_css_padding_left(ctx.styled_dom, span_dom_id, node_state)
7617            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7618        let padding_right = crate::solver3::getters::get_css_padding_right(ctx.styled_dom, span_dom_id, node_state)
7619            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7620        let border_top = get_css_border_top_width(ctx.styled_dom, span_dom_id, node_state)
7621            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7622        let border_bottom = get_css_border_bottom_width(ctx.styled_dom, span_dom_id, node_state)
7623            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7624        let border_left = crate::solver3::getters::get_css_border_left_width(ctx.styled_dom, span_dom_id, node_state)
7625            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7626        let border_right = crate::solver3::getters::get_css_border_right_width(ctx.styled_dom, span_dom_id, node_state)
7627            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7628        let margin_left = crate::solver3::getters::get_css_margin_left(ctx.styled_dom, span_dom_id, node_state)
7629            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7630        let margin_right = crate::solver3::getters::get_css_margin_right(ctx.styled_dom, span_dom_id, node_state)
7631            .exact().map_or(0.0, |pv| pv.to_pixels_internal(cb_width, font_size, DEFAULT_FONT_SIZE));
7632
7633        let resolved_line_height = line_height.resolve(font_size, 0.0, 0.0, 0.0, 0);
7634        let total_height = resolved_line_height + padding_top + padding_bottom + border_top + border_bottom;
7635        let total_width = margin_left + padding_left + border_left
7636            + border_right + padding_right + margin_right;
7637
7638        content.push(InlineContent::Shape(InlineShape {
7639            shape_def: ShapeDefinition::Rectangle {
7640                size: crate::text3::cache::Size {
7641                    width: total_width,
7642                    height: total_height,
7643                },
7644                corner_radius: None,
7645            },
7646            fill: None,
7647            stroke: None,
7648            baseline_offset: 0.0,
7649            alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, span_dom_id),
7650            source_node_id: Some(span_dom_id),
7651        }));
7652
7653        return Ok(());
7654    }
7655
7656    for &child_dom_id in &span_dom_children {
7657        let node_data = &ctx.styled_dom.node_data.as_container()[child_dom_id];
7658
7659        // CASE 1: Text node - collect with span's style
7660        if let NodeType::Text(ref text_content) = node_data.get_node_type() {
7661            debug_info!(
7662                ctx,
7663                "[collect_inline_span_recursive] ✓ Found text in span: '{}'",
7664                text_content.as_str()
7665            );
7666            let text_items = split_text_for_whitespace(
7667                ctx.styled_dom,
7668                child_dom_id,
7669                text_content.as_str(),
7670                &Arc::new(span_style.clone()),
7671            );
7672            content.extend(text_items);
7673            continue;
7674        }
7675
7676        // CASE 2: Element node - check its display type
7677        let child_display =
7678            get_display_property(ctx.styled_dom, Some(child_dom_id)).unwrap_or_default();
7679
7680        // Find the corresponding layout tree node
7681        let child_index = parent_children
7682            .iter()
7683            .find(|&&idx| {
7684                tree.get(idx)
7685                    .and_then(|n| n.dom_node_id)
7686                    .is_some_and(|id| id == child_dom_id)
7687            })
7688            .copied();
7689
7690        match child_display {
7691            LayoutDisplay::Inline => {
7692                // Nested inline span - recurse with child's style
7693                debug_info!(
7694                    ctx,
7695                    "[collect_inline_span_recursive] Found nested inline span {:?}",
7696                    child_dom_id
7697                );
7698                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));
7699                collect_inline_span_recursive(
7700                    ctx,
7701                    tree,
7702                    child_dom_id,
7703                    &child_style,
7704                    content,
7705                    parent_children,
7706                    constraints,
7707                )?;
7708            }
7709            LayoutDisplay::InlineBlock => {
7710                // Inline-block inside span - measure and add as shape
7711                let Some(child_index) = child_index else {
7712                    debug_info!(
7713                        ctx,
7714                        "[collect_inline_span_recursive] WARNING: inline-block {:?} has no layout \
7715                         node",
7716                        child_dom_id
7717                    );
7718                    continue;
7719                };
7720
7721                let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
7722                let intrinsic_size = tree.warm(child_index).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
7723                let width = intrinsic_size.max_content_width;
7724
7725                let styled_node_state = ctx
7726                    .styled_dom
7727                    .styled_nodes
7728                    .as_container()
7729                    .get(child_dom_id)
7730                    .map(|n| n.styled_node_state)
7731                    .unwrap_or_default();
7732                let writing_mode =
7733                    get_writing_mode(ctx.styled_dom, child_dom_id, &styled_node_state)
7734                        .unwrap_or_default();
7735                let child_wm_ctx = super::geometry::WritingModeContext::new(
7736                    writing_mode,
7737                    get_direction_property(ctx.styled_dom, child_dom_id, &styled_node_state)
7738                        .unwrap_or_default(),
7739                    get_text_orientation_property(ctx.styled_dom, child_dom_id, &styled_node_state)
7740                        .unwrap_or_default(),
7741                );
7742                let child_constraints = LayoutConstraints {
7743                    available_size: LogicalSize::new(width, f32::INFINITY),
7744                    writing_mode,
7745                    writing_mode_ctx: child_wm_ctx,
7746                    bfc_state: None,
7747                    text_align: TextAlign::Start,
7748                    containing_block_size: constraints.containing_block_size,
7749                    available_width_type: Text3AvailableSpace::Definite(width),
7750                };
7751
7752                drop(child_node);
7753
7754                let mut empty_float_cache = HashMap::new();
7755                let layout_result = layout_formatting_context(
7756                    ctx,
7757                    tree,
7758                    &mut TextLayoutCache::default(),
7759                    child_index,
7760                    &child_constraints,
7761                    &mut empty_float_cache,
7762                )?;
7763                let final_height = layout_result.output.overflow_size.height;
7764                let final_size = LogicalSize::new(width, final_height);
7765
7766                tree.get_mut(child_index).unwrap().used_size = Some(final_size);
7767
7768                // CSS 2.2 § 10.8.1: inline-block baseline fallback
7769                let overflow_x = get_overflow_x(ctx.styled_dom, child_dom_id, &styled_node_state).unwrap_or_default();
7770                let overflow_y = get_overflow_y(ctx.styled_dom, child_dom_id, &styled_node_state).unwrap_or_default();
7771                let overflow_is_visible = matches!(
7772                    (overflow_x, overflow_y),
7773                    (LayoutOverflow::Visible, LayoutOverflow::Visible)
7774                );
7775                let baseline_offset = if overflow_is_visible {
7776                    layout_result.output.baseline.unwrap_or(final_height)
7777                } else {
7778                    final_height
7779                };
7780
7781                content.push(InlineContent::Shape(InlineShape {
7782                    shape_def: ShapeDefinition::Rectangle {
7783                        size: crate::text3::cache::Size {
7784                            width,
7785                            height: final_height,
7786                        },
7787                        corner_radius: None,
7788                    },
7789                    fill: None,
7790                    stroke: None,
7791                    baseline_offset,
7792                    alignment: crate::solver3::getters::get_vertical_align_for_node(ctx.styled_dom, child_dom_id),
7793                    source_node_id: Some(child_dom_id),
7794                }));
7795
7796                // Note: We don't add to child_map here because this is inside a span
7797                debug_info!(
7798                    ctx,
7799                    "[collect_inline_span_recursive] Added inline-block shape {}x{}",
7800                    width,
7801                    final_height
7802                );
7803            }
7804            _ => {
7805                // +spec:display-property:0684c4 - block box inlinified: inner display becomes flow-root (treated as atomic inline)
7806                // in-flow children of an inline box are recursively inlinified so they
7807                // don't break the IFC. Treat them as inline spans and recurse into their
7808                // children to collect text and inline content.
7809                debug_info!(
7810                    ctx,
7811                    "[collect_inline_span_recursive] Inlinifying block-level child {:?} \
7812                     (display: {:?}) inside inline span per css-display-3 §2.7",
7813                    child_dom_id,
7814                    child_display
7815                );
7816                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));
7817                collect_inline_span_recursive(
7818                    ctx,
7819                    tree,
7820                    child_dom_id,
7821                    &child_style,
7822                    content,
7823                    parent_children,
7824                    constraints,
7825                )?;
7826            }
7827        }
7828    }
7829
7830    Ok(())
7831}
7832
7833/// Positions a floated child within the BFC and updates the floating context.
7834/// This function is fully writing-mode aware.
7835fn position_floated_child(
7836    _child_index: usize,
7837    child_margin_box_size: LogicalSize,
7838    float_type: LayoutFloat,
7839    constraints: &LayoutConstraints<'_>,
7840    _bfc_content_box: LogicalRect,
7841    current_main_offset: f32,
7842    floating_context: &mut FloatingContext,
7843) -> Result<LogicalPosition> {
7844    let wm = constraints.writing_mode;
7845    let child_main_size = child_margin_box_size.main(wm);
7846    let child_cross_size = child_margin_box_size.cross(wm);
7847    let bfc_cross_size = constraints.available_size.cross(wm);
7848    let mut placement_main_offset = current_main_offset;
7849
7850    loop {
7851        // 1. Determine the available cross-axis space at the current
7852        // `placement_main_offset`.
7853        let (available_cross_start, available_cross_end) = floating_context
7854            .available_line_box_space(
7855                placement_main_offset,
7856                placement_main_offset + child_main_size,
7857                bfc_cross_size,
7858                wm,
7859            );
7860
7861        let available_cross_width = available_cross_end - available_cross_start;
7862
7863        // 2. Check if the new float can fit in the available space.
7864        if child_cross_size <= available_cross_width {
7865            // It fits! Determine the final position and add it to the context.
7866            // +spec:floats:5cfc93 - float:right positions box at cross-end, content flows on left
7867            let final_cross_pos = match float_type {
7868                LayoutFloat::Left => available_cross_start,
7869                // +spec:floats:5cfc93 - float:right positions box at cross-end, content flows on left
7870                LayoutFloat::Right => available_cross_end - child_cross_size,
7871                LayoutFloat::None => {
7872                    return Err(LayoutError::PositioningFailed);
7873                }
7874            };
7875            let final_pos =
7876                LogicalPosition::from_main_cross(placement_main_offset, final_cross_pos, wm);
7877
7878            let new_float_box = FloatBox {
7879                kind: float_type,
7880                rect: LogicalRect::new(final_pos, child_margin_box_size),
7881                margin: EdgeSizes::default(), // TODO: Pass actual margin if this function is used
7882            };
7883            floating_context.floats.push(new_float_box);
7884            return Ok(final_pos);
7885        }
7886        {
7887            // +spec:floats:3d89d8 - shift float downward when not enough horizontal room
7888            // It doesn't fit. We must move the float down past an obstacle.
7889            // Find the lowest main-axis end of all floats that are blocking
7890            // the current line.
7891            let mut next_main_offset = f32::INFINITY;
7892            for existing_float in &floating_context.floats {
7893                let float_main_start = existing_float.rect.origin.main(wm);
7894                let float_main_end = float_main_start + existing_float.rect.size.main(wm);
7895
7896                // Consider only floats that are above or at the current placement line.
7897                if placement_main_offset < float_main_end {
7898                    next_main_offset = next_main_offset.min(float_main_end);
7899                }
7900            }
7901
7902            if next_main_offset.is_infinite() {
7903                // This indicates an unrecoverable state, e.g., a float wider
7904                // than the container.
7905                return Err(LayoutError::PositioningFailed);
7906            }
7907            placement_main_offset = next_main_offset;
7908        }
7909    }
7910}
7911
7912// CSS Property Getters
7913
7914/// Get the CSS `float` property for a node.
7915fn get_float_property(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> LayoutFloat {
7916    let Some(id) = dom_id else {
7917        return LayoutFloat::None;
7918    };
7919    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
7920    get_float(styled_dom, id, node_state).unwrap_or(LayoutFloat::None)
7921}
7922
7923fn get_clear_property(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> LayoutClear {
7924    let Some(id) = dom_id else {
7925        return LayoutClear::None;
7926    };
7927    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
7928    get_clear(styled_dom, id, node_state).unwrap_or(LayoutClear::None)
7929}
7930/// Helper to determine if scrollbars are needed.
7931///
7932/// # CSS Spec Reference
7933/// CSS Overflow Module Level 3 § 3: Scrollable overflow
7934// +spec:block-formatting-context:50d915 - overflow-x handles horizontal, overflow-y handles vertical
7935// +spec:box-model:63d6f2 - scrollable overflow extends beyond padding edge, needs scroll mechanism
7936// +spec:box-model:45b5fb - scrollbar space subtracted from content area, inserted between inner border edge and outer padding edge
7937// +spec:box-model:70a0a4 - UAs must start assuming no scrollbars needed, recalculate if they are
7938// +spec:box-model:c1b0b2 - scrollbar gutter is space between inner border edge and outer padding edge
7939// +spec:overflow:4f5b99 - scrollable overflow rectangle: content_size is the minimal axis-aligned rect containing scrollable overflow
7940// +spec:overflow:e983f4 - overflow:auto/scroll boxes must allow user to access overflowed content via scrollbars
7941// +spec:overflow:97c257 - relative positioning causing overflow in auto/scroll boxes must trigger scrollbar creation
7942#[must_use] pub fn check_scrollbar_necessity(
7943    content_size: LogicalSize,
7944    container_size: LogicalSize,
7945    overflow_x: OverflowBehavior,
7946    overflow_y: OverflowBehavior,
7947    scrollbar_width_px: f32,
7948) -> ScrollbarRequirements {
7949    // Use epsilon for float comparisons to avoid showing scrollbars due to 
7950    // floating-point rounding errors. Without this, content that exactly fits
7951    // may show scrollbars due to sub-pixel differences (e.g., 299.9999 vs 300.0).
7952    const EPSILON: f32 = 1.0;
7953
7954    // +spec:height-calculation:c5af64 - assume no scrollbars initially; only add if content overflows
7955    // Determine if scrolling is needed based on overflow properties.
7956    // +spec:overflow:30a49c - start assuming no scrollbars, recalculate if needed
7957    // Note: scrollbar_width_px can be 0 for overlay scrollbars (e.g. macOS),
7958    // but we still need to register scroll nodes so that scrolling works —
7959    // overlay scrollbars just don't reserve any layout space.
7960    let mut needs_horizontal = match overflow_x {
7961        OverflowBehavior::Visible | OverflowBehavior::Hidden | OverflowBehavior::Clip => false,
7962        OverflowBehavior::Scroll => true,
7963        OverflowBehavior::Auto => content_size.width > container_size.width + EPSILON,
7964    };
7965
7966    let mut needs_vertical = match overflow_y {
7967        OverflowBehavior::Visible | OverflowBehavior::Hidden | OverflowBehavior::Clip => false,
7968        OverflowBehavior::Scroll => true,
7969        OverflowBehavior::Auto => content_size.height > container_size.height + EPSILON,
7970    };
7971
7972    // +spec:box-model:c3d73f - scrollbar presence affects available content area; padding preserved at scroll end
7973    // +spec:overflow:d79159 - scrollbar sizing: adding a scrollbar reduces available space,
7974    // which may cause content to overflow, confirming the scrollbar is needed (two-pass check)
7975    // A classic layout problem: a vertical scrollbar can reduce horizontal space,
7976    // causing a horizontal scrollbar to appear, which can reduce vertical space...
7977    // A full solution involves a loop, but this two-pass check handles most cases.
7978    // Only relevant when scrollbars reserve layout space (non-overlay).
7979    if scrollbar_width_px > 0.0 {
7980        if needs_vertical && !needs_horizontal && overflow_x == OverflowBehavior::Auto
7981            && content_size.width > (container_size.width - scrollbar_width_px) + EPSILON {
7982                needs_horizontal = true;
7983            }
7984        if needs_horizontal && !needs_vertical && overflow_y == OverflowBehavior::Auto
7985            && content_size.height > (container_size.height - scrollbar_width_px) + EPSILON {
7986                needs_vertical = true;
7987            }
7988    }
7989
7990    ScrollbarRequirements {
7991        needs_horizontal,
7992        needs_vertical,
7993        scrollbar_width: if needs_vertical {
7994            scrollbar_width_px
7995        } else {
7996            0.0
7997        },
7998        scrollbar_height: if needs_horizontal {
7999            scrollbar_width_px
8000        } else {
8001            0.0
8002        },
8003        // visual_width_px is set by the caller (compute_scrollbar_info_core)
8004        // since this function doesn't have access to the CSS style context.
8005        visual_width_px: 0.0,
8006    }
8007}
8008
8009/// Calculates a single collapsed margin from two adjoining vertical margins.
8010///
8011/// Implements the rules from CSS 2.1 section 8.3.1:
8012/// - If both margins are positive, the result is the larger of the two.
8013/// - If both margins are negative, the result is the more negative of the two.
8014/// - If the margins have mixed signs, they are effectively summed.
8015// +spec:margin-collapsing:814a26 - vertical margins between sibling blocks collapse
8016#[must_use] pub fn collapse_margins(a: f32, b: f32) -> f32 {
8017    if a.is_sign_positive() && b.is_sign_positive() {
8018        a.max(b)
8019    } else if a.is_sign_negative() && b.is_sign_negative() {
8020        a.min(b)
8021    } else {
8022        a + b
8023    }
8024}
8025
8026/// Helper function to advance the pen position with margin collapsing.
8027///
8028/// This implements CSS 2.1 margin collapsing for adjacent block-level boxes in a BFC.
8029///
8030/// - `pen` - Current main-axis position (will be modified)
8031/// - `last_margin_bottom` - The bottom margin of the previous in-flow element
8032/// - `current_margin_top` - The top margin of the current element
8033///
8034/// # Returns
8035///
8036/// The new `last_margin_bottom` value (the bottom margin of the current element)
8037///
8038/// # CSS Spec Compliance
8039///
8040/// Per CSS 2.1 Section 8.3.1 "Collapsing margins":
8041///
8042/// - Adjacent vertical margins of block boxes collapse
8043/// - The resulting margin width is the maximum of the adjoining margins (if both positive)
8044/// - Or the sum of the most positive and most negative (if signs differ)
8045fn advance_pen_with_margin_collapse(
8046    pen: &mut f32,
8047    last_margin_bottom: f32,
8048    current_margin_top: f32,
8049) -> f32 {
8050    // Collapse the previous element's bottom margin with current element's top margin
8051    let collapsed_margin = collapse_margins(last_margin_bottom, current_margin_top);
8052
8053    // Advance pen by the collapsed margin
8054    *pen += collapsed_margin;
8055
8056    // Return collapsed_margin so caller knows how much space was actually added
8057    collapsed_margin
8058}
8059
8060/// Checks if an element's border or padding prevents margin collapsing.
8061///
8062/// Per CSS 2.1 Section 8.3.1:
8063///
8064/// - Border between margins prevents collapsing
8065/// - Padding between margins prevents collapsing
8066///
8067/// # Arguments
8068///
8069/// - `box_props` - The box properties containing border and padding
8070/// - `writing_mode` - The writing mode to determine main axis
8071/// - `check_start` - If true, check main-start (top); if false, check main-end (bottom)
8072///
8073/// # Returns
8074///
8075/// `true` if border or padding exists and prevents collapsing
8076// +spec:box-model:ca8ceb - margin collapsing uses block-start/block-end per writing mode
8077fn has_margin_collapse_blocker(
8078    box_props: &crate::solver3::geometry::BoxProps,
8079    writing_mode: LayoutWritingMode,
8080    check_start: bool, // true = check top/start, false = check bottom/end
8081) -> bool {
8082    if check_start {
8083        // Check if there's border-top or padding-top
8084        let border_start = box_props.border.main_start(writing_mode);
8085        let padding_start = box_props.padding.main_start(writing_mode);
8086        border_start > 0.0 || padding_start > 0.0
8087    } else {
8088        // Check if there's border-bottom or padding-bottom
8089        let border_end = box_props.border.main_end(writing_mode);
8090        let padding_end = box_props.padding.main_end(writing_mode);
8091        border_end > 0.0 || padding_end > 0.0
8092    }
8093}
8094
8095/// Checks if an element is empty (has no content).
8096///
8097/// Per CSS 2.1 Section 8.3.1:
8098///
8099/// > If a block element has no border, padding, inline content, height, or min-height,
8100/// > then its top and bottom margins collapse with each other.
8101///
8102/// # Arguments
8103///
8104/// - `node` - The layout node to check
8105///
8106/// # Returns
8107///
8108/// `true` if the element is empty and its margins can collapse internally
8109fn is_empty_block(tree: &LayoutTree, node_index: usize) -> bool {
8110    let Some(node) = tree.get(node_index) else {
8111        return true;
8112    };
8113    // Per CSS 2.2 § 8.3.1: An empty block is one that:
8114    // - Has zero computed 'min-height'
8115    // - Has zero or 'auto' computed 'height'
8116    // - Has no in-flow children
8117    // - Has no line boxes (no text/inline content)
8118
8119    // Check if node has children
8120    if !tree.children(node_index).is_empty() {
8121        return false;
8122    }
8123
8124    // Check if node has inline content (text)
8125    if tree.warm(node_index).and_then(|w| w.inline_layout_result.as_ref()).is_some() {
8126        return false;
8127    }
8128
8129    // Check if node has explicit height > 0
8130    // CSS 2.2 § 8.3.1: Elements with explicit height are NOT empty
8131    if let Some(size) = node.used_size {
8132        if size.height > 0.0 {
8133            return false;
8134        }
8135    }
8136
8137    // Empty block: no children, no inline content, no height
8138    true
8139}
8140
8141/// Generates marker text for a list item marker.
8142///
8143/// This function looks up the counter value from the cache and formats it
8144/// according to the list-style-type property.
8145///
8146/// Per CSS Lists Module Level 3, the `::marker` pseudo-element is the first child
8147/// of the list-item, and references the same DOM node. Counter resolution happens
8148/// on the list-item (parent) node.
8149fn generate_list_marker_text(
8150    tree: &LayoutTree,
8151    styled_dom: &StyledDom,
8152    marker_index: usize,
8153    counters: &HashMap<(usize, String), i32>,
8154    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8155) -> String {
8156    use crate::solver3::counters::format_counter;
8157
8158    // Get the marker node
8159    let Some(marker_node) = tree.get(marker_index) else {
8160        return String::new();
8161    };
8162
8163    // Verify this is actually a ::marker pseudo-element
8164    // Per spec, markers must be pseudo-elements, not anonymous boxes
8165    let marker_pseudo = tree.warm(marker_index).and_then(|w| w.pseudo_element);
8166    let marker_anonymous_type = tree.cold(marker_index).and_then(|c| c.anonymous_type);
8167    if marker_pseudo != Some(PseudoElement::Marker) {
8168        if let Some(msgs) = debug_messages {
8169            msgs.push(LayoutDebugMessage::warning(format!(
8170                "[generate_list_marker_text] WARNING: Node {marker_index} is not a ::marker pseudo-element \
8171                 (pseudo={marker_pseudo:?}, anonymous_type={marker_anonymous_type:?})"
8172            )));
8173        }
8174        // Fallback for old-style anonymous markers during transition
8175        if marker_anonymous_type != Some(AnonymousBoxType::ListItemMarker) {
8176            return String::new();
8177        }
8178    }
8179
8180    // Get the parent list-item node (::marker is first child of list-item)
8181    let Some(list_item_index) = marker_node.parent else {
8182        if let Some(msgs) = debug_messages {
8183            msgs.push(LayoutDebugMessage::error(
8184                "[generate_list_marker_text] ERROR: Marker has no parent".to_string(),
8185            ));
8186        }
8187        return String::new();
8188    };
8189
8190    let Some(list_item_node) = tree.get(list_item_index) else {
8191        return String::new();
8192    };
8193
8194    let Some(list_item_dom_id) = list_item_node.dom_node_id else {
8195        if let Some(msgs) = debug_messages {
8196            msgs.push(LayoutDebugMessage::error(
8197                "[generate_list_marker_text] ERROR: List-item has no DOM ID".to_string(),
8198            ));
8199        }
8200        return String::new();
8201    };
8202
8203    if let Some(msgs) = debug_messages {
8204        msgs.push(LayoutDebugMessage::info(format!(
8205            "[generate_list_marker_text] marker_index={marker_index}, list_item_index={list_item_index}, \
8206             list_item_dom_id={list_item_dom_id:?}"
8207        )));
8208    }
8209
8210    // Get list-style-type from the list-item or its container
8211    let list_container_dom_id = list_item_node.parent.and_then(|grandparent_index| {
8212        tree.get(grandparent_index).and_then(|grandparent| grandparent.dom_node_id)
8213    });
8214
8215    // Try to get list-style-type from the list container first,
8216    // then fall back to the list-item
8217    let list_style_type = list_container_dom_id.map_or_else(|| get_list_style_type(styled_dom, Some(list_item_dom_id)), |container_id| {
8218        let container_type = get_list_style_type(styled_dom, Some(container_id));
8219        if container_type == StyleListStyleType::default() {
8220            get_list_style_type(styled_dom, Some(list_item_dom_id))
8221        } else {
8222            container_type
8223        }
8224    });
8225
8226    // Get the counter value for "list-item" counter from the LIST-ITEM node
8227    // Per CSS spec, counters are scoped to elements, and the list-item counter
8228    // is incremented at the list-item element, not the marker pseudo-element
8229    let counter_value = counters
8230        .get(&(list_item_index, "list-item".to_string()))
8231        .copied()
8232        .unwrap_or_else(|| {
8233            if let Some(msgs) = debug_messages {
8234                msgs.push(LayoutDebugMessage::warning(format!(
8235                    "[generate_list_marker_text] WARNING: No counter found for list-item at index \
8236                     {list_item_index}, defaulting to 1"
8237                )));
8238            }
8239            1
8240        });
8241
8242    if let Some(msgs) = debug_messages {
8243        msgs.push(LayoutDebugMessage::info(format!(
8244            "[generate_list_marker_text] counter_value={counter_value} for list_item_index={list_item_index}"
8245        )));
8246    }
8247
8248    // Format the counter according to the list-style-type
8249    let marker_text = format_counter(counter_value, list_style_type);
8250
8251    // For ordered lists (non-symbolic markers), add a period and space
8252    // For unordered lists (symbolic markers like •, ◦, ▪), just add a space
8253    if matches!(
8254        list_style_type,
8255        StyleListStyleType::Decimal
8256            | StyleListStyleType::DecimalLeadingZero
8257            | StyleListStyleType::LowerAlpha
8258            | StyleListStyleType::UpperAlpha
8259            | StyleListStyleType::LowerRoman
8260            | StyleListStyleType::UpperRoman
8261            | StyleListStyleType::LowerGreek
8262            | StyleListStyleType::UpperGreek
8263    ) {
8264        format!("{marker_text}. ")
8265    } else {
8266        format!("{marker_text} ")
8267    }
8268}
8269
8270/// Generates marker text segments for a list item marker.
8271///
8272/// Simply returns a single `StyledRun` with the marker text using the `base_style`.
8273/// The font stack in `base_style` already includes fallbacks with 100% Unicode coverage,
8274/// so font resolution happens during text shaping, not here.
8275fn generate_list_marker_segments(
8276    tree: &LayoutTree,
8277    styled_dom: &StyledDom,
8278    marker_index: usize,
8279    counters: &HashMap<(usize, String), i32>,
8280    base_style: Arc<StyleProperties>,
8281    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8282) -> Vec<StyledRun> {
8283    // Generate the marker text
8284    let marker_text =
8285        generate_list_marker_text(tree, styled_dom, marker_index, counters, debug_messages);
8286    if marker_text.is_empty() {
8287        return Vec::new();
8288    }
8289
8290    if let Some(msgs) = debug_messages {
8291        let font_families: Vec<&str> = match &base_style.font_stack {
8292            text3::cache::FontStack::Stack(selectors) => {
8293                selectors.iter().map(|f| f.family.as_str()).collect()
8294            }
8295            text3::cache::FontStack::Ref(_) => vec!["<embedded-font>"],
8296        };
8297        msgs.push(LayoutDebugMessage::info(format!(
8298            "[generate_list_marker_segments] Marker text: '{marker_text}' with font stack: {font_families:?}"
8299        )));
8300    }
8301
8302    // Return single segment - font fallback happens during shaping
8303    // List markers are generated content, not from DOM nodes
8304    vec![StyledRun {
8305        text: marker_text,
8306        style: base_style,
8307        logical_start_byte: 0,
8308        source_node_id: None,
8309    }]
8310}
8311
8312/// Returns true if a character has Unicode line breaking class BK (mandatory break)
8313/// or NL (next line). Per CSS Text 3 §5.1, these must be treated as forced line
8314/// breaks regardless of the white-space property value.
8315#[inline]
8316const fn is_bk_or_nl_class(c: char) -> bool {
8317    matches!(c, '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}')
8318}
8319
8320/// Splits text at all forced break points: newlines (\n, \r\n, \r) and BK/NL class chars.
8321/// Used for white-space modes that preserve segment breaks (pre, pre-wrap, pre-line, break-spaces).
8322// +spec:white-space-processing:af4e3f - each newline/segment break in text is treated as a segment break, interpreted per white-space property
8323fn split_at_forced_breaks(text: &str) -> Vec<String> {
8324    let mut segments = Vec::new();
8325    let mut current = String::new();
8326    let mut chars = text.chars().peekable();
8327    while let Some(c) = chars.next() {
8328        if c == '\n' {
8329            segments.push(std::mem::take(&mut current));
8330        } else if c == '\r' {
8331            segments.push(std::mem::take(&mut current));
8332            if chars.peek() == Some(&'\n') {
8333                chars.next();
8334            }
8335        } else if is_bk_or_nl_class(c) {
8336            segments.push(std::mem::take(&mut current));
8337        } else {
8338            current.push(c);
8339        }
8340    }
8341    segments.push(current);
8342    segments
8343}
8344
8345/// Splits text only at BK/NL class characters (not \n which is collapsed in normal/nowrap).
8346/// Used for white-space: normal/nowrap where \n is collapsed to space but BK/NL chars
8347/// still produce forced breaks per CSS Text 3 §5.1.
8348fn split_at_bk_nl_chars(text: &str) -> Vec<String> {
8349    let mut segments = Vec::new();
8350    let mut current = String::new();
8351    for c in text.chars() {
8352        if is_bk_or_nl_class(c) {
8353            segments.push(std::mem::take(&mut current));
8354        } else {
8355            current.push(c);
8356        }
8357    }
8358    segments.push(current);
8359    segments
8360}
8361
8362/// Returns true if the character is East Asian (CJK) for the purposes of
8363/// segment break transformation rules (CSS Text Level 3, §4.1.3).
8364fn is_east_asian_wide(c: char) -> bool {
8365    let cp = c as u32;
8366    // CJK Unified Ideographs
8367    (0x4E00..=0x9FFF).contains(&cp)
8368    || (0x3400..=0x4DBF).contains(&cp)
8369    || (0x20000..=0x2A6DF).contains(&cp)
8370    || (0xF900..=0xFAFF).contains(&cp)
8371    // Hiragana
8372    || (0x3040..=0x309F).contains(&cp)
8373    // Katakana
8374    || (0x30A0..=0x30FF).contains(&cp)
8375    || (0x31F0..=0x31FF).contains(&cp)
8376    // CJK Radicals / Kangxi / Ideographic Description
8377    || (0x2E80..=0x2EFF).contains(&cp)
8378    || (0x2F00..=0x2FDF).contains(&cp)
8379    || (0x2FF0..=0x2FFF).contains(&cp)
8380    // CJK Symbols and Punctuation
8381    || (0x3000..=0x303F).contains(&cp)
8382    || (0x3200..=0x32FF).contains(&cp)
8383    || (0x3300..=0x33FF).contains(&cp)
8384    // Bopomofo
8385    || (0x3100..=0x312F).contains(&cp)
8386    // Hangul Syllables
8387    || (0xAC00..=0xD7AF).contains(&cp)
8388    // Fullwidth forms
8389    || (0xFF01..=0xFF60).contains(&cp)
8390    || (0xFFE0..=0xFFE6).contains(&cp)
8391}
8392
8393// +spec:block-formatting-context:b78223 - fullwidth/wide chars treated as vertical script, halfwidth as horizontal per UAX#11
8394fn is_east_asian_fullwidth_or_wide(ch: char) -> bool {
8395    let cp = ch as u32;
8396    // Exclude Hangul
8397    if (0x1100..=0x11FF).contains(&cp)
8398        || (0x3130..=0x318F).contains(&cp)
8399        || (0xAC00..=0xD7AF).contains(&cp)
8400        || (0xA960..=0xA97F).contains(&cp)
8401        || (0xD7B0..=0xD7FF).contains(&cp)
8402    {
8403        return false;
8404    }
8405    is_east_asian_wide(ch)
8406        || (0xFF61..=0xFFDC).contains(&cp)
8407        || (0xFFE8..=0xFFEE).contains(&cp)
8408        || (0xA000..=0xA4CF).contains(&cp)
8409}
8410
8411/// +spec:white-space-processing:159dbf - segment breaks converted to spaces (default transform)
8412/// +spec:white-space-processing:79891b - segment break transform: convert to space or remove
8413// +spec:white-space-processing:7e9529 - Segment break transformation rules (§4.1.3): collapse consecutive breaks, remove around ZWSP/CJK, else convert to space
8414/// Transforms segment breaks (newlines) in text according to CSS Text Level 3 §4.1.3.
8415/// - If adjacent to a zero-width space (U+200B), the segment break is removed.
8416/// - If both adjacent chars are East Asian F/W/H (not Hangul), removed entirely.
8417/// - Otherwise, converted to a single space.
8418fn apply_segment_break_transform(text: &str) -> String {
8419    let chars: Vec<char> = text.chars().collect();
8420    let len = chars.len();
8421    let mut result = String::with_capacity(text.len());
8422    let mut i = 0;
8423
8424    while i < len {
8425        let ch = chars[i];
8426        if ch == '\n' || ch == '\r' {
8427            let break_end = if ch == '\r' && i + 1 < len && chars[i + 1] == '\n' {
8428                i + 2
8429            } else {
8430                i + 1
8431            };
8432
8433            // +spec:white-space-processing:3c3680 - remove tabs/spaces around segment break before transform
8434            // §4.1.1: remove collapsible whitespace around segment breaks
8435            while result.ends_with(' ') || result.ends_with('\t') {
8436                result.pop();
8437            }
8438
8439            let mut after_idx = break_end;
8440            while after_idx < len && (chars[after_idx] == ' ' || chars[after_idx] == '\t') {
8441                after_idx += 1;
8442            }
8443
8444            let char_before = result.chars().last();
8445            let char_after = if after_idx < len { Some(chars[after_idx]) } else { None };
8446
8447            // Rule 1: adjacent to zero-width space → remove
8448            if char_before == Some('\u{200B}') || char_after == Some('\u{200B}') {
8449                // remove segment break
8450            }
8451            // Rule 2: both sides East Asian F/W/H (not Hangul) → remove
8452            else if let (Some(before), Some(after)) = (char_before, char_after) {
8453                if is_east_asian_fullwidth_or_wide(before) && is_east_asian_fullwidth_or_wide(after) {
8454                    // remove segment break
8455                } else {
8456                    result.push(' ');
8457                }
8458            } else {
8459                result.push(' ');
8460            }
8461
8462            i = after_idx;
8463        } else {
8464            result.push(ch);
8465            i += 1;
8466        }
8467    }
8468
8469    result
8470}
8471
8472// ============================================================================
8473// +spec:white-space-processing:b64e38 - parser may normalize/collapse whitespace before CSS; CSS cannot restore
8474
8475// +spec:display-property:1389e3 - bidi control characters per UAX #9 for Unicode bidirectional algorithm
8476// +spec:display-property:aad99b - inline boxes can be split into fragments due to bidi text processing
8477// Bidi_Control property (UAX #9). These characters are ignored during white-space processing.
8478const fn is_bidi_control(c: char) -> bool {
8479    matches!(c,
8480        '\u{200E}' | // LEFT-TO-RIGHT MARK
8481        '\u{200F}' | // RIGHT-TO-LEFT MARK
8482        '\u{202A}' | // LEFT-TO-RIGHT EMBEDDING
8483        '\u{202B}' | // RIGHT-TO-LEFT EMBEDDING
8484        '\u{202C}' | // POP DIRECTIONAL FORMATTING
8485        '\u{202D}' | // LEFT-TO-RIGHT OVERRIDE
8486        '\u{202E}' | // RIGHT-TO-LEFT OVERRIDE
8487        '\u{2066}' | // LEFT-TO-RIGHT ISOLATE
8488        '\u{2067}' | // RIGHT-TO-LEFT ISOLATE
8489        '\u{2068}' | // FIRST STRONG ISOLATE
8490        '\u{2069}' | // POP DIRECTIONAL ISOLATE
8491        '\u{061C}'   // ARABIC LETTER MARK
8492    )
8493}
8494
8495/// +spec:white-space-processing:1188f6 - only spaces, tabs, and segment breaks are document white space
8496/// Returns true if `c` is a CSS "document white space character" per CSS Text Level 3 §4.1.
8497/// Only spaces (U+0020), tabs (U+0009), and segment breaks (LF, CR, FF) qualify.
8498/// Other Unicode whitespace (e.g. U+00A0 non-breaking space) is NOT document white space.
8499#[inline]
8500const fn is_css_document_whitespace(c: char) -> bool {
8501    matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0C')
8502}
8503
8504// +spec:white-space-processing:efbece - white-space property controls collapsing/preserving of formatting characters for rendering
8505// +spec:writing-modes:b87688 - inlines laid out with bidi reordering and white-space wrapping
8506// +spec:writing-modes:cdd4f1 - white space trimming before bidi reordering preserves end-of-line spaces per UAX9 L1
8507// white space characters are processed prior to line breaking and bidi reordering
8508// +spec:inline-block:381c0c - white-space property: collapsing, wrapping, and forced breaks per mode
8509// +spec:display-property:8acfaa - Phase I white-space collapsing for each inline in an IFC, ignoring bidi controls
8510/// Splits text content into `InlineContent` items based on white-space CSS property.
8511///
8512/// For `white-space: pre`, `pre-wrap`, and `pre-line`, newlines (`\n`) are treated as
8513/// forced line breaks per CSS Text Level 3 specification:
8514/// <https://www.w3.org/TR/css-text-3/#white-space-property>
8515///
8516/// Additionally, Unicode characters with BK or NL line breaking class (VT, FF, NEL, LS, PS)
8517/// are always treated as forced line breaks regardless of the white-space value.
8518///
8519/// This function:
8520/// 1. Checks the white-space property of the node (or its parent for text nodes)
8521/// 2. If `pre`, `pre-wrap`, or `pre-line`: splits text by `\n` and inserts `InlineContent::LineBreak`
8522/// 3. Otherwise: returns the text as a single `InlineContent::Text`
8523/// 4. In ALL modes: BK/NL class chars (VT, FF, NEL, LS, PS) produce forced breaks
8524///
8525/// Returns a Vec of `InlineContent` items that correctly represent line breaks.
8526#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8527pub fn split_text_for_whitespace(
8528    styled_dom: &StyledDom,
8529    dom_id: NodeId,
8530    text: &str,
8531    style: &Arc<StyleProperties>,
8532) -> Vec<InlineContent> {
8533    use crate::text3::cache::{BreakType, ClearType, InlineBreak};
8534
8535    // (characters with the Bidi_Control property) as if they were not there"
8536    // Strip bidi control characters before white-space processing so they don't
8537    // interfere with collapsing (e.g. a bidi mark between two spaces).
8538    let text_owned;
8539    let text: &str = if text.chars().any(is_bidi_control) {
8540        text_owned = text.chars().filter(|c| !is_bidi_control(*c)).collect::<String>();
8541        &text_owned
8542    } else {
8543        text
8544    };
8545
8546    // Get the white-space property - TEXT NODES inherit from parent!
8547    // We need to check the parent element's white-space, not the text node itself
8548    let node_hierarchy = styled_dom.node_hierarchy.as_container();
8549    let parent_id = node_hierarchy[dom_id].parent_id();
8550    
8551    // Try parent first, then fall back to the node itself
8552    let white_space = parent_id.map_or(StyleWhiteSpace::Normal, |parent| {
8553        let styled_nodes = styled_dom.styled_nodes.as_container();
8554        let parent_state = styled_nodes
8555            .get(parent)
8556            .map(|n| n.styled_node_state)
8557            .unwrap_or_default();
8558        
8559        match get_white_space_property(styled_dom, parent, &parent_state) {
8560            MultiValue::Exact(ws) => ws,
8561            _ => StyleWhiteSpace::Normal,
8562        }
8563    });
8564
8565    let mut result = Vec::new();
8566
8567    // +spec:white-space-processing:3a0f58 - HTML newlines normalized to U+000A, each treated as segment break
8568    // +spec:white-space-processing:6eb1a2 - CR (U+000D) not treated as segment break by HTML; handle if inserted via DOM
8569    // HTML parsers convert \r to \n during preprocessing, but \r can survive
8570    // via escape sequences (e.g. &#x0d;). Any remaining U+000D must be
8571    // treated identically to U+000A (line feed).
8572    let text_cr;
8573    let text: &str = if text.contains('\r') {
8574        text_cr = text.replace("\r\n", "\n").replace('\r', "\n");
8575        &text_cr
8576    } else {
8577        text
8578    };
8579
8580    // +spec:white-space-processing:bd11da - white-space property: new lines, spaces/tabs, wrapping per value table
8581    // +spec:white-space-processing:b166c5 - segment breaks preserved as forced line feeds for pre/pre-wrap/break-spaces/pre-line
8582    // For `pre`, `pre-wrap`, `pre-line`, and `break-spaces`, newlines must be preserved as forced breaks
8583    // CSS Text Level 3: "Newlines in the source will be honored as forced line breaks."
8584    match white_space {
8585        StyleWhiteSpace::Pre | StyleWhiteSpace::PreWrap | StyleWhiteSpace::BreakSpaces => {
8586            // Pre, pre-wrap, break-spaces: preserve whitespace and honor newlines
8587            // Split by newlines and BK/NL class chars, insert LineBreak between parts
8588            // Also handle tab characters (\t) by inserting InlineContent::Tab
8589            let segments = split_at_forced_breaks(text);
8590            let segment_count = segments.len();
8591            let mut content_index = 0;
8592
8593            for (seg_idx, segment) in segments.into_iter().enumerate() {
8594                // Split the segment by tab characters and insert Tab elements
8595                let mut tab_parts = segment.split('\t').peekable();
8596                while let Some(part) = tab_parts.next() {
8597                    if !part.is_empty() {
8598                        result.push(InlineContent::Text(StyledRun {
8599                            text: part.to_string(),
8600                            style: Arc::clone(style),
8601                            logical_start_byte: 0,
8602                            source_node_id: Some(dom_id),
8603                        }));
8604                    }
8605
8606                    if tab_parts.peek().is_some() {
8607                        result.push(InlineContent::Tab { style: Arc::clone(style) });
8608                    }
8609                }
8610
8611                if seg_idx + 1 < segment_count {
8612                    result.push(InlineContent::LineBreak(InlineBreak {
8613                        break_type: BreakType::Hard,
8614                        clear: ClearType::None,
8615                        content_index,
8616                    }));
8617                    content_index += 1;
8618                }
8619            }
8620        }
8621        StyleWhiteSpace::PreLine => {
8622            // Pre-line: collapse whitespace but honor newlines and BK/NL class chars
8623            let segments = split_at_forced_breaks(text);
8624            let segment_count = segments.len();
8625            let mut content_index = 0;
8626
8627            for (seg_idx, segment) in segments.into_iter().enumerate() {
8628                // Collapse only CSS document white space within the line (not all Unicode whitespace)
8629                let collapsed: String = segment
8630                    .split(|c: char| is_css_document_whitespace(c))
8631                    .filter(|s| !s.is_empty())
8632                    .collect::<Vec<_>>()
8633                    .join(" ");
8634
8635                if !collapsed.is_empty() {
8636                    result.push(InlineContent::Text(StyledRun {
8637                        text: collapsed,
8638                        style: Arc::clone(style),
8639                        logical_start_byte: 0,
8640                        source_node_id: Some(dom_id),
8641                    }));
8642                }
8643
8644                if seg_idx + 1 < segment_count {
8645                    result.push(InlineContent::LineBreak(InlineBreak {
8646                        break_type: BreakType::Hard,
8647                        clear: ClearType::None,
8648                        content_index,
8649                    }));
8650                    content_index += 1;
8651                }
8652            }
8653        }
8654        StyleWhiteSpace::Normal | StyleWhiteSpace::Nowrap => {
8655            // +spec:white-space-processing:adbebb - Phase I collapsing for normal/nowrap modes
8656            // CSS Text Level 3, Section 4.1.1 - Phase I: Collapsing and Transformation
8657            // https://www.w3.org/TR/css-text-3/#white-space-phase-1
8658            //
8659            // For `white-space: normal` and `nowrap`:
8660            // 1. Segment breaks are transformed per §4.1.3
8661            // 2. Any sequence of consecutive spaces/tabs is collapsed to a single space
8662            // 3. Leading/trailing spaces at line boundaries are handled during line layout
8663            //
8664            // are forced breaks regardless of white-space value. Split on them first,
8665            // then collapse whitespace within each segment.
8666            let segments = split_at_bk_nl_chars(text);
8667            let segment_count = segments.len();
8668            let mut content_index = 0;
8669
8670            for (seg_idx, segment) in segments.into_iter().enumerate() {
8671                let after_segment_breaks = apply_segment_break_transform(&segment);
8672
8673                // Collapse document white space within this segment (normal/nowrap rules)
8674                let collapsed: String = after_segment_breaks
8675                    .chars()
8676                    .map(|c| if is_css_document_whitespace(c) { ' ' } else { c })
8677                    .collect::<String>()
8678                    .split(' ')
8679                    .filter(|s| !s.is_empty())
8680                    .collect::<Vec<_>>()
8681                    .join(" ");
8682
8683                let final_text = if collapsed.is_empty() && !segment.is_empty() {
8684                    " ".to_string()
8685                } else if !collapsed.is_empty() {
8686                    // Check if original had leading/trailing document whitespace
8687                    let had_leading = segment.chars().next().is_some_and(is_css_document_whitespace);
8688                    let had_trailing = segment.chars().last().is_some_and(is_css_document_whitespace);
8689
8690                    let mut r = String::new();
8691                    if had_leading { r.push(' '); }
8692                    r.push_str(&collapsed);
8693                    if had_trailing && !had_leading { r.push(' '); }
8694                    else if had_trailing && had_leading && collapsed.is_empty() { /* already have one space */ }
8695                    else if had_trailing { r.push(' '); }
8696                    r
8697                } else {
8698                    collapsed
8699                };
8700
8701                if !final_text.is_empty() {
8702                    result.push(InlineContent::Text(StyledRun {
8703                        text: final_text,
8704                        style: Arc::clone(style),
8705                        logical_start_byte: 0,
8706                        source_node_id: Some(dom_id),
8707                    }));
8708                }
8709
8710                // Insert forced break between segments (for BK/NL chars)
8711                if seg_idx + 1 < segment_count {
8712                    result.push(InlineContent::LineBreak(InlineBreak {
8713                        break_type: BreakType::Hard,
8714                        clear: ClearType::None,
8715                        content_index,
8716                    }));
8717                    content_index += 1;
8718                }
8719            }
8720        }
8721    }
8722
8723    // +spec:white-space-processing:5e3f70 - text-transform applied after Phase I collapsing, before Phase II trimming
8724    // This means full-width only transforms spaces (U+0020) to U+3000 IDEOGRAPHIC SPACE
8725    // within preserved white space, because non-preserved spaces were already collapsed in Phase I above.
8726    let text_transform = style.text_transform;
8727    if text_transform != text3::cache::TextTransform::None {
8728        for item in &mut result {
8729            if let InlineContent::Text(run) = item {
8730                run.text = apply_text_transform(&run.text, text_transform);
8731            }
8732        }
8733    }
8734
8735    result
8736}
8737
8738fn apply_text_transform(text: &str, transform: text3::cache::TextTransform) -> String {
8739    use crate::text3::cache::TextTransform;
8740    match transform {
8741        TextTransform::None => text.to_string(),
8742        TextTransform::Uppercase => text.to_uppercase(),
8743        TextTransform::Lowercase => text.to_lowercase(),
8744        TextTransform::Capitalize => {
8745            let mut result = String::with_capacity(text.len());
8746            let mut prev_is_word_boundary = true;
8747            for c in text.chars() {
8748                if prev_is_word_boundary && c.is_alphabetic() {
8749                    for uc in c.to_uppercase() {
8750                        result.push(uc);
8751                    }
8752                    prev_is_word_boundary = false;
8753                } else {
8754                    result.push(c);
8755                    prev_is_word_boundary = c.is_whitespace() || c.is_ascii_punctuation();
8756                }
8757            }
8758            result
8759        }
8760        TextTransform::FullWidth => {
8761            // Full-width transforms ASCII characters to their full-width equivalents.
8762            // Spaces (U+0020) become U+3000 IDEOGRAPHIC SPACE — but only those that
8763            // survived Phase I collapsing (i.e. preserved white space).
8764            text.chars().map(|c| match c {
8765                ' ' => '\u{3000}',  // U+0020 SPACE -> U+3000 IDEOGRAPHIC SPACE
8766                '!' ..= '~' => {
8767                    // ASCII printable range U+0021..U+007E -> fullwidth U+FF01..U+FF5E
8768                    char::from_u32(c as u32 - 0x0021 + 0xFF01).unwrap_or(c)
8769                }
8770                _ => c,
8771            }).collect()
8772        }
8773    }
8774}
8775
8776// ============================================================================
8777// INITIAL LETTER / DROP CAPS STUB
8778// ============================================================================
8779
8780/// Computes the geometric exclusion area for an initial letter (drop cap).
8781///
8782/// CSS Inline Layout Module Level 3, section 3:
8783/// The `initial-letter` property specifies styling for dropped, raised, and sunken
8784/// initial letters. When set, the first glyph(s) of the first line are enlarged to
8785/// span multiple lines, with the remaining text wrapping around them.
8786///
8787// +spec:box-model:c93797 - initial-letter alignment points determined from contents (not border-box)
8788///
8789/// # Algorithm
8790///
8791/// 1. The letter box height spans `size` lines: `height = size * line_height`.
8792/// 2. The letter box width is estimated using a typical capital letter aspect ratio
8793///    (cap-height-to-advance-width ~0.7 for Latin text). A proper implementation
8794///    would measure the actual glyph, but this gives a reasonable default.
8795/// 3. The letter is positioned at the inline-start of the first line.
8796/// 4. The `sink` value determines how many lines the letter drops below the
8797///    first baseline. When `sink == size`, this is a classic drop cap.
8798///    When `sink < size`, the letter rises above the first line (raised cap).
8799/// 5. A small gap (4px default) is added between the letter box and adjacent text.
8800///
8801/// # Parameters
8802/// - `initial_letter_size`: The number of lines the initial letter should span (e.g., 3.0)
8803/// - `initial_letter_sink`: How many lines the letter sinks below the first line
8804/// - `content_box_width`: Available width in the content box (for clamping)
8805/// - `line_height`: The computed line height for the containing block
8806///
8807/// # Returns
8808/// A tuple of `(letter_width, letter_height)` representing the space reserved for
8809/// the initial letter exclusion, or `(0.0, 0.0)` if the parameters are invalid.
8810///
8811/// The caller should use these dimensions to create a float-like exclusion at the
8812/// start of the block container, causing subsequent lines to wrap around the letter.
8813// +spec:width-calculation:7f4f68 - initial-letter-wrap exclusion area (none behavior; first/grid require glyph outlines)
8814#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
8815#[must_use] pub fn layout_initial_letter(
8816    initial_letter_size: f32,
8817    initial_letter_sink: u32,
8818    content_box_width: f32,
8819    line_height: f32,
8820) -> (f32, f32) {
8821    // Estimate the letter width using a typical Latin capital letter aspect ratio.
8822    // The advance width of a capital letter is approximately 0.7x the cap height.
8823    // This is a heuristic; a full implementation would measure the actual glyph(s).
8824    const CAP_WIDTH_RATIO: f32 = 0.7;
8825
8826    // Add a small gap between the letter box and the adjacent inline content.
8827    // CSS Inline Level 3 section 3.5: browsers typically add ~4px padding.
8828    const LETTER_GAP: f32 = 4.0;
8829
8830    // Guard against degenerate values
8831    if initial_letter_size <= 0.0 || line_height <= 0.0 || content_box_width <= 0.0 {
8832        return (0.0, 0.0);
8833    }
8834
8835    // +spec:overflow:dd0679 - auto-sized initial letter content box fits exactly to content; alignment props do not apply
8836    // +spec:width-calculation:170742 - atomic initial letters with auto block size use inline initial letter sizing
8837    // CSS Inline Level 3 section 3.3: The initial letter box height spans `size` lines.
8838    let letter_height = initial_letter_size * line_height;
8839
8840    let letter_width_raw = letter_height * CAP_WIDTH_RATIO;
8841
8842    let letter_width = (letter_width_raw + LETTER_GAP).min(content_box_width);
8843
8844    // +spec:containing-block:67fd99 - block-axis positioning: size >= sink shifts by (sink-1)*line_height toward block-end
8845    // The actual exclusion height accounts for the sink value.
8846    // sink == size means the letter is fully dropped (classic drop cap).
8847    // sink < size means part of the letter rises above the first line (raised cap).
8848    // The exclusion area height is always `sink * line_height` since that's how
8849    // many lines of subsequent text need to wrap around the letter.
8850    let exclusion_height = (initial_letter_sink as f32) * line_height;
8851
8852    // Use the larger of exclusion_height and letter_height as the actual
8853    // vertical space consumed. For raised caps (sink < size), the letter
8854    // extends above the first line but the exclusion only covers sink lines.
8855    // For sunken caps (sink >= size), the exclusion covers the full letter height.
8856    let effective_height = exclusion_height.max(letter_height);
8857
8858    (letter_width, effective_height)
8859}