refactor(doc): split table.rs into table/

This commit is contained in:
aiongg
2026-10-26 21:00:23 +00:00
parent a24e9dffd1
commit bc345384f6
6 changed files with 1390 additions and 1350 deletions
+7 -1350
View File
File diff suppressed because it is too large Load Diff
+348
View File
@@ -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<crate::Graphic>,
}
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<CellStroke>; 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<AltFills>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_cols: Option<AltFills>,
#[serde(skip_serializing_if = "Option::is_none")]
pub row_strokes: Option<AltStrokes>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_strokes: Option<AltStrokes>,
}
fn no_borders(borders: &[Option<CellStroke>; 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,
}
}
}
+260
View File
@@ -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<Cell> = (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(&regions);
}
/// 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(&regions);
}
/// 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(&regions);
}
/// 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(&regions);
}
/// 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<Cell> = 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))
}
}
+239
View File
@@ -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<String>], 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<usize> {
(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<usize> {
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(())
}
}
+341
View File
@@ -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<Story> = 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(&regions);
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(&regions);
}
/// 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(&regions);
} 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(&regions);
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 &regions {
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(&regions);
} 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 &regions {
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(&regions);
}
}
}
#[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::<std::collections::BTreeSet<_>>().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::<std::collections::BTreeSet<_>>().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);
}
}
+195
View File
@@ -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<crate::CellAddr>) -> 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<crate::CellAddr>) -> 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<Table>> {
self.tables.get(&self.paras.get(pi)?.table?)
}
/// Byte offset of a table's anchor character.
pub fn table_anchor(&self, id: u64) -> Option<usize> {
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<u64> {
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<usize> {
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<usize> = 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<usize, u64> =
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(())
}
}