render: table vertical-align and specified column/row sizing

Cells now vertical-align the way tables actually do: middle by default
(the effective UA behavior), with author top/middle/bottom parsed and
applied to whichever shape the cell took: main-axis alignment on the
flex-column stand-in, line alignment on a promoted inline context, or a
pinned-origin offset for the pure-text leaf path where the shaped text
height is known at finalize.

Specified column widths finally apply. <col>/<colgroup> widths (style
or attribute, with span) and colspan-1 cell widths are recorded per
column at build time and pinned inside the column-balancing pass: px
directly, percent against the table's content width, with each column's
content min-content still flooring it. The cell's own width is
neutralized once it has fed the column, so the cell box fills its grid
area instead of shrinking to a fraction of its own already-sized track
(a width:50% cell previously ended up at 25 percent). Unspecified
columns keep content sizing but stretch to fill a definite table width
instead of leaving a dead strip of table background. Row and cell
heights act as row minimums.

Verified against Chromium on a 5-case repro (column from cell width,
row backgrounds, vertical alignment, col element width, percent cell in
a fixed table): all match. Wikipedia and the prose spot set unchanged;
obstacle course 33/33.
This commit is contained in:
SGavrl committed 2026-07-16 12:47:47 +00:00
1 parent 1d96c316ca
commit 953292563d
3 files changed
+221 -20

No files matched your search

