From fb2a83edb64f361fba517b8946218a8a6ad6bcaf Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 20:44:37 +0300 Subject: [PATCH] Table: inserting and deleting rows and columns keeps merges consistent (#408) Row and column insert/delete ignored merged cells, leaving covered cells with no anchor or new cells overlapping an existing span. These edits now read the merged areas first, shift or resize each one (a row or column inserted inside a merge widens it, one deleted from it narrows it), move the anchor and its text to the next covered cell when the anchor row or column is deleted, and rebuild spans and merge flags from the result. Co-authored-by: Cursor --- crates/model/src/table.rs | 222 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 2dd46e3b..507b9bd0 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -106,39 +106,120 @@ impl Table { pub fn height(&self) -> f64 { self.rows.iter().map(|r| r.height).sum() } + /// Insert an empty row before `at`. A merge the new row falls inside grows to cover it. pub fn insert_row(&mut self, at: usize) { let at = at.min(self.rows.len()); + let merges = self.merges(); let h = self.rows.get(at.saturating_sub(1)).map(|r| r.height).unwrap_or(30.0); self.rows.insert(at, Row { height: h, cells: (0..self.cols.len()).map(|_| Cell::new("")).collect() }); + self.set_merges(merges.into_iter().map(|m| m.insert_row(at))); } + /// Insert an empty column before `at`. A merge the new column falls inside grows to cover it. pub fn insert_col(&mut self, at: usize) { let at = at.min(self.cols.len()); + let merges = self.merges(); let w = self.cols.get(at.saturating_sub(1)).copied().unwrap_or(80.0); self.cols.insert(at, w); for r in &mut self.rows { let i = at.min(r.cells.len()); r.cells.insert(i, Cell::new("")); } + self.set_merges(merges.into_iter().map(|m| m.insert_col(at))); } + /// Delete row `at`. Merges through it shrink; a merge anchored on it moves its anchor (and + /// text) to the next row it covers. pub fn delete_row(&mut self, at: usize) -> bool { if self.rows.len() <= 1 || at >= self.rows.len() { return false; } + let merges = self.merges(); + for m in &merges { + if m.r == at && m.rs > 1 { + self.move_anchor((m.r, m.c), (m.r + 1, m.c)); + } + } self.rows.remove(at); + self.set_merges(merges.into_iter().filter_map(|m| m.delete_row(at))); true } + /// Delete column `at`. Merges through it shrink; a merge anchored on it moves its anchor (and + /// text) to the next column it covers. pub fn delete_col(&mut self, at: usize) -> bool { if self.cols.len() <= 1 || at >= self.cols.len() { return false; } + let merges = self.merges(); + for m in &merges { + if m.c == at && m.gs > 1 { + self.move_anchor((m.r, m.c), (m.r, m.c + 1)); + } + } self.cols.remove(at); for r in &mut self.rows { if at < r.cells.len() { r.cells.remove(at); } } + self.set_merges(merges.into_iter().filter_map(|m| m.delete_col(at))); true } + /// The merged areas: every uncovered cell spanning more than one cell, clipped to the grid. + fn merges(&self) -> Vec { + let (nr, nc) = (self.rows.len(), self.cols.len()); + let mut out = vec![]; + for (r, row) in self.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate().take(nc) { + if cell.is_covered() { + continue; + } + let rs = (cell.row_span.max(1) as usize).min(nr - r); + let gs = (cell.grid_span.max(1) as usize).min(nc - c); + if rs > 1 || gs > 1 { + out.push(Merge { r, c, rs, gs }); + } + } + } + out + } + /// Rebuild every cell's spans and merge flags from `merges`. + fn set_merges(&mut self, merges: impl IntoIterator) { + for row in &mut self.rows { + for cell in &mut row.cells { + cell.grid_span = 1; + cell.row_span = 1; + cell.h_merge = false; + cell.v_merge = false; + } + } + for m in merges { + if m.rs <= 1 && m.gs <= 1 { + continue; + } + for r in m.r..m.r.saturating_add(m.rs) { + for c in m.c..m.c.saturating_add(m.gs) { + if let Some(cell) = self.cell_mut(r, c) { + cell.h_merge = c > m.c; + cell.v_merge = r > m.r; + } + } + } + if let Some(cell) = self.cell_mut(m.r, m.c) { + cell.grid_span = u32::try_from(m.gs).unwrap_or(u32::MAX); + cell.row_span = u32::try_from(m.rs).unwrap_or(u32::MAX); + } + } + } + /// Swap the anchor cell's contents into the cell that takes over as anchor. + fn move_anchor(&mut self, from: (usize, usize), to: (usize, usize)) { + let Some(a) = self.cell(from.0, from.1).cloned() else { return }; + let Some(b) = self.cell(to.0, to.1).cloned() else { return }; + if let Some(x) = self.cell_mut(to.0, to.1) { + *x = a; + } + if let Some(x) = self.cell_mut(from.0, from.1) { + *x = b; + } + } /// Merge the rectangle (r0,c0)–(r1,c1) inclusive into its top-left cell. pub fn merge(&mut self, r0: usize, c0: usize, r1: usize, c1: usize) -> bool { let (r0, r1) = (r0.min(r1), r0.max(r1)); @@ -203,6 +284,56 @@ impl Table { } } +/// A merged area: anchor (r, c) spanning `rs` rows and `gs` columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Merge { + r: usize, + c: usize, + rs: usize, + gs: usize, +} + +impl Merge { + fn insert_row(self, at: usize) -> Self { + if at <= self.r { + Merge { r: self.r + 1, ..self } + } else if at < self.r + self.rs { + Merge { rs: self.rs + 1, ..self } + } else { + self + } + } + fn insert_col(self, at: usize) -> Self { + if at <= self.c { + Merge { c: self.c + 1, ..self } + } else if at < self.c + self.gs { + Merge { gs: self.gs + 1, ..self } + } else { + self + } + } + fn delete_row(self, at: usize) -> Option { + let m = if at < self.r { + Merge { r: self.r - 1, ..self } + } else if at < self.r + self.rs { + Merge { rs: self.rs - 1, ..self } + } else { + self + }; + (m.rs > 1 || m.gs > 1).then_some(m) + } + fn delete_col(self, at: usize) -> Option { + let m = if at < self.c { + Merge { c: self.c - 1, ..self } + } else if at < self.c + self.gs { + Merge { gs: self.gs - 1, ..self } + } else { + self + }; + (m.rs > 1 || m.gs > 1).then_some(m) + } +} + /// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in /// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and /// last rows and columns, corner cells) is filled, bordered and lettered. @@ -329,6 +460,97 @@ pub fn table_styles() -> Vec<(String, String)> { mod tests { use super::*; + /// Every cell is owned by exactly one anchor (itself or a span over it), and no span leaves the grid. + fn assert_topology(t: &Table) { + let (nr, nc) = (t.n_rows(), t.n_cols()); + let mut owner = vec![vec![0u32; nc]; nr]; + for (r, row) in t.rows.iter().enumerate() { + assert_eq!(row.cells.len(), nc); + for (c, cell) in row.cells.iter().enumerate() { + if cell.is_covered() { + assert_eq!((cell.grid_span, cell.row_span), (1, 1), "covered ({r},{c}) has spans"); + continue; + } + let (rs, gs) = (cell.row_span as usize, cell.grid_span as usize); + assert!(r + rs <= nr && c + gs <= nc, "span at ({r},{c}) leaves the grid"); + for o in owner.iter_mut().skip(r).take(rs) { + for x in o.iter_mut().skip(c).take(gs) { + *x += 1; + } + } + } + } + for (r, o) in owner.iter().enumerate() { + for (c, n) in o.iter().enumerate() { + assert_eq!(*n, 1, "cell ({r},{c}) owned {n} times"); + } + } + } + + fn merged() -> Table { + // 4x4 with a 2x2 merge at (1,1) holding "M". + let mut t = Table::new(4, 4, 400.0, 30.0); + if let Some(c) = t.cell_mut(1, 1) { + c.text = TextBody::from_text("M"); + } + assert!(t.merge(1, 1, 2, 2)); + assert_topology(&t); + t + } + + fn anchor_text(t: &Table, r: usize, c: usize) -> String { + t.cell(r, c).unwrap().text.text() + } + + #[test] + fn row_and_column_edits_keep_merges_consistent() { + for at in 0..=4 { + let mut t = merged(); + t.insert_row(at); + assert_topology(&t); + let mut t = merged(); + t.insert_col(at); + assert_topology(&t); + } + for at in 0..4 { + let mut t = merged(); + assert!(t.delete_row(at)); + assert_topology(&t); + let mut t = merged(); + assert!(t.delete_col(at)); + assert_topology(&t); + } + } + + #[test] + fn inserting_inside_a_merge_grows_it() { + let mut t = merged(); + t.insert_row(2); + let a = t.cell(1, 1).unwrap(); + assert_eq!((a.row_span, a.grid_span), (3, 2)); + assert!(t.cell(2, 2).unwrap().h_merge && t.cell(2, 2).unwrap().v_merge); + t.insert_col(0); + assert_eq!(t.cell(1, 2).unwrap().grid_span, 2); + assert_eq!(anchor_text(&t, 1, 2), "M"); + assert_topology(&t); + } + + #[test] + fn deleting_the_anchor_row_or_column_keeps_the_text() { + let mut t = merged(); + assert!(t.delete_row(1)); + let a = t.cell(1, 1).unwrap(); + assert!(!a.is_covered()); + assert_eq!((a.row_span, a.grid_span), (1, 2)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + assert!(t.delete_col(1)); + let a = t.cell(1, 1).unwrap(); + assert!(!a.is_covered()); + assert_eq!((a.row_span, a.grid_span), (1, 1)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + assert_topology(&t); + } + #[test] fn insert_delete_merge_split() { let mut t = Table::new(3, 3, 300.0, 30.0);