//! 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;
/// Most rows a table holds, as InDesign allows.
pub const MAX_TABLE_ROWS: usize = 10_000;
/// Most columns a table holds, as InDesign allows.
pub const MAX_TABLE_COLS: usize = 1_000;
/// Most cells a table holds, whatever its shape.
///
/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the
/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an
/// IDML story holding one `
` with 5,000 `
` and 5,000 `` -- about 1 KB
/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts
/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open
/// document's unsaved work goes with it (#1583).
pub const MAX_TABLE_CELLS: usize = 250_000;
/// The rows and columns a table of `rows × cols` is actually given, inside the limits above.
///
/// Columns are clamped first and rows take what is left of the cell budget, because a table is
/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its
/// rows loses less of it than the other way round. Callers that read a file report the
/// difference; the counts are a document's claim, not something to trust.
pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) {
let cols = cols.clamp(1, MAX_TABLE_COLS);
let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1));
(rows, cols)
}
#[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,
}
#[cfg(test)]
mod tests {
use super::*;
fn story(t: &str) -> Story {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
/// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so
/// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the
/// process rather than failing, taking every open document's unsaved work with it (#1583).
#[test]
fn a_table_larger_than_the_limits_is_clamped_not_allocated() {
// The reported shape: 5,000 rows and 5,000 columns.
let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0);
assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit");
assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len());
assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular");
// What it would have been: 25,000,000 cells at 1,360 bytes each.
assert!(25_000_000 * std::mem::size_of::| () > 30_000_000_000, "the size this test is about");
// Freed before the next large table, so the peak stays at one of them.
drop(t);
// A long table keeps its rows while it fits the budget (the budget itself is checked by
// `clamped_size`, which allocates nothing).
let long = Table::new(2, 10_000, 5, 0, 0, 500.0);
assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched");
assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget");
drop(long);
// Past the row limit the rows are clamped too.
let longer = Table::new(3, 50_000, 2, 0, 0, 100.0);
assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit");
drop(longer);
// Header, body and footer share the budget, and the table still has a body.
let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0);
assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len());
assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len());
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives");
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives");
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row");
drop(with_parts);
// A header and footer bigger than the whole budget still leave a body row.
let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0);
assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len());
assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives");
}
#[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);
}
}
| |