diff --git a/moli-layout/src/builder.rs b/moli-layout/src/builder.rs index cd263e2b5..2145844ef 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -52,6 +52,35 @@ where viewport_body_candidate: Option>, } +/// Selects both the generated wrapper and its whitespace-suppression rule. +/// Mixed block/inline flow follows `white-space-collapse`; Flex and Grid omit +/// an all-CSS-whitespace text sequence before generating an anonymous item, +/// even when the inherited whitespace mode preserves those characters. +#[derive(Clone, Copy)] +enum AnonymousInlineRunRole { + MixedFlow, + FlexItem, + GridItem, +} + +impl AnonymousInlineRunRole { + fn box_kind(self) -> LayoutBoxKind { + match self { + Self::MixedFlow => LayoutBoxKind::AnonymousBlock, + Self::FlexItem => LayoutBoxKind::AnonymousFlexItem, + Self::GridItem => LayoutBoxKind::AnonymousGridItem, + } + } + + fn reason(self) -> LayoutAnonymousReason { + match self { + Self::MixedFlow => LayoutAnonymousReason::MixedFlowInlineRun, + Self::FlexItem => LayoutAnonymousReason::FlexTextRun, + Self::GridItem => LayoutAnonymousReason::GridTextRun, + } + } +} + impl<'a, S, R> BoxBuilder<'a, S, R> where S: LayoutSource, @@ -561,7 +590,7 @@ where owner, parent_style, children, - LayoutBoxKind::AnonymousFlexItem, + AnonymousInlineRunRole::FlexItem, )? } else if parent_style.display().is_grid_container() { self.normalize_item_children( @@ -569,7 +598,7 @@ where owner, parent_style, children, - LayoutBoxKind::AnonymousGridItem, + AnonymousInlineRunRole::GridItem, )? } else { self.normalize_flow_children(world, owner, parent_style, children)? @@ -737,7 +766,7 @@ where world, owner, parent_style, - LayoutBoxKind::AnonymousBlock, + AnonymousInlineRunRole::MixedFlow, &mut output, &mut inline_run, )?; @@ -754,7 +783,7 @@ where world, owner, parent_style, - LayoutBoxKind::AnonymousBlock, + AnonymousInlineRunRole::MixedFlow, &mut output, &mut inline_run, )?; @@ -765,7 +794,7 @@ where world, owner, parent_style, - LayoutBoxKind::AnonymousBlock, + AnonymousInlineRunRole::MixedFlow, &mut output, &mut inline_run, )?; @@ -778,7 +807,7 @@ where owner: S::NodeId, parent_style: &ResolvedLayoutStyle, children: Vec, - anonymous_kind: LayoutBoxKind, + run_role: AnonymousInlineRunRole, ) -> Result, LayoutError> { let mut output = Vec::new(); let mut text_run = Vec::new(); @@ -794,7 +823,7 @@ where world, owner, parent_style, - anonymous_kind, + run_role, &mut output, &mut text_run, )?; @@ -809,7 +838,7 @@ where world, owner, parent_style, - anonymous_kind, + run_role, &mut output, &mut text_run, )?; @@ -821,7 +850,7 @@ where world: &mut LayoutWorld, owner: S::NodeId, parent_style: &ResolvedLayoutStyle, - anonymous_kind: LayoutBoxKind, + run_role: AnonymousInlineRunRole, output: &mut Vec, run: &mut Vec, ) -> Result<(), LayoutError> { @@ -830,33 +859,14 @@ where } if run .iter() - .all(|id| self.is_ignorable_whitespace_text(world, *id)) + .all(|id| self.is_ignorable_text_for_anonymous_run(world, *id, run_role)) { run.clear(); return Ok(()); } - let (reason, display) = match anonymous_kind { - LayoutBoxKind::AnonymousBlock => ( - LayoutAnonymousReason::MixedFlowInlineRun, - LayoutDisplay::Block, - ), - LayoutBoxKind::AnonymousFlexItem => { - (LayoutAnonymousReason::FlexTextRun, LayoutDisplay::Block) - } - LayoutBoxKind::AnonymousGridItem => { - (LayoutAnonymousReason::GridTextRun, LayoutDisplay::Block) - } - _ => { - return Err(LayoutError::source_contract( - self.source.label(owner), - format!( - "box kind {} cannot be constructed as an anonymous inline-run wrapper", - anonymous_kind.debug_name() - ), - )); - } - }; - let style = self.styles.anonymous_style(owner, parent_style, display)?; + let style = self + .styles + .anonymous_style(owner, parent_style, LayoutDisplay::Block)?; let mut anonymous = LayoutWorld::new_box( None, Some(owner), @@ -864,8 +874,8 @@ where format!("anonymous({})", self.source.label(owner)), Some(self.source.label(owner)), None, - Some(reason), - anonymous_kind, + Some(run_role.reason()), + run_role.box_kind(), style, None, ); @@ -1205,6 +1215,15 @@ where &self, world: &LayoutWorld, id: LayoutBoxId, + ) -> bool { + self.is_ignorable_text_for_anonymous_run(world, id, AnonymousInlineRunRole::MixedFlow) + } + + fn is_ignorable_text_for_anonymous_run( + &self, + world: &LayoutWorld, + id: LayoutBoxId, + run_role: AnonymousInlineRunRole, ) -> bool { world.box_by_id(id).is_some_and(|layout_box| { layout_box.kind.is_text() @@ -1212,7 +1231,11 @@ where .capability_diagnostics .contains(&LayoutCapabilityDiagnostic::GeneratedContentUnsupported) && layout_box.text.as_deref().is_none_or(|text| { - whitespace_text_is_ignorable(text, layout_box.style.white_space_collapse()) + text_is_ignorable_in_anonymous_run( + text, + layout_box.style.white_space_collapse(), + run_role, + ) }) }) } @@ -1343,10 +1366,7 @@ fn whitespace_text_is_ignorable(text: &str, mode: InlineWhiteSpaceCollapse) -> b if text.is_empty() { return true; } - if !text - .chars() - .all(|character| matches!(character, ' ' | '\t' | '\n' | '\r' | '\u{000C}')) - { + if !text_contains_only_css_whitespace(text) { return false; } match mode { @@ -1358,6 +1378,24 @@ fn whitespace_text_is_ignorable(text: &str, mode: InlineWhiteSpaceCollapse) -> b } } +fn text_contains_only_css_whitespace(text: &str) -> bool { + text.chars() + .all(|character| matches!(character, ' ' | '\t' | '\n' | '\r' | '\u{000C}')) +} + +fn text_is_ignorable_in_anonymous_run( + text: &str, + mode: InlineWhiteSpaceCollapse, + run_role: AnonymousInlineRunRole, +) -> bool { + match run_role { + AnonymousInlineRunRole::MixedFlow => whitespace_text_is_ignorable(text, mode), + AnonymousInlineRunRole::FlexItem | AnonymousInlineRunRole::GridItem => { + text_contains_only_css_whitespace(text) + } + } +} + fn principal_kind( semantics: &LayoutElementSemantics, style: &ResolvedLayoutStyle, @@ -1456,7 +1494,10 @@ fn push_diagnostic( #[cfg(test)] mod tests { - use super::{InlineWhiteSpaceCollapse, whitespace_text_is_ignorable}; + use super::{ + AnonymousInlineRunRole, InlineWhiteSpaceCollapse, text_contains_only_css_whitespace, + text_is_ignorable_in_anonymous_run, whitespace_text_is_ignorable, + }; #[test] fn only_collapsible_whitespace_is_ignorable_during_box_construction() { @@ -1485,4 +1526,37 @@ mod tests { InlineWhiteSpaceCollapse::Collapse, )); } + + #[test] + fn flex_and_grid_item_runs_ignore_css_whitespace_independently_of_collapse_mode() { + assert!(text_contains_only_css_whitespace(" \t\n\r\u{000C}")); + assert!(!text_contains_only_css_whitespace("\u{00a0}")); + + for role in [ + AnonymousInlineRunRole::FlexItem, + AnonymousInlineRunRole::GridItem, + ] { + assert!(text_is_ignorable_in_anonymous_run( + " \t\n\r\u{000C}", + InlineWhiteSpaceCollapse::Preserve, + role, + )); + assert!(!text_is_ignorable_in_anonymous_run( + "\u{00a0}", + InlineWhiteSpaceCollapse::Collapse, + role, + )); + assert!(!text_is_ignorable_in_anonymous_run( + " text ", + InlineWhiteSpaceCollapse::Preserve, + role, + )); + } + + assert!(!text_is_ignorable_in_anonymous_run( + " \n", + InlineWhiteSpaceCollapse::Preserve, + AnonymousInlineRunRole::MixedFlow, + )); + } } diff --git a/moli-layout/src/stacking.rs b/moli-layout/src/stacking.rs index 54c6a54ad..e173e7342 100644 --- a/moli-layout/src/stacking.rs +++ b/moli-layout/src/stacking.rs @@ -53,9 +53,15 @@ impl AtomicPaintEntry { } } +/// Paint level inherited while traversing a CSS atomic pseudo-context. +/// +/// Floats, positioned boxes, and inline-level atomic boxes keep their +/// non-stacking descendants together at one parent-context paint level. Real +/// stacking contexts are still hoisted before this grouping is applied. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AtomicGroup { - Normal, +enum PaintGroup { + NormalFlow, + AtomicInline, Float, Positioned, } @@ -160,18 +166,15 @@ fn emit_context( fn collect_subtree( world: &LayoutWorld, id: LayoutBoxId, - inherited_group: Option, + inherited_atomic_group: Option, sequence: &mut usize, collection: &mut ContextCollection, ) where N: Copy + Debug + Eq + Hash, { let layout_box = &world.boxes[id.index()]; - let parent_is_flex_or_grid = layout_box.parent.is_some_and(|parent| { - let display = world.boxes[parent.index()].style.display(); - display.is_flex_container() || display.is_grid_container() - }); - if layout_box.creates_stacking_context(false, parent_is_flex_or_grid) { + let is_flex_or_grid_item = is_flex_or_grid_item(world, id); + if layout_box.creates_stacking_context(false, is_flex_or_grid_item) { let context = ChildContext { id, z_index: layout_box.style.explicit_z_index().unwrap_or(0), @@ -187,15 +190,27 @@ fn collect_subtree( return; } - let group = inherited_group.unwrap_or_else(|| { - if layout_box.style.position() != LayoutPosition::Static { - AtomicGroup::Positioned - } else if layout_box.style.is_floated() { - AtomicGroup::Float - } else { - AtomicGroup::Normal - } - }); + // Positioned descendants escape the pseudo-context of an atomic inline or + // float and participate in the nearest real stacking context. Resolve + // that level before inheriting the atomic group; ordinary descendants + // remain inside their atomic ancestor. + let group = if layout_box.style.position() != LayoutPosition::Static { + PaintGroup::Positioned + } else { + inherited_atomic_group.unwrap_or_else(|| { + if is_flex_or_grid_item { + // CSS Flexbox/Grid paint each item as an atomic inline-level box. + // Chromium carries the same boundary as IsPaintedAtomically on + // the item's constraint space. Floats do not apply to flex/grid + // items, so this classification precedes the float level. + PaintGroup::AtomicInline + } else if layout_box.style.is_floated() { + PaintGroup::Float + } else { + PaintGroup::NormalFlow + } + }) + }; push_unit( collection, group, @@ -207,66 +222,70 @@ fn collect_subtree( ); push_unit( collection, - if group == AtomicGroup::Normal { - AtomicGroup::Normal - } else { - group - }, + group, PaintUnit { id, kind: UnitKind::Contents, sequence: next_sequence(sequence), }, ); - // Floats and positioned descendants are painted atomically at their - // ancestor's paint level. Ordinary in-flow descendants are not: each child - // must still classify itself as normal, floating, or positioned. Carrying - // `Normal` down here would incorrectly bury a positioned grandchild in the - // block-background/inline-content buckets. + // Atomic pseudo-context descendants inherit their ancestor's paint level. + // Ordinary in-flow descendants do not: each child must classify itself as + // normal, floating, atomic-inline, or positioned. Positioned descendants + // override an inherited pseudo-context at the start of this function. let descendant_group = match group { - AtomicGroup::Normal => None, - AtomicGroup::Float | AtomicGroup::Positioned => Some(group), + PaintGroup::NormalFlow => None, + PaintGroup::AtomicInline | PaintGroup::Float | PaintGroup::Positioned => Some(group), }; for child in ordered_children(world, id) { collect_subtree(world, child, descendant_group, sequence, collection); } if layout_box.collapsed_table_borders.is_some() { - let unit = PaintUnit { + push_unit( + collection, + group, + PaintUnit { + id, + kind: UnitKind::TableCollapsedBorders, + sequence: next_sequence(sequence), + }, + ); + } + push_unit( + collection, + group, + PaintUnit { id, - kind: UnitKind::TableCollapsedBorders, + kind: UnitKind::Outline, sequence: next_sequence(sequence), - }; - match group { - AtomicGroup::Normal => collection.table_collapsed_borders.push(unit), - AtomicGroup::Float => collection.floats.push(unit), - AtomicGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(unit)), - } - } - let outline = PaintUnit { - id, - kind: UnitKind::Outline, - sequence: next_sequence(sequence), - }; - match group { - AtomicGroup::Normal => collection.outlines.push(outline), - AtomicGroup::Float => collection.floats.push(outline), - AtomicGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(outline)), - } + }, + ); } -fn push_unit(collection: &mut ContextCollection, group: AtomicGroup, unit: PaintUnit) { +fn push_unit(collection: &mut ContextCollection, group: PaintGroup, unit: PaintUnit) { match group { - AtomicGroup::Normal => match unit.kind { + PaintGroup::NormalFlow => match unit.kind { UnitKind::Background => collection.block_backgrounds.push(unit), UnitKind::TableCollapsedBorders => collection.table_collapsed_borders.push(unit), UnitKind::Contents => collection.inline_contents.push(unit), UnitKind::Outline => collection.outlines.push(unit), }, - AtomicGroup::Float => collection.floats.push(unit), - AtomicGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(unit)), + PaintGroup::AtomicInline => collection.inline_contents.push(unit), + PaintGroup::Float => collection.floats.push(unit), + PaintGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(unit)), } } +fn is_flex_or_grid_item(world: &LayoutWorld, id: LayoutBoxId) -> bool +where + N: Copy + Debug + Eq + Hash, +{ + world.boxes[id.index()].parent.is_some_and(|parent| { + let display = world.boxes[parent.index()].style.display(); + display.is_flex_container() || display.is_grid_container() + }) +} + fn emit_units(units: Vec, events: &mut Vec) { for unit in units { emit_unit(unit, events); diff --git a/moli-layout/src/style.rs b/moli-layout/src/style.rs index 7e933ccc5..4cc8ab0d6 100644 --- a/moli-layout/src/style.rs +++ b/moli-layout/src/style.rs @@ -1465,10 +1465,21 @@ impl ResolvedLayoutStyle { self.list_marker_position } - pub(crate) fn table_layout_is_fixed(&self) -> bool { - self.computed.as_ref().is_some_and(|computed| { + /// Whether this table uses the fixed table-layout algorithm after the + /// preferred inline-size eligibility rule is applied. + /// + /// `table-layout: fixed` is not sufficient on its own. CSS Tables routes + /// an automatic or max-content-sized table through automatic layout; + /// Blink exposes this combined decision as `IsFixedTableLayout()`. + pub(crate) fn uses_fixed_table_layout(&self) -> bool { + let authored_fixed = self.computed.as_ref().is_some_and(|computed| { computed.clone_table_layout() == style::computed_values::table_layout::T::Fixed - }) + }); + if !authored_fixed { + return false; + } + let preferred_inline_size = self.writing_mode().to_logical(self.taffy.size).inline_size; + !preferred_inline_size.is_auto() && !preferred_inline_size.is_max_content() } pub(crate) fn table_border_is_collapsed(&self) -> bool { diff --git a/moli-layout/src/table.rs b/moli-layout/src/table.rs index 8746b4a7d..1b60fb200 100644 --- a/moli-layout/src/table.rs +++ b/moli-layout/src/table.rs @@ -31,9 +31,10 @@ mod columns; pub(crate) use collapsed_borders::CollapsedTableBorders; use collapsed_borders::{prepare_collapsed_table_borders, set_collapsed_border_geometry}; use columns::{ - AutomaticTableSizingTarget, TableCellInlineConstraint, TableCellSpanConstraint, - TableColumnConstraint, TableLayoutMode, apply_cell_constraints, compute_grid_inline_min_max, - distribute_auto_columns, distribute_fixed_columns, fixed_grid_min_inline_size, + AutomaticTableSizingTarget, TABLE_MAX_INLINE_SIZE, TableCellInlineConstraint, + TableCellSpanConstraint, TableColumnConstraint, TableLayoutMode, apply_cell_constraints, + compute_grid_inline_min_max, distribute_auto_columns, distribute_fixed_columns, + fixed_grid_min_inline_size, }; #[derive(Clone)] @@ -137,6 +138,37 @@ struct TableContext { writing_mode: WritingMode, } +/// Parent-facing min/max-content sizes of the complete table wrapper. +/// +/// Column constraints produce GRID_MIN/GRID_MAX. CSS Tables adds one wrapper +/// rule after that calculation: a percentage-dependent fixed table has an +/// effectively unbounded max-content contribution. Keeping the wrapper result +/// separate prevents that rule from contaminating final column distribution. +#[derive(Clone, Copy, Debug, PartialEq)] +struct TableIntrinsicInlineSizes { + min_content: f32, + max_content: f32, +} + +impl TableIntrinsicInlineSizes { + fn from_grid( + grid: columns::TableGridInlineMinMax, + layout_mode: TableLayoutMode, + preferred_inline_size: Dimension, + ) -> Self { + let max_content = + if layout_mode.is_fixed() && preferred_inline_size.may_have_percentage_dependence() { + TABLE_MAX_INLINE_SIZE + } else { + grid.max + }; + Self { + min_content: grid.min, + max_content: max_content.max(grid.min), + } + } +} + pub(crate) fn prepare_table_layout_trees(world: &mut LayoutWorld) where N: Copy + Debug + Eq + Hash, @@ -319,7 +351,7 @@ where let mut columns = Vec::new(); let mut max_columns = 0usize; let mut column_tracks = Vec::new(); - let layout_mode = if root_style.table_layout_is_fixed() { + let layout_mode = if root_style.uses_fixed_table_layout() { TableLayoutMode::Fixed } else { TableLayoutMode::Automatic @@ -463,10 +495,16 @@ impl TableContext { undistributable_space, self.layout_mode, ); + let preferred_inline_size = self.writing_mode.to_logical(self.style.size).inline_size; + let intrinsic_inline_sizes = TableIntrinsicInlineSizes::from_grid( + grid_min_max, + self.layout_mode, + preferred_inline_size, + ); let used_inline_size = self.resolve_used_inline_size( inputs, - grid_min_max.min, - grid_min_max.max, + grid_min_max, + intrinsic_inline_sizes, inline_insets, ); let assignable_inline_size = (used_inline_size - undistributable_space).max(0.0); @@ -517,16 +555,22 @@ impl TableContext { fn resolve_used_inline_size( &self, inputs: LayoutInput, - grid_min: f32, - grid_max: f32, + grid: columns::TableGridInlineMinMax, + intrinsic: TableIntrinsicInlineSizes, inline_insets: f32, ) -> f32 { let space = inputs.constraint_space(self.writing_mode); + let (min_content, max_content) = + if space.sizing_purpose == SizingPurpose::IntrinsicContribution { + (intrinsic.min_content, intrinsic.max_content) + } else { + (grid.min, grid.max) + }; let available = space.available_size.inline_size; let fit_content = || match available { - AvailableSpace::Definite(value) => grid_min.max(value.max(0.0).min(grid_max)), - AvailableSpace::MinContent => grid_min, - AvailableSpace::MaxContent => grid_max, + AvailableSpace::Definite(value) => min_content.max(value.max(0.0).min(max_content)), + AvailableSpace::MinContent => min_content, + AvailableSpace::MaxContent => max_content, }; let logical_size = self.writing_mode.to_logical(self.style.size); let logical_min_size = self.writing_mode.to_logical(self.style.min_size); @@ -539,16 +583,16 @@ impl TableContext { }; let resolve_dimension = |dimension: Dimension| { if dimension.is_min_content() { - Some(grid_min) + Some(min_content) } else if dimension.is_max_content() { - Some(grid_max) + Some(max_content) } else if dimension.is_fit_content() { Some(fit_content()) } else if dimension.is_stretch() { match available { AvailableSpace::Definite(value) => Some(value.max(0.0)), - AvailableSpace::MinContent => Some(grid_min), - AvailableSpace::MaxContent => Some(grid_max), + AvailableSpace::MinContent => Some(min_content), + AvailableSpace::MaxContent => Some(max_content), } } else { dimension @@ -574,7 +618,7 @@ impl TableContext { .or(preferred) .unwrap_or_else(fit_content); if !self.layout_mode.is_fixed() { - used = used.max(grid_min); + used = used.max(min_content); } if let Some(max_size) = max_size { used = used.min(max_size); @@ -1446,3 +1490,46 @@ where self.context.detailed = Some(detailed_grid_info); } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percentage_dependent_fixed_table_has_unbounded_parent_max_content_size() { + let grid = columns::TableGridInlineMinMax { min: 4.0, max: 4.0 }; + + assert_eq!( + TableIntrinsicInlineSizes::from_grid( + grid, + TableLayoutMode::Fixed, + Dimension::percent(1.0), + ), + TableIntrinsicInlineSizes { + min_content: 4.0, + max_content: TABLE_MAX_INLINE_SIZE, + }, + ); + assert_eq!( + TableIntrinsicInlineSizes::from_grid( + grid, + TableLayoutMode::Automatic, + Dimension::percent(1.0), + ), + TableIntrinsicInlineSizes { + min_content: 4.0, + max_content: 4.0, + }, + ); + assert_eq!( + TableIntrinsicInlineSizes::from_grid( + grid, + TableLayoutMode::Fixed, + Dimension::length(40.0), + ), + TableIntrinsicInlineSizes { + min_content: 4.0, + max_content: 4.0, + }, + ); + } +} diff --git a/moli-layout/src/table/columns.rs b/moli-layout/src/table/columns.rs index ab35f0709..f00010b5a 100644 --- a/moli-layout/src/table/columns.rs +++ b/moli-layout/src/table/columns.rs @@ -1,9 +1,18 @@ mod auto; pub(super) use auto::{ - AutomaticTableSizingTarget, compute_grid_inline_min_max, distribute_auto_columns, + AutomaticTableSizingTarget, TableGridInlineMinMax, compute_grid_inline_min_max, + distribute_auto_columns, }; +/// Finite stand-in for an unbounded CSS table inline-size contribution. +/// +/// Blink uses the same one-million CSS pixel ceiling for percentage-dependent +/// fixed tables and for percentage column systems that have no finite maximum. +/// Keeping the ceiling beside the shared table constraint types ensures those +/// two parent-facing cases cannot drift apart. +pub(super) const TABLE_MAX_INLINE_SIZE: f32 = 1_000_000.0; + /// Cell rows consulted while collecting authored table column constraints. /// /// CSS fixed layout is defined by columns and the first visual row. Automatic diff --git a/moli-layout/src/table/columns/auto.rs b/moli-layout/src/table/columns/auto.rs index 980193f55..2979739cf 100644 --- a/moli-layout/src/table/columns/auto.rs +++ b/moli-layout/src/table/columns/auto.rs @@ -1,8 +1,8 @@ -use super::{TableCellSpanConstraint, TableColumnConstraint, TableLayoutMode}; +use super::{ + TABLE_MAX_INLINE_SIZE, TableCellSpanConstraint, TableColumnConstraint, TableLayoutMode, +}; use crate::LAYOUT_SUBPIXELS_PER_CSS_PIXEL; -const TABLE_MAX_INLINE_SIZE: f32 = 1_000_000.0; - /// Compare sizes at Blink's 26.6 `LayoutUnit` boundary. /// /// Table distribution still uses floats internally, but the exact-maximum diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index fef4eadc2..24a37f24a 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -77,6 +77,28 @@ impl LayoutBoxKind { matches!(self, Self::Text) } + /// Whether this box is one of CSS Display's internal table boxes. + /// + /// A table wrapper and its caption are deliberately excluded. Properties + /// such as `aspect-ratio` apply to those boxes, but not to row groups, + /// rows, columns, or cells. Anonymous repair boxes obey the same used-value + /// boundary as their source-backed counterparts. + pub(crate) const fn is_internal_table_box(self) -> bool { + matches!( + self, + Self::TableRowGroup + | Self::TableHeaderGroup + | Self::TableFooterGroup + | Self::TableColumnGroup + | Self::TableColumn + | Self::TableRow + | Self::TableCell + | Self::AnonymousTableRowGroup + | Self::AnonymousTableRow + | Self::AnonymousTableCell + ) + } + pub(crate) const fn debug_name(self) -> &'static str { match self { Self::PrincipalBlock => "principal-block", @@ -553,6 +575,9 @@ impl LayoutBox { /// Resolve the used ratio at the layout-node boundary, after both authored /// style and natural replaced-element sizing are available. pub(crate) fn resolved_aspect_ratio(&self) -> Option { + if self.kind.is_internal_table_box() { + return None; + } let natural_ratio = self .replaced_context .and_then(|context| context.inherent_ratio()); @@ -1061,9 +1086,18 @@ where element_semantics: Option, anonymous_reason: Option, kind: LayoutBoxKind, - style: ResolvedLayoutStyle, + mut style: ResolvedLayoutStyle, text: Option>, ) -> LayoutBox { + if kind.is_internal_table_box() { + // Keep the authored/computed value in `preferred_aspect_ratio`. + // Only the numeric backend projection is suppressed: CSS Sizing + // excludes internal table boxes from `aspect-ratio`, and the table + // formatter supplies their used track and cell sizes. Chromium + // represents the same boundary with its table-cell constraint + // space before generic block-size resolution. + style.taffy.aspect_ratio = None; + } let capability_diagnostics = default_capability_diagnostics(kind, element_semantics.as_ref(), &style); LayoutBox { diff --git a/moli-layout/tests/box_builder_contract.rs b/moli-layout/tests/box_builder_contract.rs index 8c5d9b382..ca152e0b1 100644 --- a/moli-layout/tests/box_builder_contract.rs +++ b/moli-layout/tests/box_builder_contract.rs @@ -584,6 +584,43 @@ fn grid_direct_text_uses_one_anonymous_grid_item_and_keeps_text_boundaries() { assert!(root.capability_diagnostics().is_empty()); } +#[test] +fn flex_and_grid_distinguish_css_whitespace_from_non_breaking_space_items() { + let cases = [ + (LayoutDisplay::Flex, LayoutBoxKind::AnonymousFlexItem), + (LayoutDisplay::Grid, LayoutBoxKind::AnonymousGridItem), + ]; + + for (container_display, anonymous_kind) in cases { + let source = TestSource { + root: 0, + nodes: vec![ + TestNode::element("root", vec![1, 2, 3]), + TestNode::text("css-whitespace", " \t\n"), + TestNode::element("item", Vec::new()), + TestNode::text("non-breaking-space", "\u{00a0}"), + ], + }; + let mut styles = TestStyles::default(); + styles.primary.insert(0, style(container_display)); + styles.primary.insert(2, style(LayoutDisplay::Block)); + + let world = build_layout_world(&source, &mut styles).unwrap(); + let root = world.box_by_id(world.root()).unwrap(); + assert_eq!(root.children().len(), 2, "display={container_display:?}"); + assert_eq!(world.source_box(1), None, "display={container_display:?}"); + assert_eq!( + world.box_by_id(root.children()[1]).unwrap().kind(), + anonymous_kind, + "display={container_display:?}", + ); + assert!( + world.source_box(3).is_some(), + "display={container_display:?}" + ); + } +} + #[test] fn contents_text_is_an_item_of_the_flattened_flex_or_grid_container() { let cases = [ diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_box_generation.rs b/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_box_generation.rs new file mode 100644 index 000000000..ed0d1b9d5 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_box_generation.rs @@ -0,0 +1,56 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn screenshot_omits_whitespace_only_flex_and_grid_items_even_when_preserved() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/grid-whitespace-box-generation.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +document.body.innerHTML = ` +
\t\n
\r\f
+
 
