mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 21:26:48 -06:00
282 lines
11 KiB
Rust
282 lines
11 KiB
Rust
//! 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};
|
||
|
||
mod cell;
|
||
mod edit;
|
||
mod grid;
|
||
mod merge;
|
||
mod story;
|
||
|
||
pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions};
|
||
pub use grid::CellRange;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Table {
|
||
pub id: u64,
|
||
pub rows: Vec<Row>,
|
||
pub columns: Vec<Column>,
|
||
/// Row-major, `rows.len() * columns.len()`.
|
||
pub cells: Vec<Cell>,
|
||
#[serde(default)]
|
||
pub options: TableOptions,
|
||
/// Applied table style ("" = [Basic Table]).
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub style: String,
|
||
}
|
||
|
||
#[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::<Vec<_>>(), [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);
|
||
}
|
||
}
|