From bc345384f69f4b2093e4f2ebc5215987205cf341 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:22:42 +0000 Subject: [PATCH] refactor(doc): split table.rs into table/ --- crates/doc/src/table.rs | 1357 +-------------------------------- crates/doc/src/table/cell.rs | 348 +++++++++ crates/doc/src/table/edit.rs | 260 +++++++ crates/doc/src/table/grid.rs | 239 ++++++ crates/doc/src/table/merge.rs | 341 +++++++++ crates/doc/src/table/story.rs | 195 +++++ 6 files changed, 1390 insertions(+), 1350 deletions(-) create mode 100644 crates/doc/src/table/cell.rs create mode 100644 crates/doc/src/table/edit.rs create mode 100644 crates/doc/src/table/grid.rs create mode 100644 crates/doc/src/table/merge.rs create mode 100644 crates/doc/src/table/story.rs diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d80b1d475..d5342b5c9 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -16,345 +16,14 @@ use crate::ids::StoryId; use crate::item::{FirstBaseline, StrokeType, VerticalJustification}; use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RowHeightMode { - /// Grows with the content (the height is a minimum). - #[default] - AtLeast, - Exactly, -} +mod cell; +mod edit; +mod grid; +mod merge; +mod story; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RowKind { - Header, - #[default] - Body, - Footer, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Row { - pub height: f64, - pub mode: RowHeightMode, - pub kind: RowKind, -} - -impl Default for Row { - fn default() -> Self { - Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Column { - pub width: f64, -} - -/// One cell edge or the table border. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct CellStroke { - pub weight: f64, - pub color: String, - pub tint: f32, - pub kind: StrokeType, - /// Painted in the gaps of dashed, dotted and striped types. - #[serde(skip_serializing_if = "is_no_swatch")] - pub gap_color: String, - #[serde(skip_serializing_if = "is_full")] - pub gap_tint: f32, -} - -fn is_no_swatch(s: &str) -> bool { - s == designcraft_color::swatch::NONE -} -fn is_full(t: &f32) -> bool { - *t == 1.0 -} - -impl Default for CellStroke { - fn default() -> Self { - CellStroke { - weight: 1.0, - color: designcraft_color::swatch::BLACK.into(), - tint: 1.0, - kind: StrokeType::Solid, - gap_color: designcraft_color::swatch::NONE.into(), - gap_tint: 1.0, - } - } -} - -impl CellStroke { - pub fn none() -> Self { - CellStroke { weight: 0.0, ..Default::default() } - } - pub fn is_visible(&self) -> bool { - self.weight > 0.0 && self.color != designcraft_color::swatch::NONE - } -} - -/// Table-level alternating row/column strokes. Retained independently of cell formatting -/// so an explicit cell style or local edge continues to take precedence. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AltStrokes { - pub first: u32, - pub next: u32, - pub first_stroke: CellStroke, - pub next_stroke: CellStroke, - pub skip_first: u32, - pub skip_last: u32, -} - -impl Default for AltStrokes { - fn default() -> Self { - Self { - first: 0, - next: 0, - first_stroke: CellStroke::default(), - next_stroke: CellStroke { weight: 0.25, ..Default::default() }, - skip_first: 0, - skip_last: 0, - } - } -} - -impl AltStrokes { - /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal - /// patterns and skip settings are preserved for round trips until their boundary - /// phase is supported; they must not be rendered with a guessed phase. - pub fn uniform_stroke(&self) -> Option<&CellStroke> { - if self.skip_first > 0 || self.skip_last > 0 { - return None; - } - match (self.first > 0, self.next > 0) { - (true, false) => Some(&self.first_stroke), - (false, true) => Some(&self.next_stroke), - (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { - Some(&self.first_stroke) - } - _ => None, - } - } -} - -fn no_flags(flags: &[bool; 4]) -> bool { - !flags.iter().any(|v| *v) -} -fn no_priorities(priorities: &[i32; 4]) -> bool { - priorities.iter().all(|v| *v == 0) -} - -fn one() -> u32 { - 1 -} -fn full() -> f32 { - 1.0 -} -fn no_fill() -> String { - designcraft_color::swatch::NONE.into() -} -fn is_ascent(f: &FirstBaseline) -> bool { - *f == FirstBaseline::Ascent -} -fn is_zero_min(v: &f64) -> bool { - *v == 0.0 -} -fn default_insets() -> [f64; 4] { - [4.0; 4] -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Cell { - #[serde(default = "one")] - pub row_span: u32, - #[serde(default = "one")] - pub col_span: u32, - /// Cell content (a frameless story). - pub text: Story, - #[serde(default = "no_fill")] - pub fill: String, - #[serde(default = "full")] - pub fill_tint: f32, - /// Top, left, bottom, right. - #[serde(default = "default_insets")] - pub insets: [f64; 4], - #[serde(default)] - pub vj: VerticalJustification, - /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the - /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). - #[serde(default, skip_serializing_if = "is_ascent")] - pub first_baseline: FirstBaseline, - #[serde(default, skip_serializing_if = "is_zero_min")] - pub first_baseline_min: f64, - /// Text rotation in degrees (only 0 is composed for now). - #[serde(default)] - pub rotation: f64, - /// Edge strokes: top, left, bottom, right. - #[serde(default)] - pub strokes: [CellStroke; 4], - /// Explicit cell-edge formatting takes precedence over the table border, in the same - /// top, left, bottom, right order as `strokes`. Older documents keep their table border. - #[serde(default)] - pub border_overrides: [bool; 4], - /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. - #[serde(default, skip_serializing_if = "no_flags")] - pub stroke_defined: [bool; 4], - /// IDML edge-conflict priorities, in top, left, bottom, right order. - #[serde(default, skip_serializing_if = "no_priorities")] - pub stroke_priorities: [i32; 4], - /// Applied cell style ("" = [None]). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub style: String, - /// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's - /// content box, origin at the box's top left). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graphic: Option, -} - -impl Default for Cell { - fn default() -> Self { - Cell { - row_span: 1, - col_span: 1, - text: Story::new(StoryId(0)), - fill: no_fill(), - fill_tint: 1.0, - insets: default_insets(), - vj: VerticalJustification::Top, - first_baseline: FirstBaseline::Ascent, - first_baseline_min: 0.0, - rotation: 0.0, - strokes: Default::default(), - border_overrides: [false; 4], - stroke_priorities: [0; 4], - stroke_defined: [false; 4], - style: String::new(), - graphic: None, - } - } -} - -impl Cell { - pub fn with_text(text: &str, para: ParaFormat) -> Cell { - Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() } - } - /// A cell with this cell's formatting and no text (new rows / columns, unmerged cells). - pub fn blank_like(&self) -> Cell { - let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() }; - let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default(); - let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default(); - c.text = Story::new(StoryId(0)); - c.text.paras[0] = para; - c.text.chars[0].format = fmt; - c - } - pub fn has_fill(&self) -> bool { - self.fill != designcraft_color::swatch::NONE - } -} - -/// Table → Table Options → Fills (alternating pattern). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AltFills { - pub first: u32, - pub first_color: String, - pub first_tint: f32, - pub next: u32, - pub next_color: String, - pub next_tint: f32, - pub skip_first: u32, - pub skip_last: u32, -} - -impl Default for AltFills { - fn default() -> Self { - AltFills { - first: 1, - first_color: designcraft_color::swatch::BLACK.into(), - first_tint: 0.2, - next: 1, - next_color: designcraft_color::swatch::NONE.into(), - next_tint: 1.0, - skip_first: 0, - skip_last: 0, - } - } -} - -impl AltFills { - /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. - pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { - let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); - if i < sf || i >= n.saturating_sub(sl) { - return None; - } - let period = (u64::from(self.first) + u64::from(self.next)).max(1); - let k = (i - sf) as u64 % period; - let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; - (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct TableOptions { - /// Logical column zero is drawn at the right edge for RTL tables. - pub direction: crate::TextDirection, - /// Outer border (overrides the outer cell edges). - pub border: CellStroke, - /// Per-side borders; absent sides use the legacy uniform `border`. - #[serde(skip_serializing_if = "no_borders")] - pub borders: [Option; 4], - pub space_before: f64, - pub space_after: f64, - /// Header rows repeat at the top of every text column / frame the table continues into. - pub repeat_header: bool, - pub repeat_footer: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub alt_rows: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub alt_cols: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub row_strokes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub column_strokes: Option, -} - -fn no_borders(borders: &[Option; 4]) -> bool { - borders.iter().all(Option::is_none) -} - -impl TableOptions { - pub fn border_for(&self, side: usize) -> &CellStroke { - self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) - } -} - -impl Default for TableOptions { - fn default() -> Self { - TableOptions { - direction: crate::TextDirection::LeftToRight, - border: CellStroke::default(), - borders: Default::default(), - space_before: 4.0, - space_after: -4.0, - repeat_header: true, - repeat_footer: true, - alt_rows: None, - alt_cols: None, - row_strokes: None, - column_strokes: None, - } - } -} +pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions}; +pub use grid::CellRange; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -371,906 +40,6 @@ pub struct Table { pub style: String, } -/// A rectangular range of cells (inclusive). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CellRange { - pub r0: usize, - pub c0: usize, - pub r1: usize, - pub c1: usize, -} - -impl CellRange { - pub fn cell(r: usize, c: usize) -> Self { - CellRange { r0: r, c0: c, r1: r, c1: c } - } - pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self { - CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) } - } - pub fn contains(&self, r: usize, c: usize) -> bool { - r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1 - } -} - -impl Table { - /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. - pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { - let cols = cols.max(1); - let mut rows = Vec::new(); - rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); - rows.extend((0..body_rows.max(1)).map(|_| Row::default())); - rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() })); - let w = (width / cols as f64).max(3.0); - let n = rows.len() * cols; - Table { - id, - rows, - columns: vec![Column { width: w }; cols], - cells: vec![Cell::default(); n], - options: TableOptions::default(), - style: String::new(), - } - } - - /// A table from rows of cell strings (Convert Text to Table). Short rows are padded. - pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { - let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); - let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); - for (r, row) in data.iter().enumerate() { - for (c, s) in row.iter().enumerate() { - let Some(cell) = t.cell_mut(r, c) else { continue }; - cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); - cell.text.chars[0].format = fmt.clone(); - cell.text.insert(0, s); - } - } - t - } - - pub fn nrows(&self) -> usize { - self.rows.len() - } - pub fn ncols(&self) -> usize { - self.columns.len() - } - pub fn width(&self) -> f64 { - self.columns.iter().map(|c| c.width).sum() - } - fn idx(&self, r: usize, c: usize) -> Option { - (r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c) - } - pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { - self.cells.get(self.idx(r, c)?) - } - pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { - let i = self.idx(r, c)?; - self.cells.get_mut(i) - } - pub fn header_rows(&self) -> usize { - self.rows.iter().take_while(|r| r.kind == RowKind::Header).count() - } - pub fn footer_rows(&self) -> usize { - self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count() - } - pub fn body_rows(&self) -> std::ops::Range { - self.header_rows()..self.nrows() - self.footer_rows() - } - - /// For every grid position, the (row, col) of the cell that owns it. - pub fn owners(&self) -> Vec<(usize, usize)> { - let (nr, nc) = (self.nrows(), self.ncols()); - let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect(); - for r in 0..nr { - for c in 0..nc { - let cell = &self.cells[r * nc + c]; - if v[r * nc + c] != (r, c) { - continue; - } - for rr in r..(r + cell.row_span.max(1) as usize).min(nr) { - for cc in c..(c + cell.col_span.max(1) as usize).min(nc) { - v[rr * nc + cc] = (r, c); - } - } - } - } - v - } - - /// Owning cells touched by the range, once each in row-major order. Bounds are clipped - /// to the grid; resolving covered positions does not expand the selection rectangle. - pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { - let (nr, nc) = (self.nrows(), self.ncols()); - if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { - return Vec::new(); - } - let owners = self.owners(); - let mut selected = std::collections::BTreeSet::new(); - for r in range.r0..=range.r1.min(nr - 1) { - for c in range.c0..=range.c1.min(nc - 1) { - if let Some(&owner) = owners.get(r * nc + c) { - selected.insert(owner); - } - } - } - selected.into_iter().collect() - } - - /// The owning (top-left) cell address of grid position (r, c). - pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { - self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) - } - - /// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction). - pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> { - let owners = self.owners(); - let mut v = Vec::new(); - for r in 0..self.nrows() { - for c in 0..self.ncols() { - let cell = &self.cells[r * self.ncols() + c]; - if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) { - v.push((r, c, cell.row_span as usize, cell.col_span as usize)); - } - } - } - v - } - - fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) { - for c in &mut self.cells { - c.row_span = 1; - c.col_span = 1; - } - for &(r, c, rs, cs) in regions { - if rs == 0 || cs == 0 { - continue; - } - if let Some(cell) = self.cell_mut(r, c) { - cell.row_span = rs as u32; - cell.col_span = cs as u32; - } - } - } - - /// Insert `n` rows before row `at` (formatted like the neighbouring row). - pub fn insert_rows(&mut self, at: usize, n: usize) { - let at = at.min(self.nrows()); - if n == 0 { - return; - } - let nc = self.ncols(); - let src = if at < self.nrows() { at } else { at.saturating_sub(1) }; - let mut row = self.rows.get(src).cloned().unwrap_or_default(); - // A row inserted between body rows (or after the last header / before the first footer) is a body row. - if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind { - row.kind = RowKind::Body; - } - if at == self.nrows() && row.kind == RowKind::Header { - row.kind = RowKind::Body; - } - if at == 0 && row.kind == RowKind::Footer { - row.kind = RowKind::Body; - } - let template: Vec = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect(); - let regions: Vec<_> = self - .regions() - .into_iter() - .map(|(r, c, rs, cs)| { - if r >= at { - (r + n, c, rs, cs) - } else if r + rs > at { - (r, c, rs + n, cs) - } else { - (r, c, rs, cs) - } - }) - .collect(); - for k in 0..n { - self.rows.insert(at + k, row.clone()); - let i = (at + k) * nc; - for (c, cell) in template.iter().enumerate() { - self.cells.insert(i + c, cell.clone()); - } - } - self.set_regions(®ions); - } - - /// Delete rows `at..at+n` (at least one row always remains). - pub fn delete_rows(&mut self, at: usize, n: usize) { - let nr = self.nrows(); - let at = at.min(nr); - let n = n.min(nr - at).min(nr.saturating_sub(1)); - if n == 0 { - return; - } - let nc = self.ncols(); - let end = at + n; - let mut regions = Vec::new(); - for (r, c, rs, cs) in self.regions() { - let r1 = r + rs; - if r >= at && r1 <= end { - continue; // removed entirely - } - let new_rs = rs - (r1.min(end).saturating_sub(r.max(at))); - if r >= at && r < end { - // The owner row goes away: move its content to the first surviving row. - let content = self.cells[r * nc + c].clone(); - self.cells[end * nc + c] = content; - regions.push((at, c, new_rs, cs)); - } else if r < at { - regions.push((r, c, new_rs, cs)); - } else { - regions.push((r - n, c, rs, cs)); - } - } - self.rows.drain(at..end); - self.cells.drain(at * nc..end * nc); - self.set_regions(®ions); - } - - /// Insert `n` columns of `width` before column `at`. - pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) { - let at = at.min(self.ncols()); - if n == 0 { - return; - } - let nc = self.ncols(); - let src = if at < nc { at } else { at.saturating_sub(1) }; - let regions: Vec<_> = self - .regions() - .into_iter() - .map(|(r, c, rs, cs)| { - if c >= at { - (r, c + n, rs, cs) - } else if c + cs > at { - (r, c, rs, cs + n) - } else { - (r, c, rs, cs) - } - }) - .collect(); - let mut cells = Vec::with_capacity(self.nrows() * (nc + n)); - for r in 0..self.nrows() { - for c in 0..nc { - if c == at { - let t = self.cells[r * nc + src].blank_like(); - cells.extend(std::iter::repeat_n(t, n)); - } - cells.push(self.cells[r * nc + c].clone()); - } - if at == nc { - let t = self.cells[r * nc + src].blank_like(); - cells.extend(std::iter::repeat_n(t, n)); - } - } - self.cells = cells; - for k in 0..n { - self.columns.insert(at + k, Column { width }); - } - self.set_regions(®ions); - } - - /// Delete columns `at..at+n` (at least one column always remains). - pub fn delete_cols(&mut self, at: usize, n: usize) { - let nc = self.ncols(); - let at = at.min(nc); - let n = n.min(nc - at).min(nc.saturating_sub(1)); - if n == 0 { - return; - } - let end = at + n; - let mut regions = Vec::new(); - for (r, c, rs, cs) in self.regions() { - let c1 = c + cs; - if c >= at && c1 <= end { - continue; - } - let new_cs = cs - (c1.min(end).saturating_sub(c.max(at))); - if c >= at && c < end { - let content = self.cells[r * nc + c].clone(); - self.cells[r * nc + end] = content; - regions.push((r, at, rs, new_cs)); - } else if c < at { - regions.push((r, c, rs, new_cs)); - } else { - regions.push((r, c - n, rs, cs)); - } - } - let mut cells = Vec::with_capacity(self.nrows() * (nc - n)); - for r in 0..self.nrows() { - for c in 0..nc { - if c < at || c >= end { - cells.push(self.cells[r * nc + c].clone()); - } - } - } - self.cells = cells; - self.columns.drain(at..end); - self.set_regions(®ions); - } - - /// The smallest range containing `r` that doesn't cut through a merged region. - pub fn expand_range(&self, mut r: CellRange) -> CellRange { - loop { - let mut grown = r; - for (rr, cc, rs, cs) in self.regions() { - let (re, ce) = (rr + rs - 1, cc + cs - 1); - let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0; - if overlaps { - grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) }; - } - } - if grown == r { - return r; - } - r = grown; - } - } - - /// Merge a range into its top-left cell; the other cells' text is appended as paragraphs. - pub fn merge(&mut self, range: CellRange) -> Result<(), String> { - if range.r1 >= self.nrows() || range.c1 >= self.ncols() { - return Err("cell range out of bounds".into()); - } - let range = self.expand_range(range); - if range.r0 == range.r1 && range.c0 == range.c1 { - return Ok(()); - } - let owners = self.owners(); - let nc = self.ncols(); - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect(); - let mut extra: Vec = Vec::new(); - for r in range.r0..=range.r1 { - for c in range.c0..=range.c1 { - if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) { - let t = std::mem::take(&mut self.cells[r * nc + c]); - self.cells[r * nc + c] = t.blank_like(); - if !t.text.is_empty() { - extra.push(t.text); - } - } - } - } - let owner = &mut self.cells[range.r0 * nc + range.c0]; - for s in extra { - append_story(&mut owner.text, &s); - } - regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1)); - self.set_regions(®ions); - Ok(()) - } - - /// Unmerge the merged cell covering (r, c). - pub fn unmerge(&mut self, r: usize, c: usize) { - let (or, oc) = self.owner(r, c); - let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); - self.set_regions(®ions); - } - - /// Split each original owner touched by `range` once. Remember merged-cell cuts in the - /// original grid: a neighboring split may refine that grid before we reach the owner. - pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { - if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { - return Err("cell range out of bounds".into()); - } - let owners = self.owners(); - let mut selected = std::collections::BTreeSet::new(); - for r in range.r0..=range.r1 { - for c in range.c0..=range.c1 { - let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; - selected.insert(*owner); - } - } - let mut targets = Vec::new(); - for (r, c) in selected { - let cell = self.cell(r, c).ok_or("missing split cell")?; - let span = if horizontal { cell.row_span } else { cell.col_span } as usize; - let start = if horizontal { r } else { c }; - let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; - targets.push((r, c, cut)); - } - for i in 0..targets.len() { - let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; - let cell = self.cell(r, c).ok_or("missing split cell")?; - let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); - let mut blank = cell.blank_like(); - // Splitting creates an empty half, not a duplicate of graphic-cell content. - blank.graphic = None; - let before = if horizontal { self.nrows() } else { self.ncols() }; - if let Some(cut) = cut { - let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; - let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); - if horizontal { - regions.push((r, c, first, cs)); - regions.push((cut, c, rs - first, cs)); - } else { - regions.push((r, c, rs, first)); - regions.push((r, cut, rs, cs - first)); - } - self.set_regions(®ions); - } else { - // An originally single-track cell may now span two tracks inserted for its - // neighbor. split_cell divides that span instead of inserting another track. - self.split_cell(r, c, horizontal); - } - let first = self.cell(r, c).ok_or("missing split half")?; - let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; - let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; - blank.row_span = second.row_span; - blank.col_span = second.col_span; - *second = blank; - let after = if horizontal { self.nrows() } else { self.ncols() }; - if after > before { - let at = if horizontal { r + 1 } else { c + 1 }; - let added = after - before; - for (row, col, cut) in targets.iter_mut().skip(i + 1) { - let axis = if horizontal { row } else { col }; - if *axis >= at { - *axis += added; - } - if let Some(cut) = cut - && *cut >= at - { - *cut += added; - } - } - } - } - Ok(()) - } - - /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell - /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a - /// new row (column) that the cells beside it span, the row height (column width) shared. - pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) { - if r >= self.nrows() || c >= self.ncols() { - return; - } - let (or, oc) = self.owner(r, c); - let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize)); - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); - let span = if horizontal { rs } else { cs }; - if span > 1 { - let a = span.div_ceil(2); - if horizontal { - regions.push((or, oc, a, cs)); - regions.push((or + a, oc, rs - a, cs)); - } else { - regions.push((or, oc, rs, a)); - regions.push((or, oc + a, rs, cs - a)); - } - self.set_regions(®ions); - return; - } - if horizontal { - let row = self.rows[or].clone(); - self.insert_rows(or + 1, 1); - self.rows[or].height = row.height / 2.0; - // Both halves keep the split row's height mode and header/body/footer role. - self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; - // insert_rows doesn't grow regions that end at the split row; everything else on it - // spans the new row now. - let mut regions: Vec<_> = self.regions(); - let (nr, nc) = (self.nrows(), self.ncols()); - let mut covered = vec![false; nr * nc]; - for &(rr, cc, rrs, ccs) in ®ions { - for y in rr..rr + rrs { - for x in cc..cc + ccs { - covered[y * nc + x] = true; - } - } - } - for reg in regions.iter_mut() { - let (rr, cc, rrs, ccs) = *reg; - if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) { - *reg = (rr, cc, rrs + 1, ccs); - } - } - for x in 0..nc { - if (oc..oc + cs).contains(&x) || covered[or * nc + x] { - continue; - } - regions.push((or, x, 2, 1)); - } - if cs > 1 { - regions.push((or + 1, oc, 1, cs)); - } - self.set_regions(®ions); - } else { - let w = self.columns[oc].width; - self.insert_cols(oc + 1, 1, w / 2.0); - self.columns[oc].width = w / 2.0; - let mut regions: Vec<_> = self.regions(); - let (nr, nc) = (self.nrows(), self.ncols()); - let mut covered = vec![false; nr * nc]; - for &(rr, cc, rrs, ccs) in ®ions { - for y in rr..rr + rrs { - for x in cc..cc + ccs { - covered[y * nc + x] = true; - } - } - } - for reg in regions.iter_mut() { - let (rr, cc, rrs, ccs) = *reg; - if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) { - *reg = (rr, cc, rrs, ccs + 1); - } - } - for y in 0..nr { - if (or..or + rs).contains(&y) || covered[y * nc + oc] { - continue; - } - regions.push((y, oc, 1, 2)); - } - if rs > 1 { - regions.push((or, oc + 1, rs, 1)); - } - self.set_regions(®ions); - } - } - - /// Move row `from` to position `to` (indices before the move). Refused when merged cells span - /// rows (the move would cut them) or when the move would take a row across a header/footer - /// boundary (`from` and `to` must be the same kind of row). - pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { - let (nr, nc) = (self.nrows(), self.ncols()); - if from >= nr || to >= nr { - return Err("no such row".into()); - } - if self.regions().iter().any(|r| r.2 > 1) { - return Err("unmerge cells that span rows first".into()); - } - if self.rows[from].kind != self.rows[to].kind { - return Err("header and footer rows must stay first and last".into()); - } - let row = self.rows.remove(from); - self.rows.insert(to, row); - let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); - let at = to * nc; - for (k, c) in cells.into_iter().enumerate() { - self.cells.insert(at + k, c); - } - Ok(()) - } - - /// Move column `from` to position `to`. Refused when merged cells span columns. - pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> { - let (nr, nc) = (self.nrows(), self.ncols()); - if from >= nc || to >= nc { - return Err("no such column".into()); - } - if self.regions().iter().any(|r| r.3 > 1) { - return Err("unmerge cells that span columns first".into()); - } - let col = self.columns.remove(from); - self.columns.insert(to, col); - for r in 0..nr { - let c = self.cells.remove(r * nc + from); - self.cells.insert(r * nc + to, c); - } - Ok(()) - } - - /// Mark the first `header` rows as header rows and the last `footer` rows as footer rows. - pub fn set_header_footer(&mut self, header: usize, footer: usize) { - let n = self.nrows(); - let header = header.min(n.saturating_sub(1)); - let footer = footer.min(n - header - 1); - for (i, r) in self.rows.iter_mut().enumerate() { - r.kind = if i < header { - RowKind::Header - } else if i >= n - footer { - RowKind::Footer - } else { - RowKind::Body - }; - } - } - - /// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind` - /// rows. Header rows grow from the top and footer rows from the bottom, so the rows must - /// touch the table's edge or the rows already converted; back to body, they must touch the - /// body. One body row stays, and no merged cell may straddle a moved boundary. - pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> { - let (r0, r1) = (r0.min(r1), r0.max(r1)); - let n = self.nrows(); - if r1 >= n { - return Err("row out of range".into()); - } - let (h, f) = (self.header_rows(), self.footer_rows()); - let body_end = n - f; - let (nh, nf) = match kind { - RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()), - RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()), - RowKind::Header => (h.max(r1 + 1), f), - RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()), - RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()), - RowKind::Footer => (h, f.max(n - r0)), - RowKind::Body => { - if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) { - return Err("select the header or footer rows next to the body".into()); - } - (if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f }) - } - }; - if (nh, nf) == (h, f) { - return Err("the rows already are of that kind".into()); - } - if nh + nf >= n { - return Err("a table keeps at least one body row".into()); - } - let owners = self.owners(); - let nc = self.ncols(); - let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b)); - if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) { - return Err("unmerge the cells that span header, body and footer rows first".into()); - } - Ok((nh, nf)) - } - - /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). - pub fn plain_text(&self) -> String { - let mut out = String::new(); - for r in 0..self.nrows() { - if r > 0 { - out.push('\n'); - } - for c in 0..self.ncols() { - if c > 0 { - out.push('\t'); - } - if let Some(cell) = self.cell(r, c) { - out.push_str(&cell.text.text.replace('\n', " ")); - } - } - } - out - } - - pub fn check(&self) -> Result<(), String> { - if self.rows.is_empty() || self.columns.is_empty() { - return Err(format!("table {}: no rows or columns", self.id)); - } - if self.cells.len() != self.nrows() * self.ncols() { - return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols())); - } - // Header rows first, footer rows last. - let (h, f) = (self.header_rows(), self.footer_rows()); - if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) { - return Err(format!("table {}: header/footer rows out of order", self.id)); - } - self.check_spans()?; - for cell in &self.cells { - cell.text.check()?; - } - Ok(()) - } - - /// Every merged cell stays inside the grid and no two merged cells overlap. - fn check_spans(&self) -> Result<(), String> { - let mut seen = vec![false; self.cells.len()]; - for r in 0..self.nrows() { - for c in 0..self.ncols() { - let cell = &self.cells[r * self.ncols() + c]; - if cell.row_span == 0 || cell.col_span == 0 { - return Err(format!("table {}: zero span at {r},{c}", self.id)); - } - if cell.row_span == 1 && cell.col_span == 1 { - continue; - } - if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() { - return Err(format!("table {}: span out of bounds at {r},{c}", self.id)); - } - for rr in r..r + cell.row_span as usize { - for cc in c..c + cell.col_span as usize { - let i = rr * self.ncols() + cc; - if seen[i] { - return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id)); - } - seen[i] = true; - } - } - } - } - Ok(()) - } -} - -/// Append `other` to `s` as new paragraphs. -fn append_story(s: &mut Story, other: &Story) { - if s.is_empty() { - *s = Story { id: s.id, ..other.clone() }; - s.rev = s.rev.wrapping_add(1); - return; - } - let base = s.len(); - s.insert(base, "\n"); - // Copy other's runs with their formats. - for (r, f) in other.runs() { - let at = s.len(); - s.insert_with(at, &other.text[r], f.clone()); - } - let first_new = s.paras.len() - other.paras.len(); - for (k, p) in other.paras.iter().enumerate() { - if let Some(dst) = s.paras.get_mut(first_new + k) { - *dst = p.clone(); - } - } -} - -// ---------- tables in stories ---------- - -impl crate::Document { - /// The story text edits go to: a cell's story when `cell` is set, else the story itself. - pub fn text_story(&self, story: StoryId, cell: Option) -> Option<&Story> { - let st = self.story(story)?; - match cell { - Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text), - Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text), - None => Some(st), - } - } - - /// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision). - pub fn text_story_mut(&mut self, story: StoryId, cell: Option) -> Option<&mut Story> { - let st = self.story_mut(story)?; - match cell { - Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text), - Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text), - None => Some(st), - } - } -} - -impl Story { - /// Visit this story and every text inside it (table cells, footnotes), mutably. - pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) { - f(self); - for t in self.tables.values_mut() { - for c in &mut std::sync::Arc::make_mut(t).cells { - c.text.for_each_text_mut(f); - } - } - for n in &mut self.notes { - std::sync::Arc::make_mut(n).text.for_each_text_mut(f); - } - } - - /// The table whose anchor paragraph is `pi`. - pub fn para_table(&self, pi: usize) -> Option<&Arc> { - self.tables.get(&self.paras.get(pi)?.table?) - } - - /// Byte offset of a table's anchor character. - pub fn table_anchor(&self, id: u64) -> Option { - let pi = self.paras.iter().position(|p| p.table == Some(id))?; - let r = self.para_ranges().get(pi)?.clone(); - self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k) - } - - /// The table anchored at or right before byte `pos` (a caret next to the anchor). - pub fn table_at(&self, pos: usize) -> Option { - let pi = self.para_at(pos); - self.paras.get(pi)?.table - } - - /// Mutable table (copy-on-write); bumps the story revision. - pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> { - let t = self.tables.get_mut(&id)?; - self.rev = self.rev.wrapping_add(1); - Some(Arc::make_mut(t)) - } - - /// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset. - pub fn insert_table(&mut self, pos: usize, table: Table) -> usize { - let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len())); - let pi = self.para_at(pos); - let pstart = self.para_ranges()[pi].start; - let mut s = String::new(); - if pos > pstart { - s.push('\n'); - } - let anchor = pos + s.len(); - s.push(TABLE_ANCHOR); - if !self.text[pos..].starts_with('\n') { - s.push('\n'); - } - let id = table.id; - self.tables.insert(id, Arc::new(table)); - // Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it. - self.insert_raw(pos, &s); - let api = self.para_at(anchor); - self.paras[api].table = Some(id); - self.fix_tables(); - self.rev = self.rev.wrapping_add(1); - anchor - } - - /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it - /// still contains an anchor character, every table is referenced once, and tables nobody - /// references are dropped. An anchor that shares a table's paragraph with another anchor gets - /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. - pub(crate) fn fix_tables(&mut self) { - self.fix_tables_with(&[]); - } - - /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are - /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph - /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other - /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before - /// the fix-up, where it inserted paragraph returns. - pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { - if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { - return Vec::new(); - } - let mut owner = std::collections::BTreeMap::new(); - let mut used = std::collections::HashSet::new(); - for &(at, id) in claims { - if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) - && self.tables.contains_key(&id) - && !owner.contains_key(&at) - && used.insert(id) - { - owner.insert(at, id); - } - } - let ranges = self.para_ranges(); - for (p, r) in self.paras.iter().zip(&ranges) { - let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; - let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); - if let Some(at) = free { - used.insert(id); - owner.insert(at, id); - } - } - // A paragraph holding a table's anchor and other anchors is split before each later anchor. - let mut splits = Vec::new(); - for r in &ranges { - let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); - if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { - splits.extend(anchors.into_iter().skip(1)); - } - } - for &at in splits.iter().rev() { - self.insert_raw(at, "\n"); - } - // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. - let owner: std::collections::BTreeMap = - owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); - let ranges = self.para_ranges(); - for (p, r) in self.paras.iter_mut().zip(ranges) { - p.table = owner.range(r).next().map(|(_, &id)| id); - } - self.tables.retain(|id, _| used.contains(id)); - splits - } - - /// Check table invariants (part of [`Story::check`]). - pub(crate) fn check_tables(&self) -> Result<(), String> { - let ranges = self.para_ranges(); - let mut used = std::collections::HashSet::new(); - for (pi, p) in self.paras.iter().enumerate() { - let Some(id) = p.table else { continue }; - let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?; - if t.id != id { - return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id)); - } - if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 { - return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0)); - } - if !used.insert(id) { - return Err(format!("story {}: table {id} anchored twice", self.id.0)); - } - t.check()?; - } - if used.len() != self.tables.len() { - return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len())); - } - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1510,115 +279,3 @@ mod tests { assert_eq!(back.blank_like().border_overrides, explicit.border_overrides); } } - -#[cfg(test)] -mod split_tests { - use super::*; - - #[test] - fn split_single_and_merged_cells() { - let mut t = Table::new(1, 2, 2, 0, 0, 100.0); - t.split_cell(0, 0, true); - assert_eq!((t.nrows(), t.ncols()), (3, 2)); - // The cell beside the split one spans both new rows. - assert_eq!(t.cell(0, 1).unwrap().row_span, 2); - assert_eq!(t.owner(1, 1), (0, 1)); - assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell"); - t.split_cell(2, 1, false); - assert_eq!(t.ncols(), 3); - assert_eq!(t.cell(0, 0).unwrap().col_span, 1); - assert_eq!(t.owner(2, 2), (2, 2)); - assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens"); - // A merged cell splits its own span instead of adding rows. - let mut m = Table::new(1, 4, 1, 0, 0, 100.0); - m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap(); - m.split_cell(0, 0, true); - assert_eq!(m.nrows(), 4); - assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); - } - - #[test] - fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { - for horizontal in [true, false] { - let mut t = Table::new(1, 4, 4, 0, 0, 280.0); - for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { - t.rows[i].mode = RowHeightMode::Exactly; - t.rows[i].height = if horizontal { size } else { 70.0 }; - t.columns[i].width = if horizontal { 70.0 } else { size }; - } - for r in 0..4 { - for c in 0..4 { - let cell = t.cell_mut(r, c).unwrap(); - cell.text.insert(0, &format!("{r},{c}")); - cell.style = format!("style-{r}-{c}"); - cell.insets = [r as f64 + c as f64 + 1.0; 4]; - } - } - let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { - (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) - } else { - (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) - }; - t.merge(merged).unwrap(); - let original = t.clone(); - t.split_cells(selected, horizontal).unwrap(); - t.check().unwrap(); - assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); - let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; - assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); - let first = t.cell(owner.0, owner.1).unwrap(); - let second = t.cell(lower.0, lower.1).unwrap(); - // The original three-track merge divides after its second original track, - // regardless of which of those tracks a neighboring selected cell subdivides. - assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); - assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); - assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); - assert!(second.text.is_empty()); - assert_eq!(second.style, first.style); - assert_eq!(second.insets, first.insets); - assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); - let owners = t.owners(); - assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); - let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); - let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); - before_text.sort(); - after_text.sort(); - assert_eq!(after_text, before_text, "no original text is copied or lost"); - } - } - - #[test] - fn split_range_resolves_covered_positions_without_expanding_selection() { - for horizontal in [true, false] { - let mut t = Table::new(1, 3, 3, 0, 0, 180.0); - t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); - let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; - t.split_cells(selected, horizontal).unwrap(); - t.check().unwrap(); - let owners = t.owners(); - assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); - assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); - assert_eq!(t.owner(0, 0), (0, 0)); - assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); - } - } - - #[test] - fn split_header_row_keeps_its_height_mode_and_role() { - let mut t = Table::new(1, 1, 2, 1, 1, 180.0); - t.rows[0].height = 60.0; - t.rows[0].mode = RowHeightMode::Exactly; - let body = t.rows[1].clone(); - let footer = t.rows[2].clone(); - t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); - t.check().unwrap(); - assert_eq!(t.header_rows(), 2); - for row in &t.rows[..2] { - assert_eq!(row.height, 30.0); - assert_eq!(row.mode, RowHeightMode::Exactly); - assert_eq!(row.kind, RowKind::Header); - } - assert_eq!(t.rows[2], body); - assert_eq!(t.rows[3], footer); - } -} diff --git a/crates/doc/src/table/cell.rs b/crates/doc/src/table/cell.rs new file mode 100644 index 000000000..100a3474f --- /dev/null +++ b/crates/doc/src/table/cell.rs @@ -0,0 +1,348 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RowHeightMode { + /// Grows with the content (the height is a minimum). + #[default] + AtLeast, + Exactly, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RowKind { + Header, + #[default] + Body, + Footer, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Row { + pub height: f64, + pub mode: RowHeightMode, + pub kind: RowKind, +} + +impl Default for Row { + fn default() -> Self { + Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Column { + pub width: f64, +} + +/// One cell edge or the table border. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct CellStroke { + pub weight: f64, + pub color: String, + pub tint: f32, + pub kind: StrokeType, + /// Painted in the gaps of dashed, dotted and striped types. + #[serde(skip_serializing_if = "is_no_swatch")] + pub gap_color: String, + #[serde(skip_serializing_if = "is_full")] + pub gap_tint: f32, +} + +fn is_no_swatch(s: &str) -> bool { + s == designcraft_color::swatch::NONE +} + +fn is_full(t: &f32) -> bool { + *t == 1.0 +} + +impl Default for CellStroke { + fn default() -> Self { + CellStroke { + weight: 1.0, + color: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + kind: StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + } + } +} + +impl CellStroke { + pub fn none() -> Self { + CellStroke { weight: 0.0, ..Default::default() } + } + pub fn is_visible(&self) -> bool { + self.weight > 0.0 && self.color != designcraft_color::swatch::NONE + } +} + +/// Table-level alternating row/column strokes. Retained independently of cell formatting +/// so an explicit cell style or local edge continues to take precedence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltStrokes { + pub first: u32, + pub next: u32, + pub first_stroke: CellStroke, + pub next_stroke: CellStroke, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltStrokes { + fn default() -> Self { + Self { + first: 0, + next: 0, + first_stroke: CellStroke::default(), + next_stroke: CellStroke { weight: 0.25, ..Default::default() }, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltStrokes { + /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal + /// patterns and skip settings are preserved for round trips until their boundary + /// phase is supported; they must not be rendered with a guessed phase. + pub fn uniform_stroke(&self) -> Option<&CellStroke> { + if self.skip_first > 0 || self.skip_last > 0 { + return None; + } + match (self.first > 0, self.next > 0) { + (true, false) => Some(&self.first_stroke), + (false, true) => Some(&self.next_stroke), + (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { + Some(&self.first_stroke) + } + _ => None, + } + } +} + +fn no_flags(flags: &[bool; 4]) -> bool { + !flags.iter().any(|v| *v) +} + +fn no_priorities(priorities: &[i32; 4]) -> bool { + priorities.iter().all(|v| *v == 0) +} + +fn one() -> u32 { + 1 +} + +fn full() -> f32 { + 1.0 +} + +fn no_fill() -> String { + designcraft_color::swatch::NONE.into() +} + +fn is_ascent(f: &FirstBaseline) -> bool { + *f == FirstBaseline::Ascent +} + +fn is_zero_min(v: &f64) -> bool { + *v == 0.0 +} + +fn default_insets() -> [f64; 4] { + [4.0; 4] +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cell { + #[serde(default = "one")] + pub row_span: u32, + #[serde(default = "one")] + pub col_span: u32, + /// Cell content (a frameless story). + pub text: Story, + #[serde(default = "no_fill")] + pub fill: String, + #[serde(default = "full")] + pub fill_tint: f32, + /// Top, left, bottom, right. + #[serde(default = "default_insets")] + pub insets: [f64; 4], + #[serde(default)] + pub vj: VerticalJustification, + /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the + /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). + #[serde(default, skip_serializing_if = "is_ascent")] + pub first_baseline: FirstBaseline, + #[serde(default, skip_serializing_if = "is_zero_min")] + pub first_baseline_min: f64, + /// Text rotation in degrees (only 0 is composed for now). + #[serde(default)] + pub rotation: f64, + /// Edge strokes: top, left, bottom, right. + #[serde(default)] + pub strokes: [CellStroke; 4], + /// Explicit cell-edge formatting takes precedence over the table border, in the same + /// top, left, bottom, right order as `strokes`. Older documents keep their table border. + #[serde(default)] + pub border_overrides: [bool; 4], + /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. + #[serde(default, skip_serializing_if = "no_flags")] + pub stroke_defined: [bool; 4], + /// IDML edge-conflict priorities, in top, left, bottom, right order. + #[serde(default, skip_serializing_if = "no_priorities")] + pub stroke_priorities: [i32; 4], + /// Applied cell style ("" = [None]). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style: String, + /// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's + /// content box, origin at the box's top left). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graphic: Option, +} + +impl Default for Cell { + fn default() -> Self { + Cell { + row_span: 1, + col_span: 1, + text: Story::new(StoryId(0)), + fill: no_fill(), + fill_tint: 1.0, + insets: default_insets(), + vj: VerticalJustification::Top, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + rotation: 0.0, + strokes: Default::default(), + border_overrides: [false; 4], + stroke_priorities: [0; 4], + stroke_defined: [false; 4], + style: String::new(), + graphic: None, + } + } +} + +impl Cell { + pub fn with_text(text: &str, para: ParaFormat) -> Cell { + Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() } + } + /// A cell with this cell's formatting and no text (new rows / columns, unmerged cells). + pub fn blank_like(&self) -> Cell { + let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() }; + let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default(); + let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default(); + c.text = Story::new(StoryId(0)); + c.text.paras[0] = para; + c.text.chars[0].format = fmt; + c + } + pub fn has_fill(&self) -> bool { + self.fill != designcraft_color::swatch::NONE + } +} + +/// Table → Table Options → Fills (alternating pattern). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltFills { + pub first: u32, + pub first_color: String, + pub first_tint: f32, + pub next: u32, + pub next_color: String, + pub next_tint: f32, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltFills { + fn default() -> Self { + AltFills { + first: 1, + first_color: designcraft_color::swatch::BLACK.into(), + first_tint: 0.2, + next: 1, + next_color: designcraft_color::swatch::NONE.into(), + next_tint: 1.0, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltFills { + /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. + pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { + let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); + if i < sf || i >= n.saturating_sub(sl) { + return None; + } + let period = (u64::from(self.first) + u64::from(self.next)).max(1); + let k = (i - sf) as u64 % period; + let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; + (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TableOptions { + /// Logical column zero is drawn at the right edge for RTL tables. + pub direction: crate::TextDirection, + /// Outer border (overrides the outer cell edges). + pub border: CellStroke, + /// Per-side borders; absent sides use the legacy uniform `border`. + #[serde(skip_serializing_if = "no_borders")] + pub borders: [Option; 4], + pub space_before: f64, + pub space_after: f64, + /// Header rows repeat at the top of every text column / frame the table continues into. + pub repeat_header: bool, + pub repeat_footer: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_cols: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub row_strokes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column_strokes: Option, +} + +fn no_borders(borders: &[Option; 4]) -> bool { + borders.iter().all(Option::is_none) +} + +impl TableOptions { + pub fn border_for(&self, side: usize) -> &CellStroke { + self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) + } +} + +impl Default for TableOptions { + fn default() -> Self { + TableOptions { + direction: crate::TextDirection::LeftToRight, + border: CellStroke::default(), + borders: Default::default(), + space_before: 4.0, + space_after: -4.0, + repeat_header: true, + repeat_footer: true, + alt_rows: None, + alt_cols: None, + row_strokes: None, + column_strokes: None, + } + } +} diff --git a/crates/doc/src/table/edit.rs b/crates/doc/src/table/edit.rs new file mode 100644 index 000000000..589c9666e --- /dev/null +++ b/crates/doc/src/table/edit.rs @@ -0,0 +1,260 @@ +use super::cell::{Cell, Column, RowKind}; +use super::*; + +impl Table { + /// Insert `n` rows before row `at` (formatted like the neighbouring row). + pub fn insert_rows(&mut self, at: usize, n: usize) { + let at = at.min(self.nrows()); + if n == 0 { + return; + } + let nc = self.ncols(); + let src = if at < self.nrows() { at } else { at.saturating_sub(1) }; + let mut row = self.rows.get(src).cloned().unwrap_or_default(); + // A row inserted between body rows (or after the last header / before the first footer) is a body row. + if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind { + row.kind = RowKind::Body; + } + if at == self.nrows() && row.kind == RowKind::Header { + row.kind = RowKind::Body; + } + if at == 0 && row.kind == RowKind::Footer { + row.kind = RowKind::Body; + } + let template: Vec = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect(); + let regions: Vec<_> = self + .regions() + .into_iter() + .map(|(r, c, rs, cs)| { + if r >= at { + (r + n, c, rs, cs) + } else if r + rs > at { + (r, c, rs + n, cs) + } else { + (r, c, rs, cs) + } + }) + .collect(); + for k in 0..n { + self.rows.insert(at + k, row.clone()); + let i = (at + k) * nc; + for (c, cell) in template.iter().enumerate() { + self.cells.insert(i + c, cell.clone()); + } + } + self.set_regions(®ions); + } + + /// Delete rows `at..at+n` (at least one row always remains). + pub fn delete_rows(&mut self, at: usize, n: usize) { + let nr = self.nrows(); + let at = at.min(nr); + let n = n.min(nr - at).min(nr.saturating_sub(1)); + if n == 0 { + return; + } + let nc = self.ncols(); + let end = at + n; + let mut regions = Vec::new(); + for (r, c, rs, cs) in self.regions() { + let r1 = r + rs; + if r >= at && r1 <= end { + continue; // removed entirely + } + let new_rs = rs - (r1.min(end).saturating_sub(r.max(at))); + if r >= at && r < end { + // The owner row goes away: move its content to the first surviving row. + let content = self.cells[r * nc + c].clone(); + self.cells[end * nc + c] = content; + regions.push((at, c, new_rs, cs)); + } else if r < at { + regions.push((r, c, new_rs, cs)); + } else { + regions.push((r - n, c, rs, cs)); + } + } + self.rows.drain(at..end); + self.cells.drain(at * nc..end * nc); + self.set_regions(®ions); + } + + /// Insert `n` columns of `width` before column `at`. + pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) { + let at = at.min(self.ncols()); + if n == 0 { + return; + } + let nc = self.ncols(); + let src = if at < nc { at } else { at.saturating_sub(1) }; + let regions: Vec<_> = self + .regions() + .into_iter() + .map(|(r, c, rs, cs)| { + if c >= at { + (r, c + n, rs, cs) + } else if c + cs > at { + (r, c, rs, cs + n) + } else { + (r, c, rs, cs) + } + }) + .collect(); + let mut cells = Vec::with_capacity(self.nrows() * (nc + n)); + for r in 0..self.nrows() { + for c in 0..nc { + if c == at { + let t = self.cells[r * nc + src].blank_like(); + cells.extend(std::iter::repeat_n(t, n)); + } + cells.push(self.cells[r * nc + c].clone()); + } + if at == nc { + let t = self.cells[r * nc + src].blank_like(); + cells.extend(std::iter::repeat_n(t, n)); + } + } + self.cells = cells; + for k in 0..n { + self.columns.insert(at + k, Column { width }); + } + self.set_regions(®ions); + } + + /// Delete columns `at..at+n` (at least one column always remains). + pub fn delete_cols(&mut self, at: usize, n: usize) { + let nc = self.ncols(); + let at = at.min(nc); + let n = n.min(nc - at).min(nc.saturating_sub(1)); + if n == 0 { + return; + } + let end = at + n; + let mut regions = Vec::new(); + for (r, c, rs, cs) in self.regions() { + let c1 = c + cs; + if c >= at && c1 <= end { + continue; + } + let new_cs = cs - (c1.min(end).saturating_sub(c.max(at))); + if c >= at && c < end { + let content = self.cells[r * nc + c].clone(); + self.cells[r * nc + end] = content; + regions.push((r, at, rs, new_cs)); + } else if c < at { + regions.push((r, c, rs, new_cs)); + } else { + regions.push((r, c - n, rs, cs)); + } + } + let mut cells = Vec::with_capacity(self.nrows() * (nc - n)); + for r in 0..self.nrows() { + for c in 0..nc { + if c < at || c >= end { + cells.push(self.cells[r * nc + c].clone()); + } + } + } + self.cells = cells; + self.columns.drain(at..end); + self.set_regions(®ions); + } + + /// Move row `from` to position `to` (indices before the move). Refused when merged cells span + /// rows (the move would cut them) or when the move would take a row across a header/footer + /// boundary (`from` and `to` must be the same kind of row). + pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { + let (nr, nc) = (self.nrows(), self.ncols()); + if from >= nr || to >= nr { + return Err("no such row".into()); + } + if self.regions().iter().any(|r| r.2 > 1) { + return Err("unmerge cells that span rows first".into()); + } + if self.rows[from].kind != self.rows[to].kind { + return Err("header and footer rows must stay first and last".into()); + } + let row = self.rows.remove(from); + self.rows.insert(to, row); + let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); + let at = to * nc; + for (k, c) in cells.into_iter().enumerate() { + self.cells.insert(at + k, c); + } + Ok(()) + } + + /// Move column `from` to position `to`. Refused when merged cells span columns. + pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> { + let (nr, nc) = (self.nrows(), self.ncols()); + if from >= nc || to >= nc { + return Err("no such column".into()); + } + if self.regions().iter().any(|r| r.3 > 1) { + return Err("unmerge cells that span columns first".into()); + } + let col = self.columns.remove(from); + self.columns.insert(to, col); + for r in 0..nr { + let c = self.cells.remove(r * nc + from); + self.cells.insert(r * nc + to, c); + } + Ok(()) + } + + /// Mark the first `header` rows as header rows and the last `footer` rows as footer rows. + pub fn set_header_footer(&mut self, header: usize, footer: usize) { + let n = self.nrows(); + let header = header.min(n.saturating_sub(1)); + let footer = footer.min(n - header - 1); + for (i, r) in self.rows.iter_mut().enumerate() { + r.kind = if i < header { + RowKind::Header + } else if i >= n - footer { + RowKind::Footer + } else { + RowKind::Body + }; + } + } + + /// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind` + /// rows. Header rows grow from the top and footer rows from the bottom, so the rows must + /// touch the table's edge or the rows already converted; back to body, they must touch the + /// body. One body row stays, and no merged cell may straddle a moved boundary. + pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> { + let (r0, r1) = (r0.min(r1), r0.max(r1)); + let n = self.nrows(); + if r1 >= n { + return Err("row out of range".into()); + } + let (h, f) = (self.header_rows(), self.footer_rows()); + let body_end = n - f; + let (nh, nf) = match kind { + RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()), + RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()), + RowKind::Header => (h.max(r1 + 1), f), + RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()), + RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()), + RowKind::Footer => (h, f.max(n - r0)), + RowKind::Body => { + if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) { + return Err("select the header or footer rows next to the body".into()); + } + (if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f }) + } + }; + if (nh, nf) == (h, f) { + return Err("the rows already are of that kind".into()); + } + if nh + nf >= n { + return Err("a table keeps at least one body row".into()); + } + let owners = self.owners(); + let nc = self.ncols(); + let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b)); + if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) { + return Err("unmerge the cells that span header, body and footer rows first".into()); + } + Ok((nh, nf)) + } +} diff --git a/crates/doc/src/table/grid.rs b/crates/doc/src/table/grid.rs new file mode 100644 index 000000000..48647cffc --- /dev/null +++ b/crates/doc/src/table/grid.rs @@ -0,0 +1,239 @@ +use super::cell::{Cell, Column, Row, RowKind, TableOptions}; +use super::*; + +/// A rectangular range of cells (inclusive). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CellRange { + pub r0: usize, + pub c0: usize, + pub r1: usize, + pub c1: usize, +} + +impl CellRange { + pub fn cell(r: usize, c: usize) -> Self { + CellRange { r0: r, c0: c, r1: r, c1: c } + } + pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self { + CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) } + } + pub fn contains(&self, r: usize, c: usize) -> bool { + r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1 + } +} + +impl Table { + /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. + pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { + let cols = cols.max(1); + let mut rows = Vec::new(); + rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); + rows.extend((0..body_rows.max(1)).map(|_| Row::default())); + rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() })); + let w = (width / cols as f64).max(3.0); + let n = rows.len() * cols; + Table { + id, + rows, + columns: vec![Column { width: w }; cols], + cells: vec![Cell::default(); n], + options: TableOptions::default(), + style: String::new(), + } + } + + /// A table from rows of cell strings (Convert Text to Table). Short rows are padded. + pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { + let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); + let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); + for (r, row) in data.iter().enumerate() { + for (c, s) in row.iter().enumerate() { + let Some(cell) = t.cell_mut(r, c) else { continue }; + cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); + cell.text.chars[0].format = fmt.clone(); + cell.text.insert(0, s); + } + } + t + } + + pub fn nrows(&self) -> usize { + self.rows.len() + } + + pub fn ncols(&self) -> usize { + self.columns.len() + } + + pub fn width(&self) -> f64 { + self.columns.iter().map(|c| c.width).sum() + } + + fn idx(&self, r: usize, c: usize) -> Option { + (r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c) + } + + pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { + self.cells.get(self.idx(r, c)?) + } + + pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { + let i = self.idx(r, c)?; + self.cells.get_mut(i) + } + + pub fn header_rows(&self) -> usize { + self.rows.iter().take_while(|r| r.kind == RowKind::Header).count() + } + + pub fn footer_rows(&self) -> usize { + self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count() + } + + pub fn body_rows(&self) -> std::ops::Range { + self.header_rows()..self.nrows() - self.footer_rows() + } + + /// For every grid position, the (row, col) of the cell that owns it. + pub fn owners(&self) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect(); + for r in 0..nr { + for c in 0..nc { + let cell = &self.cells[r * nc + c]; + if v[r * nc + c] != (r, c) { + continue; + } + for rr in r..(r + cell.row_span.max(1) as usize).min(nr) { + for cc in c..(c + cell.col_span.max(1) as usize).min(nc) { + v[rr * nc + cc] = (r, c); + } + } + } + } + v + } + + /// Owning cells touched by the range, once each in row-major order. Bounds are clipped + /// to the grid; resolving covered positions does not expand the selection rectangle. + pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { + return Vec::new(); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1.min(nr - 1) { + for c in range.c0..=range.c1.min(nc - 1) { + if let Some(&owner) = owners.get(r * nc + c) { + selected.insert(owner); + } + } + } + selected.into_iter().collect() + } + + /// The owning (top-left) cell address of grid position (r, c). + pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { + self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) + } + + /// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction). + pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> { + let owners = self.owners(); + let mut v = Vec::new(); + for r in 0..self.nrows() { + for c in 0..self.ncols() { + let cell = &self.cells[r * self.ncols() + c]; + if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) { + v.push((r, c, cell.row_span as usize, cell.col_span as usize)); + } + } + } + v + } + + pub(super) fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) { + for c in &mut self.cells { + c.row_span = 1; + c.col_span = 1; + } + for &(r, c, rs, cs) in regions { + if rs == 0 || cs == 0 { + continue; + } + if let Some(cell) = self.cell_mut(r, c) { + cell.row_span = rs as u32; + cell.col_span = cs as u32; + } + } + } + + /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). + pub fn plain_text(&self) -> String { + let mut out = String::new(); + for r in 0..self.nrows() { + if r > 0 { + out.push('\n'); + } + for c in 0..self.ncols() { + if c > 0 { + out.push('\t'); + } + if let Some(cell) = self.cell(r, c) { + out.push_str(&cell.text.text.replace('\n', " ")); + } + } + } + out + } + + pub fn check(&self) -> Result<(), String> { + if self.rows.is_empty() || self.columns.is_empty() { + return Err(format!("table {}: no rows or columns", self.id)); + } + if self.cells.len() != self.nrows() * self.ncols() { + return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols())); + } + // Header rows first, footer rows last. + let (h, f) = (self.header_rows(), self.footer_rows()); + if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) { + return Err(format!("table {}: header/footer rows out of order", self.id)); + } + self.check_spans()?; + for cell in &self.cells { + cell.text.check()?; + } + Ok(()) + } + + /// Every merged cell stays inside the grid and no two merged cells overlap. + fn check_spans(&self) -> Result<(), String> { + let mut seen = vec![false; self.cells.len()]; + for r in 0..self.nrows() { + for c in 0..self.ncols() { + let cell = &self.cells[r * self.ncols() + c]; + if cell.row_span == 0 || cell.col_span == 0 { + return Err(format!("table {}: zero span at {r},{c}", self.id)); + } + if cell.row_span == 1 && cell.col_span == 1 { + continue; + } + if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() { + return Err(format!("table {}: span out of bounds at {r},{c}", self.id)); + } + for rr in r..r + cell.row_span as usize { + for cc in c..c + cell.col_span as usize { + let i = rr * self.ncols() + cc; + if seen[i] { + return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id)); + } + seen[i] = true; + } + } + } + } + Ok(()) + } +} diff --git a/crates/doc/src/table/merge.rs b/crates/doc/src/table/merge.rs new file mode 100644 index 000000000..f6eaae3da --- /dev/null +++ b/crates/doc/src/table/merge.rs @@ -0,0 +1,341 @@ +use super::cell::Row; +#[cfg(test)] +use super::cell::{RowHeightMode, RowKind}; +use super::grid::CellRange; +use super::story::append_story; +use super::*; + +impl Table { + /// The smallest range containing `r` that doesn't cut through a merged region. + pub fn expand_range(&self, mut r: CellRange) -> CellRange { + loop { + let mut grown = r; + for (rr, cc, rs, cs) in self.regions() { + let (re, ce) = (rr + rs - 1, cc + cs - 1); + let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0; + if overlaps { + grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) }; + } + } + if grown == r { + return r; + } + r = grown; + } + } + + /// Merge a range into its top-left cell; the other cells' text is appended as paragraphs. + pub fn merge(&mut self, range: CellRange) -> Result<(), String> { + if range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let range = self.expand_range(range); + if range.r0 == range.r1 && range.c0 == range.c1 { + return Ok(()); + } + let owners = self.owners(); + let nc = self.ncols(); + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect(); + let mut extra: Vec = Vec::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) { + let t = std::mem::take(&mut self.cells[r * nc + c]); + self.cells[r * nc + c] = t.blank_like(); + if !t.text.is_empty() { + extra.push(t.text); + } + } + } + } + let owner = &mut self.cells[range.r0 * nc + range.c0]; + for s in extra { + append_story(&mut owner.text, &s); + } + regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1)); + self.set_regions(®ions); + Ok(()) + } + + /// Unmerge the merged cell covering (r, c). + pub fn unmerge(&mut self, r: usize, c: usize) { + let (or, oc) = self.owner(r, c); + let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); + self.set_regions(®ions); + } + + /// Split each original owner touched by `range` once. Remember merged-cell cuts in the + /// original grid: a neighboring split may refine that grid before we reach the owner. + pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { + if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; + selected.insert(*owner); + } + } + let mut targets = Vec::new(); + for (r, c) in selected { + let cell = self.cell(r, c).ok_or("missing split cell")?; + let span = if horizontal { cell.row_span } else { cell.col_span } as usize; + let start = if horizontal { r } else { c }; + let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; + targets.push((r, c, cut)); + } + for i in 0..targets.len() { + let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; + let cell = self.cell(r, c).ok_or("missing split cell")?; + let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); + let mut blank = cell.blank_like(); + // Splitting creates an empty half, not a duplicate of graphic-cell content. + blank.graphic = None; + let before = if horizontal { self.nrows() } else { self.ncols() }; + if let Some(cut) = cut { + let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; + let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); + if horizontal { + regions.push((r, c, first, cs)); + regions.push((cut, c, rs - first, cs)); + } else { + regions.push((r, c, rs, first)); + regions.push((r, cut, rs, cs - first)); + } + self.set_regions(®ions); + } else { + // An originally single-track cell may now span two tracks inserted for its + // neighbor. split_cell divides that span instead of inserting another track. + self.split_cell(r, c, horizontal); + } + let first = self.cell(r, c).ok_or("missing split half")?; + let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; + let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; + blank.row_span = second.row_span; + blank.col_span = second.col_span; + *second = blank; + let after = if horizontal { self.nrows() } else { self.ncols() }; + if after > before { + let at = if horizontal { r + 1 } else { c + 1 }; + let added = after - before; + for (row, col, cut) in targets.iter_mut().skip(i + 1) { + let axis = if horizontal { row } else { col }; + if *axis >= at { + *axis += added; + } + if let Some(cut) = cut + && *cut >= at + { + *cut += added; + } + } + } + } + Ok(()) + } + + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell + /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a + /// new row (column) that the cells beside it span, the row height (column width) shared. + pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) { + if r >= self.nrows() || c >= self.ncols() { + return; + } + let (or, oc) = self.owner(r, c); + let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize)); + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); + let span = if horizontal { rs } else { cs }; + if span > 1 { + let a = span.div_ceil(2); + if horizontal { + regions.push((or, oc, a, cs)); + regions.push((or + a, oc, rs - a, cs)); + } else { + regions.push((or, oc, rs, a)); + regions.push((or, oc + a, rs, cs - a)); + } + self.set_regions(®ions); + return; + } + if horizontal { + let row = self.rows[or].clone(); + self.insert_rows(or + 1, 1); + self.rows[or].height = row.height / 2.0; + // Both halves keep the split row's height mode and header/body/footer role. + self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; + // insert_rows doesn't grow regions that end at the split row; everything else on it + // spans the new row now. + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) { + *reg = (rr, cc, rrs + 1, ccs); + } + } + for x in 0..nc { + if (oc..oc + cs).contains(&x) || covered[or * nc + x] { + continue; + } + regions.push((or, x, 2, 1)); + } + if cs > 1 { + regions.push((or + 1, oc, 1, cs)); + } + self.set_regions(®ions); + } else { + let w = self.columns[oc].width; + self.insert_cols(oc + 1, 1, w / 2.0); + self.columns[oc].width = w / 2.0; + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) { + *reg = (rr, cc, rrs, ccs + 1); + } + } + for y in 0..nr { + if (or..or + rs).contains(&y) || covered[y * nc + oc] { + continue; + } + regions.push((y, oc, 1, 2)); + } + if rs > 1 { + regions.push((or, oc + 1, rs, 1)); + } + self.set_regions(®ions); + } + } +} + +#[cfg(test)] +mod split_tests { + use super::*; + + #[test] + fn split_single_and_merged_cells() { + let mut t = Table::new(1, 2, 2, 0, 0, 100.0); + t.split_cell(0, 0, true); + assert_eq!((t.nrows(), t.ncols()), (3, 2)); + // The cell beside the split one spans both new rows. + assert_eq!(t.cell(0, 1).unwrap().row_span, 2); + assert_eq!(t.owner(1, 1), (0, 1)); + assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell"); + t.split_cell(2, 1, false); + assert_eq!(t.ncols(), 3); + assert_eq!(t.cell(0, 0).unwrap().col_span, 1); + assert_eq!(t.owner(2, 2), (2, 2)); + assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens"); + // A merged cell splits its own span instead of adding rows. + let mut m = Table::new(1, 4, 1, 0, 0, 100.0); + m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap(); + m.split_cell(0, 0, true); + assert_eq!(m.nrows(), 4); + assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); + } + + #[test] + fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { + for horizontal in [true, false] { + let mut t = Table::new(1, 4, 4, 0, 0, 280.0); + for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { + t.rows[i].mode = RowHeightMode::Exactly; + t.rows[i].height = if horizontal { size } else { 70.0 }; + t.columns[i].width = if horizontal { 70.0 } else { size }; + } + for r in 0..4 { + for c in 0..4 { + let cell = t.cell_mut(r, c).unwrap(); + cell.text.insert(0, &format!("{r},{c}")); + cell.style = format!("style-{r}-{c}"); + cell.insets = [r as f64 + c as f64 + 1.0; 4]; + } + } + let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { + (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) + } else { + (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) + }; + t.merge(merged).unwrap(); + let original = t.clone(); + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); + let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; + assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); + let first = t.cell(owner.0, owner.1).unwrap(); + let second = t.cell(lower.0, lower.1).unwrap(); + // The original three-track merge divides after its second original track, + // regardless of which of those tracks a neighboring selected cell subdivides. + assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); + assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); + assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); + assert!(second.text.is_empty()); + assert_eq!(second.style, first.style); + assert_eq!(second.insets, first.insets); + assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); + let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + before_text.sort(); + after_text.sort(); + assert_eq!(after_text, before_text, "no original text is copied or lost"); + } + } + + #[test] + fn split_range_resolves_covered_positions_without_expanding_selection() { + for horizontal in [true, false] { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); + assert_eq!(t.owner(0, 0), (0, 0)); + assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); + } + } + + #[test] + fn split_header_row_keeps_its_height_mode_and_role() { + let mut t = Table::new(1, 1, 2, 1, 1, 180.0); + t.rows[0].height = 60.0; + t.rows[0].mode = RowHeightMode::Exactly; + let body = t.rows[1].clone(); + let footer = t.rows[2].clone(); + t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); + t.check().unwrap(); + assert_eq!(t.header_rows(), 2); + for row in &t.rows[..2] { + assert_eq!(row.height, 30.0); + assert_eq!(row.mode, RowHeightMode::Exactly); + assert_eq!(row.kind, RowKind::Header); + } + assert_eq!(t.rows[2], body); + assert_eq!(t.rows[3], footer); + } +} diff --git a/crates/doc/src/table/story.rs b/crates/doc/src/table/story.rs new file mode 100644 index 000000000..67573d645 --- /dev/null +++ b/crates/doc/src/table/story.rs @@ -0,0 +1,195 @@ +use super::*; + +/// Append `other` to `s` as new paragraphs. +pub(super) fn append_story(s: &mut Story, other: &Story) { + if s.is_empty() { + *s = Story { id: s.id, ..other.clone() }; + s.rev = s.rev.wrapping_add(1); + return; + } + let base = s.len(); + s.insert(base, "\n"); + // Copy other's runs with their formats. + for (r, f) in other.runs() { + let at = s.len(); + s.insert_with(at, &other.text[r], f.clone()); + } + let first_new = s.paras.len() - other.paras.len(); + for (k, p) in other.paras.iter().enumerate() { + if let Some(dst) = s.paras.get_mut(first_new + k) { + *dst = p.clone(); + } + } +} + +// ---------- tables in stories ---------- + +impl crate::Document { + /// The story text edits go to: a cell's story when `cell` is set, else the story itself. + pub fn text_story(&self, story: StoryId, cell: Option) -> Option<&Story> { + let st = self.story(story)?; + match cell { + Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text), + Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text), + None => Some(st), + } + } + + /// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision). + pub fn text_story_mut(&mut self, story: StoryId, cell: Option) -> Option<&mut Story> { + let st = self.story_mut(story)?; + match cell { + Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text), + Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text), + None => Some(st), + } + } +} + +impl Story { + /// Visit this story and every text inside it (table cells, footnotes), mutably. + pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) { + f(self); + for t in self.tables.values_mut() { + for c in &mut std::sync::Arc::make_mut(t).cells { + c.text.for_each_text_mut(f); + } + } + for n in &mut self.notes { + std::sync::Arc::make_mut(n).text.for_each_text_mut(f); + } + } + + /// The table whose anchor paragraph is `pi`. + pub fn para_table(&self, pi: usize) -> Option<&Arc
> { + self.tables.get(&self.paras.get(pi)?.table?) + } + + /// Byte offset of a table's anchor character. + pub fn table_anchor(&self, id: u64) -> Option { + let pi = self.paras.iter().position(|p| p.table == Some(id))?; + let r = self.para_ranges().get(pi)?.clone(); + self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k) + } + + /// The table anchored at or right before byte `pos` (a caret next to the anchor). + pub fn table_at(&self, pos: usize) -> Option { + let pi = self.para_at(pos); + self.paras.get(pi)?.table + } + + /// Mutable table (copy-on-write); bumps the story revision. + pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> { + let t = self.tables.get_mut(&id)?; + self.rev = self.rev.wrapping_add(1); + Some(Arc::make_mut(t)) + } + + /// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset. + pub fn insert_table(&mut self, pos: usize, table: Table) -> usize { + let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len())); + let pi = self.para_at(pos); + let pstart = self.para_ranges()[pi].start; + let mut s = String::new(); + if pos > pstart { + s.push('\n'); + } + let anchor = pos + s.len(); + s.push(TABLE_ANCHOR); + if !self.text[pos..].starts_with('\n') { + s.push('\n'); + } + let id = table.id; + self.tables.insert(id, Arc::new(table)); + // Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it. + self.insert_raw(pos, &s); + let api = self.para_at(anchor); + self.paras[api].table = Some(id); + self.fix_tables(); + self.rev = self.rev.wrapping_add(1); + anchor + } + + /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it + /// still contains an anchor character, every table is referenced once, and tables nobody + /// references are dropped. An anchor that shares a table's paragraph with another anchor gets + /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. + pub(crate) fn fix_tables(&mut self) { + self.fix_tables_with(&[]); + } + + /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are + /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph + /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other + /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before + /// the fix-up, where it inserted paragraph returns. + pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { + if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { + return Vec::new(); + } + let mut owner = std::collections::BTreeMap::new(); + let mut used = std::collections::HashSet::new(); + for &(at, id) in claims { + if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) + && self.tables.contains_key(&id) + && !owner.contains_key(&at) + && used.insert(id) + { + owner.insert(at, id); + } + } + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter().zip(&ranges) { + let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; + let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); + if let Some(at) = free { + used.insert(id); + owner.insert(at, id); + } + } + // A paragraph holding a table's anchor and other anchors is split before each later anchor. + let mut splits = Vec::new(); + for r in &ranges { + let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); + if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { + splits.extend(anchors.into_iter().skip(1)); + } + } + for &at in splits.iter().rev() { + self.insert_raw(at, "\n"); + } + // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. + let owner: std::collections::BTreeMap = + owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter_mut().zip(ranges) { + p.table = owner.range(r).next().map(|(_, &id)| id); + } + self.tables.retain(|id, _| used.contains(id)); + splits + } + + /// Check table invariants (part of [`Story::check`]). + pub(crate) fn check_tables(&self) -> Result<(), String> { + let ranges = self.para_ranges(); + let mut used = std::collections::HashSet::new(); + for (pi, p) in self.paras.iter().enumerate() { + let Some(id) = p.table else { continue }; + let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?; + if t.id != id { + return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id)); + } + if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 { + return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0)); + } + if !used.insert(id) { + return Err(format!("story {}: table {id} anchored twice", self.id.0)); + } + t.check()?; + } + if used.len() != self.tables.len() { + return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len())); + } + Ok(()) + } +}