diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index c360bfca5..9a260d4c4 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -43,6 +43,13 @@ pub struct Row { pub controls: ControlWrap, } +impl Row { + /// Grid column where the row's first cell starts: the columns `w:gridBefore` skips. + pub fn first_grid(&self) -> usize { + (self.props.grid_before as usize).min(MAX_COLS) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] pub struct Table { #[serde(default)] @@ -86,18 +93,22 @@ impl Table { } pub fn cols(&self) -> usize { - self.grid.len().max(self.rows.iter().map(|r| r.cells.iter().map(Cell::span).sum::()).max().unwrap_or(0)) + let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::() + r.props.grid_after as usize; + self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0)) } /// Grid column where cell `c` of row `r` starts. pub fn grid_col(&self, r: usize, c: usize) -> usize { - self.rows.get(r).map(|row| row.cells.iter().take(c).map(Cell::span).sum()).unwrap_or(0) + self.rows.get(r).map(|row| row.first_grid() + row.cells.iter().take(c).map(Cell::span).sum::()).unwrap_or(0) } /// Cell index in row `r` that covers grid column `g`. pub fn cell_at_grid(&self, r: usize, g: usize) -> Option { let row = self.rows.get(r)?; - let mut x = 0; + let mut x = row.first_grid(); + if g < x { + return None; + } for (i, c) in row.cells.iter().enumerate() { if g < x + c.span() { return Some(i); @@ -495,6 +506,18 @@ impl Table { mod tests { use super::*; + #[test] + fn grid_before_shifts_the_row() { + let mut t = Table::new(2, 3, 300.0); + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + assert_eq!(t.grid_col(1, 0), 1); + assert_eq!(t.grid_col(1, 1), 2); + assert_eq!(t.cell_at_grid(1, 0), None); + assert_eq!(t.cell_at_grid(1, 2), Some(1)); + assert_eq!(t.cols(), 3); + } + #[test] fn new_table_shape() { let t = Table::new(3, 4, 468.0); diff --git a/crates/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index 38ac1409d..f4bd68054 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -374,7 +374,7 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult { /// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after) fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> { t.rows.iter().enumerate().find_map(|(r, row)| { - let mut g = 0usize; + let mut g = row.first_grid(); for (c, cell) in row.cells.iter().enumerate() { if g == line { return Some((r, c, false)); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 864ec0b72..aaea032d0 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -91,7 +91,7 @@ impl CellRange { let ((r0, r1), (g0, g1)) = (self.rows, self.cols); let mut out = Vec::new(); for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { - let mut g = 0usize; + let mut g = row.first_grid(); for (c, cell) in row.cells.iter().enumerate() { let end = g.saturating_add(cell.span()); if g <= g1 && end > g0 { diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 2b3cd1c08..17ad6ce36 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -5,7 +5,7 @@ use wordcraft_doc::numbering::Counters; use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge}; use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps}; -use wordcraft_doc::{Block, Blocks, Paragraph, StoryRef, Table}; +use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table}; use wordcraft_geom::Rect; use crate::para::{CellText, ClKind, ParaLayout}; @@ -181,7 +181,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f let mut spanned: Vec<(usize, usize, f32, f32)> = Vec::new(); let mut budget = MEASURE_BUDGET; 'rows: for (ri, row) in t.rows.iter().enumerate() { - let mut g = 0usize; + let mut g = row.first_grid(); for cell in &row.cells { let span = cell.span(); let margins = cell.props.resolved_margins(margins_def); @@ -240,6 +240,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f } g += span; } + spanned.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, w, w))); } for (g, span, min, max) in spanned { let Some(cs) = cols.get_mut(g..(g + span).min(ncols)) else { continue }; @@ -473,7 +474,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut heights: Vec = Vec::with_capacity(nrows); let mut bottoms: Vec = Vec::with_capacity(nrows); for (ri, row) in t.rows.iter().enumerate() { - let mut g = 0usize; + let mut g = row.first_grid(); let mut cells = Vec::with_capacity(row.cells.len()); let mut rh = 0.0f32; // The tallest cell margins plus border bands in the row. @@ -520,7 +521,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let cb = cell.props.borders.unwrap_or_default(); // A row's table property exceptions (`w:tblPrEx`) override the table's borders for its cells. let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default(); - let (first_g, last_g) = (g == 0, g + span >= ncols); + // A row that skips grid columns (`w:gridBefore`/`w:gridAfter`) has its edge at its own + // first and last cell. + let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.props.grid_after as usize)); let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default(); let mut borders = Borders { top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between), @@ -671,7 +674,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava .rows .iter() .map(|r| { - let mut gx = 0; + let mut gx = r.first_grid(); r.cells .iter() .filter_map(|x| { @@ -1105,7 +1108,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep let mut spanning = Vec::new(); let mut any = false; for (ri, row) in t.rows.iter().enumerate() { - let mut g = 0usize; + let mut g = row.first_grid(); for cell in &row.cells { let span = cell.span(); if cell.props.vmerge != VMerge::Continue { @@ -1121,6 +1124,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep } g += span; } + spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w)))); } for (g, span, need) in spanning { let Some(cs) = cols.get_mut(g..(g + span).min(t.cols())) else { continue }; @@ -1134,6 +1138,17 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep any.then_some(cols) } +/// The grid columns row `row` leaves empty before its first cell and after its last one (which +/// ends at grid column `end`) when it gives their width (`w:wBefore`, `w:wAfter`): (first column, +/// columns, width in points). Column widths make room for them like for an empty cell. +fn row_gaps(row: &Row, end: usize) -> impl Iterator { + let p = &row.props; + [(0, row.first_grid(), p.w_before), (end, p.grid_after as usize, p.w_after)] + .into_iter() + .filter(|&(_, n, _)| n > 0) + .filter_map(|(g, n, w)| w.filter(|w| w.is_finite() && *w > 0.0).map(|w| (g, n, w.min(1584.0)))) +} + /// The narrowest and widest a block list can be laid out. fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth: usize) -> (f32, f32) { let (mut lo, mut hi) = (0.0f32, 0.0f32); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 789854212..c1e96134b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1474,6 +1474,147 @@ fn table_border_corners_close() { assert!((h_left - (v_left - 1.5)).abs() < 0.01, "horizontal starts at {h_left}, vertical centre line at {v_left}"); } +/// A row with `w:gridBefore` starts at the grid column after the skipped ones, not at the first. +#[test] +fn grid_before_row_starts_after_skipped_columns() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(2, 3, 468.0); + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let x_of = |r: usize, c: usize| { + l.pages[0].items.iter().find_map(|i| match i { + Placed::Cell { rect, row, cell, .. } if *row == r && *cell == c => Some(rect.x), + _ => None, + }) + }; + let (shifted, second) = (x_of(1, 0).unwrap(), x_of(0, 1).unwrap()); + assert!((shifted - second).abs() < 0.01, "row 1 starts at {shifted}, grid column 1 at {second}"); +} + +/// The area of cell `c` of row `r` of the table on page `page`. +fn cell_rect(l: &DocLayout, page: usize, r: usize, c: usize) -> Option { + l.pages.get(page)?.items.iter().find_map(|i| match i { + Placed::Cell { rect, row, cell, .. } if *row == r && *cell == c => Some(*rect), + _ => None, + }) +} + +/// Rows that skip grid columns at the start and at the end (`w:gridBefore`, `w:gridAfter`, as in +/// #1174): the cells sit on their own grid columns, the middle one keeps its width, and a merge +/// down the last column matches by the shifted column. +#[test] +fn grid_before_and_after_rows_keep_their_columns_and_merges() { + let mut d = Document::from_text("before\nafter"); + let grid = [239.0f32, 679.0, 101.0, 3790.0, 2312.0, 1189.0].map(|w| w / 20.0); + let mut t = Table::new(3, 4, 468.0); + t.props.fixed = true; + t.grid = grid.to_vec(); + for (ri, row) in t.rows.iter_mut().enumerate() { + row.cells.truncate(3); + row.props.grid_before = 1; + row.props.w_before = Some(grid[0]); + for (c, span) in row.cells.iter_mut().zip([2, 1, 2]) { + c.props.span = span; + c.props.width = None; + } + row.cells[1] = wordcraft_doc::Cell::with_text("Middle cell, wide enough for a few words on a line."); + row.cells[2].props.span = 2; + row.cells[2].props.vmerge = if ri == 0 { wordcraft_doc::props::VMerge::Restart } else { wordcraft_doc::props::VMerge::Continue }; + } + // A row without the gap, and one skipping the last column instead. + t.rows[1].props = Default::default(); + t.rows[1].cells.insert(0, wordcraft_doc::Cell::empty()); + t.rows[2].props.grid_before = 0; + t.rows[2].props.grid_after = 1; + t.rows[2].cells[0].props.span = 3; + t.rows[2].cells[2].props.span = 1; + let colx = |g: usize| grid[..g].iter().sum::(); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let rect = |r, c| cell_rect(&l, 0, r, c).unwrap(); + let x0 = rect(1, 0).x; + let middle = rect(0, 1); + assert!((rect(0, 0).x - x0 - colx(1)).abs() < 0.01, "row 0 starts at {}", rect(0, 0).x - x0); + assert!((middle.x - x0 - colx(3)).abs() < 0.01 && (middle.w - grid[3]).abs() < 0.01, "middle cell {middle:?}"); + assert!((rect(2, 2).x - x0 - colx(4)).abs() < 0.01 && (rect(2, 2).w - grid[4]).abs() < 0.01, "row 2's last cell {:?}", rect(2, 2)); + // The merge runs down all three rows. + let merged = rect(0, 2); + let rows_h = rect(0, 0).h + rect(1, 0).h + rect(2, 0).h; + assert!((merged.h - rows_h).abs() < 0.01, "merged cell {} tall, rows {rows_h}", merged.h); +} + +/// A row's own first and last cell carry the table's outer side borders when it skips the grid +/// columns beyond them. +#[test] +fn skipped_columns_put_the_side_borders_on_the_rows_cells() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(3, 3, 468.0); + t.props.style = None; + let thick = Some(Border::single(3.0)); + t.props.borders = Some(Borders { left: thick, right: thick, ..Borders::default() }); + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + t.rows[2].cells.pop(); + t.rows[2].props.grid_after = 1; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let rect = |r, c| cell_rect(&l, 0, r, c).unwrap(); + let vertical_at = |x: f32, y: f32| { + l.pages[0].items.iter().any(|i| matches!(i, Placed::Rule { x0, y0, x1, y1, .. } if x0 == x1 && (x0 - x).abs() < 0.01 && *y0 <= y && *y1 >= y)) + }; + let (r1, r2) = (rect(1, 0), rect(2, 1)); + assert!(vertical_at(r1.x, r1.y + r1.h / 2.0), "no left border at row 1's first cell {r1:?}"); + assert!(vertical_at(r2.x + r2.w, r2.y + r2.h / 2.0), "no right border at row 2's last cell {r2:?}"); +} + +/// AutoFit makes the skipped columns as wide as the row says (`w:wBefore`). +#[test] +fn autofit_gives_skipped_columns_their_width() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(2, 2, 468.0); + t.props.style = None; + for row in &mut t.rows { + for c in &mut row.cells { + *c = wordcraft_doc::Cell::with_text("A"); + } + } + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + t.rows[1].props.w_before = Some(150.0); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let gap = cell_rect(&l, 0, 1, 0).unwrap().x - cell_rect(&l, 0, 0, 0).unwrap().x; + assert!((gap - 150.0).abs() < 0.01, "skipped column {gap} wide"); +} + +/// Rows skipping a grid column keep it skipped on every page, under the repeated header row. +#[test] +fn grid_before_rows_stay_shifted_across_pages() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(80, 3, 468.0); + t.rows[0].props.header = true; + for row in t.rows.iter_mut().skip(1) { + row.cells.remove(0); + row.props.grid_before = 1; + } + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 2, "pages {}", l.pages.len()); + let column1 = cell_rect(&l, 0, 0, 1).unwrap().x; + for (pi, p) in l.pages.iter().enumerate() { + assert!(cell_rect(&l, pi, 0, 1).is_some_and(|r| (r.x - column1).abs() < 0.01), "no header on page {pi}"); + for i in &p.items { + if let Placed::Cell { rect, row, cell: 0, .. } = i + && *row > 0 + { + assert!((rect.x - column1).abs() < 0.01, "row {row} on page {pi} starts at {}", rect.x); + } + } + } +} + #[test] fn table_own_border_overrides_only_that_side() { let mut d = Document::from_text("before\nafter");