//! 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::{ AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, 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 { let mut v = style_specs(); v.extend(table_specs()); v } fn style_specs() -> Vec { 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, stroke?: {weight, color, tint?}, paragraphStyle?} → {name}", has_doc, |s, p| cell_style_create(s, p) ), cmd!( "style.cell.apply", "Apply Cell Style", [], None, "{name, story?, table?} — 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, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight, color}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", has_doc, table_style_create ), cmd!("style.table.apply", "Apply Table Style", [], None, "{name, story?, table?}", 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 { vec![ cmd!( "table.insert", "Create Table", [], Some("Cmd+Alt+Shift+T"), "{rows?: body rows (4), cols? (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} — selected paragraphs become rows", has_text, convert_from_text ), cmd!( "table.convertToText", "Convert Table to Text", ["Table"], None, "{} — cells separated by tabs, rows by paragraphs", in_table, convert_to_text ), cmd!("table.insertRow", "Insert Row", [], None, "{where?: above|below, count? (1)}", in_table, |s, p| insert_row(s, p, None)), cmd!("table.insertRowAbove", "Insert Row Above", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(true))), cmd!("table.insertRowBelow", "Insert Row Below", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(false))), cmd!("table.insertColumn", "Insert Column", [], None, "{where?: left|right, count? (1), width?}", in_table, |s, p| insert_col(s, p, None)), cmd!("table.insertColumnLeft", "Insert Column Left", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(true))), cmd!("table.insertColumnRight", "Insert Column Right", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(false))), cmd!("table.deleteRow", "Delete Row", ["Table", "Delete"], None, "{} — the rows of the target cells", in_table, delete_rows), cmd!("table.deleteColumn", "Delete Column", ["Table", "Delete"], None, "{} — the columns of the target cells", in_table, delete_cols), cmd!("table.delete", "Delete Table", ["Table", "Delete"], None, "{}", in_table, delete_table), cmd!("table.merge", "Merge Cells", ["Table"], None, "{} — 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, fit?: proportional|fill (default proportional)} — 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, "{} — 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 in g.range.r0..=g.range.r1 { for c in g.range.c0..=g.range.c1 { if let Some(cell) = t.cell_mut(r, c) { cell.graphic = None; } } } ok() }) } ), cmd!( "table.moveRow", "Move Row", [], None, "{from?, to} (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} (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} — body rows by that column's text (numbers numerically)", in_table, sort_rows ), cmd!("table.unmerge", "Unmerge Cells", ["Table"], None, "{}", in_table, unmerge), cmd!( "table.splitHorizontally", "Split Cell Horizontally", ["Table"], None, "{} — the target cell becomes two, one above the other", in_table, |s, p| split(s, p, true) ), cmd!( "table.splitVertically", "Split Cell Vertically", ["Table"], None, "{} — the target cell becomes two, side by side", in_table, |s, p| split(s, p, false) ), cmd!( "table.setCell", "Cell Options", [], None, "{fill?: swatch, tint?, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?, tint?, type?: solid|dashed|dotted, edges?: all|outer|inner|top|left|bottom|right}}", in_table, set_cell ), cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height), cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width), cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols), cmd!( "table.distributeRows", "Distribute Rows Evenly", ["Table"], None, "{story?, table?, rows?: [a,b]} — 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?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}", in_table, options ), cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b], cols?: [a,b], what?: cell|row|column|table}", in_table_or_ids, select), cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{}", in_table, |s, p| { select(s, &super::with_param(p, "what", json!("table"))) }), cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{}", in_table, |s, p| { select(s, &super::with_param(p, "what", json!("row"))) }), cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{}", 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, 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 { 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 { 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(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result) -> Result { 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 len = tb.cell(r, col).map_or(0, |x| x.text.len()); sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t }); } None => { let len = story.map_or(0, |x| x.len()); sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), 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 { 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 { 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 { 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> = 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 { 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 { 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) -> Result { 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) -> Result { 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 { 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 { 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 { 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 { let g = target(s, p, "table.split")?; edit_table(s, &g, "table.split", |t| { t.split_cell(g.range.r0, g.range.c0, horizontal); Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) }) } fn unmerge(s: &mut Session, p: &Value) -> Result { 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() }) } fn parse_stroke(v: &Value, mut base: CellStroke) -> CellStroke { if let Some(w) = v.get("weight").and_then(Value::as_f64) { base.weight = w.max(0.0); } if let Some(c) = color_param(v, "color") { base.color = c; } if let Some(t) = v.get("tint").and_then(Value::as_f64) { base.tint = t.clamp(0.0, 1.0) as f32; } match str_param(v, "type") { Some("dashed") => base.kind = StrokeType::Dashed { pattern: vec![base.weight.max(1.0) * 3.0, base.weight.max(1.0) * 2.0] }, Some("dotted") => base.kind = StrokeType::Dotted, Some("solid") => base.kind = StrokeType::Solid, _ => {} } base } fn set_cell(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.setCell")?; let fill = color_param(p, "fill"); let tint = p.get("tint").and_then(Value::as_f64).map(|t| t.clamp(0.0, 1.0) 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 = 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("table.setCell", format!("unknown vj `{o}`"))), None => None, }; let text = str_param(p, "text").map(str::to_string); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); let nc = t.ncols(); let nr = t.nrows(); let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; let borders: [CellStroke; 4] = std::array::from_fn(|side| t.options.border_for(side).clone()); // 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; for r in rg.r0..=rg.r1 { for c in rg.c0..=rg.c1 { if owners[r * nc + c] != (r, c) { continue; } 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(tx) = &text { let len = cell.text.len(); cell.text.replace(0..len, tx); } if let Some(sv) = &stroke { let edges = str_param(sv, "edges").unwrap_or("all"); let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); let at_right = c.saturating_add(cell.col_span.max(1) as usize); let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; for (i, on) in [ ("top", r == rg.r0), ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), ("bottom", at_bottom.saturating_sub(1) == rg.r1), ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), ] .iter() .enumerate() .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) { if on { let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; cell.strokes[i] = parse_stroke(sv, base.clone()); cell.stroke_defined[i] = true; cell.stroke_priorities[i] = priority; cell.border_overrides[i] = true; } } } } } // Keep both copies of unmerged shared boundaries consistent. Left/right name // physical sides, also in RTL tables. if let Some(sv) = &stroke { let edges = str_param(sv, "edges").unwrap_or("all"); let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; if selected("top") && let Some(row) = rg.r0.checked_sub(1) { for c in rg.c0..=rg.c1 { mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2); } } if selected("bottom") && rg.r1.saturating_add(1) < nr { for c in rg.c0..=rg.c1 { mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0); } } if selected(if rtl { "right" } else { "left" }) && let Some(col) = rg.c0.checked_sub(1) { for r in rg.r0..=rg.r1 { mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); } } if selected(if rtl { "left" } else { "right" }) && rg.c1.saturating_add(1) < nc { for r in rg.r0..=rg.r1 { mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); } } } ok() }) } /// 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 { 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 { 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() }) } fn distribute_rows(s: &mut Session, p: &Value) -> Result { 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 { 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::() / cols.len() as f64; cols.iter_mut().for_each(|c| c.width = avg); ok() }) } fn parse_alt(v: &Value) -> Option { 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| x as u32), 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| x as u32), 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| x as u32), skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32), }) } fn options(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.options")?; 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) = p.get("border") { o.border = parse_stroke(b, 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); } let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize); let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v 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})) }) } // ---------- selection ---------- fn select(s: &mut Session, p: &Value) -> Result { 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.revision += 1; 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 { 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.revision += 1; Ok(json!({"row": row, "col": col})) } fn get(s: &mut Session, p: &Value) -> Result { 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 = (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, "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 ---------- fn stroke_param(v: Option<&Value>) -> Option { let o = v?.as_object()?; let mut st = designcraft_doc::CellStroke::default(); if let Some(w) = o.get("weight").and_then(Value::as_f64) { st.weight = w.max(0.0); } if let Some(c) = o.get("color").and_then(Value::as_str) { st.color = c.to_string(); } if let Some(t) = o.get("tint").and_then(Value::as_f64) { st.tint = t.clamp(0.0, 1.0) as f32; } Some(st) } /// Fill in the cell style fields given in `p`. fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, 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 = 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}")))?); } if let Some(st) = stroke_param(p.get("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 { 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| t.cell(g.range.r0, g.range.c0)) .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.stroke = Some(cell.strokes[0].clone()); cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); } cell_style_fields(&mut cs, p, "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 { 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 owners = t.owners(); let nc = t.ncols(); let mut n = 0; for r in g.range.r0..=g.range.r1 { for c in g.range.c0..=g.range.c1 { if owners[r * nc + c] == (r, c) && 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::>() { 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 += 1; } } /// Include descendants when editing an inherited style, with the resolver's depth limit. fn dependent_styles<'a>(name: &str, styles: impl Iterator)>) -> Vec { 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 { let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); 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, "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) { 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) = stroke_param(p.get("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| v as u32); 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 { let base = str_param(p, "name").unwrap_or("Table Style 1").to_string(); let mut ts = designcraft_doc::TableStyle::default(); table_style_fields(&mut ts, p); 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 { 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 { let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string(); 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); 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::>() { 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 += 1; } ok() }) } #[cfg(test)] mod style_tests { use serde_json::json; use crate::Session; #[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 { const ID: &str = "table.dropCells"; let frame = super::id_param(p, "frame").ok_or_else(|| bad(ID, "`frame` required"))?; let pt = |k: &str| -> Result { 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 && ts.range.contains(fr, fc) { 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 { 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 fill = p.get("fit").and_then(Value::as_str) == Some("fill"); 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 { page: 0, id: fresh, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)) }); 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 (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[c..(c + cs).min(t.ncols())].iter().map(|x| x.width).sum(); let h: f64 = t.rows[r..(r + rs).min(t.nrows())].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 (nw, nh) = (pw as f64, ph as f64); 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); let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; cell.graphic = Some(designcraft_doc::Graphic { asset: aid, size: (nw, nh), xf, auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, fit_align: 4, crop: [0.0; 4], }); clamp_selection(d, sel); Ok(json!({"asset": aid.0, "row": r, "col": c})) }) } fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { 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 { 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 = (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::(), kb.replace(',', "").parse::()) { (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::>(), [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", ¶ms).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 { 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 = (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())); } } #[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 { 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"); } } }