+192 -20
View File
@@ -78,6 +78,11 @@ pub struct DomLayout {
struct IfcRegistry { struct IfcRegistry {
whole: HashMap<NodeId, usize>, whole: HashMap<NodeId, usize>,
runs: HashMap<NodeId, Vec<usize>>, runs: HashMap<NodeId, Vec<usize>>,
/// Specified column widths per table grid node, from `<col>` elements and
/// colspan-1 cells: `(px, percent)` per column index. Consumed by the
/// 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>>)>,
} }
/// Walk the tree top-down accumulating the clip rect imposed by ancestor /// Walk the tree top-down accumulating the clip rect imposed by ancestor
@@ -764,12 +769,36 @@ 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,
// 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);
// 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.
if let Some((spec_px, spec_pct)) = ifc_items.table_cols.get(&tnode) {
for j in 0..ncols {
let spec = spec_px
.get(j)
.copied()
.flatten()
.or_else(|| spec_pct.get(j).copied().flatten().map(|p| p * target));
if let Some(w) = spec {
let w = w.max(col_min[j]);
col_min[j] = w;
col_max[j] = w;
}
}
}
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();
// overhead = table border + padding + inter-column spacing,
// recovered from the whole-table min-content width.
let overhead = (min_c - sum_min).max(0.0);
let target = (used - overhead).max(0.0);
let widths: Vec<f32> = if sum_max <= sum_min || target <= sum_min { let widths: Vec<f32> = if sum_max <= sum_min || target <= sum_min {
col_min.clone() col_min.clone()
} else if target >= sum_max { } else if target >= sum_max {
@@ -828,8 +857,22 @@ pub fn layout_dom_with_images(
#[cfg(feature = "paint")] #[cfg(feature = "paint")]
for (nid, &idx) in &ifc_items.whole { for (nid, &idx) in &ifc_items.whole {
if let (Some(rect), Some(style)) = (rects.get(nid), styles.get(nid)) { if let (Some(rect), Some(style)) = (rects.get(nid), styles.get(nid)) {
let origin = crate::inline::content_origin(rect, style); let mut origin = crate::inline::content_origin(rect, style);
let cw = crate::inline::content_width(rect, style); let cw = crate::inline::content_width(rect, style);
// A table cell stretched taller than its text (row height from a
// neighbor) aligns its content per vertical-align; the pure-text
// leaf path has no inner box to align, so shift the pinned origin
// by the leftover space instead.
if let Some(va @ (crate::VerticalAlign::Middle | crate::VerticalAlign::Bottom)) = style.vertical_align {
let (_, th) = engine.measure(idx, Some(cw));
let content_h = rect.height
- style.padding.top
- style.padding.bottom
- style.border.top
- style.border.bottom;
let free = (content_h - th).max(0.0);
origin.1 += if va == crate::VerticalAlign::Middle { free / 2.0 } else { free };
}
// The shaped text is this container's own content, so an // The shaped text is this container's own content, so an
// `overflow: hidden` on the container clips it (this is what keeps // `overflow: hidden` on the container clips it (this is what keeps
// the 1x1 "visually hidden" skip-link box from painting its text, // the 1x1 "visually hidden" skip-link box from painting its text,
@@ -1534,6 +1577,11 @@ fn build_table(
// Grid does the sizing; a leftover flex_grow from the flex-table // Grid does the sizing; a leftover flex_grow from the flex-table
// heuristic would be ignored anyway, but clear it to be explicit. // heuristic would be ignored anyway, but clear it to be explicit.
cstyle.flex_grow = 0.0; cstyle.flex_grow = 0.0;
// A cell's specified width sizes its COLUMN (fed into the track
// by the pre-pass above); the cell box itself always fills its
// grid area. Left in place, a `width:50%` cell would shrink to
// half of its own already-halved track.
cstyle.size.width = Dimension::auto();
let _ = taffy_tree.set_style(cell_node, cstyle); let _ = taffy_tree.set_style(cell_node, cstyle);
} }
children.push(cell_node); children.push(cell_node);
@@ -1542,20 +1590,116 @@ fn build_table(
return None; return None;
} }
// The grid container inherits the table's own box (border, padding, // Column sizing pre-pass: specified widths on `<col>` elements and on
// background, margin) but is forced to width:auto so the intrinsic-sizing // colspan-1 cells feed the tracks, so author column sizing actually
// pass in layout_dom can measure content before choosing the used width. // applies (a `td{width:200px}` must size the COLUMN, across every row).
let col = || { // A percent width becomes a percent track (resolving against the table),
taffy::GridTemplateComponent::Single(taffy::MinMax { // a px width caps the track at that length (min-content still protects
min: taffy::MinTrackSizingFunction::min_content(), // the content), and unspecified columns keep content sizing with an
max: taffy::MaxTrackSizingFunction::max_content(), // `auto` max so they stretch to fill a definite table width instead of
}) // 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 attr_width = |cid: NodeId| -> (Option<f32>, Option<f32>) {
let Some(v) = tree.get_node(cid).and_then(|n| n.get_attribute("width").map(|s| s.trim().to_string())) else {
return (None, None);
};
if let Some(p) = v.strip_suffix('%').and_then(|s| s.trim().parse::<f32>().ok()) {
(None, Some(p / 100.0))
} else {
(v.trim_end_matches("px").trim().parse::<f32>().ok(), None)
}
}; };
let row_track = || { let style_width = |cid: NodeId| -> (Option<f32>, Option<f32>) {
taffy::GridTemplateComponent::Single(taffy::MinMax { match styles.get(&cid).map(|s| s.width) {
min: taffy::MinTrackSizingFunction::auto(), Some(crate::Dimension::Px(w)) if w > 0.0 => (Some(w), None),
max: taffy::MaxTrackSizingFunction::auto(), 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();
for cid in tree.children(id) {
match tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string())).as_deref() {
Some("col") => col_elems.push(cid),
Some("colgroup") => {
for gc in tree.children(cid) {
if tree.get_node(gc).and_then(|n| n.as_element().map(|e| e.local.as_ref() == "col")).unwrap_or(false) {
col_elems.push(gc);
}
}
}
_ => {}
}
}
for col_el in col_elems {
let span = tree
.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);
for _ in 0..span {
if next_col >= ncols {
break;
}
col_px[next_col] = px;
col_pct[next_col] = pct;
next_col += 1;
}
}
// colspan-1 cells override <col> (they are closer to the content).
for (cid, _r, c, _rs, cs) in &placed {
if *cs != 1 || *c >= ncols {
continue;
}
let (px, pct) = style_width(*cid);
if let Some(w) = px {
col_px[*c] = Some(col_px[*c].map_or(w, |cur| cur.max(w)));
}
if let Some(p) = pct {
col_pct[*c] = Some(col_pct[*c].map_or(p, |cur| cur.max(p)));
}
}
// 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() {
if let Some(crate::Dimension::Px(h)) = styles.get(&tr).map(|s| s.height) {
if h > 0.0 {
row_min[r] = Some(h);
}
}
}
for (cid, r, _c, rs, _cs) in &placed {
if *rs != 1 {
continue;
}
if let Some(crate::Dimension::Px(h)) = styles.get(cid).map(|s| s.height) {
if h > 0.0 {
row_min[*r] = Some(row_min[*r].map_or(h, |cur| cur.max(h)));
}
}
}
let col = |i: usize| {
let max = if let Some(p) = col_pct[i] {
taffy::MaxTrackSizingFunction::percent(p)
} else if let Some(px) = col_px[i] {
taffy::MaxTrackSizingFunction::length(px)
} else {
taffy::MaxTrackSizingFunction::auto()
};
taffy::GridTemplateComponent::Single(taffy::MinMax { min: taffy::MinTrackSizingFunction::min_content(), max })
};
let row_track = |r: usize| {
let min = match row_min[r] {
Some(h) => taffy::MinTrackSizingFunction::length(h),
None => taffy::MinTrackSizingFunction::auto(),
};
taffy::GridTemplateComponent::Single(taffy::MinMax { min, max: taffy::MaxTrackSizingFunction::auto() })
}; };
let mut tstyle = to_taffy_style(style); let mut tstyle = to_taffy_style(style);
tstyle.display = Display::Grid; tstyle.display = Display::Grid;
@@ -1565,13 +1709,16 @@ fn build_table(
if !matches!(style.width, crate::Dimension::Percent(_)) { if !matches!(style.width, crate::Dimension::Percent(_)) {
tstyle.size.width = Dimension::auto(); tstyle.size.width = Dimension::auto();
} }
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 { if let Some((h, v)) = style.border_spacing {
tstyle.gap = taffy::Size { width: length(h), height: length(v) }; tstyle.gap = taffy::Size { width: length(h), height: length(v) };
} }
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) {
ifc_items.table_cols.insert(table_node, (col_px, col_pct));
}
Some(table_node) Some(table_node)
} }
@@ -1691,6 +1838,31 @@ fn build(
taffy_style.align_items = Some(taffy::AlignItems::FLEX_START); taffy_style.align_items = Some(taffy::AlignItems::FLEX_START);
} }
// Table-cell vertical alignment: place the cell's content along the
// vertical axis of whichever shape the cell took, the flex-column
// stand-in's main axis or the wrapped lines of a promoted inline
// context. (The pure-text leaf path is aligned at finalize instead,
// where the shaped text height is known.)
if let Some(va) = style.vertical_align {
if taffy_style.display == taffy::style::Display::Flex {
if taffy_style.flex_direction == taffy::FlexDirection::Column {
if style.justify_content.is_none() {
taffy_style.justify_content = Some(match va {
crate::VerticalAlign::Top => taffy::JustifyContent::FLEX_START,
crate::VerticalAlign::Middle => taffy::JustifyContent::CENTER,
crate::VerticalAlign::Bottom => taffy::JustifyContent::FLEX_END,
});
}
} else {
taffy_style.align_content = Some(match va {
crate::VerticalAlign::Top => taffy::AlignContent::FLEX_START,
crate::VerticalAlign::Middle => taffy::AlignContent::CENTER,
crate::VerticalAlign::Bottom => taffy::AlignContent::FLEX_END,
});
}
}
}
let mut child_ids: Vec<taffy::NodeId> = if has_float_child { let mut child_ids: Vec<taffy::NodeId> = if has_float_child {
build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles) build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles)
} else { } else {
+16
View File
@@ -320,6 +320,11 @@ pub struct LayoutStyle {
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the /// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
/// default of 1.0. /// default of 1.0.
pub opacity: Option<f32>, pub opacity: Option<f32>,
/// `vertical-align` for a table cell's content. Cells effectively default
/// to `middle` in browsers (the HTML UA sheet sets it on row groups and
/// cells inherit it); obscura applies it as main-axis alignment of the
/// cell's flex-column stand-in. `None` on non-cell elements.
pub vertical_align: Option<VerticalAlign>,
/// Resolved during the inheritance pass: true when this element should /// Resolved during the inheritance pass: true when this element should
/// not be painted at all, either from its own or an inherited /// not be painted at all, either from its own or an inherited
/// `visibility: hidden`, or because the product of its own and every /// `visibility: hidden`, or because the product of its own and every
@@ -447,6 +452,17 @@ pub enum ObjectFit {
None, None,
} }
/// `vertical-align` positions for table-cell content. `baseline` (and the
/// text-level values like sub/super, which do not apply to cells) map to
/// `Top` as an approximation: real per-row baseline alignment needs shared
/// ascent metrics across the row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerticalAlign {
Top,
Middle,
Bottom,
}
/// `line-height`: `normal` (a font-relative default), a unitless multiple of /// `line-height`: `normal` (a font-relative default), a unitless multiple of
/// font-size, or an absolute pixel length. /// font-size, or an absolute pixel length.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
+13
View File
@@ -130,6 +130,10 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
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: 0.0, right: 5.0, bottom: 0.0, left: 0.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());
} }
@@ -438,6 +442,15 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
} }
"visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")), "visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")),
"opacity" => style.opacity = value.trim().parse::<f32>().ok(), "opacity" => style.opacity = value.trim().parse::<f32>().ok(),
"vertical-align" => {
style.vertical_align = match value.trim().to_ascii_lowercase().as_str() {
"top" | "baseline" | "text-top" => Some(crate::VerticalAlign::Top),
"middle" => Some(crate::VerticalAlign::Middle),
"bottom" | "text-bottom" => Some(crate::VerticalAlign::Bottom),
// sub/super/lengths are text-level; leave the cell default.
_ => style.vertical_align,
};
}
"list-style-type" => { "list-style-type" => {
style.list_style = Some(list_style_keyword(value.trim()).unwrap_or(crate::ListStyle::Disc)); style.list_style = Some(list_style_keyword(value.trim()).unwrap_or(crate::ListStyle::Disc));
} }