diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 25bacdc39..c6feb0e57 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -357,7 +357,15 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let ncols = t.cols().max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { - t.grid.iter().map(|w| if w.is_finite() { w.max(4.0) } else { 72.0 }).collect() + // Grid units can be narrower than a text box, including omitted row-end columns. + t.grid + .iter() + .map(|&w| match w { + w if !w.is_finite() => 72.0, + w if w <= 0.0 => 4.0, + w => w, + }) + .collect() } else { vec![avail / ncols as f32; ncols] }; @@ -373,8 +381,13 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava _ => None, }; if let Some(tw) = target { - let k = tw / total.max(1.0); - grid.iter_mut().for_each(|w| *w *= k); + if total > 0.0 && total < 1.0 { + // Divide first: a tiny positive total can overflow the scale factor. + grid.iter_mut().for_each(|w| *w = (*w / total) * tw); + } else { + let k = tw / total.max(1.0); + grid.iter_mut().for_each(|w| *w *= k); + } total = tw; } if total > avail * 1.5 && !t.props.fixed { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4f9602c2d..e5547e3b2 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8528,3 +8528,87 @@ fn cell_break_only_is_marker() { .unwrap(); assert!(para.clusters.iter().all(|c| c.kind == para::ClKind::Marker)); } + +#[test] +fn subpoint_grid_keeps_widths() { + for gap in [0.05, 0.5, 1.0, 3.0] { + let mut d = Document::new(); + let mut table = Table::new(1, 3, 300.0); + table.props.fixed = true; + table.props.width = Some(300.0); + table.grid = vec![100.0, 200.0 - gap, gap]; + let row = &mut table.rows[0]; + row.props.grid_after = 1; + row.props.w_after = Some(gap); + row.cells.truncate(1); + row.cells[0].props.span = 2; + row.cells[0].props.width = Some(300.0 - gap); + d.body = vec![Arc::new(Block::Table(table))]; + let l = lay(&d); + let width = l.pages[0] + .items + .iter() + .find_map(|item| match item { + Placed::Cell { rect, .. } => Some(rect.w), + _ => None, + }) + .unwrap(); + assert!((width - (300.0 - gap)).abs() < 0.001, "gap {gap}: cell width {width}"); + } +} + +#[test] +fn invalid_grid_keeps_fallback() { + for width in [0.0, -1.0, f32::NAN, f32::INFINITY] { + let mut d = Document::new(); + let mut table = Table::new(1, 2, 300.0); + table.props.fixed = true; + table.props.width = Some(300.0); + table.grid = vec![width; 2]; + for cell in &mut table.rows[0].cells { + cell.props.width = None; + } + d.body = vec![Arc::new(Block::Table(table))]; + let l = lay(&d); + let widths: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|item| match item { + Placed::Cell { rect, .. } => Some(rect.w), + _ => None, + }) + .collect(); + assert_eq!(widths, [150.0, 150.0], "grid {width}"); + } +} + +#[test] +fn tiny_grid_scales_to_width() { + for unit in [0.05, 0.25, 0.5, f32::MIN_POSITIVE, f32::from_bits(1)] { + for ratio in [1.0, 3.0] { + let mut d = Document::new(); + let mut table = Table::new(1, 2, 300.0); + table.props.fixed = true; + table.props.width = Some(300.0); + table.grid = vec![unit, unit * ratio]; + for cell in &mut table.rows[0].cells { + cell.props.width = None; + } + d.body = vec![Arc::new(Block::Table(table))]; + let l = lay(&d); + let widths: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|item| match item { + Placed::Cell { rect, .. } => Some(rect.w), + _ => None, + }) + .collect(); + let first = 300.0 / (1.0 + ratio); + assert_eq!(widths.len(), 2); + for (width, want) in widths.iter().zip([first, 300.0 - first]) { + assert!(width.is_finite() && (width - want).abs() < 0.001, "unit {unit}, ratio {ratio}: {widths:?}"); + } + } + } +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 71ab85a36..bafe3b130 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -24,6 +24,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic |---|---|---|---| | 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | | Hint-reliant CJK glyph outlines (DFKai-SB / ๆจ™ๆฅท้ซ”) | ๐ŸŸก | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. | โ€” | +| Subpoint table grid columns | ๐ŸŸก | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 ยง17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`) | โœ… | `layout::tests::no_line_break_right_after_a_slash` | โ€” | | Narrow table cells | โœ… | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 | | Justification, Word 2013+ space shrinking (compat mode 15) | โœ… | #109 | โ€” | @@ -227,6 +228,7 @@ Footer flow-table strokes reserve their drawn top edge, including the half-width | 2026-10-16 | trivial | Preserve direct TOC number tabs during regeneration | | 2026-10-16 | trivial | Explicit cell page breaks take no line height; original native Word controls in modes 14 and 15 and synthetic cluster/cache regressions. | | 2026-10-16 | trivial | Flow-table top border strokes in footers reserve body clearance; original mode-15 Word controls and a synthetic layout regression. | +| 2026-10-16 | trivial | Subpoint grid units retain their declared widths without shrinking adjacent cells; tiny totals normalize without scale-factor overflow | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |