Match table containing block geometry

This commit is contained in:
SGavrl committed 2026-08-03 13:08:04 +00:00
1 parent 17944a4b01
commit 33d94eaf85
2 files changed
+432 -45

No files matched your search

+331 -45
View File
@@ -3459,23 +3459,63 @@ fn layout_dom_once(
.and_then(|n| n.as_element().map(|e| e.local.as_ref() == "table"))
.unwrap_or(false)
})
.map(|(t, d)| (*t, *d, dom_depth(tree, *d)))
.map(|(t, d)| (*t, *d, table_ancestor_depth(tree, *d)))
.collect();
// Deepest table first: an inner table gets its final width set
// before an outer table measures the subtree that contains it.
// Two tables at the same depth cannot be nested in one another
// (nesting increases depth), so their relative order is
// irrelevant and the final layout is deterministic even though
// id_map iteration order is not.
tables.sort_by(|a, b| b.2.cmp(&a.2));
for (tnode, dom, _depth) in tables {
// Outer tables consume their nested tables' intrinsic sizes.
// Only after an outer depth is fixed can a root layout expose
// the real cell width available to the next nested depth.
tables.sort_by(|a, b| a.2.cmp(&b.2));
let mut table_index = 0usize;
while table_index < tables.len() {
let depth = tables[table_index].2;
let mut group_end = table_index + 1;
while group_end < tables.len() && tables[group_end].2 == depth {
group_end += 1;
}
let group = &tables[table_index..group_end];
let mut available_widths: HashMap<taffy::NodeId, f32> = HashMap::new();
let needs_layout_snapshot = group.iter().any(|(_, dom, _)| {
styles.get(dom).is_some_and(|style| {
style.width == crate::Dimension::Auto
&& (depth > 0
|| reliable_table_available_width(
tree,
*dom,
&styles,
initial_cb_width,
)
.is_none())
})
});
if needs_layout_snapshot {
// This pass is gated to layout-dependent containing
// blocks and nested auto tables. Flat tables in normal
// block flow use the reliable cascade-time width below
// and pay no additional full-tree layout.
let _ = taffy_tree.compute_layout_with_measure(
taffy_root,
available,
&mut measure,
);
for &(tnode, dom, _) in group {
if styles
.get(&dom)
.is_some_and(|style| style.width == crate::Dimension::Auto)
{
if let Ok(layout) = taffy_tree.layout(tnode) {
available_widths.insert(tnode, layout.size.width.max(0.0));
}
}
}
}
for &(tnode, dom, _) in group {
let Some(table_style) = styles.get(&dom) else {
continue;
};
// A percentage-width table resolves against its container, so
// leave taffy's percentage handling in place.
let width_style = Some(table_style.width);
if matches!(width_style, Some(crate::Dimension::Percent(_))) {
let width_style = table_style.width;
if matches!(width_style, crate::Dimension::Percent(_)) {
continue;
}
let min_c = {
@@ -3496,7 +3536,10 @@ fn layout_dom_once(
// overflow it. Floor min-content by the widest fixed child,
// matching CSS (a table's min-content is at least any
// fixed-width content it contains).
let min_c = min_c.max(max_definite_descendant_width(tree, dom, &styles).unwrap_or(0.0));
let min_c = min_c.max(
max_definite_table_content_width(tree, dom, &styles)
.unwrap_or(0.0),
);
let max_c = {
let _ = taffy_tree.compute_layout_with_measure(
tnode,
@@ -3507,15 +3550,37 @@ fn layout_dom_once(
};
let inline_outer_edges = table_inline_outer_edges(table_style);
let preferred_outer = match width_style {
Some(crate::Dimension::Px(w))
crate::Dimension::Px(w)
if table_style.box_sizing == crate::BoxSizing::ContentBox =>
{
w + inline_outer_edges
}
Some(crate::Dimension::Px(w)) => w,
crate::Dimension::Px(w) => w,
_ => max_c,
};
let used_outer = preferred_outer.max(min_c).min(initial_cb_width.max(min_c));
let used_outer = match width_style {
// A definite table width is not clamped to its
// containing block. Like other fixed boxes it may
// overflow, while min-content can still make it wider.
crate::Dimension::Px(_) => preferred_outer.max(min_c),
_ => {
let available_outer = available_widths
.get(&tnode)
.copied()
.or_else(|| {
reliable_table_available_width(
tree,
dom,
&styles,
initial_cb_width,
)
})
.unwrap_or(initial_cb_width);
preferred_outer
.max(min_c)
.min(available_outer.max(min_c))
}
};
let used_declaration = if table_style.box_sizing == crate::BoxSizing::ContentBox
{
(used_outer - inline_outer_edges).max(0.0)
@@ -3623,22 +3688,28 @@ fn layout_dom_once(
let target =
(used_outer - inline_outer_edges - interior_spacing)
.max(0.0);
// Specified column widths (a `<col>` or colspan-1 cell
// width, recorded at build time) pin their columns:
// px directly, percent against the table's content
// width. Content min-content still floors them, so a
// too-narrow spec never crushes its content. The
// remaining space interpolates across the auto
// columns exactly as before.
// A specified length on a cell/column is a preferred
// contribution, not a min-content floor. Preserve the
// content minimum and raise only the preferred width so
// a constrained table can interpolate below the hint.
// Percentage tracks retain the existing definite-table
// behavior here; broader percent balancing is separate.
let specified_columns = ifc_items.table_cols.get(&tnode);
if let Some((spec_px, spec_pct)) = specified_columns {
for j in 0..ncols {
let spec = spec_px
let fixed = spec_px
.get(j)
.copied()
.flatten();
if let Some(w) = fixed {
let w = w.max(col_min[j]);
col_max[j] = w;
} else if let Some(w) = spec_pct
.get(j)
.copied()
.flatten()
.or_else(|| spec_pct.get(j).copied().flatten().map(|p| p * target));
if let Some(w) = spec {
.map(|percent| percent * target)
{
let w = w.max(col_min[j]);
col_min[j] = w;
col_max[j] = w;
@@ -3726,6 +3797,8 @@ fn layout_dom_once(
s.size.width = length(used_declaration);
let _ = taffy_tree.set_style(tnode, s);
}
}
table_index = group_end;
}
let _ = taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure);
@@ -6649,22 +6722,159 @@ fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap<NodeId, Rect>) {
}
}
/// Collect `<tr>` elements of a table in document order, descending through
/// `<thead>`/`<tbody>`/`<tfoot>` section wrappers but never into a cell or a
/// nested `<table>` (whose rows belong to that inner table).
/// Number of ancestors of `id` up to the document root. Used to order nested
/// tables deepest-first in the table used-width pass.
fn dom_depth(tree: &DomTree, id: NodeId) -> usize {
let mut d = 0usize;
/// Number of ancestor tables containing `id`. Table sizing runs outer-first:
/// an outer table consumes an inner table's intrinsic contributions, then a
/// root layout establishes the inner table's real cell containing block.
fn table_ancestor_depth(tree: &DomTree, id: NodeId) -> usize {
let mut depth = 0usize;
let mut cur = id;
while let Some(p) = tree.get_node(cur).and_then(|n| n.parent) {
d += 1;
if tree.get_node(p).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "table")
}) {
depth += 1;
}
cur = p;
if d > 4096 {
if depth > 4096 {
break;
}
}
d
depth
}
/// Resolve a content-box width without running layout when the complete
/// containing-block chain is ordinary block flow. Flex/grid allocation,
/// floats, positioned boxes, inline-blocks, and intrinsic sizing need a real
/// Taffy pass and deliberately return `None`.
fn reliable_normal_flow_content_width(
tree: &DomTree,
id: NodeId,
styles: &HashMap<NodeId, crate::LayoutStyle>,
initial_cb_width: f32,
depth: usize,
) -> Option<f32> {
if depth > 4096 {
return None;
}
let style = styles.get(&id)?;
if style.float.is_some()
|| matches!(style.position, Some(taffy::Position::Absolute))
|| style.is_inline_block
|| style.width_fit_content
|| style.size_expressions[0].is_some()
{
return None;
}
let mut parent = tree.get_node(id).and_then(|node| node.parent);
while parent.is_some_and(|parent_id| {
styles
.get(&parent_id)
.is_some_and(|parent_style| parent_style.display_contents)
}) {
parent = parent.and_then(|parent_id| tree.get_node(parent_id).and_then(|node| node.parent));
}
let containing_width = if let Some(parent_id) = parent {
if let Some(parent_style) = styles.get(&parent_id) {
if matches!(parent_style.display, crate::Display::Flex | crate::Display::Grid)
|| parent_style.internal_flex_container
|| parent_style.column_count.is_some()
{
return None;
}
reliable_normal_flow_content_width(
tree,
parent_id,
styles,
initial_cb_width,
depth + 1,
)?
} else {
initial_cb_width
}
} else {
initial_cb_width
};
let horizontal_edges =
style.padding.left + style.padding.right + style.border.left + style.border.right;
let declared_content = |dimension: crate::Dimension| match dimension {
crate::Dimension::Px(value) => Some(if style.box_sizing == crate::BoxSizing::ContentBox {
value
} else {
(value - horizontal_edges).max(0.0)
}),
crate::Dimension::Percent(percent) => {
let value = containing_width * percent;
Some(if style.box_sizing == crate::BoxSizing::ContentBox {
value
} else {
(value - horizontal_edges).max(0.0)
})
}
_ => None,
};
let mut content_width = declared_content(style.width).unwrap_or_else(|| {
(containing_width
- style.margin.left
- style.margin.right
- horizontal_edges)
.max(0.0)
});
if let Some(minimum) = declared_content(style.min_width) {
content_width = content_width.max(minimum);
}
if let Some(maximum) = declared_content(style.max_width) {
content_width = content_width.min(maximum);
}
Some(content_width.max(0.0))
}
fn reliable_table_available_width(
tree: &DomTree,
id: NodeId,
styles: &HashMap<NodeId, crate::LayoutStyle>,
initial_cb_width: f32,
) -> Option<f32> {
let table_style = styles.get(&id)?;
if table_style.float.is_some()
|| matches!(table_style.position, Some(taffy::Position::Absolute))
|| table_style.is_inline_block
{
return None;
}
let mut parent = tree.get_node(id).and_then(|node| node.parent);
while parent.is_some_and(|parent_id| {
styles
.get(&parent_id)
.is_some_and(|parent_style| parent_style.display_contents)
}) {
parent = parent.and_then(|parent_id| tree.get_node(parent_id).and_then(|node| node.parent));
}
let containing_width = match parent {
Some(parent_id) if styles.contains_key(&parent_id) => {
let parent_style = styles.get(&parent_id)?;
if matches!(parent_style.display, crate::Display::Flex | crate::Display::Grid)
|| parent_style.internal_flex_container
|| parent_style.column_count.is_some()
{
return None;
}
reliable_normal_flow_content_width(
tree,
parent_id,
styles,
initial_cb_width,
0,
)?
}
_ => initial_cb_width,
};
Some(
(containing_width - table_style.margin.left - table_style.margin.right)
.max(0.0),
)
}
/// Resolve the `width: fit-content` keyword after the containing inline space
@@ -6827,15 +7037,48 @@ where
changed
}
fn collect_table_rows(tree: &DomTree, id: NodeId, rows: &mut Vec<NodeId>) {
/// Collect rows together with the exclusive end of their originating row
/// group. HTML rowspans never cross a thead/tbody/tfoot boundary; `rowspan=0`
/// means the remainder of that group, not the remainder of the whole table.
fn collect_table_rows(tree: &DomTree, id: NodeId, rows: &mut Vec<(NodeId, usize)>) {
let mut direct_start: Option<usize> = None;
for cid in tree.children(id) {
let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string()));
match local.as_deref() {
Some("tr") => rows.push(cid),
Some("thead") | Some("tbody") | Some("tfoot") => collect_table_rows(tree, cid, rows),
Some("tr") => {
direct_start.get_or_insert(rows.len());
rows.push((cid, 0));
}
Some("thead") | Some("tbody") | Some("tfoot") => {
if let Some(start) = direct_start.take() {
let end = rows.len();
for entry in &mut rows[start..end] {
entry.1 = end;
}
}
let start = rows.len();
for row in tree.children(cid) {
if tree.get_node(row).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "tr")
}) {
rows.push((row, 0));
}
}
let end = rows.len();
for entry in &mut rows[start..end] {
entry.1 = end;
}
}
_ => {}
}
}
if let Some(start) = direct_start {
let end = rows.len();
for entry in &mut rows[start..end] {
entry.1 = end;
}
}
}
/// Effective separate-border spacing. Collapsed tables contribute no spacing
@@ -6882,7 +7125,7 @@ fn build_table(
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Option<taffy::NodeId> {
let style = styles.get(&id)?;
let mut rows: Vec<NodeId> = Vec::new();
let mut rows: Vec<(NodeId, usize)> = Vec::new();
collect_table_rows(tree, id, &mut rows);
if rows.is_empty() {
return None;
@@ -6911,7 +7154,7 @@ fn build_table(
let mut occupied: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
let mut placed: Vec<(NodeId, usize, usize, usize, usize)> = Vec::new();
let mut ncols = 0usize;
for (r, &tr) in rows.iter().enumerate() {
for (r, &(tr, group_end)) in rows.iter().enumerate() {
let mut c = 0usize;
for cid in tree.children(tr) {
let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string()));
@@ -6930,11 +7173,12 @@ fn build_table(
break;
}
let cs = span_attr(cid, "colspan").clamp(1, MAX_SPAN);
// rowspan=0 means "span to the end"; a cell can never span more rows
// than remain, so clamp both to the rows left (which also bounds the
// occupancy fill and keeps spans within taffy's range).
// rowspan=0 means "span to the end of this row group". Explicit
// spans are clipped at the same boundary by the effective cell map.
let rs_raw = span_attr(cid, "rowspan");
let rs = if rs_raw == 0 { nrows - r } else { rs_raw }.clamp(1, nrows - r);
let rows_left_in_group = group_end.min(nrows).saturating_sub(r).max(1);
let rs = if rs_raw == 0 { rows_left_in_group } else { rs_raw }
.clamp(1, rows_left_in_group);
for dr in 0..rs {
for dc in 0..cs {
occupied.insert((r + dr, c + dc));
@@ -7066,7 +7310,7 @@ fn build_table(
// Row sizing: a `height` on the row or a rowspan-1 cell is a MINIMUM
// (content can always grow a row), matching how tables treat heights.
let mut row_min: Vec<Option<f32>> = vec![None; nrows];
for (r, &tr) in rows.iter().enumerate() {
for (r, &(tr, _)) in rows.iter().enumerate() {
if let Some(crate::Dimension::Px(h)) = styles.get(&tr).map(|s| s.height) {
if h > 0.0 {
row_min[r] = Some(h);
@@ -9037,6 +9281,48 @@ fn max_definite_descendant_width(tree: &DomTree, id: NodeId, styles: &HashMap<No
best
}
/// Fixed content descendants can floor a table's intrinsic minimum when the
/// generic grid measurement fails to surface them through an intervening
/// formatting context. Width hints on cells/rows/columns are different: the
/// CSS table algorithm treats those as preferred column constraints, so they
/// must remain shrinkable down to the content minimum.
fn max_definite_table_content_width(
tree: &DomTree,
id: NodeId,
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Option<f32> {
let mut best: Option<f32> = None;
for descendant in tree.descendants(id) {
let structural = tree.get_node(descendant).is_some_and(|node| {
node.as_element().is_some_and(|element| {
matches!(
element.local.as_ref(),
"caption"
| "col"
| "colgroup"
| "thead"
| "tbody"
| "tfoot"
| "tr"
| "td"
| "th"
)
})
});
if structural {
continue;
}
if let Some(crate::Dimension::Px(width)) =
styles.get(&descendant).map(|style| style.width)
{
if width > 0.0 {
best = Some(best.map_or(width, |current| current.max(width)));
}
}
}
best
}
fn establishes_block_formatting_context(style: &crate::LayoutStyle) -> bool {
matches!(style.display, crate::Display::Flex | crate::Display::Grid)
|| effective_container_type(style) != crate::ContainerType::Normal
+101
View File
@@ -90,6 +90,107 @@ fn hn_shaped_table_lays_out_without_site_hardcoding() {
);
}
#[test]
fn table_width_uses_local_space_and_keeps_width_hints_shrinkable() {
let tree = parse_html(
r#"
<style>
html,body{margin:0;font:16px/20px Arial}
table{border-spacing:0;box-sizing:border-box} td{padding:0;box-sizing:border-box}
</style>
<div id="local" style="width:300px">
<table id="local-table"><tr><td id="local-cell">alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu</td></tr></table>
</div>
<table id="outer" style="width:300px"><tr><td id="outer-cell">
<table id="inner"><tr><td id="inner-cell">alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu</td></tr></table>
</td></tr></table>
<div id="narrow" style="width:300px">
<table id="overflow" style="width:500px"><tr><td>fixed overflow</td></tr></table>
</div>
<table id="preferred" style="width:200px"><tr>
<td id="preferred-wide" style="width:300px">A</td>
<td id="preferred-narrow" style="width:50px">B</td>
</tr></table>
"#,
);
let layout = layout_dom(&tree, (400.0, 1000.0));
let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()];
let local = rect("local-table");
let local_cell = rect("local-cell");
assert!((local.width - 300.0).abs() < 0.01, "local table: {local:?}");
assert!(
local.height >= 39.0 && (local_cell.width - 300.0).abs() < 0.01,
"wrappable content must use the 300px local containing block: table={local:?} cell={local_cell:?}"
);
for id in ["outer", "outer-cell", "inner", "inner-cell"] {
let box_rect = rect(id);
assert!(
(box_rect.width - 300.0).abs() < 0.01 && box_rect.height >= 39.0,
"nested table sizing must settle outer-first for {id}: {box_rect:?}"
);
}
let overflow = rect("overflow");
assert!(
(overflow.width - 500.0).abs() < 0.01,
"a definite table width may overflow its local CB and viewport: {overflow:?}"
);
let preferred = rect("preferred");
let wide = rect("preferred-wide");
let narrow = rect("preferred-narrow");
assert!((preferred.width - 200.0).abs() < 0.01, "preferred table: {preferred:?}");
assert!(
wide.width > 160.0
&& wide.width < 175.0
&& narrow.width > 25.0
&& narrow.width < 40.0
&& (wide.width + narrow.width - 200.0).abs() < 0.01,
"cell width hints are preferred contributions, not minimums: wide={wide:?} narrow={narrow:?}"
);
}
#[test]
fn table_rowspans_are_clipped_to_the_originating_row_group() {
let tree = parse_html(
r#"
<style>
html,body{margin:0} table{border-spacing:0} td{padding:0}
.span{width:20px}.short{width:20px;height:20px}.last{width:20px;height:60px}
</style>
<table id="zero"><tbody>
<tr><td id="zero-span" class="span" rowspan="0"></td><td class="short"></td></tr>
<tr><td class="short"></td></tr>
</tbody><tbody><tr><td id="zero-next" class="last"></td></tr></tbody></table>
<table id="oversized"><tbody>
<tr><td id="oversized-span" class="span" rowspan="999"></td><td class="short"></td></tr>
<tr><td class="short"></td></tr>
</tbody><tbody><tr><td id="oversized-next" class="last"></td></tr></tbody></table>
"#,
);
let layout = layout_dom(&tree, (400.0, 1000.0));
let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()];
for (table_id, span_id, next_id) in [
("zero", "zero-span", "zero-next"),
("oversized", "oversized-span", "oversized-next"),
] {
let table = rect(table_id);
let spanning = rect(span_id);
let next_group = rect(next_id);
assert!(
(spanning.height - 40.0).abs() < 0.01,
"{span_id} must stop at the tbody boundary: {spanning:?}"
);
assert!(
(next_group.x - table.x).abs() < 0.01,
"{next_id} must restart at column zero in its row group: table={table:?} cell={next_group:?}"
);
}
}
#[test]
fn relative_units_resolve_against_viewport_and_font_size() {
// 50vw of a 1000px viewport = 500px; 10em at the default 16px = 160px.