//! Tables. //! //! A table lives in a story: a [`TABLE_ANCHOR`](crate::story::TABLE_ANCHOR) character occupies its //! own paragraph and that paragraph's [`ParaFormat::table`](crate::story::ParaFormat) names the //! table in [`Story::tables`]. Deleting the anchor character deletes the table. //! //! The grid is `rows × columns` cells stored row-major. A merged cell is the top-left cell of its //! region with `row_span`/`col_span` > 1; the cells it covers stay in the grid (empty, ignored). //! Each cell holds a [`Story`] (no frames) composed into the cell rectangle. use std::sync::Arc; use serde::{Deserialize, Serialize}; 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, } #[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, } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Table { pub id: u64, pub rows: Vec, pub columns: Vec, /// Row-major, `rows.len() * columns.len()`. pub cells: Vec, #[serde(default)] pub options: TableOptions, /// Applied table style ("" = [Basic Table]). #[serde(default, skip_serializing_if = "String::is_empty")] 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::*; fn story(t: &str) -> Story { Story::with_text(StoryId(1), t, ParaFormat::default()) } #[test] fn move_row_keeps_header_and_footer_rows_in_place() { let mut t = Table::new(1, 3, 2, 1, 1, 120.0); for (from, to) in [(0, 2), (0, 4), (2, 0), (2, 4), (4, 2), (4, 0)] { assert!(t.move_row(from, to).is_err(), "move_row({from}, {to}) should be refused"); t.check().unwrap(); } t.move_row(1, 3).unwrap(); t.check().unwrap(); } #[test] fn selected_owners_include_covered_cells_without_expanding_range() { for column in [true, false] { let mut t = Table::new(1, 2, 2, 0, 0, 120.0); let range = if column { CellRange::new(0, 1, 1, 1) } else { CellRange::new(1, 0, 1, 1) }; assert_eq!(t.selected_owners(range), if column { vec![(0, 1), (1, 1)] } else { vec![(1, 0), (1, 1)] }); t.merge(if column { CellRange::new(0, 0, 0, 1) } else { CellRange::new(0, 0, 1, 0) }).unwrap(); assert_eq!(t.selected_owners(range), vec![(0, 0), (1, 1)]); } } #[test] fn selected_owners_are_bounded_sorted_and_unique() { let mut t = Table::new(1, 3, 3, 0, 0, 180.0); t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); assert_eq!(t.selected_owners(CellRange::new(1, 1, usize::MAX, usize::MAX)), vec![(0, 0), (1, 2), (2, 1), (2, 2)]); assert_eq!(t.selected_owners(CellRange::new(0, 0, 1, 1)), vec![(0, 0)], "covered positions do not duplicate an owner"); assert!(t.selected_owners(CellRange::new(3, 0, usize::MAX, usize::MAX)).is_empty()); assert!(t.selected_owners(CellRange::new(0, 3, usize::MAX, usize::MAX)).is_empty()); assert!(t.selected_owners(CellRange { r0: 1, c0: 0, r1: 0, c1: 1 }).is_empty()); t.rows.clear(); t.cells.clear(); assert!(t.selected_owners(CellRange::cell(0, 0)).is_empty()); let mut right = Table::new(2, 2, 2, 0, 0, 120.0); right.merge(CellRange::new(0, 1, 1, 1)).unwrap(); assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order"); } #[test] fn insert_table_gets_own_paragraph() { let mut s = story("before after"); let a = s.insert_table(6, Table::new(7, 3, 2, 1, 0, 200.0)); s.check().unwrap(); assert_eq!(s.text, format!("before\n{TABLE_ANCHOR}\n after")); assert_eq!(s.table_anchor(7), Some(a)); assert_eq!(s.para_table(1).map(|t| t.id), Some(7)); assert_eq!(s.tables[&7].nrows(), 4); // At the start of a paragraph / end of the story. let mut s = story("x"); s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0)); s.check().unwrap(); assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n")); let mut s = story(""); s.insert_table(0, Table::new(1, 1, 1, 0, 0, 10.0)); s.check().unwrap(); assert_eq!(s.text, format!("{TABLE_ANCHOR}\n")); } #[test] fn deleting_the_anchor_removes_the_table() { let mut s = story("ab"); let a = s.insert_table(2, Table::new(9, 2, 2, 0, 0, 100.0)); s.delete(a..a + TABLE_ANCHOR.len_utf8()); s.check().unwrap(); assert!(s.tables.is_empty()); // Deleting across the paragraph keeps the story consistent too. let mut s = story("ab\ncd"); s.insert_table(2, Table::new(9, 2, 2, 0, 0, 100.0)); s.delete(1..s.len() - 1); s.check().unwrap(); assert!(s.tables.is_empty()); } #[test] fn deleting_the_return_between_two_tables_keeps_both() { // Backspace at the start of the second table's paragraph. let mut s = story("x"); let a1 = s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0)); let a2 = s.insert_table(s.len(), Table::new(2, 1, 1, 0, 0, 10.0)); let before = s.text.clone(); s.delete(a2 - 1..a2); s.check().unwrap(); assert_eq!(s.text, before); assert_eq!((s.table_anchor(1), s.table_anchor(2)), (Some(a1), Some(a2))); // Deleting the table between two others keeps the outer ones on their own anchors. let mut s = story("x"); for id in 1..=3 { s.insert_table(s.len(), Table::new(id, 1, 1, 0, 0, 10.0)); } let (a1, a3) = (s.table_anchor(1).unwrap(), s.table_anchor(3).unwrap()); s.delete(a1 + TABLE_ANCHOR.len_utf8()..a3); s.check().unwrap(); assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n")); assert_eq!(s.tables.keys().copied().collect::>(), [1, 3]); assert_eq!(s.table_anchor(3), Some(a1 + TABLE_ANCHOR.len_utf8() + 1)); } #[test] fn edits_around_the_anchor_keep_the_table() { let mut s = story("ab\ncd"); let a = s.insert_table(2, Table::new(3, 1, 1, 0, 0, 10.0)); // New paragraphs before and after the anchor. s.insert(a, "\n"); s.check().unwrap(); let a = s.table_anchor(3).unwrap(); s.insert(a + 3, "\nzz"); s.check().unwrap(); assert!(s.tables.contains_key(&3)); // Merging the anchor paragraph with the previous one keeps the table. let a = s.table_anchor(3).unwrap(); s.delete(a - 1..a); s.check().unwrap(); assert!(s.tables.contains_key(&3), "{:?}", s.text); // Restyling the paragraph keeps the table. let a = s.table_anchor(3).unwrap(); s.format_paras(a..a, |p| *p = ParaFormat { style: "X".into(), ..Default::default() }); s.check().unwrap(); } #[test] fn rows_columns_and_merges() { let mut t = Table::new(1, 3, 3, 1, 0, 300.0); t.cell_mut(1, 1).unwrap().text.insert(0, "a"); t.cell_mut(1, 2).unwrap().text.insert(0, "b"); t.merge(CellRange::new(1, 1, 2, 2)).unwrap(); t.check().unwrap(); assert_eq!(t.cell(1, 1).unwrap().text.text, "a\nb"); assert_eq!(t.owner(2, 2), (1, 1)); // Inserting a row inside the merged region extends it. t.insert_rows(2, 1); t.check().unwrap(); assert_eq!(t.cell(1, 1).unwrap().row_span, 3); assert_eq!(t.nrows(), 5); // Header stays first. t.insert_rows(0, 1); t.check().unwrap(); assert_eq!(t.rows[0].kind, RowKind::Header); assert_eq!(t.header_rows(), 2); // Deleting the owner row moves the content down. let (r, c) = (2, 1); assert_eq!(t.owner(r, c), (2, 1)); t.delete_rows(2, 1); t.check().unwrap(); assert_eq!(t.cell(2, 1).unwrap().text.text, "a\nb"); assert_eq!(t.cell(2, 1).unwrap().row_span, 2); // Columns. t.insert_cols(1, 2, 50.0); t.check().unwrap(); assert_eq!(t.ncols(), 5); assert_eq!(t.cell(2, 3).unwrap().col_span, 2); t.delete_cols(3, 1); t.check().unwrap(); assert_eq!(t.cell(2, 3).unwrap().text.text, "a\nb"); assert_eq!(t.cell(2, 3).unwrap().col_span, 1); t.unmerge(3, 3); t.check().unwrap(); assert!(t.regions().is_empty()); // Never below one row / column. t.delete_rows(0, 99); t.delete_cols(0, 99); t.check().unwrap(); assert_eq!((t.nrows(), t.ncols()), (1, 1)); } #[test] fn alternating_fills() { let a = AltFills { first: 1, next: 1, ..Default::default() }; assert!(a.fill_for(0, 4).is_some()); assert!(a.fill_for(1, 4).is_none()); assert!(a.fill_for(2, 4).is_some()); let b = AltFills { first: 2, next: 1, skip_first: 1, ..Default::default() }; assert!(b.fill_for(0, 9).is_none()); assert!(b.fill_for(1, 9).is_some()); assert!(b.fill_for(2, 9).is_some()); assert!(b.fill_for(3, 9).is_none()); } #[test] fn alternating_fill_counts_cannot_overflow() { let fills = AltFills { first: u32::MAX, next: 1, ..Default::default() }; assert!(fills.fill_for(0, 2).is_some()); assert!(fills.fill_for(1, 2).is_some()); let skipped = AltFills { skip_last: u32::MAX, ..fills }; assert!(skipped.fill_for(1, 2).is_none()); assert!(skipped.fill_for(usize::MAX, usize::MAX).is_none()); } #[test] fn serde_roundtrip() { let mut s = story("x"); s.insert_table(1, Table::new(4, 2, 2, 1, 0, 100.0)); let j = serde_json::to_string(&s).unwrap(); let back: Story = serde_json::from_str(&j).unwrap(); assert_eq!(back, s); back.check().unwrap(); // Old files without tables still load. let old = r#"{"id":1,"text":"a","paras":[{"style":"[Basic Paragraph]"}],"chars":[{"len":1,"style":"[None]"}],"frames":[]}"#; let st: Story = serde_json::from_str(old).unwrap(); assert!(st.tables.is_empty()); } #[test] fn cell_border_overrides_default_for_old_documents_and_roundtrip() { let cell = Cell::default(); let mut old = serde_json::to_value(&cell).unwrap(); old.as_object_mut().unwrap().remove("borderOverrides"); let back: Cell = serde_json::from_value(old).unwrap(); assert_eq!(back.border_overrides, [false; 4]); assert_eq!(back.stroke_defined, [false; 4]); assert_eq!(back.stroke_priorities, [0; 4]); let legacy_options: TableOptions = serde_json::from_str(r#"{"border":{"weight":3}}"#).unwrap(); assert!(legacy_options.borders.iter().all(Option::is_none)); assert!(legacy_options.row_strokes.is_none()); assert!(legacy_options.column_strokes.is_none()); for side in 0..4 { assert_eq!(legacy_options.border_for(side).weight, 3.0); } let explicit = Cell { border_overrides: [true, false, true, false], stroke_defined: [true, true, false, false], stroke_priorities: [3, 0, 7, 0], ..cell }; let back: Cell = serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap(); assert_eq!(back, explicit); 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); } }