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