Fix table cell containing blocks and measured baselines

This commit is contained in:
ldm0
2026-09-18 14:40:27 +08:00
parent dd80db23ca
commit ceeae06387
8 changed files with 406 additions and 21 deletions
Generated
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"
+10
View File
@@ -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::<Vec<_>>(),
before,
"complete baseline measurement must not publish numeric layouts"
);
assert_eq!(
cold.size,
Size {
+71 -18
View File
@@ -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::<f32>()
+ 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;
+68 -1
View File
@@ -33,6 +33,9 @@ use crate::{
world::InlineStaticPosition,
};
mod measurement;
use measurement::FullMeasurementTree;
pub(crate) struct PreparedWorldLayout {
positioned_static_sources: Vec<PositionedStaticSource>,
numeric_unrounded_layouts: Vec<Layout>,
@@ -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<f32>,
) -> Option<f32> {
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<N> LayoutWorld<N>
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
+171
View File
@@ -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<N>);
impl<N: Copy + Debug + Eq + Hash> 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<N: Copy + Debug + Eq + Hash> LayoutPartialTree for FullMeasurementTree<'_, N> {
type CoreContainerStyle<'a>
= &'a Style<Atom>
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<f32> {
self.0.get_scrollbar_insets(node_id)
}
fn get_resolved_aspect_ratio(&self, node_id: NodeId) -> Option<taffy::ResolvedAspectRatio> {
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<N: Copy + Debug + Eq + Hash> LayoutBlockContainer for FullMeasurementTree<'_, N> {
type BlockContainerStyle<'a>
= &'a Style<Atom>
where
Self: 'a;
type BlockItemStyle<'a>
= &'a Style<Atom>
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<f32>,
) -> Option<f32> {
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<N: Copy + Debug + Eq + Hash> LayoutFlexboxContainer for FullMeasurementTree<'_, N> {
type FlexboxContainerStyle<'a>
= &'a Style<Atom>
where
Self: 'a;
type FlexboxItemStyle<'a>
= &'a Style<Atom>
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<N: Copy + Debug + Eq + Hash> LayoutGridContainer for FullMeasurementTree<'_, N> {
type GridContainerStyle<'a>
= &'a Style<Atom>
where
Self: 'a;
type GridItemStyle<'a>
= &'a Style<Atom>
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) {}
}
+6
View File
@@ -609,6 +609,10 @@ where
pub(crate) viewport_scroll_policy: ViewportScrollPolicy,
pub(crate) viewport_layout: ViewportLayoutState,
pub(crate) css_image_references: Vec<LayoutCssImageReference<N>>,
/// 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<LayoutBoxId>,
numeric_layout_touched_marks: Vec<bool>,
@@ -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],
@@ -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 = `<style>
body { margin:0 } table { width:200px;border-spacing:0;table-layout:fixed }
td { padding:0;font-size:0;line-height:0;vertical-align:top }
</style>`;
document.body.innerHTML = `<table><tr>
<td id=cell style="position:relative">
<div style="height:10px"></div><div id=inflow style="height:50%"></div>
<div id=percent style="position:absolute;top:0;left:0;height:50%;width:10px"></div>
<div id=fill style="position:absolute;inset:0"></div>
</td><td><div id=tall style="height:100px"></div></td>
</tr></table>`;
"#)?;
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 = `<style>
body { margin:0 } table { width:200px;border-spacing:0;table-layout:fixed }
td { padding:0;font-size:0;line-height:0;vertical-align:baseline }
</style>`;
document.body.innerHTML = `<table id=outer><tr><td>
<table id=inner style="width:100px"><tr>
<td id=cell style="height:100px"><div id=percent style="height:50%;width:10px"></div></td>
<td><div id=peer style="height:20px;width:10px"></div></td>
</tr></table>
</td><td><span id=reference style="display:inline-block;height:50px;width:10px"></span></td></tr></table>`;
"#)?;
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 {