From 21dd5819aeec7eafd86f16a4bd79a5b749dfdb30 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:45:07 +0000 Subject: [PATCH] render: finalize table row geometry --- crates/obscura-render/src/dom.rs | 459 +++++++++++++++++++++++++++---- 1 file changed, 410 insertions(+), 49 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 2479aeb..a0dd6fd 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -83,6 +83,10 @@ struct IfcRegistry { /// table column-balancing pass in `layout_dom_with_images`, which pins /// specified columns instead of sizing them purely from content. table_cols: HashMap>, Vec>)>, + /// Minimum row heights per table grid node, one entry per source row. + /// The post-width row-sizing pass combines these with each cell's final + /// content height and pins the tracks before vertical alignment. + table_rows: HashMap>>, /// Floats whose direct block parent does not establish a block formatting /// context and whose exclusion can therefore continue through later /// descendant blocks of the same BFC. The first layout pass gives these @@ -1236,6 +1240,30 @@ pub fn layout_dom_with_images( &mut measure, ); } + if apply_table_row_geometry( + &mut taffy_tree, + &id_map, + &styles, + &ifc_items, + ) { + let _ = taffy_tree.compute_layout_with_measure( + taffy_root, + available, + &mut measure, + ); + } + if apply_table_cell_block_alignment( + tree, + &mut taffy_tree, + &id_map, + &styles, + ) { + let _ = taffy_tree.compute_layout_with_measure( + taffy_root, + available, + &mut measure, + ); + } } #[cfg(not(feature = "paint"))] { @@ -1256,6 +1284,22 @@ pub fn layout_dom_with_images( ) { let _ = taffy_tree.compute_layout(taffy_root, available); } + if apply_table_row_geometry( + &mut taffy_tree, + &id_map, + &styles, + &ifc_items, + ) { + let _ = taffy_tree.compute_layout(taffy_root, available); + } + if apply_table_cell_block_alignment( + tree, + &mut taffy_tree, + &id_map, + &styles, + ) { + let _ = taffy_tree.compute_layout(taffy_root, available); + } } compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &words, &mut rects, &mut text_runs, &mut anon_rects); synthesize_row_rects(tree, &mut rects); @@ -1991,6 +2035,256 @@ fn apply_float_continuations( changed } +/// Recompute table row tracks from the cells' content at their final column +/// widths. Generic grid intrinsic sizing can retain a taller contribution +/// from an earlier, narrower nested-table measurement even after that child +/// resolves to a shorter final box. CSS tables instead finish columns first, +/// measure cells at those widths, then derive rows bottom-up. +fn apply_table_row_geometry( + taffy_tree: &mut TaffyTree, + id_map: &HashMap, + styles: &HashMap, + ifc_items: &IfcRegistry, +) -> bool { + let mut changed = false; + for (&table_node, row_minimums) in &ifc_items.table_rows { + let nrows = row_minimums.len(); + if nrows == 0 { + continue; + } + let mut row_heights: Vec = row_minimums + .iter() + .map(|height| height.unwrap_or(0.0).max(0.0)) + .collect(); + let mut cells = Vec::new(); + for cell in taffy_tree.children(table_node).unwrap_or_default() { + let (Ok(cell_style), Ok(layout)) = + (taffy_tree.style(cell), taffy_tree.layout(cell)) + else { + continue; + }; + let row = match cell_style.grid_row.start { + GridPlacement::Line(line) => (line.as_i16().max(1) as usize) - 1, + _ => continue, + }; + let span = match cell_style.grid_row.end { + GridPlacement::Span(span) => (span as usize).max(1), + _ => 1, + }; + if row >= nrows { + continue; + } + let edges = layout.padding.top + + layout.padding.bottom + + layout.border.top + + layout.border.bottom; + // Taffy's content_size is the furthest content/child overflow in + // border-box coordinates (an empty padded cell reports its padding + // extent), so it is already the right natural border-box floor. + let mut natural = layout.content_size.height.max(edges); + if let Some(dom_id) = id_map.get(&cell) { + if let Some(style) = styles.get(dom_id) { + if let crate::Dimension::Px(height) = style.height { + let specified = if style.box_sizing == crate::BoxSizing::ContentBox { + height + style.padding.top + + style.padding.bottom + + style.border.top + + style.border.bottom + } else { + height + }; + natural = natural.max(specified); + } + } + } + cells.push((cell, row, span.min(nrows - row), natural.max(0.0))); + } + + // Non-spanning cells define their row directly. + for &(_, row, span, natural) in &cells { + if span == 1 { + row_heights[row] = row_heights[row].max(natural); + } + } + + let table_dom = id_map.get(&table_node).copied(); + let (row_gap, table_has_height) = table_dom + .and_then(|dom_id| styles.get(&dom_id)) + .map(|style| { + ( + table_spacing(style).1, + matches!(style.height, crate::Dimension::Px(_)), + ) + }) + .unwrap_or((0.0, false)); + + // A rowspan contributes only the shortfall beyond the rows and gaps + // it spans. Prefer unstyled rows; when they already have content, + // distribute proportionally, matching the table row-group algorithm. + for &(_, row, span, natural) in &cells { + if span <= 1 { + continue; + } + let end = row + span; + let current: f32 = row_heights[row..end].iter().sum::() + + row_gap * span.saturating_sub(1) as f32; + let extra = natural - current; + if extra <= 0.0 { + continue; + } + let mut targets: Vec = (row..end) + .filter(|&index| row_minimums[index].is_none()) + .collect(); + if targets.is_empty() { + targets = (row..end).collect(); + } + let weight: f32 = targets.iter().map(|&index| row_heights[index]).sum(); + for &index in &targets { + let share = if weight > 0.0 { + extra * row_heights[index] / weight + } else { + extra / targets.len() as f32 + }; + row_heights[index] += share; + } + } + + // A definite table height distributes surplus into rows; an auto + // table must not preserve stale free space from the generic grid pass. + if table_has_height { + if let Ok(table_layout) = taffy_tree.layout(table_node) { + let track_target = (table_layout.size.height + - table_layout.padding.top + - table_layout.padding.bottom + - table_layout.border.top + - table_layout.border.bottom + - row_gap * nrows.saturating_sub(1) as f32) + .max(0.0); + let current: f32 = row_heights.iter().sum(); + let extra = track_target - current; + if extra > 0.0 { + let mut targets: Vec = (0..nrows) + .filter(|&index| row_minimums[index].is_none()) + .collect(); + if targets.is_empty() { + targets = (0..nrows).collect(); + } + let weight: f32 = + targets.iter().map(|&index| row_heights[index]).sum(); + for &index in &targets { + let share = if weight > 0.0 { + extra * row_heights[index] / weight + } else { + extra / targets.len() as f32 + }; + row_heights[index] += share; + } + } + } + } + + if let Ok(current) = taffy_tree.style(table_node) { + let mut fixed = current.clone(); + fixed.grid_template_rows = row_heights + .iter() + .map(|height| { + taffy::GridTemplateComponent::Single(taffy::MinMax { + min: taffy::MinTrackSizingFunction::length(*height), + max: taffy::MaxTrackSizingFunction::length(*height), + }) + }) + .collect(); + if taffy_tree.set_style(table_node, fixed).is_ok() { + changed = true; + } + } + // A CSS height on a cell is a row minimum, not a smaller final cell + // box. Once the tracks are pinned, restore auto block-size so every + // cell stretches to its complete (possibly spanned) grid area. + for (cell, _, _, _) in cells { + if let Ok(current) = taffy_tree.style(cell) { + let mut stretched = current.clone(); + stretched.size.height = taffy::Dimension::auto(); + let _ = taffy_tree.set_style(cell, stretched); + } + } + } + changed +} + +/// Table-cell block alignment is a post-row-sizing operation. Feeding +/// `justify-content:center/end` into a cell while grid is still computing its +/// auto row tracks lets alignment offsets contaminate the cell's intrinsic +/// block-size (a nested 24px table can incorrectly demand a 29px row). Measure +/// cells from block-start first, freeze each final cell border-box height, then +/// enable middle/bottom alignment for the final layout pass. +fn apply_table_cell_block_alignment( + tree: &DomTree, + taffy_tree: &mut TaffyTree, + id_map: &HashMap, + styles: &HashMap, +) -> bool { + let mut changed = false; + for (&node, &dom_id) in id_map { + let Some(style) = styles.get(&dom_id) else { + continue; + }; + if !style.internal_flex_container + || !matches!( + style.vertical_align, + Some(crate::VerticalAlign::Middle | crate::VerticalAlign::Bottom) + ) + { + continue; + } + let is_cell = tree.get_node(dom_id).map_or(false, |dom_node| { + dom_node.as_element().map_or(false, |element| { + matches!(element.local.as_ref(), "td" | "th") + }) + }); + if !is_cell || taffy_tree.children(node).map_or(true, |children| children.is_empty()) { + // Pure-text cells are aligned after shaping in the text finalize + // path, where their actual line box height is available. + continue; + } + let (Ok(current), Ok(layout)) = (taffy_tree.style(node), taffy_tree.layout(node)) else { + continue; + }; + if current.display != taffy::style::Display::Flex { + continue; + } + let mut aligned = current.clone(); + let border_padding = layout.border.top + + layout.border.bottom + + layout.padding.top + + layout.padding.bottom; + let declared_height = if aligned.box_sizing == taffy::BoxSizing::ContentBox { + (layout.size.height - border_padding).max(0.0) + } else { + layout.size.height + }; + aligned.size.height = length(declared_height); + let is_middle = style.vertical_align == Some(crate::VerticalAlign::Middle); + if aligned.flex_direction == taffy::FlexDirection::Column { + aligned.justify_content = Some(if is_middle { + taffy::JustifyContent::CENTER + } else { + taffy::JustifyContent::FLEX_END + }); + } else { + aligned.align_content = Some(if is_middle { + taffy::AlignContent::CENTER + } else { + taffy::AlignContent::FLEX_END + }); + } + if taffy_tree.set_style(node, aligned).is_ok() { + changed = true; + } + } + changed +} + fn resolve_static_positions_and_reparent( taffy_tree: &mut TaffyTree, candidates: &[StaticPositionCandidate], @@ -2278,42 +2572,131 @@ fn tokenize_with_spaces(text: &str) -> Vec { tokens } -/// Give each ``/``/``/`` a rect equal to the union of -/// its descendant cell rects. In the grid table model these wrappers are not -/// taffy boxes, so without this their backgrounds and borders (zebra striping, -/// a `thead` background, a per-row border) would never paint. Skips a wrapper -/// that already has a rect (a table that fell back to the flex path keeps its -/// real box). +/// Give each ``/``/``/`` its CSS table-grid band. In +/// the grid table model these wrappers are not taffy boxes, so without this +/// their backgrounds, borders, and DOM geometry would disappear. +/// +/// A row's inline extent includes all cells that start in it, but its block +/// extent must ignore the portion of a `rowspan` that continues through later +/// rows. Nested-table cells must not participate at all. Sections are then the +/// union of their already-synthesized direct rows. fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap) { + let mut rows = Vec::new(); + let mut sections = Vec::new(); + let mut table_inline: HashMap = HashMap::new(); for id in tree.descendants(tree.document()) { let local = match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) { Some(l) => l, None => continue, }; - if !matches!(local.as_str(), "tr" | "tbody" | "thead" | "tfoot") { - continue; + match local.as_str() { + "tr" => rows.push(id), + "tbody" | "thead" | "tfoot" => sections.push(id), + "td" | "th" => { + let mut ancestor = tree.get_node(id).and_then(|node| node.parent); + while let Some(parent) = ancestor { + let is_table = tree.get_node(parent).map_or(false, |node| { + node.as_element() + .map_or(false, |element| element.local.as_ref() == "table") + }); + if is_table { + if let Some(rect) = rects.get(&id) { + table_inline + .entry(parent) + .and_modify(|current| *current = current.union(rect)) + .or_insert(*rect); + } + break; + } + ancestor = tree.get_node(parent).and_then(|node| node.parent); + } + } + _ => {} } + } + + for id in rows { if rects.contains_key(&id) { continue; } - let mut acc: Option = None; - for d in tree.descendants(id) { + let mut inline: Option = None; + let mut block: Option = None; + for cell in tree.children(id) { let is_cell = tree - .get_node(d) + .get_node(cell) .and_then(|n| n.as_element().map(|e| matches!(e.local.as_ref(), "td" | "th"))) .unwrap_or(false); if !is_cell { continue; } - if let Some(r) = rects.get(&d) { - acc = Some(match acc { + let Some(r) = rects.get(&cell) else { + continue; + }; + inline = Some(match inline { + Some(a) => a.union(r), + None => *r, + }); + let spans_one_row = tree + .get_node(cell) + .and_then(|node| { + node.get_attribute("rowspan") + .and_then(|value| value.trim().parse::().ok()) + }) + .map_or(true, |span| span == 1); + if spans_one_row { + block = Some(match block { Some(a) => a.union(r), None => *r, }); } } - if let Some(r) = acc { - rects.insert(id, r); + if let Some(mut inline) = inline { + let mut ancestor = tree.get_node(id).and_then(|node| node.parent); + while let Some(parent) = ancestor { + if let Some(table_band) = table_inline.get(&parent) { + inline.x = table_band.x; + inline.width = table_band.width; + break; + } + ancestor = tree.get_node(parent).and_then(|node| node.parent); + } + let block = block.unwrap_or(inline); + rects.insert( + id, + Rect { + x: inline.x, + y: block.y, + width: inline.width, + height: block.height, + }, + ); + } + } + + for id in sections { + if rects.contains_key(&id) { + continue; + } + let mut section: Option = None; + for row in tree.children(id) { + let is_row = tree + .get_node(row) + .map_or(false, |node| { + node.as_element() + .map_or(false, |element| element.local.as_ref() == "tr") + }); + if !is_row { + continue; + } + if let Some(rect) = rects.get(&row) { + section = Some(match section { + Some(current) => current.union(rect), + None => *rect, + }); + } + } + if let Some(rect) = section { + rects.insert(id, rect); } } } @@ -2636,6 +3019,7 @@ fn build_table( }; let table_node = taffy_tree.new_with_children(tstyle, &children).ok()?; id_map.insert(table_node, id); + ifc_items.table_rows.insert(table_node, row_min); 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)); } @@ -2906,31 +3290,6 @@ 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, id, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles) } else { @@ -2950,10 +3309,11 @@ fn build( && taffy_style.flex_direction == taffy::FlexDirection::Column { // Table cells and the other native block-layout stand-ins are not - // genuine CSS flex containers. A direct in-flow block with auto width - // must fill their inline size; `align-items:flex-start` would otherwise - // shrink-wrap it like a flex item (notably a pure-text
in a - // table cell). + // genuine CSS flex containers. Their block children neither flex-shrink + // to an authored container height nor shrink-wrap along the inline + // axis. Preserving the children's block sizes is particularly + // important for table cells, where `height` is only a row minimum and + // taller content must grow the row. for child in &child_ids { let fills_inline_axis = id_map .get(child) @@ -2967,12 +3327,13 @@ fn build( Some(taffy::Position::Absolute) ) }); - if fills_inline_axis { - if let Ok(current) = taffy_tree.style(*child) { - let mut fill = current.clone(); - fill.size.width = taffy::style::Dimension::percent(1.0); - let _ = taffy_tree.set_style(*child, fill); + if let Ok(current) = taffy_tree.style(*child) { + let mut block_item = current.clone(); + block_item.flex_shrink = 0.0; + if fills_inline_axis { + block_item.size.width = taffy::style::Dimension::percent(1.0); } + let _ = taffy_tree.set_style(*child, block_item); } } }