From 953292563d619fcb77e23e61d4df76a2434e5390 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:47:47 +0000
Subject: [PATCH] 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.
/ 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.
---
crates/obscura-render/src/dom.rs | 212 ++++++++++++++++++++++++++---
crates/obscura-render/src/lib.rs | 16 +++
crates/obscura-render/src/style.rs | 13 ++
3 files changed, 221 insertions(+), 20 deletions(-)
diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs
index 781fa22..cb698fd 100644
--- a/crates/obscura-render/src/dom.rs
+++ b/crates/obscura-render/src/dom.rs
@@ -78,6 +78,11 @@ pub struct DomLayout {
struct IfcRegistry {
whole: HashMap,
runs: HashMap>,
+ /// Specified column widths per table grid node, from ` ` 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>, Vec>)>,
}
/// 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];
}
}
+ // 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 ` ` 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_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 = if sum_max <= sum_min || target <= sum_min {
col_min.clone()
} else if target >= sum_max {
@@ -828,8 +857,22 @@ pub fn layout_dom_with_images(
#[cfg(feature = "paint")]
for (nid, &idx) in &ifc_items.whole {
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);
+ // 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
// `overflow: hidden` on the container clips it (this is what keeps
// 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
// heuristic would be ignored anyway, but clear it to be explicit.
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);
}
children.push(cell_node);
@@ -1542,20 +1590,116 @@ fn build_table(
return None;
}
- // The grid container inherits the table's own box (border, padding,
- // background, margin) but is forced to width:auto so the intrinsic-sizing
- // pass in layout_dom can measure content before choosing the used width.
- let col = || {
- taffy::GridTemplateComponent::Single(taffy::MinMax {
- min: taffy::MinTrackSizingFunction::min_content(),
- max: taffy::MaxTrackSizingFunction::max_content(),
- })
+ // Column sizing pre-pass: specified widths on ` ` elements and on
+ // colspan-1 cells feed the tracks, so author column sizing actually
+ // applies (a `td{width:200px}` must size the COLUMN, across every row).
+ // A percent width becomes a percent track (resolving against the table),
+ // a px width caps the track at that length (min-content still protects
+ // the content), and unspecified columns keep content sizing with an
+ // `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> = vec![None; ncols];
+ let mut col_pct: Vec > = vec![None; ncols];
+ let attr_width = |cid: NodeId| -> (Option, Option) {
+ 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::().ok()) {
+ (None, Some(p / 100.0))
+ } else {
+ (v.trim_end_matches("px").trim().parse::().ok(), None)
+ }
};
- let row_track = || {
- taffy::GridTemplateComponent::Single(taffy::MinMax {
- min: taffy::MinTrackSizingFunction::auto(),
- max: taffy::MaxTrackSizingFunction::auto(),
- })
+ let style_width = |cid: NodeId| -> (Option, Option) {
+ 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),
+ }
+ };
+ // elements (direct or under ), each spanning `span` columns.
+ let mut next_col = 0usize;
+ let mut col_elems: Vec = 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::().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 (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> = 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);
tstyle.display = Display::Grid;
@@ -1565,13 +1709,16 @@ fn build_table(
if !matches!(style.width, crate::Dimension::Percent(_)) {
tstyle.size.width = Dimension::auto();
}
- tstyle.grid_template_columns = (0..ncols).map(|_| col()).collect();
- tstyle.grid_template_rows = (0..nrows).map(|_| row_track()).collect();
+ 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) };
}
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) {
+ ifc_items.table_cols.insert(table_node, (col_px, col_pct));
+ }
Some(table_node)
}
@@ -1691,6 +1838,31 @@ fn build(
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 = if has_float_child {
build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles)
} else {
diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs
index da67d53..701c0b8 100644
--- a/crates/obscura-render/src/lib.rs
+++ b/crates/obscura-render/src/lib.rs
@@ -320,6 +320,11 @@ pub struct LayoutStyle {
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
/// default of 1.0.
pub opacity: Option,
+ /// `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,
/// Resolved during the inheritance pass: true when this element should
/// not be painted at all, either from its own or an inherited
/// `visibility: hidden`, or because the product of its own and every
@@ -447,6 +452,17 @@ pub enum ObjectFit {
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
/// font-size, or an absolute pixel length.
#[derive(Debug, Clone, Copy, PartialEq)]
diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs
index 361f757..c12bb54 100644
--- a/crates/obscura-render/src/style.rs
+++ b/crates/obscura-render/src/style.rs
@@ -130,6 +130,10 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
style.align_items = Some(taffy::AlignItems::FLEX_START);
style.padding = Edges { top: 0.0, right: 5.0, bottom: 0.0, left: 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" {
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")),
"opacity" => style.opacity = value.trim().parse::().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" => {
style.list_style = Some(list_style_keyword(value.trim()).unwrap_or(crate::ListStyle::Disc));
}