Files
designcraft/crates/engine/src/cmd/table.rs
T
skylar taylor-barrick 9fc02f8f7b Merge contributor branch codex/placed-graphic-layer-overrides (PR #1647) into main
Conflict resolutions:
- crates/pdf/src/export.rs: keep main's `graphic(.., hidden)` signature from
  #1241 and report retained graphic-layer overrides that drawing cannot apply.
- docs/gaps.md: keep main's change list and add the graphic-layer entry.
2026-10-24 15:30:03 -06:00

2503 lines
113 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Table menu: create, convert, insert/delete rows and columns, merge, cell/row/column options,
//! table options and cell selection.
//!
//! Commands target the table at the text selection (a caret in a cell or next to a table anchor),
//! the selected cells, or an explicit `{story, table}`; `rows: [a, b]` / `cols: [a, b]` (or `row` /
//! `col`) narrow the cell range (default: the selected cells, else the caret's cell, else the
//! whole table).
use designcraft_compose as compose;
use designcraft_doc::story::floor_char_boundary;
use designcraft_doc::{
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, RowKind, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
VerticalJustification,
};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, f64_or, has_doc, has_text, ok, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
let mut v = style_specs();
v.extend(table_specs());
v
}
fn style_specs() -> Vec<CommandSpec> {
vec![
cmd!(
"style.cell.create",
"New Cell Style…",
[],
None,
"{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
has_doc,
|s, p| cell_style_create(s, p)
),
cmd!(
"style.cell.apply",
"Apply Cell Style",
[],
None,
"{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — the target cells (the whole table when the cursor is in it)",
in_table,
cell_style_apply
),
cmd!(
"style.cell.edit",
"Cell Style Options…",
[],
None,
"{name, …same as style.cell.create} — cells using the style follow",
has_doc,
cell_style_edit
),
cmd!(
"style.table.create",
"New Table Style…",
[],
None,
"{name, basedOn?: table style name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}",
has_doc,
table_style_create
),
cmd!(
"style.table.apply",
"Apply Table Style",
[],
None,
"{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?}",
in_table,
table_style_apply
),
cmd!(
"style.table.edit",
"Table Style Options…",
[],
None,
"{name, …same as style.table.create} — tables using the style follow",
has_doc,
table_style_edit
),
]
}
fn table_specs() -> Vec<CommandSpec> {
vec![
cmd!(
"table.insert",
"Create Table",
[],
Some("Cmd+Alt+Shift+T"),
"{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
has_text,
insert
),
cmd!(
"table.convertFromText",
"Convert Text to Table",
["Table"],
None,
"{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows",
has_text,
convert_from_text
),
cmd!(
"table.convertToText",
"Convert Table to Text",
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs",
in_table,
convert_to_text
),
cmd!(
"table.insertRow",
"Insert Row",
[],
None,
"{where?: above|below, count? (1), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
|s, p| insert_row(s, p, None)
),
cmd!(
"table.insertRowAbove",
"Insert Row Above",
["Table", "Insert"],
None,
"{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
|s, p| insert_row(s, p, Some(true))
),
cmd!(
"table.insertRowBelow",
"Insert Row Below",
["Table", "Insert"],
None,
"{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
|s, p| insert_row(s, p, Some(false))
),
cmd!(
"table.insertColumn",
"Insert Column",
[],
None,
"{where?: left|right, count? (1), width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
|s, p| insert_col(s, p, None)
),
cmd!(
"table.insertColumnLeft",
"Insert Column Left",
["Table", "Insert"],
None,
"{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
|s, p| insert_col(s, p, Some(true))
),
cmd!(
"table.insertColumnRight",
"Insert Column Right",
["Table", "Insert"],
None,
"{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
|s, p| insert_col(s, p, Some(false))
),
cmd!(
"table.deleteRow",
"Delete Row",
["Table", "Delete"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the rows of the target cells",
in_table,
delete_rows
),
cmd!(
"table.deleteColumn",
"Delete Column",
["Table", "Delete"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the columns of the target cells",
in_table,
delete_cols
),
cmd!(
"table.delete",
"Delete Table",
["Table", "Delete"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
delete_table
),
cmd!(
"table.merge",
"Merge Cells",
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — merge the target cell range",
in_table,
merge
),
cmd!(
"table.dropCells",
"Drag Rows or Columns",
[],
None,
"{frame, from: [x, y], to: [x, y]} — selected whole rows (or columns) dragged from `from` move to the row (column) at `to`; otherwise a text drag",
has_doc,
drop_cells
),
cmd!(
"table.placeGraphic",
"Convert Cell to Graphic Cell",
["Table", "Convert Cell Type"],
None,
"{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)",
in_table,
place_graphic
),
cmd!(
"table.textCell",
"Convert Cell to Text Cell",
["Table", "Convert Cell Type"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics",
in_table,
|s, p| {
let g = target(s, p, "table.textCell")?;
edit_table(s, &g, "table.textCell", |t| {
for (r, c) in t.selected_owners(g.range) {
if let Some(cell) = t.cell_mut(r, c) {
cell.graphic = None;
}
}
ok()
})
}
),
cmd!(
"table.moveRow",
"Move Row",
[],
None,
"{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target row) — like dragging a row",
in_table,
|s, p| move_rc(s, p, true)
),
cmd!(
"table.moveColumn",
"Move Column",
[],
None,
"{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target column)",
in_table,
|s, p| move_rc(s, p, false)
),
cmd!(
"table.sortRows",
"Sort",
["Table"],
None,
"{column?: 0-based (default: the target cell's), descending?: bool, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — body rows by that column's text (numbers numerically)",
in_table,
sort_rows
),
cmd!(
"table.unmerge",
"Unmerge Cells",
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
unmerge
),
cmd!(
"table.splitHorizontally",
"Split Cell Horizontally",
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, one above the other",
in_table,
|s, p| split(s, p, true)
),
cmd!(
"table.splitVertically",
"Split Cell Vertically",
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, side by side",
in_table,
|s, p| split(s, p, false)
),
cmd!(
"table.setCell",
"Cell Options",
[],
None,
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
cmd!(
query "table.getCellStroke",
"Get Cell Stroke",
[],
None,
"{story?, table?, rows?, cols?, edges?} → {weight, color, tint, type, gapColor, gapTint, fill, fillTint, edges: count} of the chosen edges of the target cells; a value that differs between them is null",
in_table_or_ids,
get_cell_stroke
),
cmd!(
"table.setRowHeight",
"Row Height",
[],
None,
"{height, mode?: atLeast|exactly, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
set_row_height
),
cmd!(
"table.setColumnWidth",
"Column Width",
[],
None,
"{width, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
set_col_width
),
cmd!(
"table.resizeBorder",
"Resize Row or Column",
[],
None,
"{story?, table?, frame?, axis: column|row, index, delta? | size?, keepTableSize?: bool} — moves the border after column/row `index` by `delta` (or makes that column/row `size` wide/high); later columns/rows move along. The last index is the table's end/bottom edge. keepTableSize: on an inner border the next column/row gives or takes the change; on the edge all columns/rows scale proportionally. A dragged row becomes At Least. Refuses a locked `frame`",
in_table_or_ids,
resize_border
),
cmd!(
"table.distributeColumns",
"Distribute Columns Evenly",
["Table"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
distribute_cols
),
cmd!(
"table.distributeRows",
"Distribute Rows Evenly",
["Table"],
None,
"{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first",
in_table,
distribute_rows
),
cmd!(
"table.options",
"Table Options",
[],
None,
"{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}",
in_table,
options
),
cmd!(
"table.convertRowsToHeader",
"To Header",
["Table", "Convert Rows"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the top of the table or right after its header rows",
|s| can_convert_rows(s, RowKind::Header),
|s, p| convert_rows(s, p, RowKind::Header)
),
cmd!(
"table.convertRowsToBody",
"To Body",
["Table", "Convert Rows"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target header rows next to the body, footer rows next to it, or both",
|s| can_convert_rows(s, RowKind::Body),
|s, p| convert_rows(s, p, RowKind::Body)
),
cmd!(
"table.convertRowsToFooter",
"To Footer",
["Table", "Convert Rows"],
None,
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the bottom of the table or right before its footer rows",
|s| can_convert_rows(s, RowKind::Footer),
|s, p| convert_rows(s, p, RowKind::Footer)
),
cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, what?: cell|row|column|table}", in_table_or_ids, select),
cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
select(s, &super::with_param(p, "what", json!("table")))
}),
cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
select(s, &super::with_param(p, "what", json!("row")))
}),
cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
select(s, &super::with_param(p, "what", json!("column")))
}),
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
]
}
// ---------- enablement and targeting ----------
fn in_table(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let st = s.active().ok_or("no document open")?;
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
return Ok(());
}
if let Some(t) = st.selection.text
&& st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some())
{
return Ok(());
}
Err("place the insertion point in a table".into())
}
fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> {
has_doc(s)
}
fn in_cell(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) }
}
/// The table a command acts on and its target cell range.
struct Tgt {
story: StoryId,
table: u64,
range: CellRange,
}
fn find_table(doc: &Document, id: u64) -> Option<StoryId> {
doc.stories.iter().find(|(_, s)| s.tables.contains_key(&id)).map(|(k, _)| *k)
}
fn pair(p: &Value, many: &str, one: &str) -> Option<(usize, usize)> {
if let Some(a) = p.get(many).and_then(Value::as_array) {
let a0 = a.first()?.as_u64()? as usize;
let a1 = a.get(1).and_then(Value::as_u64).map_or(a0, |v| v as usize);
return Some((a0.min(a1), a0.max(a1)));
}
p.get(one).and_then(Value::as_u64).map(|v| (v as usize, v as usize))
}
fn target(s: &Session, p: &Value, cmd: &str) -> Result<Tgt> {
let st = s.doc()?;
let doc = &st.doc;
let explicit_table = p.get("table").and_then(Value::as_u64);
let explicit_story = p.get("story").and_then(Value::as_u64).map(StoryId);
let (story, table, range) = if let Some(tid) = explicit_table {
let sid = explicit_story.or_else(|| find_table(doc, tid)).ok_or_else(|| bad(cmd, format!("no table {tid}")))?;
(sid, tid, None)
} else if let Some(c) = st.selection.cells {
(c.story, c.table, Some(c.range))
} else if let Some(t) = st.selection.text {
match t.cell {
Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))),
None => {
let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?;
(t.story, tid, None)
}
}
} else {
return Err(bad(cmd, "place the insertion point in a table"));
};
let t = doc.story(story).and_then(|x| x.tables.get(&table)).ok_or_else(|| bad(cmd, format!("no table {table}")))?;
let (nr, nc) = (t.nrows(), t.ncols());
let mut range = range.unwrap_or(CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 });
if let Some((a, b)) = pair(p, "rows", "row") {
range.r0 = a;
range.r1 = b;
}
if let Some((a, b)) = pair(p, "cols", "col") {
range.c0 = a;
range.c1 = b;
}
if range.r1 >= nr || range.c1 >= nc {
return Err(bad(cmd, format!("cell range out of bounds ({nr}×{nc} table)")));
}
Ok(Tgt { story, table, range })
}
/// Edit the target table; afterwards selections pointing into it are clamped to its new shape.
fn edit_table<T>(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result<T>) -> Result<T> {
let cmd = cmd.to_string();
s.edit(|d, sel| {
let st = d.story_mut(g.story).ok_or_else(|| bad(&cmd, "no story"))?;
let t = st.table_mut(g.table).ok_or_else(|| bad(&cmd, "no table"))?;
let r = f(t)?;
clamp_selection(d, sel);
Ok(r)
})
}
/// Keep text/cell selections valid after the table changed shape (or disappeared).
fn clamp_selection(d: &Document, sel: &mut Selection) {
if let Some(ts) = sel.cells {
match d.story(ts.story).and_then(|x| x.tables.get(&ts.table)) {
Some(t) => {
let (mr, mc) = (t.nrows() - 1, t.ncols() - 1);
let r = ts.range;
sel.cells = Some(TableSel { range: CellRange::new(r.r0.min(mr), r.c0.min(mc), r.r1.min(mr), r.c1.min(mc)), ..ts });
}
None => sel.cells = None,
}
}
if let Some(t) = sel.text
&& let Some(c) = t.cell
{
let story = d.story(t.story);
match story.and_then(|x| x.tables.get(&c.table)) {
Some(tb) => {
let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1));
let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str());
sel.text = Some(TextSel {
anchor: floor_char_boundary(text, t.anchor),
focus: floor_char_boundary(text, t.focus),
cell: Some(CellAddr { table: c.table, row: r, col }),
..t
});
}
None => {
let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor));
sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t });
}
}
}
}
fn count(p: &Value) -> usize {
p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize
}
fn color_param(p: &Value, key: &str) -> Option<String> {
str_param(p, key).map(str::to_string)
}
// ---------- create / convert ----------
/// Width of the text column at the caret (the new table spans it).
fn column_width(s: &Session, t: &TextSel) -> f64 {
let Some(st) = s.active() else { return 300.0 };
let specs = compose::frame_specs(&st.doc, t.story);
let spec = t.frame.and_then(|f| specs.iter().find(|x| x.id == f)).or(specs.first());
spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0)
}
/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style
/// chain, then local overrides). A table created there takes it.
fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection {
d.story(story)
.and_then(|st| st.paras.get(st.para_at(offset)))
.map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction)
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
if t.cell.is_some() {
return Err(bad("table.insert", "tables can't be nested in cells"));
}
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize;
let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize;
let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
let mut table = Table::new(id, rows, cols, header, footer, width);
table.options.direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?;
// Cells start with the paragraph format at the caret.
let pf = st.paras[st.para_at(t.range().start)].clone();
let cf = st.char_format_at(t.range().start).clone();
for c in &mut table.cells {
c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() };
c.text.chars[0].format = cf.clone();
}
let r = t.range();
st.delete(r.clone());
st.insert_table(r.start, table);
*sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) });
Ok(json!({"story": t.story.0, "table": id}))
})
}
fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
if t.cell.is_some() {
return Err(bad("table.convertFromText", "select text outside tables"));
}
let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
let direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?;
let ranges = st.para_ranges();
let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end));
if (pa..=pb).any(|i| st.paras[i].table.is_some()) {
return Err(bad("table.convertFromText", "the selection contains a table"));
}
let (a, b) = (ranges[pa].start, ranges[pb].end);
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
let pf = st.paras[pa].clone();
let cf = st.format_after(a).clone();
let mut table = Table::from_strings(id, &data, width, &pf, &cf);
table.options.direction = direction;
st.delete(a..b);
let anchor = st.insert_table(a, table);
*sel = Selection::text(TextSel::caret(t.story, anchor));
Ok(json!({"table": id, "rows": data.len()}))
})
}
fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.convertToText")?;
s.edit(|d, sel| {
let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
*sel = Selection::text(TextSel::caret(g.story, a + text.len()));
ok()
})
}
fn delete_table(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.delete")?;
s.edit(|d, sel| {
let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?;
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?;
// Remove the anchor paragraph (with one separator) so no empty paragraph is left behind.
let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8();
let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') {
// Inserted at the end of the story: remove the empty paragraph after it too.
a - 1..end + 1
} else if st.text[end..].starts_with('\n') {
a..end + 1
} else if a > 0 && st.text[..a].ends_with('\n') {
a - 1..end
} else {
a..end
};
st.delete(r.clone());
*sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len())));
ok()
})
}
// ---------- rows and columns ----------
fn insert_row(s: &mut Session, p: &Value, above: Option<bool>) -> Result<Value> {
let g = target(s, p, "table.insertRow")?;
let above = above.unwrap_or(str_param(p, "where") == Some("above"));
let n = count(p);
let at = if above { g.range.r0 } else { g.range.r1 + 1 };
let r = edit_table(s, &g, "table.insertRow", |t| {
t.insert_rows(at, n);
Ok(json!({"rows": t.nrows()}))
})?;
shift_selection(s, g.table, at, n, true);
Ok(r)
}
/// After inserting rows/columns at `at`, selections at or after it follow their cells.
fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool) {
let Ok(st) = s.doc_mut() else { return };
if let Some(t) = st.selection.text.as_mut()
&& let Some(c) = t.cell.as_mut()
&& c.table == table
{
let v = if rows { &mut c.row } else { &mut c.col };
if *v >= at {
*v += n;
}
}
if let Some(ts) = st.selection.cells.as_mut()
&& ts.table == table
{
let (a, b) = if rows { (&mut ts.range.r0, &mut ts.range.r1) } else { (&mut ts.range.c0, &mut ts.range.c1) };
if *a >= at {
*a += n;
}
if *b >= at {
*b += n;
}
}
}
fn insert_col(s: &mut Session, p: &Value, left: Option<bool>) -> Result<Value> {
let g = target(s, p, "table.insertColumn")?;
let left = left.unwrap_or(str_param(p, "where") == Some("left"));
let rtl = s
.doc()?
.doc
.story(g.story)
.and_then(|st| st.tables.get(&g.table))
.is_some_and(|t| t.options.direction == designcraft_doc::TextDirection::RightToLeft);
let n = count(p);
let at = if left != rtl { g.range.c0 } else { g.range.c1 + 1 };
let width = p.get("width").and_then(Value::as_f64);
let r = edit_table(s, &g, "table.insertColumn", |t| {
let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width);
t.insert_cols(at, n, w);
Ok(json!({"columns": t.ncols()}))
})?;
shift_selection(s, g.table, at, n, false);
Ok(r)
}
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.deleteRow")?;
let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.r0 == 0 && g.range.r1 + 1 >= t.nrows());
if all {
return delete_table(s, p);
}
edit_table(s, &g, "table.deleteRow", |t| {
t.delete_rows(g.range.r0, g.range.r1 - g.range.r0 + 1);
Ok(json!({"rows": t.nrows()}))
})
}
fn delete_cols(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.deleteColumn")?;
let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.c0 == 0 && g.range.c1 + 1 >= t.ncols());
if all {
return delete_table(s, p);
}
edit_table(s, &g, "table.deleteColumn", |t| {
t.delete_cols(g.range.c0, g.range.c1 - g.range.c0 + 1);
Ok(json!({"columns": t.ncols()}))
})
}
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.merge")?;
let r = edit_table(s, &g, "table.merge", |t| {
t.merge(g.range).map_err(|e| bad("table.merge", e))?;
Ok(t.expand_range(g.range))
})?;
// The caret goes to the merged cell.
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel {
story: g.story,
anchor: 0,
focus: 0,
frame: st.selection.text.and_then(|t| t.frame),
cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }),
});
ok()
}
fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let g = target(s, p, "table.split")?;
edit_table(s, &g, "table.split", |t| {
t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?;
Ok(json!({"rows": t.nrows(), "cols": t.ncols()}))
})
}
fn unmerge(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.unmerge")?;
edit_table(s, &g, "table.unmerge", |t| {
for r in g.range.r0..=g.range.r1 {
for c in g.range.c0..=g.range.c1 {
t.unmerge(r, c);
}
}
ok()
})
}
/// Cell edges chosen for a stroke edit: the target range's four outer sides (physical left and
/// right) and the lines between its rows and columns.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct EdgeSel {
top: bool,
left: bool,
bottom: bool,
right: bool,
inner_h: bool,
inner_v: bool,
}
impl EdgeSel {
const ALL: EdgeSel = EdgeSel { top: true, left: true, bottom: true, right: true, inner_h: true, inner_v: true };
fn parse(v: Option<&Value>, cmd: &str) -> Result<EdgeSel> {
let names: Vec<&Value> = match v {
None | Some(Value::Null) => return Ok(EdgeSel::ALL),
Some(Value::Array(a)) => a.iter().collect(),
Some(v) => vec![v],
};
let mut e = EdgeSel::default();
for n in names {
match n.as_str() {
Some("all") => e = EdgeSel::ALL,
Some("outer") => (e.top, e.left, e.bottom, e.right) = (true, true, true, true),
Some("inner") => (e.inner_h, e.inner_v) = (true, true),
Some("top") => e.top = true,
Some("left") => e.left = true,
Some("bottom") => e.bottom = true,
Some("right") => e.right = true,
Some("innerHorizontal") => e.inner_h = true,
Some("innerVertical") => e.inner_v = true,
_ => return Err(bad(cmd, format!("unknown edge {n}; use all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical"))),
}
}
if e == EdgeSel::default() {
return Err(bad(cmd, "no edges chosen"));
}
Ok(e)
}
/// Which sides (top, left, bottom, right) of the cell at `(r, c)` with `spans` are chosen
/// within `rg`. Left and right are physical sides, also in RTL tables.
fn sides(&self, rg: CellRange, (r, c): (usize, usize), spans: (u32, u32), rtl: bool) -> [bool; 4] {
let last_row = r.saturating_add((spans.0.max(1) - 1) as usize);
let last_col = c.saturating_add((spans.1.max(1) - 1) as usize);
let (start, end) = (c <= rg.c0, last_col >= rg.c1);
let (left_outer, right_outer) = if rtl { (end, start) } else { (start, end) };
[
if r <= rg.r0 { self.top } else { self.inner_h },
if left_outer { self.left } else { self.inner_v },
if last_row >= rg.r1 { self.bottom } else { self.inner_h },
if right_outer { self.right } else { self.inner_v },
]
}
}
/// A finite number in `lo..=hi`, or an error naming `key`.
fn num_in(v: &Value, key: &str, lo: f64, hi: f64, cmd: &str) -> Result<Option<f64>> {
match v.get(key) {
None | Some(Value::Null) => Ok(None),
Some(x) => match x.as_f64() {
Some(n) if n.is_finite() && (lo..=hi).contains(&n) => Ok(Some(n)),
_ => Err(bad(cmd, format!("{key} must be a number from {lo} to {hi}"))),
},
}
}
/// A swatch of the document (or [None]), or an error naming `key`.
fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result<Option<String>> {
let Some((key, x)) = keys.iter().find_map(|k| v.get(*k).filter(|x| !x.is_null()).map(|x| (*k, x))) else { return Ok(None) };
let name = x.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a swatch name")))?;
if name != designcraft_color::swatch::NONE && doc.swatch(name).is_none() {
return Err(bad(cmd, format!("no swatch `{name}`")));
}
Ok(Some(name.to_string()))
}
/// The cell stroke type names of `type`.
const CELL_STROKE_TYPES: &[&str] =
&["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"];
/// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the
/// resulting weight of each edge.
struct StrokeEdit {
attrs: designcraft_doc::CellStrokeAttrs,
scaled_dashes: bool,
}
impl StrokeEdit {
fn parse(doc: &Document, v: &Value, cmd: &str) -> Result<StrokeEdit> {
if !v.is_object() {
return Err(bad(cmd, "stroke must be an object"));
}
let mut attrs = designcraft_doc::CellStrokeAttrs {
weight: num_in(v, "weight", 0.0, 1000.0, cmd)?,
color: swatch_in(doc, v, &["color"], cmd)?,
tint: num_in(v, "tint", 0.0, 1.0, cmd)?.map(|t| t as f32),
gap_color: swatch_in(doc, v, &["gapColor", "gapSwatch"], cmd)?,
gap_tint: num_in(v, "gapTint", 0.0, 1.0, cmd)?.map(|t| t as f32),
kind: None,
};
let mut scaled_dashes = false;
match v.get("type") {
None | Some(Value::Null) => {}
Some(Value::String(name)) if name == "dashed" => {
scaled_dashes = true;
attrs.kind = Some(StrokeType::Dashed { pattern: Vec::new() });
}
Some(Value::String(name)) if CELL_STROKE_TYPES.contains(&name.as_str()) => {
attrs.kind = Some(serde_json::from_value(json!({"kind": name})).map_err(|e| bad(cmd, e.to_string()))?);
}
Some(t @ Value::Object(_)) => {
let kind: StrokeType = serde_json::from_value(t.clone()).map_err(|e| bad(cmd, format!("bad stroke type: {e}")))?;
let finite = |v: &[f64]| v.len() <= 64 && v.iter().all(|x| x.is_finite() && *x >= 0.0);
match &kind {
StrokeType::Dashed { pattern } if !StrokeType::valid_dash_pattern(pattern) => {
return Err(bad(cmd, "a dash pattern needs 1 to 64 lengths, each 0 or 0.1 to 10000 pt, not all 0"));
}
StrokeType::Stripes { bands } if bands.len() > 64 || !bands.iter().all(|(a, b)| finite(&[*a, *b])) => {
return Err(bad(cmd, "stripes need up to 64 bands of non-negative numbers"));
}
StrokeType::Style { name } if !doc.stroke_styles.iter().any(|s| s.name == *name) => {
return Err(bad(cmd, format!("no stroke style `{name}`")));
}
_ => {}
}
attrs.kind = Some(kind);
}
Some(other) => return Err(bad(cmd, format!("unknown stroke type {other}"))),
}
Ok(StrokeEdit { attrs, scaled_dashes })
}
fn apply(&self, mut base: CellStroke) -> CellStroke {
self.attrs.apply_to(&mut base);
if self.scaled_dashes {
let w = base.weight.max(1.0);
base.kind = StrokeType::Dashed { pattern: vec![w * 3.0, w * 2.0] };
}
base
}
}
/// The stroke drawn on side `side` of the owner cell at `(r, c)` (where that side meets its
/// first neighbour), as rendering resolves it; the cell's own copy if the side has no stretch.
fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side: usize) -> Option<CellStroke> {
let drawn = compose::table::cell_side_strokes(t, owners, (r, c), side).first().map(|s| s.stroke.clone());
drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned()))
}
/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt).
fn first_baseline_params(p: &Value, id: &str) -> Result<(Option<designcraft_doc::FirstBaseline>, Option<f64>)> {
let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?),
None => None,
};
let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) {
Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) {
Some(m) => Some(m),
None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")),
},
None => None,
};
Ok((fb, min))
}
fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.setCell";
let g = target(s, p, ID)?;
let doc = &s.doc()?.doc;
let fill = swatch_in(doc, p, &["fill"], ID)?;
let tint = num_in(p, "tint", 0.0, 1.0, ID)?.map(|t| t as f32);
let insets: Option<[f64; 4]> = match p.get("insets") {
Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]),
Some(Value::Array(a)) if a.len() == 4 => {
let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect();
Some([v[0], v[1], v[2], v[3]])
}
_ => None,
};
let vj = match str_param(p, "vj").or_else(|| str_param(p, "verticalJustification")) {
Some("top") => Some(VerticalJustification::Top),
Some("center") => Some(VerticalJustification::Center),
Some("bottom") => Some(VerticalJustification::Bottom),
Some("justify") => Some(VerticalJustification::Justify),
Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))),
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
// Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
// composed so far, which the IDML import reports (#1468).
let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
_ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
},
None => None,
};
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
None => None,
};
edit_table(s, &g, ID, |t| {
let owners = t.owners();
let nc = t.ncols();
let nr = t.nrows();
let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft;
// Local edits outrank previously imported edges, including explicit zero/None.
let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 };
let rg = g.range;
// Edges are inner or outer within the selection grown to whole merged cells, so a
// merged cell selected through one of its grid positions keeps its own outline.
let outline = t.expand_range(rg);
// Each chosen edge starts from the stroke drawn there, which both copies of a shared
// edge then take, so the edit changes only the attributes it names.
let mut bases = std::collections::HashMap::new();
if let Some((_, edges)) = &stroke {
for (r, c) in t.selected_owners(rg) {
let Some(cell) = t.cell(r, c) else { continue };
for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() {
if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) {
bases.insert((r, c, i), base);
}
}
}
}
let mut edited = std::collections::HashSet::new();
for (r, c) in t.selected_owners(rg) {
let Some(cell) = t.cell_mut(r, c) else { continue };
if let Some(f) = &fill {
cell.fill = f.clone();
}
if let Some(ti) = tint {
cell.fill_tint = ti;
}
if let Some(i) = insets {
cell.insets = i;
}
if let Some(v) = vj {
cell.vj = v;
}
if let Some(v) = first_baseline {
cell.first_baseline = v;
}
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
if let Some(v) = rotation {
cell.rotation = v;
}
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
}
if let Some((edit, _)) = &stroke {
for i in 0..4 {
if let Some(base) = bases.remove(&(r, c, i)) {
edited.insert((r, c, i));
cell.strokes[i] = edit.apply(base);
cell.stroke_defined[i] = true;
cell.stroke_priorities[i] = priority;
cell.border_overrides[i] = true;
}
}
}
}
// Keep both copies of unmerged shared boundaries consistent: each edited edge is
// copied to its neighbour. Left/right name physical sides, also in RTL tables.
let mirror = |t: &mut Table, from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize| {
if edited.contains(&(from.0, from.1, edge)) {
mirror_cell_edge(t, &owners, from, edge, to, opposite);
}
};
if let Some((_, edges)) = &stroke {
if edges.top
&& let Some(row) = rg.r0.checked_sub(1)
{
for c in rg.c0..=rg.c1 {
mirror(t, (rg.r0, c), 0, (row, c), 2);
}
}
if edges.bottom && rg.r1.saturating_add(1) < nr {
for c in rg.c0..=rg.c1 {
mirror(t, (rg.r1, c), 2, (rg.r1 + 1, c), 0);
}
}
if (if rtl { edges.right } else { edges.left })
&& let Some(col) = rg.c0.checked_sub(1)
{
for r in rg.r0..=rg.r1 {
mirror(t, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 });
}
}
if (if rtl { edges.left } else { edges.right }) && rg.c1.saturating_add(1) < nc {
for r in rg.r0..=rg.r1 {
mirror(t, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 });
}
}
}
ok()
})
}
/// The shown stroke attributes of the chosen edges of the target cells, and their fill; values
/// that differ are null.
fn get_cell_stroke(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.getCellStroke";
let g = target(s, p, ID)?;
let edges = EdgeSel::parse(p.get("edges"), ID)?;
let st = s.doc()?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
let owners = t.owners();
let outline = t.expand_range(g.range);
let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft;
// One value per key, or null once two edges (cells) disagree.
fn merge(slot: &mut Option<Value>, v: Value) {
match slot {
None => *slot = Some(v),
Some(cur) if *cur != v => *cur = Value::Null,
_ => {}
}
}
let keys = ["weight", "color", "tint", "type", "gapColor", "gapTint"];
let mut stroke: [Option<Value>; 6] = Default::default();
let (mut fill, mut fill_tint) = (None, None);
// Each drawn edge once: a shared edge is a side of both its cells.
let mut seen = std::collections::HashSet::new();
for (r, c) in t.selected_owners(g.range) {
let Some(cell) = t.cell(r, c) else { continue };
merge(&mut fill, json!(cell.fill));
merge(&mut fill_tint, json!(cell.fill_tint));
for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() {
if !on {
continue;
}
for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) {
let (x, y) = (Some((r, c)), side.neighbor);
if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) {
continue;
}
let e = side.stroke;
let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)];
for (slot, v) in stroke.iter_mut().zip(values) {
merge(slot, v);
}
}
}
}
let mut out = serde_json::Map::new();
for (k, v) in keys.iter().zip(stroke) {
out.insert((*k).into(), v.unwrap_or(Value::Null));
}
out.insert("fill".into(), fill.unwrap_or(Value::Null));
out.insert("fillTint".into(), fill_tint.unwrap_or(Value::Null));
out.insert("edges".into(), json!(seen.len()));
Ok(Value::Object(out))
}
/// Only saturation needs rebasing. Preserve every distinct priority's order and keep
/// zero/negative priorities untouched; the work is bounded by the table's edges.
fn next_cell_stroke_priority(t: &mut Table) -> i32 {
let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0);
if highest < i32::MAX {
return highest + 1;
}
let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect();
let mut ranks = std::collections::BTreeMap::new();
let mut highest = 0i32;
for value in values {
highest = highest.saturating_add(1);
ranks.insert(value, highest);
}
for cell in &mut t.cells {
for priority in &mut cell.stroke_priorities {
if let Some(rank) = ranks.get(priority) {
*priority = *rank;
}
}
}
highest.saturating_add(1)
}
fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) {
let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied();
if owner(from) != Some(from) || owner(to) != Some(to) {
return;
}
// A merged cell's edge may border several separately editable cells. Mirroring
// a segment onto that whole edge would change unselected neighbors; priorities
// instead resolve each segment without altering the merged cell's formatting.
let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1;
if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) {
return;
}
let source = t
.cell(from.0, from.1)
.map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge]));
if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) {
neighbor.strokes[opposite] = stroke;
neighbor.stroke_defined[opposite] = defined;
neighbor.stroke_priorities[opposite] = priority;
neighbor.border_overrides[opposite] = border_override;
}
}
fn set_row_height(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.setRowHeight")?;
let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0));
let mode = match str_param(p, "mode") {
Some("exactly") => Some(RowHeightMode::Exactly),
Some("atLeast") => Some(RowHeightMode::AtLeast),
Some(o) => return Err(bad("table.setRowHeight", format!("unknown mode `{o}`"))),
None => None,
};
if h.is_none() && mode.is_none() {
return Err(bad("table.setRowHeight", "missing height"));
}
edit_table(s, &g, "table.setRowHeight", |t| {
for row in &mut t.rows[g.range.r0..=g.range.r1] {
if let Some(h) = h {
row.height = h;
}
if let Some(m) = mode {
row.mode = m;
}
}
ok()
})
}
fn set_col_width(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.setColumnWidth")?;
let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad("table.setColumnWidth", "missing width"))?.clamp(3.0, 10000.0);
edit_table(s, &g, "table.setColumnWidth", |t| {
for c in &mut t.columns[g.range.c0..=g.range.c1] {
c.width = w;
}
ok()
})
}
/// Smallest column width and row height a border drag leaves.
const MIN_TRACK: f64 = 3.0;
const MAX_TRACK: f64 = 10000.0;
/// Composed height of `row` (laid out from cells that span only that row), else its model height.
fn composed_row_height(cs: &compose::ComposedStory, t: &Table, row: usize) -> Option<f64> {
let laid_out = cs
.frames
.iter()
.flat_map(|f| &f.tables)
.filter(|f| f.table == t.id)
.flat_map(|f| &f.cells)
.filter(|c| c.row == row && t.cell(c.row, c.col).is_some_and(|x| x.row_span <= 1))
.map(|c| c.rect.height())
.filter(|h| h.is_finite() && *h > 0.0)
.reduce(f64::max);
laid_out.or_else(|| t.rows.get(row).map(|r| r.height))
}
/// Move the border after `index` among `sizes` by `delta`. Returns the new sizes.
fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option<Vec<f64>> {
let mut out = sizes.to_vec();
let cur = *sizes.get(index)?;
let last = index + 1 == sizes.len();
if keep && last {
// Scale every track so the table edge moves by `delta`.
let total: f64 = sizes.iter().sum();
let smallest = sizes.iter().copied().fold(f64::INFINITY, f64::min);
let largest = sizes.iter().copied().fold(0.0, f64::max);
if !(total > 0.0 && smallest > 0.0 && largest > 0.0) {
return None;
}
let lo = (MIN_TRACK / smallest).min(1.0);
let hi = (MAX_TRACK / largest).max(1.0);
let f = ((total + delta) / total).clamp(lo, hi);
out.iter_mut().for_each(|v| *v *= f);
} else if keep {
let next = *sizes.get(index + 1)?;
let pair = cur + next;
let a = (cur + delta).clamp(MIN_TRACK, (pair - MIN_TRACK).max(MIN_TRACK));
let [this, after] = out.get_mut(index..index + 2)? else { return None };
*this = a;
*after = (pair - a).max(MIN_TRACK);
} else {
*out.get_mut(index)? = (cur + delta).clamp(MIN_TRACK, MAX_TRACK);
}
out.iter().all(|v| v.is_finite()).then_some(out)
}
/// The frame a border drag names must hold the table's story and be editable.
fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> {
const ID: &str = "table.resizeBorder";
if let Some(fid) = super::id_param(p, "frame") {
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
if it.text_frame().map(|f| f.story) != Some(story) {
return Err(bad(ID, "the table is not in that frame's story"));
}
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(ID, "the frame or its layer is locked"));
}
}
Ok(())
}
/// The change in track size: `delta`, or `size` measured from the current size `cur`.
fn border_delta(p: &Value, cur: f64) -> Result<f64> {
const ID: &str = "table.resizeBorder";
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
(Some(d), _) => d,
(None, Some(sz)) => sz - cur,
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
};
if !delta.is_finite() {
return Err(bad(ID, "`delta`/`size` must be a finite number"));
}
Ok(delta)
}
fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.resizeBorder";
let column = match str_param(p, "axis") {
Some("column") => true,
Some("row") => false,
_ => return Err(bad(ID, "axis must be `column` or `row`")),
};
let index = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(ID, "`index` required"))?;
let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false);
let g = target(s, p, ID)?;
let st = s.doc()?;
check_border_frame(st, p, g.story)?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
let sizes: Vec<f64> = if column {
t.columns.iter().map(|c| c.width).collect()
} else {
let cs = s.cache.get(&st.doc, g.story, None);
(0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect()
};
let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?;
let delta = border_delta(p, cur)?;
let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?;
let size = new.get(index).copied().unwrap_or(cur);
let scaled = keep && index + 1 == sizes.len();
edit_table(s, &g, ID, |t| {
if column {
for (c, w) in t.columns.iter_mut().zip(&new) {
c.width = *w;
}
} else {
for (i, (r, h)) in t.rows.iter_mut().zip(&new).enumerate() {
// Only the rows the drag changed take the new height.
if scaled || i == index || (keep && i == index + 1) {
r.height = *h;
}
if i == index && !scaled {
r.mode = RowHeightMode::AtLeast;
}
}
}
Ok(json!({"axis": if column { "column" } else { "row" }, "index": index, "size": size}))
})
}
fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.distributeRows";
if !p.is_object() {
return Err(bad(ID, "parameters must be an object"));
}
if let Some(rows) = p.get("rows") {
let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())));
if !valid {
return Err(bad(ID, "rows must be two non-negative row indices"));
}
}
for key in ["row", "story", "table"] {
if let Some(v) = p.get(key)
&& v.as_u64().is_none_or(|n| usize::try_from(n).is_err())
{
return Err(bad(ID, format!("{key} must be a non-negative integer")));
}
}
let g = target(s, p, ID)?;
let st = s.doc()?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
if t.cells.iter().any(|c| c.row_span > 1) {
return Err(bad(ID, "unmerge cells that span rows first"));
}
let cs = s.cache.get(&st.doc, g.story, None);
let mut total = 0.0;
for row in g.range.r0..=g.range.r1 {
let height = cs
.frames
.iter()
.flat_map(|f| &f.tables)
.filter(|f| f.table == g.table)
.flat_map(|f| &f.cells)
.filter(|c| c.row == row)
.map(|c| c.rect.height())
.reduce(f64::max)
.ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?;
if !height.is_finite() || height <= 0.0 {
return Err(bad(ID, "invalid composed row height"));
}
total += height;
}
let count = g.range.r1 - g.range.r0 + 1;
let height = total / count as f64;
if !height.is_finite() {
return Err(bad(ID, "invalid total row height"));
}
edit_table(s, &g, ID, |t| {
let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?;
for row in rows {
row.height = height;
row.mode = RowHeightMode::Exactly;
}
Ok(json!({"rows": count, "height": height}))
})
}
fn distribute_cols(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.distributeColumns")?;
edit_table(s, &g, "table.distributeColumns", |t| {
let cols = &mut t.columns[g.range.c0..=g.range.c1];
let avg = cols.iter().map(|c| c.width).sum::<f64>() / cols.len() as f64;
cols.iter_mut().for_each(|c| c.width = avg);
ok()
})
}
fn parse_alt(v: &Value) -> Option<AltFills> {
if v.is_null() {
return None;
}
let d = AltFills::default();
Some(AltFills {
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
})
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.options")?;
let border = match p.get("border").filter(|v| !v.is_null()) {
Some(b) => Some(StrokeEdit::parse(&s.doc()?.doc, b, "table.options")?),
None => None,
};
let p = p.clone();
edit_table(s, &g, "table.options", |t| {
let o = &mut t.options;
if let Some(v) = p.get("direction") {
o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?;
}
if let Some(b) = &border {
o.border = b.apply(o.border.clone());
o.borders = Default::default();
}
o.space_before = f64_or(&p, "spaceBefore", o.space_before);
o.space_after = f64_or(&p, "spaceAfter", o.space_after);
if let Some(b) = p.get("repeatHeader").and_then(Value::as_bool) {
o.repeat_header = b;
}
if let Some(b) = p.get("repeatFooter").and_then(Value::as_bool) {
o.repeat_footer = b;
}
if let Some(v) = p.get("altRows") {
o.alt_rows = parse_alt(v);
}
if let Some(v) = p.get("altCols") {
o.alt_cols = parse_alt(v);
}
// At most 100 of each, as in `table.insert`: the rows the table lacks are added below.
let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize);
let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize);
if h.is_some() || f.is_some() {
let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows()));
// Add rows when there aren't enough for the requested header/footer plus one body row.
let need = h + f + 1;
if t.nrows() < need {
let at = t.nrows();
t.insert_rows(at, need - at);
}
t.set_header_footer(h, f);
}
Ok(json!({"options": t.options}))
})
}
/// Convert Rows is greyed out unless the selected cells (or the caret's cell) can change kind.
fn can_convert_rows(s: &Session, kind: RowKind) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.cells.is_none() {
in_cell(s)?;
}
let g = target(s, &Value::Null, "table.convertRows").map_err(|e| e.to_string())?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or("no table")?;
t.convert_rows_counts(g.range.r0, g.range.r1, kind).map(|_| ())
}
fn convert_rows(s: &mut Session, p: &Value, kind: RowKind) -> Result<Value> {
const ID: &str = "table.convertRows";
let g = target(s, p, ID)?;
edit_table(s, &g, ID, |t| {
let (h, f) = t.convert_rows_counts(g.range.r0, g.range.r1, kind).map_err(|e| bad(ID, e))?;
t.set_header_footer(h, f);
Ok(json!({"headerRows": h, "footerRows": f}))
})
}
// ---------- selection ----------
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.select")?;
let (nr, nc) = {
let st = s.doc()?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.select", "no table"))?;
(t.nrows(), t.ncols())
};
let mut r = g.range;
match str_param(p, "what") {
Some("table") => r = CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 },
Some("row") => {
r.c0 = 0;
r.c1 = nc - 1;
}
Some("column") => {
r.r0 = 0;
r.r1 = nr - 1;
}
_ => {}
}
let st = s.doc_mut()?;
let frame = st.selection.text.and_then(|t| t.frame);
st.selection = Selection {
cells: Some(TableSel { story: g.story, table: g.table, range: r }),
text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }),
..Default::default()
};
st.bump_revision();
Ok(json!({"story": g.story.0, "table": g.table, "range": r}))
}
/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last
/// cell adds a row.
pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.nextCell", "no caret"))?;
let c = t.cell.ok_or_else(|| bad("table.nextCell", "not in a cell"))?;
let (owners, nr, nc) = {
let st = s.doc()?;
let tb = st.doc.story(t.story).and_then(|x| x.tables.get(&c.table)).ok_or_else(|| bad("table.nextCell", "no table"))?;
(tb.owners(), tb.nrows(), tb.ncols())
};
let cur = c.row * nc + c.col;
let next =
if forward { (cur + 1..nr * nc).find(|&i| owners[i] == (i / nc, i % nc)) } else { (0..cur).rev().find(|&i| owners[i] == (i / nc, i % nc)) };
let (row, col) = match next {
Some(i) => (i / nc, i % nc),
None if forward => {
let g = Tgt { story: t.story, table: c.table, range: CellRange::cell(nr - 1, 0) };
edit_table(s, &g, "table.nextCell", |tb| {
tb.insert_rows(nr, 1);
ok()
})?;
(nr, 0)
}
None => return ok(),
};
let st = s.doc_mut()?;
let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len());
st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t });
st.bump_revision();
Ok(json!({"row": row, "col": col}))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.get")?;
let st = s.doc()?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?;
let owners = t.owners();
let cells: Vec<Value> = (0..t.nrows())
.flat_map(|r| (0..t.ncols()).map(move |c| (r, c)))
.filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c))
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
})
.collect();
Ok(json!({
"story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)),
"rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(),
"cells": cells, "options": t.options, "range": g.range, "style": t.style,
}))
}
// ---------- cell and table styles ----------
/// A style's uniform edge stroke from `stroke`/`border`: the given values over the default
/// edge, validated like `table.setCell`'s stroke.
fn stroke_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<CellStroke>> {
match v.filter(|v| !v.is_null()) {
Some(v) => Ok(Some(StrokeEdit::parse(&s.doc()?.doc, v, cmd)?.apply(CellStroke::default()))),
None => Ok(None),
}
}
/// Fill in the cell style fields given in `p`.
fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Option<CellStroke>, cmd: &str) -> Result<()> {
if let Some(v) = p.get("basedOn") {
cs.based_on = v.as_str().map(str::to_string);
}
if let Some(f) = color_param(p, "fill") {
cs.fill = Some(f);
}
if let Some(t) = p.get("tint").and_then(Value::as_f64) {
cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32);
}
match p.get("insets") {
Some(Value::Number(n)) => {
cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]);
cs.inset_overrides = Default::default();
}
Some(Value::Array(a)) if a.len() == 4 => {
let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect();
cs.insets = Some([v[0], v[1], v[2], v[3]]);
cs.inset_overrides = Default::default();
}
_ => {}
}
if let Some(v) = p.get("vj") {
cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?);
}
let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?;
if first_baseline.is_some() {
cs.first_baseline = first_baseline;
}
if first_baseline_min.is_some() {
cs.first_baseline_min = first_baseline_min;
}
if let Some(st) = stroke {
cs.stroke = Some(st);
cs.strokes = Default::default();
}
if let Some(ps) = str_param(p, "paragraphStyle") {
cs.paragraph_style = Some(ps.to_string());
}
Ok(())
}
fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Cell Style 1").to_string();
let mut cs = designcraft_doc::CellStyle::default();
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) {
let g = target(s, p, "style.cell.create")?;
let d = &s.doc()?.doc;
let cell = d
.story(g.story)
.and_then(|st| st.tables.get(&g.table))
.and_then(|t| {
let (r, c) = t.owner(g.range.r0, g.range.c0);
t.cell(r, c)
})
.ok_or_else(|| bad("style.cell.create", "no cell"))?;
cs.fill = Some(cell.fill.clone());
cs.fill_tint = Some(cell.fill_tint);
cs.insets = Some(cell.insets);
cs.vj = Some(cell.vj);
cs.first_baseline = Some(cell.first_baseline);
cs.first_baseline_min = Some(cell.first_baseline_min);
cs.stroke = Some(cell.strokes[0].clone());
cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone());
}
let stroke = stroke_param(s, p.get("stroke"), "style.cell.create")?;
cell_style_fields(&mut cs, p, stroke, "style.cell.create")?;
s.edit(|d, _| {
let name = designcraft_doc::Styles::unique_name(|n| d.styles.cell.iter().any(|c| c.name == n), &base);
cs.name = name.clone();
d.styles_mut().cell.push(cs);
Ok(json!({"name": name}))
})
}
fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string();
let styles = &s.doc()?.doc.styles.cell;
if !styles.iter().any(|c| c.name == name) {
return Err(bad("style.cell.apply", format!("no cell style `{name}`")));
}
let cs = designcraft_doc::resolve_cell_style(styles, &name);
let g = target(s, p, "style.cell.apply")?;
edit_table(s, &g, "style.cell.apply", |t| {
let mut n = 0;
for (r, c) in t.selected_owners(g.range) {
if let Some(cell) = t.cell_mut(r, c) {
cs.apply_to(cell);
n += 1;
}
}
Ok(json!({"cells": n}))
})
}
/// Re-apply cell styles named in `names` to every cell using them (after a style edit).
fn reapply_cell_styles(d: &mut Document, names: &[String]) {
let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect();
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(st) = d.story_mut(sid) else { continue };
for t in st.tables.values_mut() {
if !t.cells.iter().any(|c| names.contains(&c.style)) {
continue;
}
for cell in &mut std::sync::Arc::make_mut(t).cells {
if names.contains(&cell.style)
&& let Some(cs) = styles.iter().find(|x| x.name == cell.style)
{
cs.apply_to(cell);
}
}
}
st.rev = st.rev.wrapping_add(1);
}
}
/// Include descendants when editing an inherited style, with the resolver's depth limit.
fn dependent_styles<'a>(name: &str, styles: impl Iterator<Item = (&'a str, Option<&'a str>)>) -> Vec<String> {
let styles: Vec<_> = styles.collect();
styles
.iter()
.filter(|(candidate, _)| {
let mut current = Some(*candidate);
for _ in 0..32 {
let Some(current_name) = current else { return false };
if current_name == name {
return true;
}
current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base);
}
false
})
.map(|(name, _)| (*name).to_string())
.collect()
}
fn cell_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string();
let stroke = stroke_param(s, p.get("stroke"), "style.cell.edit")?;
let p = p.clone();
s.edit(|d, _| {
let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?;
cell_style_fields(cs, &p, stroke, "style.cell.edit")?;
let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref())));
reapply_cell_styles(d, &names);
ok()
})
}
fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: Option<CellStroke>) {
if let Some(v) = p.get("basedOn") {
ts.based_on = v.as_str().map(str::to_string);
}
for (k, f) in [
("header", &mut ts.header),
("body", &mut ts.body),
("footer", &mut ts.footer),
("leftColumn", &mut ts.left_column),
("rightColumn", &mut ts.right_column),
] {
if let Some(v) = p.get(k) {
*f = v.as_str().map(str::to_string);
}
}
if let Some(b) = border {
ts.border = Some(b);
ts.borders = Default::default();
}
if let Some(a) = p.get("altRows").and_then(Value::as_object) {
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX));
let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string();
let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32);
ts.alt_rows = Some(designcraft_doc::AltFills {
first: n("first", 1),
first_color: c("firstColor"),
first_tint: t("firstTint"),
next: n("next", 1),
next_color: c("nextColor"),
next_tint: t("nextTint"),
skip_first: n("skipFirst", 0),
skip_last: n("skipLast", 0),
});
}
if let Some(v) = p.get("spaceBefore").and_then(Value::as_f64) {
ts.space_before = Some(v);
}
if let Some(v) = p.get("spaceAfter").and_then(Value::as_f64) {
ts.space_after = Some(v);
}
}
fn table_style_create(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Table Style 1").to_string();
let mut ts = designcraft_doc::TableStyle::default();
let border = stroke_param(s, p.get("border"), "style.table.create")?;
table_style_fields(&mut ts, p, border);
s.edit(|d, _| {
let name = designcraft_doc::Styles::unique_name(|n| d.styles.table.iter().any(|c| c.name == n), &base);
ts.name = name.clone();
d.styles_mut().table.push(ts);
Ok(json!({"name": name}))
})
}
fn table_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string();
let doc = &s.doc()?.doc;
if !doc.styles.table.iter().any(|c| c.name == name) {
return Err(bad("style.table.apply", format!("no table style `{name}`")));
}
let ts = doc.styles.resolve_table_style(&name);
let cells = doc.styles.cell.clone();
let g = target(s, p, "style.table.apply")?;
edit_table(s, &g, "style.table.apply", |t| {
ts.apply_to(t, &cells);
ok()
})
}
fn table_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string();
let border = stroke_param(s, p.get("border"), "style.table.edit")?;
let p = p.clone();
s.edit(|d, _| {
let ts =
d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?;
table_style_fields(ts, &p, border);
let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref())));
let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect();
let cells = d.styles.cell.clone();
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(st) = d.story_mut(sid) else { continue };
for t in st.tables.values_mut() {
if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) {
ts.apply_to(std::sync::Arc::make_mut(t), &cells);
}
}
st.rev = st.rev.wrapping_add(1);
}
ok()
})
}
#[cfg(test)]
mod style_tests {
use serde_json::json;
use crate::Session;
#[test]
fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap();
let got = s.execute("table.get", &json!({})).unwrap();
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
// Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
// which the IDML import reports (#1468).
for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
}
for bad in [45, 1, 100] {
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
}
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
let d = &s.doc().unwrap().doc;
let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap();
assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading);
assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent);
}
#[test]
fn cell_and_table_styles_apply_and_follow_edits() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Cell Head"})).unwrap();
s.execute("style.cell.create", &json!({"name": "Head", "fill": "[Black]", "paragraphStyle": "Cell Head", "insets": 6})).unwrap();
s.execute("style.cell.create", &json!({"name": "Body", "fill": "[Paper]"})).unwrap();
s.execute("style.table.create", &json!({"name": "Data", "header": "Head", "body": "Body", "border": {"weight": 2, "color": "[Black]"}}))
.unwrap();
s.execute("style.table.apply", &json!({"name": "Data"})).unwrap();
let t = |s: &Session| -> designcraft_doc::Table {
let d = &s.doc().unwrap().doc;
(**d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap()).clone()
};
let tb = t(&s);
assert_eq!(tb.style, "Data");
assert_eq!(tb.options.border.weight, 2.0);
let head = tb.cell(0, 0).unwrap();
assert_eq!((head.fill.as_str(), head.style.as_str(), head.insets), ("[Black]", "Head", [6.0; 4]));
assert_eq!(head.text.paras[0].style, "Cell Head");
assert_eq!(tb.cell(1, 1).unwrap().style, "Body");
// Editing the cell style updates its cells.
s.execute("style.cell.edit", &json!({"name": "Head", "fill": "C=100 M=0 Y=0 K=0"})).unwrap();
assert_eq!(t(&s).cell(0, 1).unwrap().fill, "C=100 M=0 Y=0 K=0");
// Editing the table style re-applies it.
s.execute("style.table.edit", &json!({"name": "Data", "border": {"weight": 4}})).unwrap();
assert_eq!(t(&s).options.border.weight, 4.0);
assert!(s.execute("style.cell.apply", &json!({"name": "Nope"})).is_err());
}
}
/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col).
fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> {
let st = s.active()?;
let loc = st.doc.find(frame)?;
let it = st.doc.item_at(&loc)?;
let sid = it.text_frame()?.story;
let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt;
let cs = s.cache.get(&st.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?;
Some((sid, table, row, col))
}
fn drop_cells(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.dropCells";
let frame = super::id_param(p, "frame").ok_or_else(|| bad(ID, "`frame` required"))?;
let pt = |k: &str| -> Result<designcraft_geom::Point> {
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(ID, format!("`{k}` required")))?;
Ok(designcraft_geom::Point::new(a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
};
let (from, to) = (pt("from")?, pt("to")?);
let sel = s.doc()?.selection.cells;
let hit_from = cell_at(s, frame, from);
let hit_to = cell_at(s, frame, to);
if let (Some(ts), Some((sid, tid, fr, fc)), Some((_, tid2, tr, tc))) = (sel, hit_from, hit_to)
&& ts.story == sid
&& ts.table == tid
&& tid2 == tid
&& s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok())
{
let (nr, nc) = {
let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?;
(t.nrows(), t.ncols())
};
let r = ts.range;
let rows = r.c0 == 0 && r.c1 + 1 == nc;
let cols = r.r0 == 0 && r.r1 + 1 == nr;
if rows || cols {
let (lo, hi, target) = if rows { (r.r0, r.r1, tr) } else { (r.c0, r.c1, tc) };
if target >= lo && target <= hi {
return Ok(json!({"moved": 0}));
}
let n = hi - lo + 1;
let r2 = s.edit(|d, selm| {
let t = d.story_mut(sid).and_then(|st| st.table_mut(tid)).ok_or_else(|| bad(ID, "no table"))?;
let mv = |t: &mut designcraft_doc::Table, a: usize, b: usize| if rows { t.move_row(a, b) } else { t.move_col(a, b) };
let new_lo = if target > hi {
for _ in 0..n {
mv(t, lo, target).map_err(|e| bad(ID, e))?;
}
target + 1 - n
} else {
for k in 0..n {
mv(t, lo + k, target + k).map_err(|e| bad(ID, e))?;
}
target
};
let range =
if rows { CellRange::new(new_lo, r.c0, new_lo + n - 1, r.c1) } else { CellRange::new(r.r0, new_lo, r.r1, new_lo + n - 1) };
selm.cells = Some(TableSel { range, ..ts });
Ok(json!({"moved": n, "to": new_lo}))
})?;
return Ok(r2);
}
}
// Not a row/column drag: select text from the press to the release.
s.execute("text.placeCaret", &json!({"frame": frame.0, "point": [from.x, from.y]}))?;
s.execute("text.extendTo", &json!({"frame": frame.0, "point": [to.x, to.y]}))
}
fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.placeGraphic";
let g = target(s, p, ID)?;
let (bytes, name, link) = super::file::read_source(p)?;
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?;
let (nw, nh) = designcraft_render::natural_size(&bytes, 0).ok_or_else(|| bad(ID, "invalid image dimensions"))?;
let fill = p.get("fit").and_then(Value::as_str) == Some("fill");
// Compute fitting before the edit: a small EPS can overflow in a huge cell.
let t = s.doc()?.doc.story(g.story).and_then(|st| st.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
let (r, c) = t.owner(g.range.r0, g.range.c0);
let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?;
let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize);
let w: f64 = t.columns.get(c..c.saturating_add(cs).min(t.ncols())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.width).sum();
let h: f64 = t.rows.get(r..r.saturating_add(rs).min(t.nrows())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.height).sum();
let ins = cell.insets;
let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0));
let k = if fill { (bw / nw).max(bh / nh) } else { (bw / nw).min(bh / nh) };
let xf = designcraft_geom::Affine::translate(((bw - nw * k) / 2.0, (bh - nh * k) / 2.0)) * designcraft_geom::Affine::scale(k);
super::file::validate_placement((nw, nh), designcraft_geom::Rect::new(0.0, 0.0, bw, bh), xf, ID)?;
s.edit(|d, sel| {
let fresh = designcraft_doc::AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
let aid = d.add_asset(designcraft_doc::Asset {
id: fresh,
name,
mime,
link,
data: std::sync::Arc::new(bytes),
pixels: Some((pw, ph)),
..Default::default()
});
let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?;
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?;
cell.graphic = Some(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (nw, nh),
xf,
clip: None,
auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally },
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
clamp_selection(d, sel);
Ok(json!({"asset": aid.0, "row": r, "col": c}))
})
}
fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
const ID: &str = "table.move";
let g = target(s, p, ID)?;
let from = p.get("from").and_then(Value::as_u64).map_or(if rows { g.range.r0 } else { g.range.c0 }, |v| v as usize);
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(ID, "`to` required"))? as usize;
edit_table(s, &g, ID, |t| {
if rows { t.move_row(from, to) } else { t.move_col(from, to) }.map_err(|e| bad(ID, e))?;
Ok(json!({"from": from, "to": to}))
})
}
fn sort_rows(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.sortRows")?;
let col = p.get("column").and_then(Value::as_u64).map_or(g.range.c0, |c| c as usize);
let desc = p.get("descending").and_then(Value::as_bool).unwrap_or(false);
edit_table(s, &g, "table.sortRows", |t| {
let (nr, nc) = (t.nrows(), t.ncols());
if col >= nc {
return Err(bad("table.sortRows", format!("no column {col}")));
}
if t.cells.iter().any(|c| c.row_span > 1) {
return Err(bad("table.sortRows", "unmerge cells that span rows first"));
}
let (h, f) = (t.header_rows(), t.footer_rows());
let body: Vec<usize> = (h..nr - f).collect();
let key = |r: usize| t.cell(r, col).map(|c| c.text.text.trim().to_string()).unwrap_or_default();
let mut order = body.clone();
order.sort_by(|a, b| {
let (ka, kb) = (key(*a), key(*b));
let o = match (ka.replace(',', "").parse::<f64>(), kb.replace(',', "").parse::<f64>()) {
(Ok(x), Ok(y)) => x.total_cmp(&y),
_ => ka.to_lowercase().cmp(&kb.to_lowercase()),
};
if desc { o.reverse() } else { o }
});
let rows = t.rows.clone();
let cells = t.cells.clone();
for (k, src) in order.iter().enumerate() {
let dst = body[k];
t.rows[dst] = rows[*src].clone();
for c in 0..nc {
t.cells[dst * nc + c] = cells[src * nc + c].clone();
}
}
Ok(json!({"sorted": body.len()}))
})
}
#[cfg(test)]
mod sort_tests {
use serde_json::json;
use crate::Session;
fn distribution_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] {
s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap();
s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap();
}
s
}
fn table(s: &Session) -> designcraft_doc::Table {
(**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone()
}
#[test]
fn distribute_selected_rows_preserves_height_content_and_undo() {
let mut s = distribution_session();
s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap();
let before = table(&s);
let selection = s.doc().unwrap().selection.cells;
let result = s.execute("table.distributeRows", &json!({})).unwrap();
assert_eq!(result, json!({"rows": 2, "height": 60.0}));
let after = table(&s);
assert_eq!(after.rows.iter().map(|r| r.height).collect::<Vec<_>>(), [25.0, 60.0, 60.0, 30.0]);
assert_eq!(after.cells, before.cells);
assert_eq!(after.rows[0].kind, before.rows[0].kind);
assert_eq!(s.doc().unwrap().selection.cells, selection);
let st = s.doc().unwrap();
let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id;
let cs = s.cache.get(&st.doc, sid, None);
let heights: Vec<_> =
cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect();
assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table(&s), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(table(&s), after);
}
#[test]
fn distribution_uses_content_grown_heights_and_explicit_table() {
let mut s = distribution_session();
s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap();
s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap();
let st = s.doc().unwrap();
let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap();
let (sid, tid) = (story.id, *story.tables.keys().next().unwrap());
let cs = s.cache.get(&st.doc, sid, None);
let total: f64 = cs
.frames
.iter()
.flat_map(|f| &f.tables)
.flat_map(|f| &f.cells)
.filter(|c| c.col == 0 && (1..=2).contains(&c.row))
.map(|c| c.rect.height())
.sum();
let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap();
assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6);
assert!(table(&s).rows[1].height > 3.0);
assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly);
}
#[test]
fn distribution_keeps_column_spans_and_caret() {
let mut s = distribution_session();
s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap();
let before = table(&s);
let caret = s.doc().unwrap().selection.text;
let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap();
assert_eq!(result["height"], 60.0);
assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2);
assert_eq!(table(&s).cells, before.cells);
assert_eq!(s.doc().unwrap().selection.text, caret);
}
#[test]
fn distribution_rejects_merges_and_missing_rows_without_changes() {
let mut s = distribution_session();
s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap();
let before = table(&s);
assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err());
assert_eq!(table(&s), before);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap();
let before = table(&s);
assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err());
assert_eq!(table(&s), before);
assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err());
for params in
[json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])]
{
assert!(s.execute("table.distributeRows", &params).is_err(), "{params}");
assert_eq!(table(&s), before);
}
}
#[test]
fn sort_body_rows_by_a_column() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
let cells = [("Name", "Qty"), ("pear", "10"), ("Apple", "9"), ("fig", "100")];
for (r, (a, b)) in cells.iter().enumerate() {
s.execute("table.setCell", &json!({"rows": [r, r], "cols": [0, 0], "text": a})).unwrap();
s.execute("table.setCell", &json!({"rows": [r, r], "cols": [1, 1], "text": b})).unwrap();
}
let col = |s: &Session, c: usize| -> Vec<String> {
let d = &s.doc().unwrap().doc;
let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
(0..t.nrows()).map(|r| t.cell(r, c).unwrap().text.text.clone()).collect()
};
s.execute("table.sortRows", &json!({"column": 0})).unwrap();
assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive");
s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap();
assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value");
s.execute("table.splitVertically", &json!({"rows": [1, 1], "cols": [0, 0]})).unwrap();
let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap();
assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3)));
}
#[test]
fn graphic_cells_draw_their_image() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
s.execute("table.rowHeight", &json!({"height": 100})).ok();
// A red image into the first cell.
let px: Vec<u8> = (0..40 * 40).flat_map(|_| [255, 0, 0, 255]).collect();
let png = designcraft_render::Rendered { width: 40, height: 40, pixels: px }.to_png();
s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": ""})).ok();
let g = s.execute("table.placeGraphic", &json!({"base64": super::super::file::base64_encode(&png), "name": "r.png", "fit": "fill"})).unwrap();
assert_eq!((g["row"].as_u64(), g["col"].as_u64()), (Some(0), Some(0)));
let d = s.doc().unwrap().doc.clone();
let st = d.stories.values().find(|st| !st.tables.is_empty()).unwrap();
let t = st.tables.values().next().unwrap();
let cs = s.cache.get(&d, st.id, None);
let cell = cs.frames[0].tables[0].cells.iter().find(|c| c.row == 0 && c.col == 0).unwrap().clone();
let fx = d.item(cs.frames[0].frame).unwrap().xf;
let centre = fx * cell.clip.center();
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
let p = img.pixel(centre.x as u32, centre.y as u32);
assert!(p[0] > 200 && p[1] < 60, "red in the graphic cell: {p:?}");
assert!(t.cell(0, 0).unwrap().graphic.is_some());
s.execute("table.textCell", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.stories.values().flat_map(|st| st.tables.values()).all(|t| t.cell(0, 0).unwrap().graphic.is_none()));
}
#[test]
fn move_rows_and_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
for (r, c, t) in [(0, 0, "a"), (1, 0, "b"), (2, 0, "c"), (0, 1, "x")] {
s.execute("table.setCell", &json!({"rows": [r, r], "cols": [c, c], "text": t})).unwrap();
}
let cell = |s: &Session, r: usize, c: usize| {
let d = &s.doc().unwrap().doc;
let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
t.cell(r, c).unwrap().text.text.clone()
};
// Dragging a selected row onto the last row moves it there.
let d = s.doc().unwrap().doc.clone();
let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap();
let fid = d.stories[&sid].frames[0];
let cs = s.cache.get(&d, sid, None);
let cells = &cs.frames[0].tables[0].cells;
let centre = |r: usize| {
let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center();
d.text_xf(d.item(fid).unwrap()) * c
};
let (from, to) = (centre(0), centre(2));
s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap();
let r2 = s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap();
assert_eq!(r2["moved"], 1);
assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["a", "b", "c"]);
s.execute("table.moveRow", &json!({"from": 0, "to": 2})).unwrap();
assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]);
s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap();
assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string()));
}
#[test]
fn move_row_cannot_move_a_header_row_out_of_place() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err());
s.doc().unwrap().doc.check().unwrap();
// Dragging the selected header row onto the last body row is refused too.
let d = s.doc().unwrap().doc.clone();
let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap();
let fid = d.stories[&sid].frames[0];
let cs = s.cache.get(&d, sid, None);
let cells = &cs.frames[0].tables[0].cells;
let centre = |r: usize| {
let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center();
d.text_xf(d.item(fid).unwrap()) * c
};
let (from, to) = (centre(0), centre(2));
s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap();
assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn table_options_limits_header_and_footer_rows() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
// The limit `table.insert` has: the table used to grow by as many rows as were asked for.
s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap();
let t = table(&s);
assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201));
}
}
#[cfg(test)]
mod arabic_tests {
use super::*;
#[test]
fn arabic_table_direction_and_physical_left_insertion_support_undo() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 400, 300], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": "Right"})).unwrap();
s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap();
s.execute("table.select", &json!({"rows": [0, 0], "cols": [0, 0]})).unwrap();
s.execute("table.insertColumnLeft", &json!({})).unwrap();
let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
assert_eq!(t.ncols(), 3);
assert_eq!(t.cell(0, 0).unwrap().text.text, "Right");
assert!(t.cell(0, 1).unwrap().text.text.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
assert_eq!(t.ncols(), 2);
assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft);
}
/// A text frame whose paragraphs have a paragraph style with `direction`, with the story's
/// text selected. Returns the session and the story id.
fn styled_frame(direction: &str, text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
(s, r["story"].as_u64().unwrap())
}
fn only_table(s: &Session) -> std::sync::Arc<designcraft_doc::Table> {
s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone()
}
/// Text converted to a table in a right-to-left paragraph laid its columns out left to right
/// (#100): the first column belongs on the right.
#[test]
fn tables_take_the_direction_of_their_paragraph() {
use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج");
s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
assert_eq!(only_table(&s).options.direction, RightToLeft);
let st = s.doc().unwrap();
let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None);
let placed = &cs.frames[0].tables[0];
let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect);
assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}");
let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc");
s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
assert_eq!(only_table(&s).options.direction, LeftToRight);
for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] {
let (mut s, _) = styled_frame(direction, "x");
s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph");
}
}
}
#[cfg(test)]
mod resize_border_tests {
use serde_json::json;
use crate::Session;
/// A 3×3 table of 80 pt columns; returns the session, frame id, story and table ids.
fn session() -> (Session, u64, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 600], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
let t = s.execute("table.insert", &json!({"rows": 3, "cols": 3, "width": 240})).unwrap();
(s, r["id"].as_u64().unwrap(), t["story"].as_u64().unwrap(), t["table"].as_u64().unwrap())
}
fn table(s: &Session) -> designcraft_doc::Table {
(**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone()
}
fn widths(s: &Session) -> Vec<f64> {
table(s).columns.iter().map(|c| c.width).collect()
}
/// Composed cell rects of column 0, top to bottom.
fn rows(s: &Session) -> Vec<designcraft_geom::Rect> {
let st = s.doc().unwrap();
let sid = st.doc.stories.values().find(|x| !x.tables.is_empty()).unwrap().id;
let cs = s.cache.get(&st.doc, sid, None);
cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect).collect()
}
fn resize(s: &mut Session, ids: (u64, u64, u64), mut p: serde_json::Value) {
p["frame"] = json!(ids.0);
p["story"] = json!(ids.1);
p["table"] = json!(ids.2);
s.execute("table.resizeBorder", &p).unwrap();
}
#[test]
fn column_border_drags_widen_the_table_or_keep_its_width() {
let (mut s, f, st, t) = session();
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": 20}));
assert_eq!(widths(&s), [100.0, 80.0, 80.0]);
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 30, "keepTableSize": true}));
assert_eq!(widths(&s), [100.0, 110.0, 50.0]);
// Clamped at the minimum; the neighbour takes only what is left.
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 500, "keepTableSize": true}));
assert_eq!(widths(&s), [100.0, 157.0, 3.0]);
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": -1000}));
assert_eq!(widths(&s), [3.0, 157.0, 3.0]);
// Shift on the end edge scales every column.
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 2, "size": 166, "keepTableSize": true}));
let w = widths(&s);
assert!((w.iter().sum::<f64>() - 326.0).abs() < 1e-9, "{w:?}");
assert!((w[1] / w[0] - 157.0 / 3.0).abs() < 1e-9, "{w:?}");
assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 3, "delta": 1})).is_err());
assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 0})).is_err());
}
#[test]
fn row_drag_sets_at_least_and_moves_later_rows() {
let (mut s, f, st, t) = session();
s.execute("table.setRowHeight", &json!({"table": t, "row": 0, "height": 30, "mode": "exactly"})).unwrap();
let before = rows(&s);
resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": 12}));
let row = &table(&s).rows[0];
assert_eq!((row.height, row.mode), (42.0, designcraft_doc::RowHeightMode::AtLeast));
let after = rows(&s);
assert!((after[0].height() - 42.0).abs() < 1e-9);
assert!((after[1].y0 - before[1].y0 - 12.0).abs() < 1e-9 && (after[1].height() - before[1].height()).abs() < 1e-9);
// Below the minimum: clamped, never negative.
resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": -1000}));
assert_eq!(table(&s).rows[0].height, 3.0);
}
#[test]
fn a_drag_is_one_undo_step_and_locked_frames_refuse() {
let (mut s, f, st, t) = session();
let before = table(&s);
s.begin_interaction("Resize Column").unwrap();
for d in [5.0, 15.0, 25.0] {
let p = json!({"frame": f, "story": st, "table": t, "axis": "column", "index": 0, "delta": d});
s.preview("table.resizeBorder", p).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(widths(&s), [105.0, 80.0, 80.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table(&s), before);
s.execute("object.setFlags", &json!({"ids": [f], "locked": true})).unwrap();
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
}
}
#[cfg(test)]
mod style_alt_rows_tests {
use serde_json::json;
use super::table_style_fields;
#[test]
fn style_alt_rows_counts_saturate() {
let mut ts = designcraft_doc::TableStyle::default();
let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}});
table_style_fields(&mut ts, &p, None);
let a = ts.alt_rows.unwrap();
assert_eq!(a.first, u32::MAX, "first");
assert_eq!(a.next, u32::MAX, "next");
assert_eq!(a.skip_first, u32::MAX, "skip_first");
assert_eq!(a.skip_last, 3, "skip_last");
}
}