render: align table track geometry

This commit is contained in:
SGavrl committed 2026-07-23 21:56:19 +00:00
1 parent 10509a222b
commit 771528c7ff
3 files changed
+221 -41

No files matched your search

+186 -34
View File
@@ -188,6 +188,9 @@ fn apply_presentational_hints(node: &obscura_dom::tree::Node, style: &mut crate:
if let Some(align) = node.get_attribute("align") { if let Some(align) = node.get_attribute("align") {
crate::style::apply_inline(style, &format!("text-align: {}", align)); crate::style::apply_inline(style, &format!("text-align: {}", align));
} }
if let Some(valign) = node.get_attribute("valign") {
crate::style::apply_inline(style, &format!("vertical-align: {}", valign));
}
if let Some(cellspacing) = node.get_attribute("cellspacing") { if let Some(cellspacing) = node.get_attribute("cellspacing") {
if cellspacing.chars().all(|c| c.is_ascii_digit()) { if cellspacing.chars().all(|c| c.is_ascii_digit()) {
crate::style::apply_inline(style, &format!("border-spacing: {}px", cellspacing)); crate::style::apply_inline(style, &format!("border-spacing: {}px", cellspacing));
@@ -223,14 +226,35 @@ fn cascade_walk(
styles: &mut HashMap<NodeId, crate::LayoutStyle>, styles: &mut HashMap<NodeId, crate::LayoutStyle>,
parent_props: &std::rc::Rc<HashMap<String, String>>, parent_props: &std::rc::Rc<HashMap<String, String>>,
quirks_mode: bool, quirks_mode: bool,
inherited_cell_padding: Option<f32>,
) { ) {
let Some(node) = tree.get_node(id) else { return }; let Some(node) = tree.get_node(id) else { return };
let is_element = node.is_element(); let is_element = node.is_element();
// The custom-property map in force for this node's subtree: the parent's, // The custom-property map in force for this node's subtree: the parent's,
// unless this element declares its own `--x` (then a richer map). // unless this element declares its own `--x` (then a richer map).
let mut this_props = parent_props.clone(); let mut this_props = parent_props.clone();
let mut descendant_cell_padding = inherited_cell_padding;
if let Some(elem) = node.as_element() { if let Some(elem) = node.as_element() {
if elem.local.as_ref() == "table" {
// `cellpadding` is a table-scoped presentational hint applied to
// its cells, below author CSS. Entering any nested table resets
// the outer table's value before reading the nested attribute.
descendant_cell_padding = node
.get_attribute("cellpadding")
.and_then(|value| value.trim().parse::<f32>().ok())
.filter(|value| value.is_finite() && *value >= 0.0);
}
let mut style = crate::style::ua_style(elem.local.as_ref()); let mut style = crate::style::ua_style(elem.local.as_ref());
if matches!(elem.local.as_ref(), "td" | "th") {
if let Some(padding) = inherited_cell_padding {
style.padding = crate::Edges {
top: padding,
right: padding,
bottom: padding,
left: padding,
};
}
}
if quirks_mode && elem.local.as_ref() == "form" { if quirks_mode && elem.local.as_ref() == "form" {
// Legacy HTML/quirks rendering keeps one em after forms. Standards // Legacy HTML/quirks rendering keeps one em after forms. Standards
// mode does not; Hacker News and many old document templates omit // mode does not; Hacker News and many old document templates omit
@@ -319,6 +343,7 @@ fn cascade_walk(
styles, styles,
&this_props, &this_props,
quirks_mode, quirks_mode,
descendant_cell_padding,
); );
} }
if is_element { if is_element {
@@ -384,13 +409,13 @@ pub fn layout_dom_with_images(
&mut styles, &mut styles,
&root_props, &root_props,
quirks_mode, quirks_mode,
None,
); );
if timing { if timing {
let (r, i, c, l, u) = sheet.debug_stats(); let (r, i, c, l, u) = sheet.debug_stats();
eprintln!("[timing] parse+index={:?} cascade={:?} rules={} id_keys={} class_keys={} local_keys={} universal={}", t_parse, t1.elapsed(), r, i, c, l, u); eprintln!("[timing] parse+index={:?} cascade={:?} rules={} id_keys={} class_keys={} local_keys={} universal={}", t_parse, t1.elapsed(), r, i, c, l, u);
} }
grow_trailing_auto_cells(tree, &mut styles); grow_trailing_auto_cells(tree, &mut styles);
propagate_border_spacing(tree, &mut styles);
// The leaf context is the index of a cosmic-text inline formatting // The leaf context is the index of a cosmic-text inline formatting
// context in `engine`; leaves without text carry no context. // context in `engine`; leaves without text carry no context.
@@ -429,6 +454,8 @@ pub fn layout_dom_with_images(
text_transform: crate::TextTransform, text_transform: crate::TextTransform,
italic: bool, italic: bool,
box_sizing: crate::BoxSizing, box_sizing: crate::BoxSizing,
border_collapse: bool,
table_vertical_align: Option<crate::VerticalAlign>,
/// Containing-block width in px for the current element, carried /// Containing-block width in px for the current element, carried
/// down so percentage padding/margin (which resolve against the /// down so percentage padding/margin (which resolve against the
/// containing block WIDTH, all sides) can be turned into px before /// containing block WIDTH, all sides) can be turned into px before
@@ -453,6 +480,8 @@ pub fn layout_dom_with_images(
text_transform: crate::TextTransform::None, text_transform: crate::TextTransform::None,
italic: false, italic: false,
box_sizing: crate::BoxSizing::ContentBox, box_sizing: crate::BoxSizing::ContentBox,
border_collapse: false,
table_vertical_align: None,
cb_width: 0.0, cb_width: 0.0,
} }
} }
@@ -586,6 +615,24 @@ pub fn layout_dom_with_images(
style.box_sizing = inh.box_sizing; style.box_sizing = inh.box_sizing;
} }
inh.box_sizing = style.box_sizing; inh.box_sizing = style.box_sizing;
match style.border_collapse {
Some(value) => inh.border_collapse = value,
None => style.border_collapse = Some(inh.border_collapse),
}
let is_table_part = tree.get_node(id).map_or(false, |node| {
node.as_element().map_or(false, |name| {
matches!(
name.local.as_ref(),
"tbody" | "thead" | "tfoot" | "tr" | "td" | "th"
)
})
});
if is_table_part {
match style.vertical_align {
Some(value) => inh.table_vertical_align = Some(value),
None => style.vertical_align = inh.table_vertical_align,
}
}
// Resolve font/viewport-relative box edges now that their // Resolve font/viewport-relative box edges now that their
// reference sizes are known. Percentage padding/margin then // reference sizes are known. Percentage padding/margin then
@@ -702,6 +749,11 @@ pub fn layout_dom_with_images(
} }
} }
// Border-collapse is inherited, so only distribute a table's
// effective spacing to the legacy flex fallback after the computed
// top-down values are known.
propagate_border_spacing(tree, &mut styles);
// Resolve native input-control intrinsic border-box geometry after // Resolve native input-control intrinsic border-box geometry after
// inheritance and author cascading. Text-like inputs use the HTML // inheritance and author cascading. Text-like inputs use the HTML
// `size` attribute (20 by default) and the control's own computed // `size` attribute (20 by default) and the control's own computed
@@ -902,9 +954,12 @@ pub fn layout_dom_with_images(
// id_map iteration order is not. // id_map iteration order is not.
tables.sort_by(|a, b| b.2.cmp(&a.2)); tables.sort_by(|a, b| b.2.cmp(&a.2));
for (tnode, dom, _depth) in tables { for (tnode, dom, _depth) in tables {
let Some(table_style) = styles.get(&dom) else {
continue;
};
// A percentage-width table resolves against its container, so // A percentage-width table resolves against its container, so
// leave taffy's percentage handling in place. // leave taffy's percentage handling in place.
let width_style = styles.get(&dom).map(|s| s.width.clone()); let width_style = Some(table_style.width);
if matches!(width_style, Some(crate::Dimension::Percent(_))) { if matches!(width_style, Some(crate::Dimension::Percent(_))) {
continue; continue;
} }
@@ -935,21 +990,30 @@ pub fn layout_dom_with_images(
); );
taffy_tree.layout(tnode).map(|l| l.size.width).unwrap_or(0.0) taffy_tree.layout(tnode).map(|l| l.size.width).unwrap_or(0.0)
}; };
let preferred = match width_style { let inline_outer_edges = table_inline_outer_edges(table_style);
let preferred_outer = match width_style {
Some(crate::Dimension::Px(w))
if table_style.box_sizing == crate::BoxSizing::ContentBox =>
{
w + inline_outer_edges
}
Some(crate::Dimension::Px(w)) => w, Some(crate::Dimension::Px(w)) => w,
_ => max_c, _ => max_c,
}; };
let used = preferred.max(min_c).min(viewport.0.max(min_c)); let used_outer = preferred_outer
// Distribute `used` across the columns proportionally between .max(min_c)
// each column's own min-content and max-content width, the way .min(viewport.0.max(min_c));
// CSS tables do, instead of letting the grid hand every auto let used_declaration =
// track an equal share of the surplus (which over-widens narrow if table_style.box_sizing == crate::BoxSizing::ContentBox {
// label columns and starves wide prose columns). NetSurf (used_outer - inline_outer_edges).max(0.0)
// layout.c layout_table: col.width = col.min + (col.max - col.min) } else {
// * (used - min) / (max - min). The table's border, padding, and used_outer
// inter-column border-spacing are layout-invariant, so they are };
// recovered once from the whole-table min-content width and kept // Distribute the used track space proportionally between
// out of the interpolation. // each column's own min-content and max-content width, the
// way CSS tables do, instead of letting the grid hand every
// auto track an equal share of the surplus (which over-widens
// narrow label columns and starves wide prose columns).
let cells: Vec<(taffy::NodeId, usize, usize)> = taffy_tree let cells: Vec<(taffy::NodeId, usize, usize)> = taffy_tree
.children(tnode) .children(tnode)
.unwrap_or_default() .unwrap_or_default()
@@ -1033,13 +1097,19 @@ pub fn layout_dom_with_images(
col_max[j] = col_min[j]; col_max[j] = col_min[j];
} }
} }
// overhead = table border + padding + inter-column spacing, // Track space is the final table border box minus its
// recovered from the whole-table min-content width // actual border/padding, the two outer spacing bands,
// (from the content mins, before specified widths pin // and the spacing between columns. Inferring this from
// any column). // min-content is wrong when a specified cell/column
let sum_min_content: f32 = col_min.iter().sum(); // width already inflated that measurement: the fixed
let overhead = (min_c - sum_min_content).max(0.0); // width gets counted as "overhead", starving later
let target = (used - overhead).max(0.0); // auto columns and forcing avoidable text wrapping.
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);
// Specified column widths (a `<col>` or colspan-1 cell // Specified column widths (a `<col>` or colspan-1 cell
// width, recorded at build time) pin their columns: // width, recorded at build time) pin their columns:
// px directly, percent against the table's content // px directly, percent against the table's content
@@ -1047,7 +1117,8 @@ pub fn layout_dom_with_images(
// too-narrow spec never crushes its content. The // too-narrow spec never crushes its content. The
// remaining space interpolates across the auto // remaining space interpolates across the auto
// columns exactly as before. // columns exactly as before.
if let Some((spec_px, spec_pct)) = ifc_items.table_cols.get(&tnode) { let specified_columns = ifc_items.table_cols.get(&tnode);
if let Some((spec_px, spec_pct)) = specified_columns {
for j in 0..ncols { for j in 0..ncols {
let spec = spec_px let spec = spec_px
.get(j) .get(j)
@@ -1063,13 +1134,58 @@ pub fn layout_dom_with_images(
} }
let sum_min: f32 = col_min.iter().sum(); let sum_min: f32 = col_min.iter().sum();
let sum_max: f32 = col_max.iter().sum(); let sum_max: f32 = col_max.iter().sum();
let widths: Vec<f32> = if sum_max <= sum_min || target <= sum_min { let widths: Vec<f32> = if target <= sum_min {
col_min.clone() col_min.clone()
} else if target >= sum_max { } else if target >= sum_max {
// Table wider than max-content (explicit/percentage-ish // Surplus follows the table-layout priority: auto
// width): hand the extra out equally, as real tables do. // columns absorb it before fixed and percentage
let extra = (target - sum_max) / ncols as f32; // columns. This is important even when every
col_max.iter().map(|m| m + extra).collect() // column has min==max (a one-word auto cell still
// fills the remainder of a definite-width table).
let mut result = col_max.clone();
let extra = target - sum_max;
let is_px = |j: usize| {
specified_columns
.and_then(|(px, _)| px.get(j))
.copied()
.flatten()
.is_some()
};
let is_pct = |j: usize| {
specified_columns
.and_then(|(_, pct)| pct.get(j))
.copied()
.flatten()
.is_some()
};
let mut candidates: Vec<usize> = (0..ncols)
.filter(|&j| !is_px(j) && !is_pct(j) && col_max[j] > 0.0)
.collect();
if candidates.is_empty() {
candidates = (0..ncols)
.filter(|&j| !is_px(j) && !is_pct(j))
.collect();
}
if candidates.is_empty() {
candidates = (0..ncols).filter(|&j| is_px(j)).collect();
}
if candidates.is_empty() {
candidates = (0..ncols).filter(|&j| is_pct(j)).collect();
}
if candidates.is_empty() {
candidates = (0..ncols).collect();
}
let weight_sum: f32 =
candidates.iter().map(|&j| col_max[j]).sum();
for &j in &candidates {
let share = if weight_sum > 0.0 {
extra * col_max[j] / weight_sum
} else {
extra / candidates.len() as f32
};
result[j] += share;
}
result
} else { } else {
let scale = (target - sum_min) / (sum_max - sum_min); let scale = (target - sum_min) / (sum_max - sum_min);
col_min col_min
@@ -1080,7 +1196,7 @@ pub fn layout_dom_with_images(
}; };
if let Ok(cur) = taffy_tree.style(tnode) { if let Ok(cur) = taffy_tree.style(tnode) {
let mut s = cur.clone(); let mut s = cur.clone();
s.size.width = length(used); s.size.width = length(used_declaration);
s.grid_template_columns = widths s.grid_template_columns = widths
.iter() .iter()
.map(|w| { .map(|w| {
@@ -1094,7 +1210,7 @@ pub fn layout_dom_with_images(
} }
} else if let Ok(cur) = taffy_tree.style(tnode) { } else if let Ok(cur) = taffy_tree.style(tnode) {
let mut s = cur.clone(); let mut s = cur.clone();
s.size.width = length(used); s.size.width = length(used_declaration);
let _ = taffy_tree.set_style(tnode, s); let _ = taffy_tree.set_style(tnode, s);
} }
} }
@@ -1296,7 +1412,10 @@ fn propagate_border_spacing(tree: &DomTree, styles: &mut HashMap<NodeId, crate::
if local_name(tree, id).as_deref() != Some("table") { if local_name(tree, id).as_deref() != Some("table") {
continue; continue;
} }
let Some((h, v)) = styles.get(&id).and_then(|s| s.border_spacing) else { continue }; let Some(table_style) = styles.get(&id) else {
continue;
};
let (h, v) = table_spacing(table_style);
if let Some(s) = styles.get_mut(&id) { if let Some(s) = styles.get_mut(&id) {
s.row_gap = Some(v); s.row_gap = Some(v);
} }
@@ -2228,6 +2347,28 @@ fn collect_table_rows(tree: &DomTree, id: NodeId, rows: &mut Vec<NodeId>) {
} }
} }
/// Effective separate-border spacing. Collapsed tables contribute no spacing
/// at either the outer table edges or between tracks.
fn table_spacing(style: &crate::LayoutStyle) -> (f32, f32) {
if style.border_collapse.unwrap_or(false) {
(0.0, 0.0)
} else {
style.border_spacing.unwrap_or((0.0, 0.0))
}
}
/// Horizontal non-track area in the table's border box, excluding the gaps
/// *between* columns: authored border/padding plus one border-spacing unit at
/// each outer edge.
fn table_inline_outer_edges(style: &crate::LayoutStyle) -> f32 {
let (spacing, _) = table_spacing(style);
style.border.left
+ style.border.right
+ style.padding.left
+ style.padding.right
+ spacing * 2.0
}
/// Build a `<table>` as a CSS grid. Modeling the table as a grid is what makes /// 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 /// 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 /// track sizing), which the old flex-row-per-`<tr>` stack could not do: each
@@ -2469,7 +2610,17 @@ fn build_table(
}; };
taffy::GridTemplateComponent::Single(taffy::MinMax { min, max: taffy::MaxTrackSizingFunction::auto() }) taffy::GridTemplateComponent::Single(taffy::MinMax { min, max: taffy::MaxTrackSizingFunction::auto() })
}; };
let mut tstyle = to_taffy_style(style); // In the separate-border model, border-spacing also exists between the
// table edge and the first/last row and column. Grid `gap` only covers
// interior tracks, so model the two outer spacing bands as internal
// layout padding while leaving the computed CSS padding unchanged.
let (horizontal_spacing, vertical_spacing) = table_spacing(style);
let mut grid_style = style.clone();
grid_style.padding.left += horizontal_spacing;
grid_style.padding.right += horizontal_spacing;
grid_style.padding.top += vertical_spacing;
grid_style.padding.bottom += vertical_spacing;
let mut tstyle = to_taffy_style(&grid_style);
tstyle.display = Display::Grid; tstyle.display = Display::Grid;
// A percentage width resolves against the container, so keep it and let the // A percentage width resolves against the container, so keep it and let the
// used-width pass leave it to taffy. Any other width (px or auto) is forced // used-width pass leave it to taffy. Any other width (px or auto) is forced
@@ -2479,9 +2630,10 @@ fn build_table(
} }
tstyle.grid_template_columns = (0..ncols).map(col).collect(); tstyle.grid_template_columns = (0..ncols).map(col).collect();
tstyle.grid_template_rows = (0..nrows).map(row_track).collect(); tstyle.grid_template_rows = (0..nrows).map(row_track).collect();
if let Some((h, v)) = style.border_spacing { tstyle.gap = taffy::Size {
tstyle.gap = taffy::Size { width: length(h), height: length(v) }; width: length(horizontal_spacing),
} height: length(vertical_spacing),
};
let table_node = taffy_tree.new_with_children(tstyle, &children).ok()?; let table_node = taffy_tree.new_with_children(tstyle, &children).ok()?;
id_map.insert(table_node, id); id_map.insert(table_node, id);
if col_px.iter().any(Option::is_some) || col_pct.iter().any(Option::is_some) { if col_px.iter().any(Option::is_some) || col_pct.iter().any(Option::is_some) {
+6
View File
@@ -345,6 +345,12 @@ pub struct LayoutStyle {
/// display mode, so `dom::propagate_border_spacing` distributes this down /// display mode, so `dom::propagate_border_spacing` distributes this down
/// as the table's own row gap and each descendant `<tr>`'s column gap. /// as the table's own row gap and each descendant `<tr>`'s column gap.
pub border_spacing: Option<(f32, f32)>, pub border_spacing: Option<(f32, f32)>,
/// Computed `border-collapse`. This property is inherited; `None` means
/// no value was specified on this node yet and is resolved top-down
/// before table construction. The collapsed-border conflict/paint model
/// is still approximate, but collapsed tables must at minimum contribute
/// no border-spacing to their geometry.
pub border_collapse: Option<bool>,
// Positioning. `position: absolute|fixed` takes the box out of normal flow. // Positioning. `position: absolute|fixed` takes the box out of normal flow.
pub position: Option<taffy::Position>, pub position: Option<taffy::Position>,
+29 -7
View File
@@ -133,7 +133,7 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
}; };
style.border_color = Some([118, 118, 118, 255]); style.border_color = Some([118, 118, 118, 255]);
style.background_color = Some([255, 255, 255, 255]); style.background_color = Some([255, 255, 255, 255]);
} else if tag == "table" || tag == "tbody" { } else if matches!(tag, "table" | "tbody" | "thead" | "tfoot") {
style.display = Display::Flex; style.display = Display::Flex;
style.internal_flex_container = true; style.internal_flex_container = true;
style.flex_direction = Some(taffy::FlexDirection::Column); style.flex_direction = Some(taffy::FlexDirection::Column);
@@ -144,8 +144,19 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
// out sideways. (Fully matching CSS auto table layout, where a table // out sideways. (Fully matching CSS auto table layout, where a table
// grows to fit unshrinkable content, needs real table layout.) // grows to fit unshrinkable content, needs real table layout.)
style.min_width = crate::Dimension::Px(0.0); style.min_width = crate::Dimension::Px(0.0);
if tag == "tbody" { if tag == "table" {
// Chromium's HTML UA sheet makes the table grid border-box and
// supplies the traditional two-pixel separate-border spacing.
// Author declarations and the legacy `cellspacing` hint cascade
// over these values.
style.box_sizing = crate::BoxSizing::BorderBox;
style.border_spacing = Some((2.0, 2.0));
style.border_collapse = Some(false);
} else {
style.width = crate::Dimension::Percent(1.0); style.width = crate::Dimension::Percent(1.0);
// The row-group UA rule is the source of the effective default
// middle alignment; rows and cells inherit it below.
style.vertical_align = Some(crate::VerticalAlign::Middle);
} }
} else if tag == "tr" { } else if tag == "tr" {
style.internal_flex_container = true; style.internal_flex_container = true;
@@ -160,12 +171,13 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
style.internal_flex_container = true; style.internal_flex_container = true;
style.flex_direction = Some(taffy::FlexDirection::Column); style.flex_direction = Some(taffy::FlexDirection::Column);
style.align_items = Some(taffy::AlignItems::FLEX_START); style.align_items = Some(taffy::AlignItems::FLEX_START);
style.padding = Edges { top: 0.0, right: 5.0, bottom: 0.0, left: 0.0 }; style.padding = Edges {
top: 1.0,
right: 1.0,
bottom: 1.0,
left: 1.0,
};
style.min_width = crate::Dimension::Px(0.0); style.min_width = crate::Dimension::Px(0.0);
// Cells are effectively middle-aligned by default in browsers (the
// HTML UA sheet's row-group vertical-align inherited into cells);
// an author `vertical-align` on the cell overrides this.
style.vertical_align = Some(crate::VerticalAlign::Middle);
if tag == "th" { if tag == "th" {
style.font_weight = Some("bold".to_string()); style.font_weight = Some("bold".to_string());
} }
@@ -688,6 +700,16 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
style.border_spacing = Some((h, *dims.get(1).unwrap_or(&h))); style.border_spacing = Some((h, *dims.get(1).unwrap_or(&h)));
} }
} }
"border-collapse" => {
style.border_collapse = match value.trim().to_ascii_lowercase().as_str() {
"collapse" => Some(true),
"separate" | "initial" | "revert" | "revert-layer" => Some(false),
// This is an inherited property, so both an omitted value and
// an explicit inherit/unset are resolved in the top-down pass.
"inherit" | "unset" => None,
_ => style.border_collapse,
};
}
"grid-template-columns" => { "grid-template-columns" => {
let (tracks, names) = parse_track_list_named(value); let (tracks, names) = parse_track_list_named(value);
style.grid_template_columns = tracks; style.grid_template_columns = tracks;