Merge PR #1760: Tables: rows with gridBefore/gridAfter keep their grid columns

This commit is contained in:
aiongg
2026-10-22 01:19:46 +00:00
5 changed files with 190 additions and 11 deletions
+26 -3
View File
@@ -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::<usize>()).max().unwrap_or(0))
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + 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::<usize>()).unwrap_or(0)
}
/// Cell index in row `r` that covers grid column `g`.
pub fn cell_at_grid(&self, r: usize, g: usize) -> Option<usize> {
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);
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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 {
+21 -6
View File
@@ -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<f32> = Vec::with_capacity(nrows);
let mut bottoms: Vec<f32> = 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<Item = (usize, usize, f32)> {
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);
+141
View File
@@ -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<wordcraft_geom::Rect> {
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::<f32>();
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");