From 33d94eaf8501a20bc7a0522c3f265f44ff1e56fc Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:08:04 +0000 Subject: [PATCH] Match table containing block geometry --- crates/obscura-render/src/dom.rs | 376 ++++++++++++++++++--- crates/obscura-render/tests/layout_test.rs | 101 ++++++ 2 files changed, 432 insertions(+), 45 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 0239e35..05c2487 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -3459,23 +3459,63 @@ fn layout_dom_once( .and_then(|n| n.as_element().map(|e| e.local.as_ref() == "table")) .unwrap_or(false) }) - .map(|(t, d)| (*t, *d, dom_depth(tree, *d))) + .map(|(t, d)| (*t, *d, table_ancestor_depth(tree, *d))) .collect(); - // Deepest table first: an inner table gets its final width set - // before an outer table measures the subtree that contains it. - // Two tables at the same depth cannot be nested in one another - // (nesting increases depth), so their relative order is - // irrelevant and the final layout is deterministic even though - // id_map iteration order is not. - tables.sort_by(|a, b| b.2.cmp(&a.2)); - for (tnode, dom, _depth) in tables { + // Outer tables consume their nested tables' intrinsic sizes. + // Only after an outer depth is fixed can a root layout expose + // the real cell width available to the next nested depth. + tables.sort_by(|a, b| a.2.cmp(&b.2)); + let mut table_index = 0usize; + while table_index < tables.len() { + let depth = tables[table_index].2; + let mut group_end = table_index + 1; + while group_end < tables.len() && tables[group_end].2 == depth { + group_end += 1; + } + let group = &tables[table_index..group_end]; + let mut available_widths: HashMap = HashMap::new(); + let needs_layout_snapshot = group.iter().any(|(_, dom, _)| { + styles.get(dom).is_some_and(|style| { + style.width == crate::Dimension::Auto + && (depth > 0 + || reliable_table_available_width( + tree, + *dom, + &styles, + initial_cb_width, + ) + .is_none()) + }) + }); + if needs_layout_snapshot { + // This pass is gated to layout-dependent containing + // blocks and nested auto tables. Flat tables in normal + // block flow use the reliable cascade-time width below + // and pay no additional full-tree layout. + let _ = taffy_tree.compute_layout_with_measure( + taffy_root, + available, + &mut measure, + ); + for &(tnode, dom, _) in group { + if styles + .get(&dom) + .is_some_and(|style| style.width == crate::Dimension::Auto) + { + if let Ok(layout) = taffy_tree.layout(tnode) { + available_widths.insert(tnode, layout.size.width.max(0.0)); + } + } + } + } + for &(tnode, dom, _) in group { 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 = Some(table_style.width); - if matches!(width_style, Some(crate::Dimension::Percent(_))) { + let width_style = table_style.width; + if matches!(width_style, crate::Dimension::Percent(_)) { continue; } let min_c = { @@ -3496,7 +3536,10 @@ fn layout_dom_once( // overflow it. Floor min-content by the widest fixed child, // matching CSS (a table's min-content is at least any // fixed-width content it contains). - let min_c = min_c.max(max_definite_descendant_width(tree, dom, &styles).unwrap_or(0.0)); + let min_c = min_c.max( + max_definite_table_content_width(tree, dom, &styles) + .unwrap_or(0.0), + ); let max_c = { let _ = taffy_tree.compute_layout_with_measure( tnode, @@ -3507,15 +3550,37 @@ fn layout_dom_once( }; let inline_outer_edges = table_inline_outer_edges(table_style); let preferred_outer = match width_style { - Some(crate::Dimension::Px(w)) + crate::Dimension::Px(w) if table_style.box_sizing == crate::BoxSizing::ContentBox => { w + inline_outer_edges } - Some(crate::Dimension::Px(w)) => w, + crate::Dimension::Px(w) => w, _ => max_c, }; - let used_outer = preferred_outer.max(min_c).min(initial_cb_width.max(min_c)); + let used_outer = match width_style { + // A definite table width is not clamped to its + // containing block. Like other fixed boxes it may + // overflow, while min-content can still make it wider. + crate::Dimension::Px(_) => preferred_outer.max(min_c), + _ => { + let available_outer = available_widths + .get(&tnode) + .copied() + .or_else(|| { + reliable_table_available_width( + tree, + dom, + &styles, + initial_cb_width, + ) + }) + .unwrap_or(initial_cb_width); + preferred_outer + .max(min_c) + .min(available_outer.max(min_c)) + } + }; let used_declaration = if table_style.box_sizing == crate::BoxSizing::ContentBox { (used_outer - inline_outer_edges).max(0.0) @@ -3623,22 +3688,28 @@ fn layout_dom_once( let target = (used_outer - inline_outer_edges - interior_spacing) .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. + // A specified length on a cell/column is a preferred + // contribution, not a min-content floor. Preserve the + // content minimum and raise only the preferred width so + // a constrained table can interpolate below the hint. + // Percentage tracks retain the existing definite-table + // behavior here; broader percent balancing is separate. 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 + let fixed = spec_px + .get(j) + .copied() + .flatten(); + if let Some(w) = fixed { + let w = w.max(col_min[j]); + col_max[j] = w; + } else if let Some(w) = spec_pct .get(j) .copied() .flatten() - .or_else(|| spec_pct.get(j).copied().flatten().map(|p| p * target)); - if let Some(w) = spec { + .map(|percent| percent * target) + { let w = w.max(col_min[j]); col_min[j] = w; col_max[j] = w; @@ -3726,6 +3797,8 @@ fn layout_dom_once( s.size.width = length(used_declaration); let _ = taffy_tree.set_style(tnode, s); } + } + table_index = group_end; } let _ = taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); @@ -6649,22 +6722,159 @@ fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap) { } } -/// Collect `` elements of a table in document order, descending through -/// ``/``/`` section wrappers but never into a cell or a -/// nested `` (whose rows belong to that inner table). -/// Number of ancestors of `id` up to the document root. Used to order nested -/// tables deepest-first in the table used-width pass. -fn dom_depth(tree: &DomTree, id: NodeId) -> usize { - let mut d = 0usize; +/// Number of ancestor tables containing `id`. Table sizing runs outer-first: +/// an outer table consumes an inner table's intrinsic contributions, then a +/// root layout establishes the inner table's real cell containing block. +fn table_ancestor_depth(tree: &DomTree, id: NodeId) -> usize { + let mut depth = 0usize; let mut cur = id; while let Some(p) = tree.get_node(cur).and_then(|n| n.parent) { - d += 1; + if tree.get_node(p).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "table") + }) { + depth += 1; + } cur = p; - if d > 4096 { + if depth > 4096 { break; } } - d + depth +} + +/// Resolve a content-box width without running layout when the complete +/// containing-block chain is ordinary block flow. Flex/grid allocation, +/// floats, positioned boxes, inline-blocks, and intrinsic sizing need a real +/// Taffy pass and deliberately return `None`. +fn reliable_normal_flow_content_width( + tree: &DomTree, + id: NodeId, + styles: &HashMap, + initial_cb_width: f32, + depth: usize, +) -> Option { + if depth > 4096 { + return None; + } + let style = styles.get(&id)?; + if style.float.is_some() + || matches!(style.position, Some(taffy::Position::Absolute)) + || style.is_inline_block + || style.width_fit_content + || style.size_expressions[0].is_some() + { + return None; + } + + let mut parent = tree.get_node(id).and_then(|node| node.parent); + while parent.is_some_and(|parent_id| { + styles + .get(&parent_id) + .is_some_and(|parent_style| parent_style.display_contents) + }) { + parent = parent.and_then(|parent_id| tree.get_node(parent_id).and_then(|node| node.parent)); + } + let containing_width = if let Some(parent_id) = parent { + if let Some(parent_style) = styles.get(&parent_id) { + if matches!(parent_style.display, crate::Display::Flex | crate::Display::Grid) + || parent_style.internal_flex_container + || parent_style.column_count.is_some() + { + return None; + } + reliable_normal_flow_content_width( + tree, + parent_id, + styles, + initial_cb_width, + depth + 1, + )? + } else { + initial_cb_width + } + } else { + initial_cb_width + }; + + let horizontal_edges = + style.padding.left + style.padding.right + style.border.left + style.border.right; + let declared_content = |dimension: crate::Dimension| match dimension { + crate::Dimension::Px(value) => Some(if style.box_sizing == crate::BoxSizing::ContentBox { + value + } else { + (value - horizontal_edges).max(0.0) + }), + crate::Dimension::Percent(percent) => { + let value = containing_width * percent; + Some(if style.box_sizing == crate::BoxSizing::ContentBox { + value + } else { + (value - horizontal_edges).max(0.0) + }) + } + _ => None, + }; + let mut content_width = declared_content(style.width).unwrap_or_else(|| { + (containing_width + - style.margin.left + - style.margin.right + - horizontal_edges) + .max(0.0) + }); + if let Some(minimum) = declared_content(style.min_width) { + content_width = content_width.max(minimum); + } + if let Some(maximum) = declared_content(style.max_width) { + content_width = content_width.min(maximum); + } + Some(content_width.max(0.0)) +} + +fn reliable_table_available_width( + tree: &DomTree, + id: NodeId, + styles: &HashMap, + initial_cb_width: f32, +) -> Option { + let table_style = styles.get(&id)?; + if table_style.float.is_some() + || matches!(table_style.position, Some(taffy::Position::Absolute)) + || table_style.is_inline_block + { + return None; + } + let mut parent = tree.get_node(id).and_then(|node| node.parent); + while parent.is_some_and(|parent_id| { + styles + .get(&parent_id) + .is_some_and(|parent_style| parent_style.display_contents) + }) { + parent = parent.and_then(|parent_id| tree.get_node(parent_id).and_then(|node| node.parent)); + } + let containing_width = match parent { + Some(parent_id) if styles.contains_key(&parent_id) => { + let parent_style = styles.get(&parent_id)?; + if matches!(parent_style.display, crate::Display::Flex | crate::Display::Grid) + || parent_style.internal_flex_container + || parent_style.column_count.is_some() + { + return None; + } + reliable_normal_flow_content_width( + tree, + parent_id, + styles, + initial_cb_width, + 0, + )? + } + _ => initial_cb_width, + }; + Some( + (containing_width - table_style.margin.left - table_style.margin.right) + .max(0.0), + ) } /// Resolve the `width: fit-content` keyword after the containing inline space @@ -6827,15 +7037,48 @@ where changed } -fn collect_table_rows(tree: &DomTree, id: NodeId, rows: &mut Vec) { +/// Collect rows together with the exclusive end of their originating row +/// group. HTML rowspans never cross a thead/tbody/tfoot boundary; `rowspan=0` +/// means the remainder of that group, not the remainder of the whole table. +fn collect_table_rows(tree: &DomTree, id: NodeId, rows: &mut Vec<(NodeId, usize)>) { + let mut direct_start: Option = None; for cid in tree.children(id) { let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string())); match local.as_deref() { - Some("tr") => rows.push(cid), - Some("thead") | Some("tbody") | Some("tfoot") => collect_table_rows(tree, cid, rows), + Some("tr") => { + direct_start.get_or_insert(rows.len()); + rows.push((cid, 0)); + } + Some("thead") | Some("tbody") | Some("tfoot") => { + if let Some(start) = direct_start.take() { + let end = rows.len(); + for entry in &mut rows[start..end] { + entry.1 = end; + } + } + let start = rows.len(); + for row in tree.children(cid) { + if tree.get_node(row).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "tr") + }) { + rows.push((row, 0)); + } + } + let end = rows.len(); + for entry in &mut rows[start..end] { + entry.1 = end; + } + } _ => {} } } + if let Some(start) = direct_start { + let end = rows.len(); + for entry in &mut rows[start..end] { + entry.1 = end; + } + } } /// Effective separate-border spacing. Collapsed tables contribute no spacing @@ -6882,7 +7125,7 @@ fn build_table( styles: &HashMap, ) -> Option { let style = styles.get(&id)?; - let mut rows: Vec = Vec::new(); + let mut rows: Vec<(NodeId, usize)> = Vec::new(); collect_table_rows(tree, id, &mut rows); if rows.is_empty() { return None; @@ -6911,7 +7154,7 @@ fn build_table( let mut occupied: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new(); let mut placed: Vec<(NodeId, usize, usize, usize, usize)> = Vec::new(); let mut ncols = 0usize; - for (r, &tr) in rows.iter().enumerate() { + for (r, &(tr, group_end)) in rows.iter().enumerate() { let mut c = 0usize; for cid in tree.children(tr) { let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string())); @@ -6930,11 +7173,12 @@ fn build_table( break; } let cs = span_attr(cid, "colspan").clamp(1, MAX_SPAN); - // rowspan=0 means "span to the end"; a cell can never span more rows - // than remain, so clamp both to the rows left (which also bounds the - // occupancy fill and keeps spans within taffy's range). + // rowspan=0 means "span to the end of this row group". Explicit + // spans are clipped at the same boundary by the effective cell map. let rs_raw = span_attr(cid, "rowspan"); - let rs = if rs_raw == 0 { nrows - r } else { rs_raw }.clamp(1, nrows - r); + let rows_left_in_group = group_end.min(nrows).saturating_sub(r).max(1); + let rs = if rs_raw == 0 { rows_left_in_group } else { rs_raw } + .clamp(1, rows_left_in_group); for dr in 0..rs { for dc in 0..cs { occupied.insert((r + dr, c + dc)); @@ -7066,7 +7310,7 @@ fn build_table( // 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() { + 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); @@ -9037,6 +9281,48 @@ fn max_definite_descendant_width(tree: &DomTree, id: NodeId, styles: &HashMap, +) -> Option { + let mut best: Option = None; + for descendant in tree.descendants(id) { + let structural = tree.get_node(descendant).is_some_and(|node| { + node.as_element().is_some_and(|element| { + matches!( + element.local.as_ref(), + "caption" + | "col" + | "colgroup" + | "thead" + | "tbody" + | "tfoot" + | "tr" + | "td" + | "th" + ) + }) + }); + if structural { + continue; + } + if let Some(crate::Dimension::Px(width)) = + styles.get(&descendant).map(|style| style.width) + { + if width > 0.0 { + best = Some(best.map_or(width, |current| current.max(width))); + } + } + } + best +} + fn establishes_block_formatting_context(style: &crate::LayoutStyle) -> bool { matches!(style.display, crate::Display::Flex | crate::Display::Grid) || effective_container_type(style) != crate::ContainerType::Normal diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 9a9f409..c747462 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -90,6 +90,107 @@ fn hn_shaped_table_lays_out_without_site_hardcoding() { ); } +#[test] +fn table_width_uses_local_space_and_keeps_width_hints_shrinkable() { + let tree = parse_html( + r#" + +
+
alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu
+ +
+
alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu
+
+
+
fixed overflow
+
+ + + +
AB
+ "#, + ); + let layout = layout_dom(&tree, (400.0, 1000.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + + let local = rect("local-table"); + let local_cell = rect("local-cell"); + assert!((local.width - 300.0).abs() < 0.01, "local table: {local:?}"); + assert!( + local.height >= 39.0 && (local_cell.width - 300.0).abs() < 0.01, + "wrappable content must use the 300px local containing block: table={local:?} cell={local_cell:?}" + ); + + for id in ["outer", "outer-cell", "inner", "inner-cell"] { + let box_rect = rect(id); + assert!( + (box_rect.width - 300.0).abs() < 0.01 && box_rect.height >= 39.0, + "nested table sizing must settle outer-first for {id}: {box_rect:?}" + ); + } + + let overflow = rect("overflow"); + assert!( + (overflow.width - 500.0).abs() < 0.01, + "a definite table width may overflow its local CB and viewport: {overflow:?}" + ); + + let preferred = rect("preferred"); + let wide = rect("preferred-wide"); + let narrow = rect("preferred-narrow"); + assert!((preferred.width - 200.0).abs() < 0.01, "preferred table: {preferred:?}"); + assert!( + wide.width > 160.0 + && wide.width < 175.0 + && narrow.width > 25.0 + && narrow.width < 40.0 + && (wide.width + narrow.width - 200.0).abs() < 0.01, + "cell width hints are preferred contributions, not minimums: wide={wide:?} narrow={narrow:?}" + ); +} + +#[test] +fn table_rowspans_are_clipped_to_the_originating_row_group() { + let tree = parse_html( + r#" + + + + +
+ + + +
+ "#, + ); + let layout = layout_dom(&tree, (400.0, 1000.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + + for (table_id, span_id, next_id) in [ + ("zero", "zero-span", "zero-next"), + ("oversized", "oversized-span", "oversized-next"), + ] { + let table = rect(table_id); + let spanning = rect(span_id); + let next_group = rect(next_id); + assert!( + (spanning.height - 40.0).abs() < 0.01, + "{span_id} must stop at the tbody boundary: {spanning:?}" + ); + assert!( + (next_group.x - table.x).abs() < 0.01, + "{next_id} must restart at column zero in its row group: table={table:?} cell={next_group:?}" + ); + } +} + #[test] fn relative_units_resolve_against_viewport_and_font_size() { // 50vw of a 1000px viewport = 500px; 10em at the default 16px = 160px.