From ceeae06387c2cd614cfc493b4dcc8bdab394ff0c Mon Sep 17 00:00:00 2001 From: ldm0 Date: Fri, 18 Sep 2026 11:50:34 +0800 Subject: [PATCH] Fix table cell containing blocks and measured baselines --- Cargo.lock | 2 +- Cargo.toml | 2 +- moli-layout/src/table.rs | 10 + moli-layout/src/table/block.rs | 89 +++++++-- moli-layout/src/taffy_tree.rs | 69 ++++++- moli-layout/src/taffy_tree/measurement.rs | 171 ++++++++++++++++++ moli-layout/src/world.rs | 6 + .../runtime/page_vm/tests/rendering_update.rs | 78 ++++++++ 8 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 moli-layout/src/taffy_tree/measurement.rs diff --git a/Cargo.lock b/Cargo.lock index 1406d1ee2b..4b045247c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5155,7 +5155,7 @@ dependencies = [ [[package]] name = "taffy" version = "0.13.0" -source = "git+https://github.com/ldm0/taffy?rev=e4e8f77c4301ba917cca96b9d67b8000c5a06995#e4e8f77c4301ba917cca96b9d67b8000c5a06995" +source = "git+https://github.com/ldm0/taffy?rev=acc16f5b0d570656c504a44b52f1736f05991a40#acc16f5b0d570656c504a44b52f1736f05991a40" dependencies = [ "arrayvec", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1dd29a0f8c..a6860a40ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,7 +106,7 @@ stylo_atoms = { git = "https://github.com/ldm0/stylo", rev = "671d13d31b3b00e7b1 stylo_dom = { git = "https://github.com/ldm0/stylo", rev = "671d13d31b3b00e7b1e0088ba2b36a4e35c0858c" } stylo_static_prefs = { git = "https://github.com/ldm0/stylo", rev = "671d13d31b3b00e7b1e0088ba2b36a4e35c0858c" } stylo_traits = { git = "https://github.com/ldm0/stylo", rev = "671d13d31b3b00e7b1e0088ba2b36a4e35c0858c" } -taffy = { git = "https://github.com/ldm0/taffy", rev = "e4e8f77c4301ba917cca96b9d67b8000c5a06995" } +taffy = { git = "https://github.com/ldm0/taffy", rev = "acc16f5b0d570656c504a44b52f1736f05991a40" } [workspace.lints.clippy] disallowed_methods = "deny" diff --git a/moli-layout/src/table.rs b/moli-layout/src/table.rs index 38174831c3..ba88d37512 100644 --- a/moli-layout/src/table.rs +++ b/moli-layout/src/table.rs @@ -2005,7 +2005,17 @@ mod tests { }, ..LayoutInput::HIDDEN }; + let before: Vec<_> = world.boxes.iter().map(|b| b.unrounded_layout).collect(); let cold = world.compute_child_layout(root.to_taffy(), inputs); + assert_eq!( + world + .boxes + .iter() + .map(|b| b.unrounded_layout) + .collect::>(), + before, + "complete baseline measurement must not publish numeric layouts" + ); assert_eq!( cold.size, Size { diff --git a/moli-layout/src/table/block.rs b/moli-layout/src/table/block.rs index 7bbf3b6a06..85851e8e00 100644 --- a/moli-layout/src/table/block.rs +++ b/moli-layout/src/table/block.rs @@ -231,7 +231,7 @@ impl TableContext { context: self, }; let output = wrapper.with_grid_cell_style(index, |world, cell| { - world.compute_child_layout(cell.to_taffy(), measure_inputs) + world.measure_complete_layout(cell.to_taffy(), measure_inputs) }); self.cells[index].style.padding = final_padding.map(style_helpers::length); for (id, style) in restored { @@ -324,6 +324,62 @@ impl TableContext { + cell.row_span.saturating_sub(1) as f32 * self.block_border_spacing; layout.definite |= !preferred.is_auto() && layout.size > layout.natural_size; } + // Percentage descendants can acquire a different baseline once the + // row heights are known. Return that baseline even during ComputeSize: + // a parent table uses it to solve its own ascent/descent constraints. + // These probes retain content bounds without publishing child layouts. + for row in &mut rows { + row.ascent = None; + } + for index in 0..self.cells.len() { + let cell = &self.cells[index]; + let layout = cell.block_layout.unwrap(); + if layout.baseline.is_none() { + continue; + } + let inline = self.column_sizes[cell.column..cell.column + cell.column_span] + .iter() + .sum::() + + self.inline_border_spacing * cell.column_span.saturating_sub(1) as f32; + let measure_inputs = LayoutInput { + known_dimensions: Size { + width: Some(inline), + height: None, + }, + definite_dimensions: Size { + width: Some(inline), + height: None, + }, + parent_size: Size { + width: Some(cell_percentage_basis), + height: None, + }, + parent_writing_mode: mode, + available_space: Size { + width: AvailableSpace::Definite(inline), + height: AvailableSpace::Definite(layout.size), + }, + run_mode: RunMode::ComputeSize, + sizing_mode: SizingMode::InherentSize, + sizing_purpose: SizingPurpose::Layout, + axis: RequestedAxis::Both, + block_auto_behavior: AutoSizeBehavior::FitContent, + vertical_margins_are_collapsible: Line::FALSE, + }; + let mut wrapper = TableTreeWrapper { + world, + context: self, + }; + let output = wrapper.with_grid_cell_style(index, |world, cell| { + layout_cell(world, cell, measure_inputs, mode, Some(layout)) + }); + let cell = &mut self.cells[index]; + cell.block_layout.as_mut().unwrap().baseline = output.first_baselines.y; + if let Some(baseline) = output.first_baselines.y { + let row = &mut rows[cell.row]; + row.ascent = Some(row.ascent.unwrap_or(0.0).max(baseline)); + } + } let mut tracks = Vec::new(); // Empty sections take up height, but do not introduce cell spacing. // With such sections, explicit spacer tracks express the gaps before @@ -459,24 +515,23 @@ where return world.compute_child_layout(cell.to_taffy(), inputs); }; set_block(mode, &mut inputs.parent_size, None); - set_block( - mode, - &mut inputs.known_dimensions, - layout.definite.then_some(layout.size), - ); + set_block(mode, &mut inputs.known_dimensions, Some(layout.size)); set_block( mode, &mut inputs.definite_dimensions, layout.definite.then_some(layout.size), ); - if !layout.definite { - set_block( - mode, - &mut inputs.available_space, - AvailableSpace::MaxContent, - ); - } - let mut output = world.compute_child_layout(cell.to_taffy(), inputs); + // The used border box is always the absolute containing block, including + // auto-height cells. Normal-flow percentages need a separate guarantee. + let previous = world + .table_cell_percentage_height + .replace((cell, layout.definite)); + let mut output = if inputs.run_mode == RunMode::ComputeSize { + world.measure_complete_layout(cell.to_taffy(), inputs) + } else { + world.compute_child_layout(cell.to_taffy(), inputs) + }; + world.table_cell_percentage_height = previous; let free = (layout.size - mode.to_logical(output.size).block_size).max(0.0); let alignment = world.boxes[cell.index()].style.taffy.align_content; let offset = if layout.baseline.is_some() { @@ -509,11 +564,9 @@ where .resolve_or_zero(inputs.parent_size.width, resolve_stylo_calc_value); // The natural border box includes empty block children whose // overflow content size can be zero (for example an empty group). - (output.size.height - padding.bottom - border.bottom).max(0.0) - } else if inputs.run_mode == RunMode::PerformLayout { - (output.content_size.height - padding.bottom).max(0.0) + (layout.natural_size - padding.bottom - border.bottom).max(0.0) } else { - layout.baseline.unwrap_or(0.0) + (output.content_size.height - padding.bottom).max(0.0) }); } output.content_size.height += offset; diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 73026a17a4..3740112246 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -33,6 +33,9 @@ use crate::{ world::InlineStaticPosition, }; +mod measurement; +use measurement::FullMeasurementTree; + pub(crate) struct PreparedWorldLayout { positioned_static_sources: Vec, numeric_unrounded_layouts: Vec, @@ -1684,6 +1687,9 @@ where .store(inputs, output); return output; } + if self.measure_baselines && inputs.run_mode == RunMode::ComputeSize { + return self.compute_child_layout_uncached(node_id, inputs, None); + } compute_cached_layout(self, node_id, inputs, |world, node_id, inputs| { world.compute_child_layout_uncached(node_id, inputs, None) }) @@ -1749,6 +1755,17 @@ where self.get_core_container_style(child_node_id) } + fn get_block_percentage_resolution_height( + &self, + node_id: NodeId, + height: Option, + ) -> Option { + match self.table_cell_percentage_height { + Some((cell, false)) if cell.to_taffy() == node_id => None, + _ => height, + } + } + fn compute_block_child_layout( &mut self, node_id: NodeId, @@ -1869,6 +1886,19 @@ impl LayoutWorld where N: Copy + Debug + Eq + Hash, { + /// Obtain baselines and content bounds without publishing numeric or paint layout. + pub(crate) fn measure_complete_layout( + &mut self, + node_id: NodeId, + inputs: LayoutInput, + ) -> LayoutOutput { + debug_assert_eq!(inputs.run_mode, RunMode::ComputeSize); + let previous = std::mem::replace(&mut self.measure_baselines, true); + let output = self.compute_child_layout(node_id, inputs); + self.measure_baselines = previous; + output + } + fn should_hide(&self, node_id: NodeId, inputs: LayoutInput) -> bool { inputs.run_mode == RunMode::PerformHiddenLayout || self.boxes[LayoutBoxId::from_taffy(node_id).index()] @@ -1908,6 +1938,29 @@ where return self.compute_inline_formatting_context(id, inputs, block_context); } + if self.measure_baselines + && inputs.run_mode == RunMode::ComputeSize + && !matches!( + kind, + LayoutBoxKind::TableWrapper + | LayoutBoxKind::InlineTableWrapper + | LayoutBoxKind::AnonymousTableWrapper + ) + { + let mut tree = FullMeasurementTree(self); + let full_inputs = LayoutInput { + run_mode: RunMode::PerformLayout, + ..inputs + }; + return if display.is_flex_container() { + compute_flexbox_layout(&mut tree, node_id, full_inputs) + } else if display.is_grid_container() { + compute_grid_layout(&mut tree, node_id, full_inputs) + } else { + compute_block_layout(&mut tree, node_id, full_inputs, block_context) + }; + } + // Pseudo origins retain a pseudo-specific box kind, so their computed // display cannot be recovered from the kind. Dispatch their formatting // context exactly like a principal box. Table remains the explicit @@ -2059,6 +2112,17 @@ where } else { inputs }; + // The leaf adapter's size-only shortcut drops the line baselines. + // Only the adapter needs full measurement: the original inputs still + // keep Parley on its measurement path without final paint fragments. + let leaf_inputs = if self.measure_baselines && inputs.run_mode == RunMode::ComputeSize { + LayoutInput { + run_mode: RunMode::PerformLayout, + ..leaf_inputs + } + } else { + leaf_inputs + }; let alignment = self.boxes[id.index()].style.text_align(); let mut inline_context = self.boxes[id.index()] .inline_layout @@ -2218,7 +2282,7 @@ where reset_inline_layout_for_probe(layout); let parent_writing_mode = self.boxes[owner.index()].style.writing_mode(); - let child_inputs = LayoutInput { + let mut child_inputs = LayoutInput { run_mode: inputs.run_mode, sizing_mode: SizingMode::InherentSize, sizing_purpose: inputs.sizing_purpose, @@ -2231,6 +2295,9 @@ where block_auto_behavior: AutoSizeBehavior::FitContent, vertical_margins_are_collapsible: Line::FALSE, }; + if self.table_cell_percentage_height == Some((owner, false)) { + child_inputs.parent_size.height = None; + } // A float's max-content contribution is measured independently from // the finite line slot it will eventually occupy. Final fit-content // layout still uses the IFC owner's content width; it must not use diff --git a/moli-layout/src/taffy_tree/measurement.rs b/moli-layout/src/taffy_tree/measurement.rs new file mode 100644 index 0000000000..2107326551 --- /dev/null +++ b/moli-layout/src/taffy_tree/measurement.rs @@ -0,0 +1,171 @@ +//! Retain the complete numeric result of Taffy's algorithms during measurement. +//! +//! Their size-only shortcuts may omit baselines and content bounds. Run the +//! numeric algorithm through its final phase, but discard layout writes and keep +//! descendant callbacks in measurement mode. In particular, Parley and nested +//! tables must not publish fragments or structural geometry during this probe. +use super::*; + +pub(super) struct FullMeasurementTree<'a, N: Copy + Debug + Eq + Hash>(pub &'a mut LayoutWorld); + +impl TraversePartialTree for FullMeasurementTree<'_, N> { + type ChildIter<'a> + = ChildIter<'a> + where + Self: 'a; + + fn child_ids(&self, node_id: NodeId) -> Self::ChildIter<'_> { + self.0.child_ids(node_id) + } + + fn child_count(&self, node_id: NodeId) -> usize { + self.0.child_count(node_id) + } + + fn get_child_id(&self, node_id: NodeId, index: usize) -> NodeId { + self.0.get_child_id(node_id, index) + } +} + +impl LayoutPartialTree for FullMeasurementTree<'_, N> { + type CoreContainerStyle<'a> + = &'a Style + where + Self: 'a; + type CustomIdent = Atom; + + fn get_core_container_style(&self, node_id: NodeId) -> Self::CoreContainerStyle<'_> { + self.0.get_core_container_style(node_id) + } + + fn get_writing_mode(&self, node_id: NodeId) -> taffy::WritingMode { + self.0.get_writing_mode(node_id) + } + + fn get_scrollbar_insets(&self, node_id: NodeId) -> taffy::Rect { + self.0.get_scrollbar_insets(node_id) + } + + fn get_resolved_aspect_ratio(&self, node_id: NodeId) -> Option { + self.0.get_resolved_aspect_ratio(node_id) + } + + fn resolve_calc_value(&self, value: *const (), basis: f32) -> f32 { + self.0.resolve_calc_value(value, basis) + } + + fn set_unrounded_layout(&mut self, _: NodeId, _: &Layout) {} + + fn compute_child_layout(&mut self, node_id: NodeId, inputs: LayoutInput) -> LayoutOutput { + if self.0.should_hide(node_id, inputs) { + return LayoutOutput::HIDDEN; + } + self.0.compute_child_layout( + node_id, + LayoutInput { + run_mode: RunMode::ComputeSize, + ..inputs + }, + ) + } + + fn compute_child_size( + &mut self, + node_id: NodeId, + inputs: LayoutInput, + ) -> taffy::IntrinsicSizeResult { + // Intrinsic probes consume only size and provenance, so they can keep + // the compact cache and the algorithms' ordinary size-only shortcuts. + let previous = std::mem::replace(&mut self.0.measure_baselines, false); + let output = self.0.compute_child_size(node_id, inputs); + self.0.measure_baselines = previous; + output + } +} + +impl LayoutBlockContainer for FullMeasurementTree<'_, N> { + type BlockContainerStyle<'a> + = &'a Style + where + Self: 'a; + type BlockItemStyle<'a> + = &'a Style + where + Self: 'a; + + fn get_block_container_style(&self, node_id: NodeId) -> Self::BlockContainerStyle<'_> { + self.0.get_block_container_style(node_id) + } + + fn get_block_child_style(&self, node_id: NodeId) -> Self::BlockItemStyle<'_> { + self.0.get_block_child_style(node_id) + } + + fn get_block_percentage_resolution_height( + &self, + node_id: NodeId, + height: Option, + ) -> Option { + self.0 + .get_block_percentage_resolution_height(node_id, height) + } + + fn compute_block_child_layout( + &mut self, + node_id: NodeId, + inputs: LayoutInput, + block_context: Option<&mut BlockContext<'_>>, + ) -> LayoutOutput { + if self.0.should_hide(node_id, inputs) { + return LayoutOutput::HIDDEN; + } + self.0.compute_block_child_layout( + node_id, + LayoutInput { + run_mode: RunMode::ComputeSize, + ..inputs + }, + block_context, + ) + } +} + +impl LayoutFlexboxContainer for FullMeasurementTree<'_, N> { + type FlexboxContainerStyle<'a> + = &'a Style + where + Self: 'a; + type FlexboxItemStyle<'a> + = &'a Style + where + Self: 'a; + + fn get_flexbox_container_style(&self, node_id: NodeId) -> Self::FlexboxContainerStyle<'_> { + self.0.get_flexbox_container_style(node_id) + } + + fn get_flexbox_child_style(&self, node_id: NodeId) -> Self::FlexboxItemStyle<'_> { + self.0.get_flexbox_child_style(node_id) + } +} + +impl LayoutGridContainer for FullMeasurementTree<'_, N> { + type GridContainerStyle<'a> + = &'a Style + where + Self: 'a; + type GridItemStyle<'a> + = &'a Style + where + Self: 'a; + + fn get_grid_container_style(&self, node_id: NodeId) -> Self::GridContainerStyle<'_> { + self.0.get_grid_container_style(node_id) + } + + fn get_grid_child_style(&self, node_id: NodeId) -> Self::GridItemStyle<'_> { + self.0.get_grid_child_style(node_id) + } + + fn set_detailed_grid_info(&mut self, _: NodeId, _: DetailedGridInfo) {} +} diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index 24da5a9ef9..dd294f93da 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -609,6 +609,10 @@ where pub(crate) viewport_scroll_policy: ViewportScrollPolicy, pub(crate) viewport_layout: ViewportLayoutState, pub(crate) css_image_references: Vec>, + /// Full numeric measurement retains baselines without publishing layouts. + pub(crate) measure_baselines: bool, + /// The active table cell's used height need not be a percentage guarantee. + pub(crate) table_cell_percentage_height: Option<(LayoutBoxId, bool)>, numeric_layout_tracking: bool, numeric_layout_touched: Vec, numeric_layout_touched_marks: Vec, @@ -628,6 +632,8 @@ where viewport_scroll_policy: ViewportScrollPolicy::default(), viewport_layout: ViewportLayoutState::default(), css_image_references: Vec::new(), + measure_baselines: false, + table_cell_percentage_height: None, numeric_layout_tracking: false, numeric_layout_touched: Vec::new(), numeric_layout_touched_marks: vec![false], diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs index 27feb222d3..381a4fa01c 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs @@ -2872,6 +2872,84 @@ async fn screenshot_preserves_table_cell_dimension_hints_and_avatar_columns() { .expect("table-cell dimension hints should preserve avatar alignment"); } +#[tokio::test(flavor = "current_thread")] +async fn table_cell_absolute_descendants_use_final_geometry() { + run_page_vm_async_test(async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; + let mut page = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/table-cell-absolute.html")?, + ); + page.vm_mut().set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + page.vm_mut().eval(r#" +document.head.innerHTML = ``; +document.body.innerHTML = ` + +
+
+
+
+
`; +"#)?; + for height in [100, 160, 100] { + page.vm_mut().eval(&format!("document.getElementById('tall').style.height='{height}px'"))?; + for _ in 0..2 { + page.vm_mut().screenshot_layout_snapshot(moli_layout::PaintViewport::new(320, 240, 1.0))?.expect("table root"); + let actual = page.vm_mut().eval(r#" +['cell','inflow','percent','fill'].map(id => document.getElementById(id).getBoundingClientRect().height).join('|') +"#)?; + assert_eq!(actual, format!("{height}|0|{}|{height}", height / 2), + "absolute descendants use the final cell box; normal-flow percentages remain indefinite"); + } + } + Ok::<_, anyhow::Error>(()) + }).await.expect("table cell absolute layout should match Chromium"); +} + +#[tokio::test(flavor = "current_thread")] +async fn table_cell_nested_percentage_baseline_reaches_parent_measurement() { + run_page_vm_async_test(async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; + let mut page = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/table-cell-nested-baseline.html")?, + ); + page.vm_mut().set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + page.vm_mut().eval(r#" +document.head.innerHTML = ``; +document.body.innerHTML = `
+ + + +
+
`; +"#)?; + for height in [100, 160, 100] { + page.vm_mut().eval(&format!("document.getElementById('cell').style.height='{height}px'"))?; + for _ in 0..2 { + page.vm_mut().screenshot_layout_snapshot(moli_layout::PaintViewport::new(320, 240, 1.0))?.expect("nested table root"); + let actual = page.vm_mut().eval(r#"(() => { +const rect = id => document.getElementById(id).getBoundingClientRect(); +const outer = rect('outer'); +return [outer.height,rect('inner').height,rect('percent').height, + rect('peer').y-outer.y,rect('reference').y-outer.y].join('|'); +})()"#)?; + assert_eq!(actual, format!("{height}|{height}|{}|{}|{}", height / 2, height / 2 - 20, height / 2 - 50), + "outer row measurement must use the nested table's final percentage-dependent baseline"); + } + } + Ok::<_, anyhow::Error>(()) + }).await.expect("nested percentage baseline should match Chromium"); +} + #[tokio::test(flavor = "current_thread")] async fn screenshot_table_row_heights_match_chromium() { run_page_vm_async_test(async move {