mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
1128 lines
40 KiB
Rust
1128 lines
40 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::{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,
|
||
}
|
||
|
||
impl Default for CellStroke {
|
||
fn default() -> Self {
|
||
CellStroke { weight: 1.0, color: designcraft_color::swatch::BLACK.into(), tint: 1.0, kind: StrokeType::Solid }
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
fn one() -> u32 {
|
||
1
|
||
}
|
||
fn full() -> f32 {
|
||
1.0
|
||
}
|
||
fn no_fill() -> String {
|
||
designcraft_color::swatch::NONE.into()
|
||
}
|
||
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,
|
||
/// 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],
|
||
/// Applied cell style ("" = [None]).
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub style: String,
|
||
}
|
||
|
||
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,
|
||
rotation: 0.0,
|
||
strokes: Default::default(),
|
||
style: String::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
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 + sl >= n {
|
||
return None;
|
||
}
|
||
let period = (self.first + self.next).max(1) as usize;
|
||
let k = (i - sf) % period;
|
||
let (c, t) = if k < self.first as usize { (&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 {
|
||
/// Outer border (overrides the outer cell edges).
|
||
pub border: CellStroke,
|
||
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>,
|
||
}
|
||
|
||
impl Default for TableOptions {
|
||
fn default() -> Self {
|
||
TableOptions {
|
||
border: CellStroke::default(),
|
||
space_before: 4.0,
|
||
space_after: -4.0,
|
||
repeat_header: true,
|
||
repeat_footer: true,
|
||
alt_rows: None,
|
||
alt_cols: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
/// 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 cell = t.cell_mut(r, c).expect("in range");
|
||
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
|
||
}
|
||
|
||
/// 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<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(®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<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(®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 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 h = self.rows[or].height;
|
||
self.insert_rows(or + 1, 1);
|
||
self.rows[or].height = h / 2.0;
|
||
self.rows[or + 1].height = h / 2.0;
|
||
// 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).
|
||
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());
|
||
}
|
||
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
|
||
};
|
||
}
|
||
}
|
||
|
||
/// 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));
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for cell in &self.cells {
|
||
cell.text.check()?;
|
||
}
|
||
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 += 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 += 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 += 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.
|
||
pub(crate) fn fix_tables(&mut self) {
|
||
if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) {
|
||
return;
|
||
}
|
||
let ranges = self.para_ranges();
|
||
let mut used = std::collections::HashSet::new();
|
||
for (p, r) in self.paras.iter_mut().zip(ranges) {
|
||
if let Some(id) = p.table
|
||
&& (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id))
|
||
{
|
||
p.table = None;
|
||
}
|
||
}
|
||
self.tables.retain(|id, _| used.contains(id));
|
||
}
|
||
|
||
/// 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 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 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 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());
|
||
}
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
}
|