Implement fixed table layout geometry
This commit is contained in:
1 parent
039a9b2ee3
commit
84968aef06
5 files changed
+447
-6
No files matched your search
@@ -1644,6 +1644,10 @@ struct IfcRegistry {
|
||||
/// table column-balancing pass in `layout_dom_with_images`, which pins
|
||||
/// specified columns instead of sizing them purely from content.
|
||||
table_cols: HashMap<taffy::NodeId, (Vec<Option<f32>>, Vec<Option<f32>>)>,
|
||||
/// Column constraints for the fixed table-layout algorithm. Unlike
|
||||
/// `table_cols`, these are sourced only from columns and the first row and
|
||||
/// deliberately exclude later-row intrinsic content.
|
||||
fixed_table_cols: HashMap<taffy::NodeId, Vec<FixedTableColumn>>,
|
||||
/// Minimum row heights per table grid node, one entry per source row.
|
||||
/// The post-width row-sizing pass combines these with each cell's final
|
||||
/// content height and pins the tracks before vertical alignment.
|
||||
@@ -1668,6 +1672,13 @@ struct IfcRegistry {
|
||||
multicol: Vec<MulticolBuild>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct FixedTableColumn {
|
||||
length: f32,
|
||||
percentage: f32,
|
||||
specified: bool,
|
||||
}
|
||||
|
||||
struct MulticolBuild {
|
||||
columns: Vec<taffy::NodeId>,
|
||||
children: Vec<taffy::NodeId>,
|
||||
@@ -5403,7 +5414,7 @@ fn layout_dom_once(
|
||||
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
|
||||
(style.width == crate::Dimension::Auto
|
||||
&& (depth > 0
|
||||
|| reliable_table_available_width(
|
||||
tree,
|
||||
@@ -5411,7 +5422,9 @@ fn layout_dom_once(
|
||||
&styles,
|
||||
initial_cb_width,
|
||||
)
|
||||
.is_none())
|
||||
.is_none()))
|
||||
|| (style.table_layout_fixed
|
||||
&& matches!(style.width, crate::Dimension::Percent(_)))
|
||||
})
|
||||
});
|
||||
if needs_layout_snapshot {
|
||||
@@ -5427,7 +5440,14 @@ fn layout_dom_once(
|
||||
for &(tnode, dom, _) in group {
|
||||
if styles
|
||||
.get(&dom)
|
||||
.is_some_and(|style| style.width == crate::Dimension::Auto)
|
||||
.is_some_and(|style| {
|
||||
style.width == crate::Dimension::Auto
|
||||
|| (style.table_layout_fixed
|
||||
&& matches!(
|
||||
style.width,
|
||||
crate::Dimension::Percent(_)
|
||||
))
|
||||
})
|
||||
{
|
||||
if let Ok(layout) = taffy_tree.layout(tnode) {
|
||||
available_widths.insert(tnode, layout.size.width.max(0.0));
|
||||
@@ -5439,6 +5459,66 @@ fn layout_dom_once(
|
||||
let Some(table_style) = styles.get(&dom) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(columns) = ifc_items.fixed_table_cols.get(&tnode) {
|
||||
let ncols = columns.len();
|
||||
if ncols == 0 {
|
||||
continue;
|
||||
}
|
||||
let inline_outer_edges = table_inline_outer_edges(table_style);
|
||||
let mut used_outer = match table_style.width {
|
||||
crate::Dimension::Px(width)
|
||||
if table_style.box_sizing
|
||||
== crate::BoxSizing::ContentBox =>
|
||||
{
|
||||
width.max(0.0) + inline_outer_edges
|
||||
}
|
||||
crate::Dimension::Px(width) => width.max(0.0),
|
||||
crate::Dimension::Percent(_) => available_widths
|
||||
.get(&tnode)
|
||||
.copied()
|
||||
.unwrap_or_else(|| {
|
||||
reliable_table_available_width(
|
||||
tree,
|
||||
dom,
|
||||
&styles,
|
||||
initial_cb_width,
|
||||
)
|
||||
.unwrap_or(initial_cb_width)
|
||||
}),
|
||||
_ => continue,
|
||||
};
|
||||
let (horizontal_spacing, _) = table_spacing(table_style);
|
||||
let interior_spacing =
|
||||
horizontal_spacing * ncols.saturating_sub(1) as f32;
|
||||
let target =
|
||||
(used_outer - inline_outer_edges - interior_spacing).max(0.0);
|
||||
let widths = distribute_fixed_table_columns(target, columns);
|
||||
let required_outer = widths.iter().sum::<f32>()
|
||||
+ inline_outer_edges
|
||||
+ interior_spacing;
|
||||
used_outer = used_outer.max(required_outer);
|
||||
let used_declaration =
|
||||
if table_style.box_sizing == crate::BoxSizing::ContentBox {
|
||||
(used_outer - inline_outer_edges).max(0.0)
|
||||
} else {
|
||||
used_outer
|
||||
};
|
||||
if let Ok(cur) = taffy_tree.style(tnode) {
|
||||
let mut fixed_style = cur.clone();
|
||||
fixed_style.size.width = length(used_declaration);
|
||||
fixed_style.grid_template_columns = widths
|
||||
.iter()
|
||||
.map(|width| {
|
||||
taffy::GridTemplateComponent::Single(taffy::MinMax {
|
||||
min: taffy::MinTrackSizingFunction::length(*width),
|
||||
max: taffy::MaxTrackSizingFunction::length(*width),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let _ = taffy_tree.set_style(tnode, fixed_style);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A percentage-width table resolves against its container, so
|
||||
// leave taffy's percentage handling in place.
|
||||
let width_style = table_style.width;
|
||||
@@ -9572,6 +9652,87 @@ fn distribute_auto_table_columns(
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve CSS 2 fixed-layout column constraints into exact track widths.
|
||||
///
|
||||
/// Columns and first-row cells establish the initial widths. Unspecified
|
||||
/// tracks share the remaining space; when every track is specified, surplus
|
||||
/// follows Gecko's fixed-length, percentage, then equal fallback order. A
|
||||
/// fixed-length over-constraint grows the table, while percentage constraints
|
||||
/// may shrink proportionally to keep the specified table width.
|
||||
fn distribute_fixed_table_columns(target: f32, columns: &[FixedTableColumn]) -> Vec<f32> {
|
||||
if columns.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let target = target.max(0.0);
|
||||
let mut widths: Vec<f32> = columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
if column.specified {
|
||||
column.length.max(0.0) + column.percentage.max(0.0) * target
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut total: f32 = widths.iter().sum();
|
||||
|
||||
// Percentage columns are the flexible part of an over-constrained fixed
|
||||
// table. Never shrink the absolute component: if lengths alone do not fit,
|
||||
// the table's used width grows to contain them.
|
||||
if total > target {
|
||||
let percentage_total: f32 = columns
|
||||
.iter()
|
||||
.map(|column| column.percentage.max(0.0) * target)
|
||||
.sum();
|
||||
let shrink = (total - target).min(percentage_total);
|
||||
if shrink > 0.0 && percentage_total > 0.0 {
|
||||
for (width, column) in widths.iter_mut().zip(columns) {
|
||||
let contribution = column.percentage.max(0.0) * target;
|
||||
*width = (*width - shrink * contribution / percentage_total).max(0.0);
|
||||
}
|
||||
total -= shrink;
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = (target - total).max(0.0);
|
||||
if remaining <= f32::EPSILON {
|
||||
return widths;
|
||||
}
|
||||
let unresolved: Vec<usize> = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| (!column.specified).then_some(index))
|
||||
.collect();
|
||||
if !unresolved.is_empty() {
|
||||
let share = remaining / unresolved.len() as f32;
|
||||
for index in unresolved {
|
||||
widths[index] = share;
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
let mut weights: Vec<f32> = columns
|
||||
.iter()
|
||||
.map(|column| column.length.max(0.0))
|
||||
.collect();
|
||||
let mut weight: f32 = weights.iter().sum();
|
||||
if weight <= f32::EPSILON {
|
||||
weights = columns
|
||||
.iter()
|
||||
.map(|column| column.percentage.max(0.0))
|
||||
.collect();
|
||||
weight = weights.iter().sum();
|
||||
}
|
||||
if weight <= f32::EPSILON {
|
||||
weights.fill(1.0);
|
||||
weight = columns.len() as f32;
|
||||
}
|
||||
for (width, column_weight) in widths.iter_mut().zip(weights) {
|
||||
*width += remaining * column_weight / weight;
|
||||
}
|
||||
widths
|
||||
}
|
||||
|
||||
/// Build a `<table>` as a CSS grid. Modeling the table as a grid is what makes
|
||||
/// columns negotiate a shared width across every row (min-content/max-content
|
||||
/// track sizing), which the old flex-row-per-`<tr>` stack could not do: each
|
||||
@@ -9791,6 +9952,9 @@ fn build_table(
|
||||
// leaving a dead strip of bare table background.
|
||||
let mut col_px: Vec<Option<f32>> = vec![None; ncols];
|
||||
let mut col_pct: Vec<Option<f32>> = vec![None; ncols];
|
||||
let fixed_layout = style.table_layout_fixed
|
||||
&& matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_));
|
||||
let mut fixed_columns = vec![FixedTableColumn::default(); ncols];
|
||||
let attr_width = |cid: NodeId| -> (Option<f32>, Option<f32>) {
|
||||
let Some(v) = tree
|
||||
.get_node(cid)
|
||||
@@ -9814,6 +9978,13 @@ fn build_table(
|
||||
_ => attr_width(cid),
|
||||
}
|
||||
};
|
||||
let fixed_style_width = |cid: NodeId| -> (Option<f32>, Option<f32>) {
|
||||
match styles.get(&cid).map(|s| s.width) {
|
||||
Some(crate::Dimension::Px(w)) if w >= 0.0 => (Some(w), None),
|
||||
Some(crate::Dimension::Percent(p)) if p >= 0.0 => (None, Some(p)),
|
||||
_ => attr_width(cid),
|
||||
}
|
||||
};
|
||||
// <col> elements (direct or under <colgroup>), each spanning `span` columns.
|
||||
let mut next_col = 0usize;
|
||||
let mut col_elems: Vec<NodeId> = Vec::new();
|
||||
@@ -9838,22 +10009,30 @@ fn build_table(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for col_el in col_elems {
|
||||
for col_el in &col_elems {
|
||||
let span = tree
|
||||
.get_node(col_el)
|
||||
.get_node(*col_el)
|
||||
.and_then(|n| {
|
||||
n.get_attribute("span")
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(1)
|
||||
.clamp(1, MAX_SPAN);
|
||||
let (px, pct) = style_width(col_el);
|
||||
let (px, pct) = style_width(*col_el);
|
||||
let (fixed_px, fixed_pct) = fixed_style_width(*col_el);
|
||||
for _ in 0..span {
|
||||
if next_col >= ncols {
|
||||
break;
|
||||
}
|
||||
col_px[next_col] = px;
|
||||
col_pct[next_col] = pct;
|
||||
if fixed_layout && (fixed_px.is_some() || fixed_pct.is_some()) {
|
||||
fixed_columns[next_col] = FixedTableColumn {
|
||||
length: fixed_px.unwrap_or(0.0),
|
||||
percentage: fixed_pct.unwrap_or(0.0),
|
||||
specified: true,
|
||||
};
|
||||
}
|
||||
next_col += 1;
|
||||
}
|
||||
}
|
||||
@@ -9881,6 +10060,48 @@ fn build_table(
|
||||
}
|
||||
}
|
||||
|
||||
// Under fixed table layout only the first row contributes cell widths,
|
||||
// and an explicit `<col>` constraint wins for every covered column. A
|
||||
// spanning cell's outer width is split with the inter-column spacing
|
||||
// removed, matching Gecko's `((width + spacing) / span) - spacing` rule.
|
||||
if fixed_layout {
|
||||
let (horizontal_spacing, _) = table_spacing(style);
|
||||
for (cid, row, start, _rowspan, colspan) in &placed {
|
||||
if *row != 0 || *start >= ncols {
|
||||
continue;
|
||||
}
|
||||
let (px, pct) = fixed_style_width(*cid);
|
||||
if px.is_none() && pct.is_none() {
|
||||
continue;
|
||||
}
|
||||
let span = (*colspan).min(ncols - *start).max(1);
|
||||
let cell_style = styles.get(cid);
|
||||
let edges = cell_style
|
||||
.filter(|cell| cell.box_sizing == crate::BoxSizing::ContentBox)
|
||||
.map(|cell| {
|
||||
cell.padding.left
|
||||
+ cell.padding.right
|
||||
+ cell.border.left
|
||||
+ cell.border.right
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
let outer_length = px.unwrap_or(0.0) + edges;
|
||||
let per_column_length =
|
||||
((outer_length + horizontal_spacing) / span as f32 - horizontal_spacing)
|
||||
.max(0.0);
|
||||
let per_column_percentage = pct.unwrap_or(0.0).max(0.0) / span as f32;
|
||||
for column in &mut fixed_columns[*start..*start + span] {
|
||||
if !column.specified {
|
||||
*column = FixedTableColumn {
|
||||
length: per_column_length,
|
||||
percentage: per_column_percentage,
|
||||
specified: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
@@ -9905,6 +10126,12 @@ fn build_table(
|
||||
}
|
||||
|
||||
let col = |i: usize| {
|
||||
if fixed_layout {
|
||||
return taffy::GridTemplateComponent::Single(taffy::MinMax {
|
||||
min: taffy::MinTrackSizingFunction::length(0.0),
|
||||
max: taffy::MaxTrackSizingFunction::length(0.0),
|
||||
});
|
||||
}
|
||||
let max = if let Some(p) = col_pct[i] {
|
||||
taffy::MaxTrackSizingFunction::percent(p)
|
||||
} else if let Some(px) = col_px[i] {
|
||||
@@ -9954,6 +10181,9 @@ fn build_table(
|
||||
let table_node = taffy_tree.new_with_children(tstyle, &children).ok()?;
|
||||
id_map.insert(table_node, id);
|
||||
ifc_items.table_rows.insert(table_node, row_min);
|
||||
if fixed_layout {
|
||||
ifc_items.fixed_table_cols.insert(table_node, fixed_columns);
|
||||
}
|
||||
if col_px.iter().any(Option::is_some) || col_pct.iter().any(Option::is_some) {
|
||||
ifc_items.table_cols.insert(table_node, (col_px, col_pct));
|
||||
}
|
||||
@@ -16180,6 +16410,160 @@ mod tests {
|
||||
assert_eq!(width("border-cell"), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_table_layout_uses_only_first_row_for_column_geometry() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
table { table-layout:fixed; width:300px; border-collapse:collapse }
|
||||
td { height:30px; padding:0; border:0 }
|
||||
#first { width:50px }
|
||||
#later { width:250px; white-space:nowrap }
|
||||
</style>
|
||||
<table><tr><td id="first"></td><td id="second"></td></tr>
|
||||
<tr><td id="later">XXXXXXXXXXXXXXXXXXXXXXXX</td><td id="last"></td></tr></table>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (800.0, 300.0));
|
||||
let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()];
|
||||
for id in ["first", "later"] {
|
||||
assert!((rect(id).width - 50.0).abs() < 0.1, "{}: {:?}", id, rect(id));
|
||||
}
|
||||
for id in ["second", "last"] {
|
||||
assert!((rect(id).width - 250.0).abs() < 0.1, "{}: {:?}", id, rect(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_table_layout_accounts_for_separate_border_spacing() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
table { table-layout:fixed; width:300px; border:4px solid black; border-spacing:10px 0 }
|
||||
td { height:30px; padding:0; border:0 }
|
||||
#first { width:50px }
|
||||
</style>
|
||||
<table id="table"><tr><td id="first"></td><td id="second"></td></tr></table>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (800.0, 300.0));
|
||||
let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()];
|
||||
assert!((rect("table").width - 300.0).abs() < 0.1, "{:?}", rect("table"));
|
||||
assert!((rect("first").x - 14.0).abs() < 0.1, "{:?}", rect("first"));
|
||||
assert!((rect("first").width - 50.0).abs() < 0.1, "{:?}", rect("first"));
|
||||
assert!((rect("second").x - 74.0).abs() < 0.1, "{:?}", rect("second"));
|
||||
assert!((rect("second").width - 212.0).abs() < 0.1, "{:?}", rect("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_percentage_table_resolves_tracks_against_final_used_width() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
table { table-layout:fixed; width:50%; border-spacing:10px 0 }
|
||||
td { height:30px; padding:0; border:0 }
|
||||
#first { width:25% }
|
||||
</style>
|
||||
<table id="table"><tr><td id="first"></td><td id="second"></td></tr></table>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (800.0, 300.0));
|
||||
let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()];
|
||||
assert!((rect("table").width - 400.0).abs() < 0.1, "{:?}", rect("table"));
|
||||
// Final paint geometry is snapped to device pixels around the 92.5 /
|
||||
// 277.5 CSS-pixel track boundary.
|
||||
assert!((rect("first").width - 92.5).abs() <= 0.5, "{:?}", rect("first"));
|
||||
assert!((rect("second").width - 277.5).abs() <= 0.5, "{:?}", rect("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_columns_win_over_first_row_spanning_cell_widths() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
table { table-layout:fixed; width:300px; border-spacing:10px 0 }
|
||||
td { height:30px; padding:0; border:0 }
|
||||
</style>
|
||||
<table><col style="width:40px"><col><col>
|
||||
<tr><td id="span" colspan="2" style="width:200px"></td><td id="top-last"></td></tr>
|
||||
<tr><td id="one"></td><td id="two"></td><td id="three"></td></tr>
|
||||
</table>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (800.0, 300.0));
|
||||
let width = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()].width;
|
||||
assert!((width("span") - 145.0).abs() < 0.1, "{}", width("span"));
|
||||
assert!((width("one") - 40.0).abs() < 0.1, "{}", width("one"));
|
||||
assert!((width("two") - 95.0).abs() < 0.1, "{}", width("two"));
|
||||
assert!((width("three") - 125.0).abs() < 0.1, "{}", width("three"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_table_layout_with_auto_width_falls_back_to_auto_algorithm() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
table { table-layout:fixed; border-collapse:collapse }
|
||||
td { padding:0; border:0 }
|
||||
#first { width:50px }
|
||||
#later { width:250px }
|
||||
</style>
|
||||
<table><tr><td id="first"></td><td></td></tr>
|
||||
<tr><td id="later"></td><td></td></tr></table>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (800.0, 300.0));
|
||||
let first = laid.rects[&tree.get_element_by_id("first").unwrap()];
|
||||
let later = laid.rects[&tree.get_element_by_id("later").unwrap()];
|
||||
assert!(first.width >= 249.9, "{first:?}");
|
||||
assert!((first.width - later.width).abs() < 0.1, "{first:?} {later:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_table_column_distribution_matches_css_fixed_rules() {
|
||||
let column = |length, percentage, specified| FixedTableColumn {
|
||||
length,
|
||||
percentage,
|
||||
specified,
|
||||
};
|
||||
let assert_widths = |actual: Vec<f32>, expected: &[f32]| {
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (actual, expected) in actual.iter().zip(expected) {
|
||||
assert!((actual - expected).abs() < 0.01, "{actual:?} != {expected:?}");
|
||||
}
|
||||
};
|
||||
assert_widths(
|
||||
distribute_fixed_table_columns(
|
||||
300.0,
|
||||
&[column(50.0, 0.0, true), column(0.0, 0.0, false)],
|
||||
),
|
||||
&[50.0, 250.0],
|
||||
);
|
||||
assert_widths(
|
||||
distribute_fixed_table_columns(
|
||||
300.0,
|
||||
&[column(50.0, 0.0, true), column(100.0, 0.0, true)],
|
||||
),
|
||||
&[100.0, 200.0],
|
||||
);
|
||||
assert_widths(
|
||||
distribute_fixed_table_columns(
|
||||
300.0,
|
||||
&[column(50.0, 0.0, true), column(0.0, 0.5, true)],
|
||||
),
|
||||
&[150.0, 150.0],
|
||||
);
|
||||
assert_widths(
|
||||
distribute_fixed_table_columns(
|
||||
300.0,
|
||||
&[column(250.0, 0.0, true), column(100.0, 0.0, true)],
|
||||
),
|
||||
&[250.0, 100.0],
|
||||
);
|
||||
assert_widths(
|
||||
distribute_fixed_table_columns(
|
||||
300.0,
|
||||
&[column(0.0, 0.75, true), column(0.0, 0.75, true)],
|
||||
),
|
||||
&[150.0, 150.0],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_table_with_mixed_direct_content_preserves_non_cell_child() {
|
||||
let tree = parse_html(
|
||||
|
||||
Reference in new issue
Block a user