+
text
+
\t\n
\r\f
+
text
`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(400, 200, 1.0))? + .expect("Grid whitespace box-generation screenshot layout"); + + let geometry = page_vm.vm_mut().eval( + r#"JSON.stringify(Object.fromEntries(['grid-whitespace','grid-nbsp','grid-text','flex-whitespace','flex-text'].map(id=>{const host=document.getElementById(id),item=host.querySelector('.item'),hostRect=host.getBoundingClientRect(),itemRect=item.getBoundingClientRect(),itemX=itemRect.x-hostRect.x;if(id.startsWith('grid-'))return [id,{width:hostRect.width,itemX,columns:getComputedStyle(host).gridTemplateColumns}];if(id==='flex-whitespace')return [id,{width:hostRect.width,itemX}];return [id,{anonymousItem:itemX>0&&hostRect.width>itemRect.width}]})))"#, + )?; + let geometry: serde_json::Value = serde_json::from_str(&geometry)?; + assert_eq!( + geometry, + serde_json::json!({ + "grid-whitespace": {"width": 20, "itemX": 0, "columns": "20px"}, + "grid-nbsp": {"width": 40, "itemX": 20, "columns": "20px 20px"}, + "grid-text": {"width": 40, "itemX": 20, "columns": "20px 20px"}, + "flex-whitespace": {"width": 20, "itemX": 0}, + "flex-text": {"anonymousItem": true}, + }), + "only CSS whitespace sequences must disappear before Grid item generation; non-breaking spaces and ordinary text must retain their anonymous item", + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("Grid whitespace box-generation fixture should run"); +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_paint_order.rs b/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_paint_order.rs new file mode 100644 index 000000000..727732985 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_paint_order.rs @@ -0,0 +1,79 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn screenshot_paints_flex_and_grid_items_as_atomic_inline_level_boxes() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/flex-grid-atomic-paint.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +const content = className => ``; +document.body.innerHTML = ` +
${content('content')}
+
${content('content')}
+
${content('content')}
+
${content('content')}
+
${content('content')}
+
`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + let snapshot = page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(120, 660, 1.0))? + .expect("flex/grid atomic-paint fixture should retain a layout root"); + let raster = moli_paint::raster_snapshot(&snapshot)?; + let pixel = |x: u32, y: u32| { + let index = ((y * raster.width + x) * 4) as usize; + <[u8; 4]>::try_from(&raster.rgba[index..index + 4]).expect("RGBA pixel") + }; + + for (label, y) in [ + ("grid item", 50), + ("inline-grid item", 160), + ("flex item", 270), + ("order-modified grid item", 380), + ] { + assert_eq!( + pixel(50, y), + [0, 128, 0, 255], + "{label} descendants must not escape the item's atomic paint boundary", + ); + } + assert_eq!( + pixel(50, 490), + [255, 0, 0, 255], + "a non-auto z-index on a static grid item must still establish a stacking context", + ); + assert_eq!( + pixel(50, 600), + [255, 0, 0, 255], + "a positioned descendant of an atomic grid item must participate in the parent stacking context", + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("flex/grid atomic-paint fixture should run"); +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index b92a11843..77ceb8fa2 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -124,6 +124,8 @@ mod element_toggle_event; mod fetch_xhr; mod file_entry_file_callback; mod file_system_directory_reader; +mod grid_item_box_generation; +mod grid_item_paint_order; mod grid_resolved_track_values; mod hash_change_delivery; mod history_traversal; diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/preferred_aspect_ratio.rs b/moli-renderer-v8/src/runtime/page_vm/tests/preferred_aspect_ratio.rs index f4b2098b6..b28a20690 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/preferred_aspect_ratio.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/preferred_aspect_ratio.rs @@ -89,3 +89,68 @@ document.body.innerHTML = ` .await .expect("preferred aspect-ratio fixture should run"); } + +/// Regression for WPT css/css-sizing/aspect-ratio/table-element-001.html. +#[tokio::test(flavor = "current_thread")] +async fn screenshot_keeps_preferred_ratios_out_of_internal_table_box_sizing() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/internal-table-box-aspect-ratio.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +document.body.innerHTML = ` + + + + + + +
+
`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + let snapshot = page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(220, 120, 1.0))? + .expect("internal-table-box aspect-ratio fixture must retain a layout root"); + + let geometry = page_vm.vm_mut().eval( + r#"JSON.stringify(Object.fromEntries(['internal','row','cell','empty-a','empty-b','wrapper'].map(id=>{const element=document.getElementById(id);const rect=element.getBoundingClientRect();return [id,{rect:[rect.x,rect.y,rect.width,rect.height],ratio:getComputedStyle(element).aspectRatio}]})))"#, + )?; + assert_eq!( + serde_json::from_str::(&geometry)?, + serde_json::json!({ + "internal": {"rect": [0, 0, 100, 50], "ratio": "auto"}, + "row": {"rect": [0, 0, 100, 50], "ratio": "auto"}, + "cell": {"rect": [0, 0, 100, 50], "ratio": "1 / 1"}, + "empty-a": {"rect": [100, 0, 0, 50], "ratio": "4 / 1"}, + "empty-b": {"rect": [100, 0, 0, 50], "ratio": "4 / 1"}, + "wrapper": {"rect": [0, 50, 100, 50], "ratio": "2 / 1"}, + }), + "internal table boxes must retain computed ratios without consuming them as used sizes, while the table wrapper still consumes its ratio", + ); + + let raster = moli_paint::raster_snapshot(&snapshot)?; + let pixel = |x: u32, y: u32| -> [u8; 4] { + let offset = ((y * raster.width + x) * 4) as usize; + raster.rgba[offset..offset + 4].try_into().unwrap() + }; + assert_eq!(pixel(99, 99), [0, 128, 0, 255]); + assert_eq!(pixel(100, 99), [255, 255, 255, 255]); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("internal-table-box aspect-ratio fixture should run"); +} diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index fa1253ad3..4ad3a1908 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -1038,6 +1038,88 @@ td{padding:0;border:0;height:10px} })); } + #[test] + fn fixed_percentage_table_exports_its_parent_facing_max_content_size() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime should build"); + + runtime.block_on(tokio::task::LocalSet::new().run_until(async move { + let snapshot = render_test_snapshot( + r#" +
+
+
+
+"#, + ) + .await; + + // Chromium gives a percentage-dependent fixed table an + // effectively unbounded max-content contribution. The wrapper + // therefore fills the 100px opportunity, while final layout still + // resolves the authored width normally. Definite and automatic + // controls must retain their ordinary finite contributions. + for (color, expected_width) in [ + (rgb(201, 11, 11), 100.0), + (rgb(201, 12, 12), 100.0), + (rgb(202, 11, 11), 100.0), + (rgb(202, 12, 12), 40.0), + (rgb(203, 11, 11), 40.0), + (rgb(203, 12, 12), 40.0), + (rgb(204, 11, 11), 4.0), + (rgb(204, 12, 12), 4.0), + ] { + let rect = solid_paint_rect(&snapshot, color); + assert!( + (rect.width - expected_width).abs() <= 0.01, + "expected width {expected_width}, got {rect:?}", + ); + } + })); + } + + #[test] + fn table_layout_fixed_with_auto_width_uses_automatic_column_measurement() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime should build"); + + runtime.block_on(tokio::task::LocalSet::new().run_until(async move { + let snapshot = render_test_snapshot( + r#"
"#, + ) + .await; + + // `table-layout: fixed` selects the fixed algorithm only when the + // table has an eligible non-auto preferred width. Chromium keeps + // the computed property value but measures this table with the + // automatic algorithm, so the second row contributes 80px. + for color in [rgb(205, 11, 11), rgb(205, 12, 12), rgb(205, 13, 13)] { + let rect = solid_paint_rect(&snapshot, color); + assert!( + (rect.width - 80.0).abs() <= 0.01, + "auto-width table should measure every row: {rect:?}", + ); + } + })); + } + #[test] fn automatic_table_layout_collects_authored_widths_after_a_colspan_header() { let runtime = tokio::runtime::Builder::new_current_thread()