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);