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") {
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 cellspacing.chars().all(|c| c.is_ascii_digit()) {
crate::style::apply_inline(style, &format!("border-spacing: {}px", cellspacing));
@@ -223,14 +226,35 @@ fn cascade_walk(
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
parent_props: &std::rc::Rc<HashMap<String, String>>,
quirks_mode: bool,
inherited_cell_padding: Option<f32>,
) {
let Some(node) = tree.get_node(id) else { return };
let is_element = node.is_element();
// 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).
let mut this_props = parent_props.clone();
let mut descendant_cell_padding = inherited_cell_padding;
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());
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" {
// Legacy HTML/quirks rendering keeps one em after forms. Standards
// mode does not; Hacker News and many old document templates omit
@@ -319,6 +343,7 @@ fn cascade_walk(
styles,
&this_props,
quirks_mode,
descendant_cell_padding,
);
}
if is_element {
@@ -384,13 +409,13 @@ pub fn layout_dom_with_images(
&mut styles,
&root_props,
quirks_mode,
None,
);
if timing {
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);
}
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
// context in `engine`; leaves without text carry no context.
@@ -429,6 +454,8 @@ pub fn layout_dom_with_images(
text_transform: crate::TextTransform,
italic: bool,
box_sizing: crate::BoxSizing,
border_collapse: bool,
table_vertical_align: Option<crate::VerticalAlign>,
/// Containing-block width in px for the current element, carried
/// down so percentage padding/margin (which resolve against the
/// 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,
italic: false,
box_sizing: crate::BoxSizing::ContentBox,
border_collapse: false,
table_vertical_align: None,
cb_width: 0.0,
}
}
@@ -586,6 +615,24 @@ pub fn layout_dom_with_images(
style.box_sizing = inh.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
// 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
// inheritance and author cascading. Text-like inputs use the HTML
// `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.
tables.sort_by(|a, b| b.2.cmp(&a.2));
for (tnode, dom, _depth) in tables {
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 = styles.get(&dom).map(|s| s.width.clone());
let width_style = Some(table_style.width);
if matches!(width_style, Some(crate::Dimension::Percent(_))) {
continue;
}
@@ -935,21 +990,30 @@ pub fn layout_dom_with_images(
);
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,
_ => max_c,
};
let used = preferred.max(min_c).min(viewport.0.max(min_c));
// Distribute `used` across the columns proportionally between
// 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). NetSurf
// layout.c layout_table: col.width = col.min + (col.max - col.min)
// * (used - min) / (max - min). The table's border, padding, and
// inter-column border-spacing are layout-invariant, so they are
// recovered once from the whole-table min-content width and kept
// out of the interpolation.
let used_outer = preferred_outer
.max(min_c)
.min(viewport.0.max(min_c));
let used_declaration =
if table_style.box_sizing == crate::BoxSizing::ContentBox {
(used_outer - inline_outer_edges).max(0.0)
} else {
used_outer
};
// Distribute the used track space proportionally between
// 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
.children(tnode)
.unwrap_or_default()
@@ -1033,13 +1097,19 @@ pub fn layout_dom_with_images(
col_max[j] = col_min[j];
}
}
// overhead = table border + padding + inter-column spacing,
// recovered from the whole-table min-content width
// (from the content mins, before specified widths pin
// any column).
let sum_min_content: f32 = col_min.iter().sum();
let overhead = (min_c - sum_min_content).max(0.0);
let target = (used - overhead).max(0.0);
// Track space is the final table border box minus its
// actual border/padding, the two outer spacing bands,
// and the spacing between columns. Inferring this from
// min-content is wrong when a specified cell/column
// width already inflated that measurement: the fixed
// width gets counted as "overhead", starving later
// 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
// width, recorded at build time) pin their columns:
// 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
// remaining space interpolates across the auto
// 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 {
let spec = spec_px
.get(j)
@@ -1063,13 +1134,58 @@ pub fn layout_dom_with_images(
}
let sum_min: f32 = col_min.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()
} else if target >= sum_max {
// Table wider than max-content (explicit/percentage-ish
// width): hand the extra out equally, as real tables do.
let extra = (target - sum_max) / ncols as f32;
col_max.iter().map(|m| m + extra).collect()
// Surplus follows the table-layout priority: auto
// columns absorb it before fixed and percentage
// columns. This is important even when every
// 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 {
let scale = (target - sum_min) / (sum_max - sum_min);
col_min
@@ -1080,7 +1196,7 @@ pub fn layout_dom_with_images(
};
if let Ok(cur) = taffy_tree.style(tnode) {
let mut s = cur.clone();
s.size.width = length(used);
s.size.width = length(used_declaration);
s.grid_template_columns = widths
.iter()
.map(|w| {
@@ -1094,7 +1210,7 @@ pub fn layout_dom_with_images(
}
} else if let Ok(cur) = taffy_tree.style(tnode) {
let mut s = cur.clone();
s.size.width = length(used);
s.size.width = length(used_declaration);
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") {
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) {
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
/// 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
@@ -2469,7 +2610,17 @@ fn build_table(
};
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;
// 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
@@ -2479,9 +2630,10 @@ fn build_table(
}
tstyle.grid_template_columns = (0..ncols).map(col).collect();
tstyle.grid_template_rows = (0..nrows).map(row_track).collect();
if let Some((h, v)) = style.border_spacing {
tstyle.gap = taffy::Size { width: length(h), height: length(v) };
}
tstyle.gap = taffy::Size {
width: length(horizontal_spacing),
height: length(vertical_spacing),
};
let table_node = taffy_tree.new_with_children(tstyle, &children).ok()?;
id_map.insert(table_node, id);
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
/// as the table's own row gap and each descendant `<tr>`'s column gap.
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.
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.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.internal_flex_container = true;
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
// grows to fit unshrinkable content, needs real table layout.)
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);
// 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" {
style.internal_flex_container = true;
@@ -160,12 +171,13 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
style.internal_flex_container = true;
style.flex_direction = Some(taffy::FlexDirection::Column);
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);
// 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" {
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)));
}
}
"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" => {
let (tracks, names) = parse_track_list_named(value);
style.grid_template_columns = tracks;