diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index d9caf82..eca1c0f 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -1644,6 +1644,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>)>, + /// Column constraints for the fixed table-layout algorithm. Unlike + /// `table_cols`, these are sourced only from columns and the first row and + /// deliberately exclude later-row intrinsic content. + fixed_table_cols: HashMap>, /// 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. @@ -1668,6 +1672,13 @@ struct IfcRegistry { multicol: Vec, } +#[derive(Clone, Copy, Debug, Default)] +struct FixedTableColumn { + length: f32, + percentage: f32, + specified: bool, +} + struct MulticolBuild { columns: Vec, children: Vec, @@ -5403,7 +5414,7 @@ fn layout_dom_once( 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 + (style.width == crate::Dimension::Auto && (depth > 0 || reliable_table_available_width( tree, @@ -5411,7 +5422,9 @@ fn layout_dom_once( &styles, initial_cb_width, ) - .is_none()) + .is_none())) + || (style.table_layout_fixed + && matches!(style.width, crate::Dimension::Percent(_))) }) }); if needs_layout_snapshot { @@ -5427,7 +5440,14 @@ fn layout_dom_once( for &(tnode, dom, _) in group { if styles .get(&dom) - .is_some_and(|style| style.width == crate::Dimension::Auto) + .is_some_and(|style| { + style.width == crate::Dimension::Auto + || (style.table_layout_fixed + && matches!( + style.width, + crate::Dimension::Percent(_) + )) + }) { if let Ok(layout) = taffy_tree.layout(tnode) { available_widths.insert(tnode, layout.size.width.max(0.0)); @@ -5439,6 +5459,66 @@ fn layout_dom_once( let Some(table_style) = styles.get(&dom) else { continue; }; + if let Some(columns) = ifc_items.fixed_table_cols.get(&tnode) { + let ncols = columns.len(); + if ncols == 0 { + continue; + } + let inline_outer_edges = table_inline_outer_edges(table_style); + let mut used_outer = match table_style.width { + crate::Dimension::Px(width) + if table_style.box_sizing + == crate::BoxSizing::ContentBox => + { + width.max(0.0) + inline_outer_edges + } + crate::Dimension::Px(width) => width.max(0.0), + crate::Dimension::Percent(_) => available_widths + .get(&tnode) + .copied() + .unwrap_or_else(|| { + reliable_table_available_width( + tree, + dom, + &styles, + initial_cb_width, + ) + .unwrap_or(initial_cb_width) + }), + _ => continue, + }; + let (horizontal_spacing, _) = table_spacing(table_style); + let interior_spacing = + horizontal_spacing * ncols.saturating_sub(1) as f32; + let target = + (used_outer - inline_outer_edges - interior_spacing).max(0.0); + let widths = distribute_fixed_table_columns(target, columns); + let required_outer = widths.iter().sum::() + + inline_outer_edges + + interior_spacing; + used_outer = used_outer.max(required_outer); + let used_declaration = + if table_style.box_sizing == crate::BoxSizing::ContentBox { + (used_outer - inline_outer_edges).max(0.0) + } else { + used_outer + }; + if let Ok(cur) = taffy_tree.style(tnode) { + let mut fixed_style = cur.clone(); + fixed_style.size.width = length(used_declaration); + fixed_style.grid_template_columns = widths + .iter() + .map(|width| { + taffy::GridTemplateComponent::Single(taffy::MinMax { + min: taffy::MinTrackSizingFunction::length(*width), + max: taffy::MaxTrackSizingFunction::length(*width), + }) + }) + .collect(); + let _ = taffy_tree.set_style(tnode, fixed_style); + } + continue; + } // A percentage-width table resolves against its container, so // leave taffy's percentage handling in place. let width_style = table_style.width; @@ -9572,6 +9652,87 @@ fn distribute_auto_table_columns( result } +/// Resolve CSS 2 fixed-layout column constraints into exact track widths. +/// +/// Columns and first-row cells establish the initial widths. Unspecified +/// tracks share the remaining space; when every track is specified, surplus +/// follows Gecko's fixed-length, percentage, then equal fallback order. A +/// fixed-length over-constraint grows the table, while percentage constraints +/// may shrink proportionally to keep the specified table width. +fn distribute_fixed_table_columns(target: f32, columns: &[FixedTableColumn]) -> Vec { + if columns.is_empty() { + return Vec::new(); + } + let target = target.max(0.0); + let mut widths: Vec = columns + .iter() + .map(|column| { + if column.specified { + column.length.max(0.0) + column.percentage.max(0.0) * target + } else { + 0.0 + } + }) + .collect(); + let mut total: f32 = widths.iter().sum(); + + // Percentage columns are the flexible part of an over-constrained fixed + // table. Never shrink the absolute component: if lengths alone do not fit, + // the table's used width grows to contain them. + if total > target { + let percentage_total: f32 = columns + .iter() + .map(|column| column.percentage.max(0.0) * target) + .sum(); + let shrink = (total - target).min(percentage_total); + if shrink > 0.0 && percentage_total > 0.0 { + for (width, column) in widths.iter_mut().zip(columns) { + let contribution = column.percentage.max(0.0) * target; + *width = (*width - shrink * contribution / percentage_total).max(0.0); + } + total -= shrink; + } + } + + let remaining = (target - total).max(0.0); + if remaining <= f32::EPSILON { + return widths; + } + let unresolved: Vec = columns + .iter() + .enumerate() + .filter_map(|(index, column)| (!column.specified).then_some(index)) + .collect(); + if !unresolved.is_empty() { + let share = remaining / unresolved.len() as f32; + for index in unresolved { + widths[index] = share; + } + return widths; + } + + let mut weights: Vec = columns + .iter() + .map(|column| column.length.max(0.0)) + .collect(); + let mut weight: f32 = weights.iter().sum(); + if weight <= f32::EPSILON { + weights = columns + .iter() + .map(|column| column.percentage.max(0.0)) + .collect(); + weight = weights.iter().sum(); + } + if weight <= f32::EPSILON { + weights.fill(1.0); + weight = columns.len() as f32; + } + for (width, column_weight) in widths.iter_mut().zip(weights) { + *width += remaining * column_weight / weight; + } + widths +} + /// Build a `` as a CSS grid. Modeling the table as a grid is what makes /// columns negotiate a shared width across every row (min-content/max-content /// track sizing), which the old flex-row-per-`` stack could not do: each @@ -9791,6 +9952,9 @@ fn build_table( // 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 fixed_layout = style.table_layout_fixed + && matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_)); + let mut fixed_columns = vec![FixedTableColumn::default(); ncols]; let attr_width = |cid: NodeId| -> (Option, Option) { let Some(v) = tree .get_node(cid) @@ -9814,6 +9978,13 @@ fn build_table( _ => attr_width(cid), } }; + let fixed_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(); @@ -9838,22 +10009,30 @@ fn build_table( _ => {} } } - for col_el in col_elems { + for col_el in &col_elems { let span = tree - .get_node(col_el) + .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); + let (px, pct) = style_width(*col_el); + let (fixed_px, fixed_pct) = fixed_style_width(*col_el); for _ in 0..span { if next_col >= ncols { break; } col_px[next_col] = px; col_pct[next_col] = pct; + if fixed_layout && (fixed_px.is_some() || fixed_pct.is_some()) { + fixed_columns[next_col] = FixedTableColumn { + length: fixed_px.unwrap_or(0.0), + percentage: fixed_pct.unwrap_or(0.0), + specified: true, + }; + } next_col += 1; } } @@ -9881,6 +10060,48 @@ fn build_table( } } + // Under fixed table layout only the first row contributes cell widths, + // and an explicit `` constraint wins for every covered column. A + // spanning cell's outer width is split with the inter-column spacing + // removed, matching Gecko's `((width + spacing) / span) - spacing` rule. + if fixed_layout { + let (horizontal_spacing, _) = table_spacing(style); + for (cid, row, start, _rowspan, colspan) in &placed { + if *row != 0 || *start >= ncols { + continue; + } + let (px, pct) = fixed_style_width(*cid); + if px.is_none() && pct.is_none() { + continue; + } + let span = (*colspan).min(ncols - *start).max(1); + let cell_style = styles.get(cid); + let edges = cell_style + .filter(|cell| cell.box_sizing == crate::BoxSizing::ContentBox) + .map(|cell| { + cell.padding.left + + cell.padding.right + + cell.border.left + + cell.border.right + }) + .unwrap_or(0.0); + let outer_length = px.unwrap_or(0.0) + edges; + let per_column_length = + ((outer_length + horizontal_spacing) / span as f32 - horizontal_spacing) + .max(0.0); + let per_column_percentage = pct.unwrap_or(0.0).max(0.0) / span as f32; + for column in &mut fixed_columns[*start..*start + span] { + if !column.specified { + *column = FixedTableColumn { + length: per_column_length, + percentage: per_column_percentage, + specified: true, + }; + } + } + } + } + // 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]; @@ -9905,6 +10126,12 @@ fn build_table( } let col = |i: usize| { + if fixed_layout { + return taffy::GridTemplateComponent::Single(taffy::MinMax { + min: taffy::MinTrackSizingFunction::length(0.0), + max: taffy::MaxTrackSizingFunction::length(0.0), + }); + } let max = if let Some(p) = col_pct[i] { taffy::MaxTrackSizingFunction::percent(p) } else if let Some(px) = col_px[i] { @@ -9954,6 +10181,9 @@ 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 fixed_layout { + ifc_items.fixed_table_cols.insert(table_node, fixed_columns); + } 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)); } @@ -16180,6 +16410,160 @@ mod tests { assert_eq!(width("border-cell"), 100.0); } + #[test] + fn fixed_table_layout_uses_only_first_row_for_column_geometry() { + let tree = parse_html( + r#" +
+
XXXXXXXXXXXXXXXXXXXXXXXX
"#, + ); + let laid = layout_dom(&tree, (800.0, 300.0)); + let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()]; + for id in ["first", "later"] { + assert!((rect(id).width - 50.0).abs() < 0.1, "{}: {:?}", id, rect(id)); + } + for id in ["second", "last"] { + assert!((rect(id).width - 250.0).abs() < 0.1, "{}: {:?}", id, rect(id)); + } + } + + #[test] + fn fixed_table_layout_accounts_for_separate_border_spacing() { + let tree = parse_html( + r#" +
"#, + ); + let laid = layout_dom(&tree, (800.0, 300.0)); + let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()]; + assert!((rect("table").width - 300.0).abs() < 0.1, "{:?}", rect("table")); + assert!((rect("first").x - 14.0).abs() < 0.1, "{:?}", rect("first")); + assert!((rect("first").width - 50.0).abs() < 0.1, "{:?}", rect("first")); + assert!((rect("second").x - 74.0).abs() < 0.1, "{:?}", rect("second")); + assert!((rect("second").width - 212.0).abs() < 0.1, "{:?}", rect("second")); + } + + #[test] + fn fixed_percentage_table_resolves_tracks_against_final_used_width() { + let tree = parse_html( + r#" +
"#, + ); + let laid = layout_dom(&tree, (800.0, 300.0)); + let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()]; + assert!((rect("table").width - 400.0).abs() < 0.1, "{:?}", rect("table")); + // Final paint geometry is snapped to device pixels around the 92.5 / + // 277.5 CSS-pixel track boundary. + assert!((rect("first").width - 92.5).abs() <= 0.5, "{:?}", rect("first")); + assert!((rect("second").width - 277.5).abs() <= 0.5, "{:?}", rect("second")); + } + + #[test] + fn fixed_columns_win_over_first_row_spanning_cell_widths() { + let tree = parse_html( + r#" + + + +
"#, + ); + let laid = layout_dom(&tree, (800.0, 300.0)); + let width = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()].width; + assert!((width("span") - 145.0).abs() < 0.1, "{}", width("span")); + assert!((width("one") - 40.0).abs() < 0.1, "{}", width("one")); + assert!((width("two") - 95.0).abs() < 0.1, "{}", width("two")); + assert!((width("three") - 125.0).abs() < 0.1, "{}", width("three")); + } + + #[test] + fn fixed_table_layout_with_auto_width_falls_back_to_auto_algorithm() { + let tree = parse_html( + r#" + +
"#, + ); + let laid = layout_dom(&tree, (800.0, 300.0)); + let first = laid.rects[&tree.get_element_by_id("first").unwrap()]; + let later = laid.rects[&tree.get_element_by_id("later").unwrap()]; + assert!(first.width >= 249.9, "{first:?}"); + assert!((first.width - later.width).abs() < 0.1, "{first:?} {later:?}"); + } + + #[test] + fn fixed_table_column_distribution_matches_css_fixed_rules() { + let column = |length, percentage, specified| FixedTableColumn { + length, + percentage, + specified, + }; + let assert_widths = |actual: Vec, expected: &[f32]| { + assert_eq!(actual.len(), expected.len()); + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() < 0.01, "{actual:?} != {expected:?}"); + } + }; + assert_widths( + distribute_fixed_table_columns( + 300.0, + &[column(50.0, 0.0, true), column(0.0, 0.0, false)], + ), + &[50.0, 250.0], + ); + assert_widths( + distribute_fixed_table_columns( + 300.0, + &[column(50.0, 0.0, true), column(100.0, 0.0, true)], + ), + &[100.0, 200.0], + ); + assert_widths( + distribute_fixed_table_columns( + 300.0, + &[column(50.0, 0.0, true), column(0.0, 0.5, true)], + ), + &[150.0, 150.0], + ); + assert_widths( + distribute_fixed_table_columns( + 300.0, + &[column(250.0, 0.0, true), column(100.0, 0.0, true)], + ), + &[250.0, 100.0], + ); + assert_widths( + distribute_fixed_table_columns( + 300.0, + &[column(0.0, 0.75, true), column(0.0, 0.75, true)], + ), + &[150.0, 150.0], + ); + } + #[test] fn authored_table_with_mixed_direct_content_preserves_non_cell_child() { let tree = parse_html( diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 5b194b6..de18e3b 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -800,6 +800,10 @@ pub struct LayoutStyle { /// features such as container-query axis availability that depend on the /// computed display rather than our internal layout approximation. pub(crate) is_table_box: bool, + /// The computed `table-layout: fixed` value. The fixed algorithm is only + /// activated when the table also has a definite inline size; otherwise + /// CSS requires the automatic table layout algorithm. + pub(crate) table_layout_fixed: bool, /// The computed inner display is `table-cell`. /// /// Authored internal table boxes need the same cell sizing path as native diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 7008c7d..4cdeacc 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -1710,6 +1710,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { _ => style.border_collapse, }; } + "table-layout" => match value.trim().to_ascii_lowercase().as_str() { + "fixed" => style.table_layout_fixed = true, + "auto" | "initial" | "unset" | "revert" | "revert-layer" => { + style.table_layout_fixed = false; + } + _ => {} + }, "grid-template-columns" => { let (tracks, names, calc_expressions) = parse_track_list_named(value); style.grid_template_columns_subgrid = is_subgrid_track_list(value); @@ -2042,6 +2049,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { | "-webkit-column-break-inside" | "border-spacing" | "border-collapse" + | "table-layout" | "grid-template-columns" | "grid-template-rows" | "grid-auto-columns" @@ -2121,6 +2129,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { value.to_ascii_lowercase().as_str(), "content-box" | "border-box" ), + "table-layout" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "fixed"), "container-type" => parse_container_type(value).is_some(), "container-name" => parse_container_names(value).is_some(), "container" => parse_container_shorthand(value).is_some(), @@ -8047,6 +8056,21 @@ fn split_ws_paren(s: &str) -> Vec<&str> { mod tests { use super::*; + #[test] + fn table_layout_parses_resets_and_reports_only_supported_values() { + let fixed = compute_style("table", Some("table-layout:fixed")); + assert!(fixed.table_layout_fixed); + + let reset = compute_style( + "table", + Some("table-layout:fixed;table-layout:auto;table-layout:banana"), + ); + assert!(!reset.table_layout_fixed); + assert!(supports_declaration("table-layout", "fixed")); + assert!(supports_declaration("table-layout", "auto")); + assert!(!supports_declaration("table-layout", "fixed-ish")); + } + #[test] fn supports_reports_only_implemented_value_subsets() { for value in ["normal", "break-all", "keep-all", "break-word"] { diff --git a/render-repros/checks.json b/render-repros/checks.json index e76a48a..7e9e50a 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -268,6 +268,16 @@ "tables": [ {"name": "content-box fixed cell column", "color": "#ffcc99", "width": 208} ], + "fixed-table-layout": [ + {"name": "first-row length fixes collapsed first track", "color": "#e03131", "x": 0, "y": 0, "width": 50, "height": 30}, + {"name": "unresolved collapsed track receives remainder", "color": "#1971c2", "x": 50, "y": 0, "width": 250, "height": 30}, + {"name": "later content cannot resize fixed first track", "color": "#2f9e44", "x": 0, "y": 30, "width": 50, "height": 30}, + {"name": "later collapsed row preserves shared tracks", "color": "#f08c00", "x": 50, "y": 30, "width": 250, "height": 30}, + {"name": "separate model charges outer spacing before first track", "color": "#862e9c", "x": 14, "y": 104, "width": 50, "height": 30}, + {"name": "separate model removes border and all spacing from track space", "color": "#15aabf", "x": 74, "y": 104, "width": 212, "height": 30}, + {"name": "later separate row cannot resize first track", "color": "#c2255c", "x": 14, "y": 134, "width": 50, "height": 30}, + {"name": "later separate row preserves remaining track", "color": "#fab005", "x": 74, "y": 134, "width": 212, "height": 30} + ], "table-row-geometry": [ {"name": "cell height remains a minimum below block content", "color": "#1971c2", "x": 0, "y": 0, "width": 40, "height": 30}, {"name": "sibling cell stretches to the content-sized row", "color": "#f783ac", "x": 40, "y": 0, "width": 50, "height": 30}, diff --git a/render-repros/fixed-table-layout.html b/render-repros/fixed-table-layout.html new file mode 100644 index 0000000..a447252 --- /dev/null +++ b/render-repros/fixed-table-layout.html @@ -0,0 +1,19 @@ + + + + + + + + + + +
XXXXXXXXXXXXXXXXXXXXXXXX