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:
1 parent
1d96c316ca
commit
953292563d
3 files changed
+221
-20
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user