//! Table Design and Table Layout tabs. use super::table_formula::parse_number; use serde_json::{Value, json}; use wordcraft_doc::collate::{SortKey, sort_key}; use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign}; use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; fn in_table(s: &Session) -> Option<&'static str> { if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") } } pub fn specs() -> Vec { let t = |c: CommandSpec| c.when(in_table); vec![ t(CommandSpec::new("table.insertRowAbove", "Insert Above", "Table Layout › Rows & Columns", |s, _| row(s, false))), t(CommandSpec::new("table.insertRowBelow", "Insert Below", "Table Layout › Rows & Columns", |s, _| row(s, true))), t(CommandSpec::new("table.insertColumnLeft", "Insert Left", "Table Layout › Rows & Columns", |s, _| col(s, false))), t(CommandSpec::new("table.insertColumnRight", "Insert Right", "Table Layout › Rows & Columns", |s, _| col(s, true))), t(CommandSpec::new("table.deleteRow", "Delete Rows", "Table Layout › Rows & Columns › Delete", del_row)), t(CommandSpec::new("table.deleteColumn", "Delete Columns", "Table Layout › Rows & Columns › Delete", del_col)), t(CommandSpec::new("table.deleteTable", "Delete Table", "Table Layout › Rows & Columns › Delete", del_table)), t(CommandSpec::new("table.deleteCells", "Delete Cells", "Table Layout › Rows & Columns › Delete", |s, _| { let (tp, r, c) = cell(s)?; let story = s.sel.focus.story; // A row's last cell stays. if !s.doc.table(story, &tp).and_then(|t| t.rows.get(r)).is_some_and(|row| row.cells.len() > 1 && c < row.cells.len()) { return fix_caret(s, &tp, r, 0); } let spans = text_spans(s, story, &[cell_prefix(&tp, r, c)]); if let Some(done) = guard_delete(s, &spans)? { return Ok(done); } if let Some(row) = s.doc.table_mut(story, &tp)?.rows.get_mut(r) && c < row.cells.len() { row.cells.remove(c); } fix_caret(s, &tp, r, 0) })), t(CommandSpec::new("table.merge", "Merge Cells", "Table Layout › Merge", merge)), t(CommandSpec::new("table.split", "Split Cells", "Table Layout › Merge", split) .params(r#"{"columns"?: n (2), "rows"?: n (1), "mergeFirst"?: bool (true: several selected cells are merged first)}"#)), t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)), t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| { let st = p::req_str(v, "style")?; let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; with_table(s, |t| t.props.style = Some(id.clone())) }) .params(r#"{"style": string}"#)), t(CommandSpec::new("table.look", "Table Style Options", "Table Design › Table Style Options", |s, v| { let set = |k: &str, cur: bool| p::bool(v, k).unwrap_or(cur); with_table(s, |t| { let l = &mut t.props.look; l.header_row = set("headerRow", l.header_row); l.total_row = set("totalRow", l.total_row); l.banded_rows = set("bandedRows", l.banded_rows); l.first_column = set("firstColumn", l.first_column); l.last_column = set("lastColumn", l.last_column); l.banded_columns = set("bandedColumns", l.banded_columns); }) }) .params( r#"{"headerRow"?: bool, "totalRow"?: bool, "bandedRows"?: bool, "firstColumn"?: bool, "lastColumn"?: bool, "bandedColumns"?: bool}"#, )), t(CommandSpec::new("table.shading", "Shading", "Table Design › Table Styles", |s, v| { let c = p::str(v, "color").and_then(Rgb::parse); with_cells(s, |cl| cl.props.shading = c) }) .params(r#"{"color": "RRGGBB" | null}"#)), t(CommandSpec::new("table.borders", "Borders", "Table Design › Borders", borders) .params(r#"{"kind": "all|outside|inside|none|top|bottom|left|right", "width"?: pt, "color"?: "RRGGBB"}"#)), t(CommandSpec::new("table.cellAlign", "Alignment", "Table Layout › Alignment", |s, v| { let (h, va) = match p::str(v, "value").unwrap_or("topLeft") { "topLeft" => (Align::Left, VAlign::Top), "topCenter" => (Align::Center, VAlign::Top), "topRight" => (Align::Right, VAlign::Top), "centerLeft" => (Align::Left, VAlign::Center), "center" => (Align::Center, VAlign::Center), "centerRight" => (Align::Right, VAlign::Center), "bottomLeft" => (Align::Left, VAlign::Bottom), "bottomCenter" => (Align::Center, VAlign::Bottom), "bottomRight" => (Align::Right, VAlign::Bottom), x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))), }; with_cells(s, |cl| { cl.props.valign = Some(va); for b in &mut cl.blocks { if let Block::Para(p) = std::sync::Arc::make_mut(b) { p.props.align = Some(h); p.touch(); } } }) }) .params(r#"{"value": "topLeft|topCenter|…|bottomRight"}"#)), t(CommandSpec::new("table.textDirection", "Text Direction", "Table Layout › Alignment", text_direction) .params(r#"{"value"?: "horizontal|down|up"} (default: the next direction after the caret cell's, like Word's button)"#)), t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", autofit).params(r#"{"mode": "contents|window|fixed"}"#)), t(CommandSpec::new("table.distributeColumns", "Distribute Columns", "Table Layout › Cell Size", |s, _| { with_table(s, |t| { let total: f32 = t.grid.iter().sum(); let n = t.grid.len().max(1) as f32; t.grid.iter_mut().for_each(|g| *g = total / n); sync_cell_widths(t); }) })), t(CommandSpec::new("table.distributeRows", "Distribute Rows", "Table Layout › Cell Size", |s, _| { with_table(s, |t| { let h = t.rows.iter().filter_map(|r| r.props.height).fold(0.0f32, f32::max).max(18.0); for r in &mut t.rows { r.props.height = Some(h); } }) })), t(CommandSpec::new("table.columnWidth", "Table Column Width", "Table Layout › Cell Size", |s, v| { let w = p::req_f32(v, "width")?.clamp(6.0, 1584.0); let cells = selected_cells(s); with_table(s, |t| set_column_widths(t, &cells, w)) }) .params( r#"{"width": pt} (every column the selection covers, the caret cell's without one; the table's preferred width is cleared so the columns get exactly this)"#, )), t(CommandSpec::new("table.rowHeight", "Table Row Height", "Table Layout › Cell Size", |s, v| { let h = p::req_f32(v, "height")?.clamp(1.0, 1584.0); let rule = match p::str(v, "rule") { None | Some("atLeast") => HeightRule::AtLeast, Some("exact") => HeightRule::Exact, Some(x) => return Err(CmdError::Params(format!("unknown height rule `{x}`"))), }; let mut rows: Vec = selected_cells(s).into_iter().map(|(r, _)| r).collect(); rows.dedup(); with_table(s, |t| { for r in &rows { if let Some(row) = t.rows.get_mut(*r) { row.props.height = Some(h); row.props.height_rule = rule; } } }) }) .params(r#"{"height": pt, "rule"?: "atLeast|exact"} (every row the selection covers, the caret's without one)"#)), t(CommandSpec::new("table.repeatHeader", "Repeat Header Rows", "Table Layout › Data", |s, v| { let (_, r, _) = cell(s)?; let on = p::bool(v, "value"); with_table(s, |t| { if let Some(row) = t.rows.get_mut(r) { row.props.header = on.unwrap_or(!row.props.header); } }) })), // Table Properties › Alt Text: the title (`w:tblCaption`) and description (`w:tblDescription`). t(CommandSpec::new("table.altText", "Alt Text", "Table Layout › Table › Properties", |s, v| { let (title, text) = (p::str(v, "title"), p::str(v, "text")); if title.is_none() && text.is_none() { return Err(CmdError::Params("`text` or `title` (string) is required".into())); } let set = |x: Option<&str>, cur: &Option| match x { Some(x) if x.trim().is_empty() => None, Some(x) => Some(wordcraft_doc::para::cap_alt(x)), None => cur.clone(), }; with_table(s, |t| { t.props.caption = set(title, &t.props.caption); t.props.description = set(text, &t.props.description); }) }) .params(r#"{"text"?: string, "title"?: string} (empty clears)"#)), t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table) .params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)), t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)), t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)), t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| { let (tp, _, _) = cell(s)?; select_cells(s, &tp, None) }) .pure()), t(CommandSpec::new("table.selectRow", "Select Row", "Table Layout › Table › Select", |s, _| { let (tp, r, _) = cell(s)?; select_cells(s, &tp, Some((r, None))) }) .pure()), t(CommandSpec::new("table.selectColumn", "Select Column", "Table Layout › Table › Select", select_column).pure()), t(CommandSpec::new("table.selectCell", "Select Cell", "Table Layout › Table › Select", |s, _| { let (tp, r, c) = cell(s)?; select_cells(s, &tp, Some((r, Some(c)))) }) .pure()), t(CommandSpec::new("table.rtl", "Right-to-Left Table", "Table Layout › Table", |s, v| { let (tp, _, _) = cell(s)?; let on = match p::bool(v, "value") { Some(b) => b, None => !s.doc.table(s.sel.focus.story, &tp).is_some_and(|t| t.props.bidi_visual), }; with_table(s, |t| t.props.bidi_visual = on) }) .params(r#"{"value"?: bool} (true: the first column stands at the right; without a value it toggles)"#)), t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params( r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#, )), CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text) .params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other), "columns"?: n, "autofit"?: "initial|fixed|contents|window", "width"?: pt (initial column width, 0 = Auto, at most 1584)}"#), CommandSpec::new("table.quick", "Quick Tables", "Insert › Tables", quick_table).params(r#"{"kind"?: "calendar|tabular|matrix"}"#), ] } /// Table Layout › AutoFit: Contents sizes the columns to their text, Window spreads them across /// the text column, Fixed keeps the widths as they are. fn autofit(s: &mut Session, v: &Value) -> CmdResult { let mode = p::str(v, "mode").unwrap_or("window"); let (tp, _, _) = cell(s)?; let story = s.sel.focus.story; let tw = super::page::sect(s).text_width(); match mode { "fixed" => with_table(s, |t| t.props.fixed = true), "contents" => { let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?; let avail = (tw - t.props.indent.filter(|i| i.is_finite()).unwrap_or(0.0).max(0.0)).max(72.0); let widths = wordcraft_layout::autofit_widths(&wordcraft_layout::measure_table_columns(&s.doc, t), avail); with_table(s, |t| { t.props.fixed = false; t.props.width = None; t.props.width_pct = None; t.grid = widths.iter().map(|w| w.max(6.0)).collect(); sync_cell_widths(t); }) } _ => with_table(s, |t| { t.props.fixed = false; t.props.width_pct = Some(100.0); let n = t.grid.len().max(1) as f32; t.grid.iter_mut().for_each(|g| *g = tw / n); sync_cell_widths(t); }), } } /// Give every cell the width of the grid columns it spans, so the cells' preferred widths /// (saved to .docx) agree with the grid. pub(crate) fn sync_cell_widths(t: &mut Table) { let grid = t.grid.clone(); for row in &mut t.rows { let mut g = 0usize; for c in &mut row.cells { let span = c.span(); let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum(); if w > 0.0 { c.props.width = Some(w); } g += span; } } } /// Set the width of the grid column cell `c` of row `r` starts in. The table's preferred width /// goes, so the layout doesn't scale the column back. /// Give every grid column the `cells` (row, cell) pairs touch the width `w`. fn set_column_widths(t: &mut Table, cells: &[(usize, usize)], w: f32) { let mut cols: Vec = cells.iter().map(|&(r, c)| t.grid_col(r, c)).collect(); cols.sort_unstable(); cols.dedup(); let mut any = false; for g in cols { if let Some(x) = t.grid.get_mut(g) { *x = w; any = true; } } if any { t.props.width = None; t.props.width_pct = None; sync_cell_widths(t); } } fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) { let g = t.grid_col(r, c); if let Some(x) = t.grid.get_mut(g) { *x = w; t.props.width = None; t.props.width_pct = None; sync_cell_widths(t); } } /// Table Properties: table, row, column and cell settings in one undoable step. Row, column and /// cell settings apply to the caret's. Without any setting it returns the current values. fn properties(s: &mut Session, v: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; let keys = [ "align", "rtl", "width", "widthPct", "indent", "rowHeight", "rowHeightRule", "allowBreak", "headerRow", "columnWidth", "cellWidth", "valign", ]; if !keys.iter().any(|k| v.get(*k).is_some()) { let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default(); let mut out = serde_json::to_value(&t.props).unwrap_or(Value::Null); let row = t.rows.get(r); let cl = row.and_then(|x| x.cells.get(c)); if let Some(o) = out.as_object_mut() { o.insert("row".into(), row.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null)); o.insert("columnWidth".into(), t.grid.get(t.grid_col(r, c)).map_or(Value::Null, |w| json!(w))); o.insert("cell".into(), cl.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null)); } return Ok(out); } let align = match p::str(v, "align") { None => None, Some("left") => Some(Align::Left), Some("center") => Some(Align::Center), Some("right") => Some(Align::Right), Some(x) => return Err(CmdError::Params(format!("unknown table alignment `{x}`"))), }; let rule = match p::str(v, "rowHeightRule") { None => None, Some("atLeast") => Some(HeightRule::AtLeast), Some("exact") => Some(HeightRule::Exact), Some(x) => return Err(CmdError::Params(format!("unknown height rule `{x}`"))), }; let valign = match p::str(v, "valign") { None => None, Some("top") => Some(VAlign::Top), Some("center") => Some(VAlign::Center), Some("bottom") => Some(VAlign::Bottom), Some(x) => return Err(CmdError::Params(format!("unknown vertical alignment `{x}`"))), }; // `null` clears a preferred size (automatic); a number sets it. let size = |k: &str, lo: f32, hi: f32| -> Option> { match v.get(k)? { Value::Null => Some(None), x => x.as_f64().map(|f| f as f32).filter(|f| f.is_finite()).map(|f| Some(f.clamp(lo, hi))), } }; let width = size("width", 6.0, 1584.0); let width_pct = p::f32(v, "widthPct").map(|x| x.clamp(1.0, 100.0)); let indent = p::f32(v, "indent").map(|x| x.clamp(-1584.0, 1584.0)); let row_height = size("rowHeight", 1.0, 1584.0); let allow_break = p::bool(v, "allowBreak"); let header = p::bool(v, "headerRow"); let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0)); let cell_width = size("cellWidth", 6.0, 1584.0); let rtl = p::bool(v, "rtl"); with_table(s, |t| { if let Some(a) = align { t.props.align = Some(a); } if let Some(on) = rtl { t.props.bidi_visual = on; } if let Some(i) = indent { t.props.indent = Some(i); } if let Some(cw) = column_width { set_column_width(t, r, c, cw); } if let Some(w) = width { t.props.width = w; t.props.width_pct = None; } if let Some(pct) = width_pct { t.props.width_pct = Some(pct); } if let Some(row) = t.rows.get_mut(r) { if let Some(h) = row_height { row.props.height = h; row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto }; } else if let Some(rule) = rule && row.props.height.is_some() { row.props.height_rule = rule; } if let Some(b) = allow_break { row.props.cant_split = !b; } if let Some(h) = header { row.props.header = h; } if let Some(cl) = row.cells.get_mut(c) { if let Some(w) = cell_width { cl.props.width = w; } if let Some(va) = valign { cl.props.valign = Some(va); } } } }) } fn cell(s: &Session) -> Result<(Path, usize, usize), CmdError> { s.sel.focus.path.cell().ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into())) } fn with_table(s: &mut Session, f: impl Fn(&mut Table)) -> CmdResult { let (tp, _, _) = cell(s)?; let t = s.doc.table_mut(s.sel.focus.story, &tp)?; f(t); touch_cells(s, &tp)?; sel_result(s) } /// Selected cells: (row, cell) pairs covered by the selection (at least the caret's cell). pub fn selected_cells(s: &Session) -> Vec<(usize, usize)> { // Whole cells selected (a column, say): just those, by grid column, so merged cells count. if let Some(range) = s.cell_range() && s.sel.focus.path.cell().is_some_and(|(tp, _, _)| tp == range.table) && let Some(t) = s.doc.table(range.story, &range.table) { return range.cells(t); } let (a, b) = s.sel.ordered(); let (Some((ta, ra, ca)), Some((tb, rb, cb))) = (a.path.cell(), b.path.cell()) else { return Vec::new() }; if ta != tb { return vec![(rb, cb)]; } let (r0, r1) = (ra.min(rb), ra.max(rb)); let (c0, c1) = (ca.min(cb), ca.max(cb)); let mut v = Vec::new(); for r in r0..=r1 { for c in c0..=c1 { v.push((r, c)); } } v } fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResult { let (tp, _, _) = cell(s)?; let cells = selected_cells(s); let t = s.doc.table_mut(s.sel.focus.story, &tp)?; for (r, c) in cells { if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) { f(cl); } } touch_cells(s, &tp)?; sel_result(s) } /// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the /// caret cell's direction (horizontal → down → up) and gives every selected cell the result. pub(crate) fn text_direction(s: &mut Session, v: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; let dir = match p::str(v, "value") { Some("horizontal" | "lrTb") => TextDirection::Horizontal, Some("down" | "tbRl") => TextDirection::Down, Some("up" | "btLr") => TextDirection::Up, Some(x) => return Err(CmdError::Params(format!("unknown text direction `{x}`"))), None => { let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?; t.rows.get(r).and_then(|row| row.cells.get(c)).map(|cl| cl.props.text_direction).unwrap_or_default().next() } }; with_cells(s, |cl| cl.props.text_direction = dir) } /// Bump the revision of every paragraph in a table (table style changes alter their layout). pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { let story = s.sel.focus.story; let paths: Vec = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect(); for p in paths { s.doc.para_mut(story, &p)?.touch(); } Ok(()) } fn fix_caret(s: &mut Session, tp: &Path, r: usize, c: usize) -> CmdResult { let story = s.sel.focus.story; let Some(t) = s.doc.table(story, tp) else { s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp.clone(), off: 0 })); return sel_result(s); }; let r = r.min(t.rows.len().saturating_sub(1)); let c = c.min(t.rows.get(r).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0)); let mut path = tp.0.clone(); path.extend([r as u32, c as u32, 0]); s.sel = Selection::caret(Pos { story, path: Path(path), off: 0 }); sel_result(s) } /// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first. pub(super) fn row(s: &mut Session, below: bool) -> CmdResult { let (tp, r, c) = cell(s)?; let cells = selected_cells(s); let n = cells.iter().map(|x| x.0).collect::>().len().max(1); let t = s.doc.table_mut(s.sel.focus.story, &tp)?; for _ in 0..n { t.insert_row(if below { r + 1 } else { r }, r); } fix_caret(s, &tp, if below { r + 1 } else { r }, c) } /// Insert Left / Right: a column beside the caret's cell; the caret goes into it. pub(super) fn col(s: &mut Session, right: bool) -> CmdResult { let (tp, r, c) = cell(s)?; let t = s.doc.table_mut(s.sel.focus.story, &tp)?; let g = t.grid_col(r, c); let span = t.rows.get(r).and_then(|x| x.cells.get(c)).map(|x| x.span()).unwrap_or(1); t.insert_col(if right { g + span } else { g }); // Keep the table within its width: shrink all columns proportionally. let total: f32 = t.grid.iter().sum(); let before: f32 = total - t.grid.get(if right { g + span } else { g }).copied().unwrap_or(0.0); if before > 0.0 { let k = before / total; t.grid.iter_mut().for_each(|w| *w *= k); } fix_caret(s, &tp, r, if right { c + 1 } else { c }) } fn del_row(s: &mut Session, _: &Value) -> CmdResult { let (tp, r, _) = cell(s)?; let rows: std::collections::BTreeSet = selected_cells(s).into_iter().map(|x| x.0).collect(); let story = s.sel.focus.story; if rows.len() >= s.doc.table(story, &tp).map_or(0, |t| t.rows.len()) { return del_table(s, &Value::Null); } let prefixes: Vec> = rows.iter().map(|rr| tp.0.iter().copied().chain([*rr as u32]).collect()).collect(); let spans = text_spans(s, story, &prefixes); if let Some(done) = guard_delete(s, &spans)? { return Ok(done); } let t = s.doc.table_mut(story, &tp)?; for rr in rows.iter().rev() { t.delete_row(*rr); } fix_caret(s, &tp, r, 0) } fn del_col(s: &mut Session, _: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; let cells = selected_cells(s); let story = s.sel.focus.story; let Some(t) = s.doc.table(story, &tp) else { return Err(CmdError::Failed("no table".into())) }; if t.grid.len() <= 1 { return del_table(s, &Value::Null); } // Every grid column a selected cell covers goes (the caret's alone without a selection). let mut cols = std::collections::BTreeSet::new(); for (rr, cc) in cells { let g = t.grid_col(rr, cc); let span = t.rows.get(rr).and_then(|x| x.cells.get(cc)).map_or(1, wordcraft_doc::Cell::span); cols.extend(g..g.saturating_add(span).min(t.grid.len())); } if cols.is_empty() { cols.insert(t.grid_col(r, c)); } if cols.len() >= t.grid.len() { return del_table(s, &Value::Null); } // The cells that go with those columns, and their text: those all of whose columns go // (a merged cell that also spans a kept column just narrows, keeping its text). let mut prefixes = Vec::new(); for (rr, row) in t.rows.iter().enumerate() { for (cc, x) in row.cells.iter().enumerate() { let g = t.grid_col(rr, cc); let covered = g..g.saturating_add(x.span()).min(t.grid.len()); if !covered.is_empty() && covered.clone().all(|k| cols.contains(&k)) { prefixes.push(cell_prefix(&tp, rr, cc)); } } } let spans = text_spans(s, story, &prefixes); if let Some(done) = guard_delete(s, &spans)? { return Ok(done); } let t = s.doc.table_mut(story, &tp)?; for g in cols.iter().rev() { t.delete_col(*g); } // Rows left without cells are dropped; if that was every row, no table is left. if t.rows.is_empty() { return del_table(s, &Value::Null); } fix_caret(s, &tp, r, c.saturating_sub(1)) } /// Backspace with whole cells selected (#1844): whole rows go like Delete Rows (every row: the /// table, like Delete Table), whole columns like Delete Columns, with their undo and Track /// Changes. `None` for any other block of cells, and for a single cell of a table with more /// (Backspace then clears them). pub(crate) fn backspace_cells(s: &mut Session) -> Option { let cr = s.cell_range()?.clone(); let (tp, _, _) = s.sel.focus.path.cell()?; if cr.story != s.sel.focus.story || cr.table != tp { return None; } let t = s.doc.table(cr.story, &tp)?; // One cell of several is just that cell, also in a one-row or one-column table (Word's cell // selection doesn't take the end-of-row mark): Backspace clears it. let cells: usize = t.rows.iter().map(|row| row.cells.len()).sum(); if cells > 1 && cr.cells(t).len() <= 1 { return None; } let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1)); let r = if cr.cols == (0, last_col) { del_row(s, &Value::Null) } else if cr.rows == (0, last_row) { del_col(s, &Value::Null) } else { return None; }; s.column = None; Some(r) } /// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in /// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its /// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text /// is marked deleted as for any selection). pub(crate) fn clear_cells(s: &mut Session) -> Option { if s.doc.settings.track_changes { return None; } let cr = s.cell_range()?.clone(); let t = s.doc.table(cr.story, &cr.table)?; let prefixes: Vec> = cr.cells(t).into_iter().map(|(r, c)| cell_prefix(&cr.table, r, c)).collect(); let spans = text_spans(s, cr.story, &prefixes); Some(clear_spans(s, cr.table.0.len().saturating_add(2), &spans)) } /// Empty each span (one cell's text, from its first paragraph to its last; the cell's path is the /// first `depth` steps of its paths) to one paragraph with the last paragraph's formatting, bottom /// up so earlier spans stay valid; the caret goes to the start of the first cell. fn clear_spans(s: &mut Session, depth: usize, spans: &[(Pos, Pos)]) -> CmdResult { // A cell's first block (its first paragraph may have been in a nested table that goes). let first_block = |p: &Pos| { let mut v = p.path.0.get(..depth).unwrap_or_default().to_vec(); v.push(0); Pos { story: p.story, path: Path(v), off: 0 } }; for (a, b) in spans { super::controls::check_delete(s, a, b)?; } for (a, b) in spans.iter().rev() { let Some(last) = s.doc.para(b.story, &b.path).map(|p| (p.props.clone(), p.mark.clone())) else { continue }; s.doc.delete_range(a, b)?; // Now the cell's only block, unless it holds a table nested deeper than the paragraph // walk (`delete_range` leaves those). let first = first_block(a); if s.doc.container(first.story, &first.path).is_some_and(|bl| bl.len() == 1) && let Ok(p) = s.doc.para_mut(first.story, &first.path) { (p.props, p.mark) = last; p.touch(); } } let at = spans.first().map(|(a, _)| s.doc.clamp(&first_block(a))).unwrap_or_else(|| s.sel.focus.clone()); s.sel = Selection::caret(at); s.column = None; s.column_mode = false; s.goal_x = None; s.pending = None; super::controls::restore_placeholder(s)?; sel_result(s) } fn del_table(s: &mut Session, _: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; let story = s.sel.focus.story; let spans = text_spans(s, story, std::slice::from_ref(&tp.0)); if let Some(done) = guard_delete(s, &spans)? { return Ok(done); } s.doc.remove_block(story, &tp)?; s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp, off: 0 })); sel_result(s) } /// The path prefix of cell (`r`, `c`) of the table at `tp`. fn cell_prefix(tp: &Path, r: usize, c: usize) -> Vec { tp.0.iter().copied().chain([r as u32, c as u32]).collect() } /// The text under each path prefix (a cell, a row or a whole table): from its first paragraph's /// start to its last paragraph's end. Prefixes without paragraphs are left out. fn text_spans(s: &Session, story: StoryRef, prefixes: &[Vec]) -> Vec<(Pos, Pos)> { let paths = s.doc.para_paths(story); prefixes .iter() .filter_map(|pre| { let mut inside = paths.iter().filter(|p| p.0.starts_with(pre)); let first = inside.next()?.clone(); let last = inside.next_back().cloned().unwrap_or_else(|| first.clone()); let end = s.doc.para(story, &last).map_or(0, Paragraph::len); Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: end })) }) .collect() } /// Before a table deletion removes the text `spans`: refuse when that deletes a content control /// locked against deletion or edits content locked against editing (#1329). With Track Changes /// on, text not yet deleted is marked deleted instead and the structure stays (like Word deleting /// a table with tracking on; the model has no tracked row or column deletion), returning the /// result. With no text at all (an empty table, or one whose deletion was accepted) there is /// nothing for Reject All to restore and the structure goes as without tracking; with only text /// already marked deleted, nothing changes (removing it would lose what Reject All restores). fn guard_delete(s: &mut Session, spans: &[(Pos, Pos)]) -> Result, CmdError> { for (a, b) in spans { super::controls::check_delete(s, a, b)?; } if !s.doc.settings.track_changes { return Ok(None); } if !spans.iter().any(|(a, b)| has_text(s, a, b, true)) { if spans.iter().any(|(a, b)| has_text(s, a, b, false)) { let msg = "The table's text is already marked deleted; accept or reject the change first."; s.status = msg.to_string(); return Err(CmdError::Failed(msg.to_string())); } return Ok(None); } let rid = super::new_revision(s, RevisionKind::Delete); for (a, b) in spans.iter().rev() { super::track_delete_as(s, a, b, rid)?; } if let Some((a, _)) = spans.first() { s.sel = Selection::caret(s.doc.clamp(a)); } sel_result(s).map(Some) } /// Whether `a..b` holds text: any text, or (`live`) text that isn't a tracked deletion. fn has_text(s: &Session, a: &Pos, b: &Pos, live: bool) -> bool { s.doc.paths_between(a, b).iter().any(|path| { let Some(p) = s.doc.para(a.story, path) else { return false }; let from = if *path == a.path { a.off } else { 0 }; let to = if *path == b.path { b.off } else { p.len() }; p.run_ranges().any(|(r, c)| (!live || c.del.is_none()) && r.end > from && r.start < to && !r.is_empty()) }) } fn merge(s: &mut Session, _: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; let cells = selected_cells(s); if cells.len() < 2 { return Err(CmdError::Failed("select two or more cells to merge".into())); } let story = s.sel.focus.story; let (r0, r1) = (cells.iter().map(|x| x.0).min().unwrap_or(0), cells.iter().map(|x| x.0).max().unwrap_or(0)); let t = s.doc.table_mut(story, &tp)?; let c0 = cells.iter().map(|x| x.1).min().unwrap_or(0); let c1 = cells.iter().map(|x| x.1).max().unwrap_or(0); let g0 = t.grid_col(r0, c0); let g1 = t.grid_col(r0, c1) + t.rows.get(r0).and_then(|r| r.cells.get(c1)).map(|c| c.span()).unwrap_or(1) - 1; t.merge(r0, r1, g0, g1); fix_caret(s, &tp, r0, c0) } /// The selected cells as Split Cells sees them: one entry per cell, a vertically merged cell /// once (its top row), each as (top row, grid column). pub fn split_targets(s: &Session) -> Vec<(usize, usize)> { let Ok((tp, _, _)) = cell(s) else { return Vec::new() }; let Some(t) = s.doc.table(s.sel.focus.story, &tp) else { return Vec::new() }; let mut v: Vec<(usize, usize)> = selected_cells(s) .into_iter() .filter(|&(r, c)| t.rows.get(r).is_some_and(|row| c < row.cells.len())) .map(|(r, c)| (t.vertical_extent(r, c).map_or(r, |(top, _)| top), t.grid_col(r, c))) .collect(); v.sort_unstable(); v.dedup(); v } /// Split Cells: the caret's cell, or the selected cells (merged first unless `mergeFirst` is /// false), into `columns` × `rows` cells. fn split(s: &mut Session, v: &Value) -> CmdResult { let cols = p::u64(v, "columns").unwrap_or(2).clamp(1, 63) as usize; let rows = p::u64(v, "rows").unwrap_or(1).clamp(1, 63) as usize; let mut cells = split_targets(s); let merge_first = cells.len() > 1 && p::bool(v, "mergeFirst").unwrap_or(true); if merge_first { // As in Word: the merged cell splits into at most the rows it covered. let mut sel_rows: Vec = selected_cells(s).iter().map(|&(r, _)| r).collect(); sel_rows.sort_unstable(); sel_rows.dedup(); if rows > sel_rows.len() { return Err(CmdError::Params(format!("The number must be between 1 and {}.", sel_rows.len()))); } merge(s, &json!({}))?; cells.clear(); } else if cells.len() > 1 && rows > 1 { // Word greys out the row count when the cells split one by one. return Err(CmdError::Params("several cells split one by one keep their rows: `rows` must be 1".into())); } let (tp, r, c) = cell(s)?; let t = s.doc.table_mut(s.sel.focus.story, &tp)?; if cells.is_empty() { cells.push((t.vertical_extent(r, c).map_or(r, |(top, _)| top), t.grid_col(r, c))); } // As in Word: a merged cell splits into 1 to its height rows; it never adds rows. if let [(top, g)] = cells[..] && let Some((_, k)) = t.cell_at_grid(top, g).and_then(|ci| t.vertical_extent(top, ci)) && k > 1 && rows > k { return Err(CmdError::Params(format!("The number must be between 1 and {k}."))); } // Rightmost grid column first, bottom up: a split only adds grid columns inside the cell it // splits, so the cells still to split keep their grid places. Each is found again by its grid // column when its turn comes. let first = cells.first().copied(); cells.sort_unstable_by_key(|&(top, g)| std::cmp::Reverse((g, top))); for &(top, g) in &cells { let Some(ci) = t.cell_at_grid(top, g) else { continue }; // 1 × 1 on a cell that was already merged down changes nothing in Word, at any height; a // cell just merged by Merge cells before split stays one cell. let height = t.vertical_extent(top, ci).map_or(1, |(_, k)| k); if !merge_first && cols == 1 && rows == 1 && height > 1 { continue; } if height > 1 && rows > 1 && !height.is_multiple_of(rows) { return Err(CmdError::Params(format!("The number must be a divisor of {height}."))); } if !t.split_cell_grid(top, ci, cols, rows) { return Err(CmdError::Failed(format!("can't split into {rows} rows: the table would get too many rows"))); } } // The caret goes to the first part of the first cell (the top of a merged cell). let (top, g) = first.unwrap_or((r, 0)); let c = t.cell_at_grid(top, g).unwrap_or(c); fix_caret(s, &tp, top, c) } fn split_table(s: &mut Session, _: &Value) -> CmdResult { let (tp, r, _) = cell(s)?; if r == 0 { return Err(CmdError::Failed("can't split at the first row".into())); } let story = s.sel.focus.story; let t = s.doc.table_mut(story, &tp)?; let tail_rows = t.rows.split_off(r); // Content controls closing after the table now close after its second part. let close = std::mem::take(&mut t.controls.close); let controls = wordcraft_doc::ControlWrap { open: Vec::new(), close }; let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows, controls }; let after = tp.with_last(tp.last() + 1); s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?; s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?; s.sel = Selection::caret(Pos { story, path: after, off: 0 }); sel_result(s) } fn borders(s: &mut Session, v: &Value) -> CmdResult { let kind = p::str(v, "kind").unwrap_or("all"); let w = p::f32(v, "width").unwrap_or(0.5).clamp(0.25, 6.0); let color = p::str(v, "color").and_then(Rgb::parse); let b = Border { style: BorderStyle::Single, width: w, color, space: 0.0 }; let none = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }; with_table(s, |t| { let mut cur = t.props.borders.unwrap_or_default(); match kind { "all" => cur = Borders::all(b), "none" => cur = Borders::all(none), "outside" => { cur.top = Some(b); cur.bottom = Some(b); cur.left = Some(b); cur.right = Some(b); // The cleared inside sides must not fall back to the table style (Word writes nil). cur.between = Some(none); cur.inside_v = Some(none); } "inside" => { cur.between = Some(b); cur.inside_v = Some(b); // The cleared outer sides must not fall back to the table style (Word writes nil). cur.top = Some(none); cur.bottom = Some(none); cur.left = Some(none); cur.right = Some(none); } "top" => cur.top = Some(b), "bottom" => cur.bottom = Some(b), "left" => cur.left = Some(b), "right" => cur.right = Some(b), _ => {} } t.props.borders = Some(cur); for r in &mut t.rows { for c in &mut r.cells { c.props.borders = None; } } }) } pub(crate) fn cell_text(c: &wordcraft_doc::Cell) -> String { c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::>().join("\n") } /// The language of a cell's text: that of its first paragraph with text. fn cell_lang(doc: &wordcraft_doc::Document, c: &wordcraft_doc::Cell) -> Option { c.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| super::para_lang(doc, p)) } /// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and /// a half in Polish) and sort first, numerically; other text sorts in the column's language. fn sort_table(s: &mut Session, v: &Value) -> CmdResult { let col = p::u64(v, "column").unwrap_or(0) as usize; let desc = p::bool(v, "descending").unwrap_or(false); let header = p::bool(v, "header"); let (tp, _, _) = cell(s)?; let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?; let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len()); let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col)); let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| super::default_lang(s)); // A total order (numbers, then text), so sorting never sees inconsistent comparisons. let keys: Vec<(Option, SortKey)> = column() .map(|c| { let text = c.map(cell_text).unwrap_or_default(); let num_lang = c.and_then(|c| cell_lang(&s.doc, c)).or_else(|| lang.clone()); let num = parse_number(text.trim(), num_lang.as_deref()).filter(|x| x.is_finite()); (num, if num.is_some() { SortKey::default() } else { sort_key(text.trim(), lang.as_deref()) }) }) .collect(); with_table(s, move |t| { if skip > t.rows.len() { return; } let body = t.rows.split_off(skip); let mut rows: Vec<_> = keys.iter().cloned().zip(body).collect(); rows.sort_by(|((a, ka), _), ((b, kb), _)| { let o = match (a, b) { (Some(x), Some(y)) => x.total_cmp(y), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => ka.cmp(kb), }; if desc { o.reverse() } else { o } }); t.rows.extend(rows.into_iter().map(|(_, r)| r)); }) } fn to_text(s: &mut Session, v: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; let sep = match p::str(v, "separator").unwrap_or("tab") { "comma" => ",", "paragraph" => "\n", _ => "\t", }; let story = s.sel.focus.story; let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?; s.doc.remove_block(story, &tp)?; let mut i = tp.last(); for row in &t.rows { let text: Vec = row.cells.iter().map(cell_text).collect(); for line in text.join(sep).split('\n') { s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?; i += 1; } } s.sel = Selection::caret(Pos { story, path: tp, off: 0 }); sel_result(s) } /// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again /// by Update Field. What a formula can use is described in [`super::table_formula`]. A formula /// that doesn't evaluate (an unknown function, a syntax error) is an error and inserts nothing. fn formula(s: &mut Session, v: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; let raw = p::str(v, "formula").unwrap_or("=SUM(ABOVE)"); let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?; let default_lang = super::default_lang(s); let instr = auto_formula_instr(t, r, c, raw, default_lang.as_deref()); let text = super::table_formula::evaluate_field_with(t, Some(&s.doc), default_lang.as_deref(), r, c, &instr)?; let props = s.typing_props(); let at = s.sel.focus.clone(); let end = s.doc.insert_object( &at, wordcraft_doc::InlineObject::Field { instr: instr.clone(), result: text.clone(), locked: false, dirty: false, code: false, form: String::new(), }, &props, )?; s.sel = Selection::caret(end); Ok(json!({"result": text, "formula": instr})) } /// A formula as a field stores it: upper case, with one leading `=`. A `\#` numeric picture is /// kept as typed. pub(crate) fn formula_instr(raw: &str) -> String { let body = raw.trim(); let body = body.strip_prefix('=').unwrap_or(body); match super::table_formula::split_switch(body) { (formula, Some(switch)) => format!("={} {switch}", formula.trim().to_ascii_uppercase()), (formula, None) => format!("={}", formula.trim().to_ascii_uppercase()), } } /// A bare function name (`=SUM`, no parentheses) takes its direction from the cell: ABOVE when a /// cell above holds a number, else LEFT. fn auto_formula_instr(t: &Table, r: usize, c: usize, raw: &str, lang: Option<&str>) -> String { let instr = formula_instr(raw); let body = instr.strip_prefix('=').unwrap_or(&instr); if !body.contains('(') && matches!(body, "SUM" | "AVERAGE" | "COUNT" | "MAX" | "MIN" | "PRODUCT") { let g = t.grid_col(r, c); let has_above = (0..r).rev().any(|row| { t.cell_at_grid(row, g) .and_then(|ci| t.rows.get(row)?.cells.get(ci)) .and_then(|cl| super::table_formula::cell_number_in(cl, lang)) .is_some() }); let dir = if has_above { "ABOVE" } else { "LEFT" }; format!("={body}({dir})") } else { instr } } /// Ctrl+A in a table, as in Word: the cell, then its row, then the table. A nested table that /// is already fully selected grows to the cell around it. Returns `None` when the next step is /// the whole story (not in a table, or the top-level table is already selected). pub(crate) fn grow_select_all(s: &mut Session) -> Option { let story = s.sel.focus.story; if let Some(cr) = s.cell_range().filter(|cr| cr.story == story).cloned() { let tp = cr.table.clone(); let (last_row, last_col, one_cell) = { let t = s.doc.table(story, &tp)?; let last_row = t.rows.len().saturating_sub(1); let last_col = t.cols().saturating_sub(1); let one_cell = cr.rows.0 == cr.rows.1 && t.rows.get(cr.rows.0).is_some_and(|row| (0..row.cells.len()).any(|c| grid_cols(t, cr.rows.0, c) == cr.cols)); (last_row, last_col, one_cell) }; if cr.rows == (0, last_row) && cr.cols == (0, last_col) { return tp.cell().map(|(otp, or, oc)| select_cells(s, &otp, Some((or, Some(oc))))); } if cr.rows.0 == cr.rows.1 && cr.cols == (0, last_col) { return Some(select_cells(s, &tp, None)); } if one_cell { return Some(select_cells(s, &tp, Some((cr.rows.0, None)))); } return Some(select_cells(s, &tp, None)); } let (a, b) = s.sel.ordered(); let (tp, r, c) = a.path.cell()?; if b.path.cell() != Some((tp.clone(), r, c)) { return None; } Some(select_cells(s, &tp, Some((r, Some(c))))) } fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option)>) -> CmdResult { let story = s.sel.focus.story; let Some(t) = s.doc.table(story, tp) else { return sel_result(s) }; let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1)); let (rows, cols) = match which { None => ((0, last_row), (0, last_col)), Some((r, None)) => ((r, r), (0, last_col)), Some((r, Some(c))) => ((r, r), grid_cols(t, r, c)), }; select_range(s, story, tp, rows, cols) } /// The grid columns cell `c` of row `r` covers (first, last). pub(super) fn grid_cols(t: &Table, r: usize, c: usize) -> (usize, usize) { let g = t.grid_col(r, c); let span = t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, wordcraft_doc::Cell::span); (g, g + span - 1) } /// Table Layout › Table › Select › Select Column: the grid columns of the selected cells, every row. fn select_column(s: &mut Session, _: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; let story = s.sel.focus.story; let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?; let (mut g0, mut g1) = grid_cols(t, r, c); for (r, c) in selected_cells(s) { let (a, b) = grid_cols(t, r, c); (g0, g1) = (g0.min(a), g1.max(b)); } let rows = (0, t.rows.len().saturating_sub(1)); select_range(s, story, &tp, rows, (g0, g1)) } /// Select whole cells of table `tp`: rows `rows`, and of them every cell reaching into grid /// columns `cols` (both inclusive, either order). The selection runs from the first cell's start /// to the last cell's end; a block of the cells' paragraphs (`CellRange`) says which cells they /// are, so the app shades them and, when they aren't one run of the text (part of a column), /// edits and formatting leave the cells between the corners alone. pub(crate) fn select_range(s: &mut Session, story: StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> CmdResult { let t = s.doc.table(story, tp).ok_or_else(|| CmdError::Failed("there's no table there".into()))?; let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1)); let rows = (rows.0.min(rows.1).min(last_row), rows.0.max(rows.1).min(last_row)); let cols = (cols.0.min(cols.1).min(last_col), cols.0.max(cols.1).min(last_col)); let stream = rows.0 == rows.1 || (cols.0 == 0 && cols.1 == last_col); let range = crate::CellRange { story, table: tp.clone(), rows, cols, stream }; let picked: std::collections::BTreeSet<(usize, usize)> = range.cells(t).into_iter().collect(); // Every paragraph of the picked cells (nested tables' too), in document order. let depth = tp.0.len(); let in_picked = |q: &Path| match (q.0.get(depth), q.0.get(depth + 1)) { (Some(r), Some(c)) => q.0.len() > depth + 2 && q.0.starts_with(&tp.0) && picked.contains(&(*r as usize, *c as usize)), _ => false, }; let segments: Vec<(Pos, Pos)> = s .doc .para_paths(story) .into_iter() .filter(|q| in_picked(q)) .map(|q| { let len = s.doc.para(story, &q).map_or(0, Paragraph::len); (Pos { story, path: q.clone(), off: 0 }, Pos { story, path: q, off: len }) }) .collect(); let (Some(first), Some(last)) = (segments.first(), segments.last()) else { return Err(CmdError::Failed("there are no cells to select there".into())); }; // The end corner is in the last cell's own last paragraph (not a nested table's), so it // names the cell. let focus = segments.iter().rev().find(|(_, b)| b.path.0.len() == depth + 3).map_or(&last.1, |(_, b)| b).clone(); let sel = Selection { anchor: first.0.clone(), focus }; s.sel = sel.clone(); s.column = Some(crate::ColumnBlock { sel, anchor_x: 0.0, focus_x: 0.0, segments, cells: Some(range) }); s.column_mode = false; s.widened = None; s.goal_x = None; s.pending = None; sel_result(s) } /// Extend the selection from its anchor to `to` the way Word does around tables: from one cell /// into another of the same table it takes whole cells, the rectangle between them; between a /// table's cell and text outside the table, whole rows, from the cell's row out to the table's /// edge facing the other end. Where the selection really runs is kept (`Session::sel_ends`), so /// extending it again goes on from there. pub(crate) fn extend_selection(s: &mut Session, to: Pos) { let ends = Selection { anchor: s.sel.anchor.clone(), focus: to }; // Shift+Up and Shift+Down keep their goal column. let goal_x = s.goal_x; s.sel = ends.clone(); s.column = None; widen(s, &ends); s.widened = if s.sel == ends { None } else { Some(crate::session::Widened { sel: s.sel.clone(), ends }) }; s.goal_x = goal_x; } /// Shift+Right (Shift+Left in right-to-left text) out of the cell the selection is in takes that /// cell whole first, as Word does (its end-of-cell mark); the next press goes on into the next /// cell. `ends` is where the selection ran before the step, `to` where the step went, `cells` the /// whole cells selected before it. Whether it took the cell. pub(crate) fn select_cell_first(s: &mut Session, ends: &Selection, to: &Pos, cells: Option<&crate::CellRange>) -> bool { let (a, f) = (&ends.anchor, &ends.focus); if a.story != f.story || to.story != f.story || to <= f { return false; } // The innermost cell both ends are in. let mut cell = None; let mut i = 0; while let (Some(x), Some(y)) = (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) { if x != y { break; } cell = Some(x); i += 3; } let Some((tp, r, c)) = cell else { return false }; // Still in the cell, or that cell alone is already selected: nothing to take first. if f.path.0.get(..tp.0.len() + 2).is_some_and(|prefix| to.path.0.starts_with(prefix)) { return false; } let Some(cols) = s.doc.table(f.story, &tp).map(|t| grid_cols(t, r, c)) else { return false }; if cells.is_some_and(|cr| cr.story == f.story && cr.table == tp && cr.rows == (r, r) && cr.cols == cols) { return false; } select_range(s, f.story, &tp, (r, r), cols).is_ok() } /// Widen the selection between `ends` (already `s.sel`) by Word's table rule (see /// `extend_selection`). fn widen(s: &mut Session, ends: &Selection) { let (a, f) = (&ends.anchor, &ends.focus); if a.story != f.story { return; } // Down through the tables both ends are in, one nesting level (three indexes) at a time. let mut i = 0; while let (Some(ab), Some(fb)) = (a.path.0.get(i), f.path.0.get(i)) { match (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) { // The same cell: look inside it. (Some(x), Some(y)) if ab == fb && x == y => i += 3, // Two cells of one table: the rectangle between them. (Some((tp, ra, ca)), Some((_, rf, cf))) if ab == fb => { let cols = s.doc.table(a.story, &tp).map(|t| { let ((g0, g1), (h0, h1)) = (grid_cols(t, ra, ca), grid_cols(t, rf, cf)); (g0.min(h0), g1.max(h1)) }); if let Some(cols) = cols { // A table without paragraphs keeps the plain selection. let _ = select_range(s, a.story, &tp, (ra, rf), cols); } return; } // Different blocks: an end in a table takes its whole row, out to the table's edge // facing the other end. (x, y) if ab != fb => { let forward = ab < fb; if let Some((tp, r, _)) = x && let Some((start, end)) = row_ends(&s.doc, a.story, &tp, r) { s.sel.anchor = if forward { start } else { end }; } if let Some((tp, r, _)) = y && let Some((start, end)) = row_ends(&s.doc, f.story, &tp, r) { s.sel.focus = if forward { end } else { start }; } return; } // One paragraph. _ => return, } } } /// The cell of the table at index `i` of paragraph path `p`, if the paragraph is in one there: /// (the table's path, row, cell). fn cell_at(p: &[u32], i: usize) -> Option<(Path, usize, usize)> { match (p.get(..=i), p.get(i + 1), p.get(i + 2)) { (Some(t), Some(r), Some(c)) if p.len() > i + 3 => Some((Path(t.to_vec()), *r as usize, *c as usize)), _ => None, } } /// Where row `r` of table `tp` starts and ends: its first cell's start and its last cell's end. pub(crate) fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) -> Option<(Pos, Pos)> { let mut row = tp.0.clone(); row.push(u32::try_from(r).ok()?); let mut paths = doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&row)); let first = paths.next()?; let last = paths.next_back().unwrap_or_else(|| first.clone()); let len = doc.para(story, &last).map_or(0, Paragraph::len); Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: len })) } /// The paragraphs Convert Text to Table would convert: the selected ones, as text. Refused /// rather than dropping content when a table lies between them. pub fn text_table_lines(s: &Session) -> Result, CmdError> { let (a, b) = s.sel.ordered(); if a.path.parent() != b.path.parent() || a.story != b.story { return Err(CmdError::Failed("select paragraphs to convert".into())); } let lines: Vec = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).map(|p| p.plain_text())).collect(); // Refuse rather than drop content: a table between the paragraphs would be removed with them. if lines.len() != b.path.last() - a.path.last() + 1 { return Err(CmdError::Failed("select only paragraphs to convert".into())); } Ok(lines) } /// The cells Convert Text to Table makes of `lines`, row by row, and the column count. `sep` /// `None` separates at paragraphs: each paragraph is a cell, `columns` (default 1) to a row. /// Otherwise each paragraph starts a row; `columns` defaults to the most fields in one, and a /// paragraph with more fields than that carries on over the next rows. pub fn text_table_cells(lines: &[String], sep: Option, columns: Option) -> (Vec>, usize) { let Some(sep) = sep else { let cols = columns.unwrap_or(1).clamp(1, wordcraft_doc::table::MAX_COLS); return (lines.chunks(cols).map(|ch| ch.iter().map(String::as_str).collect()).collect(), cols); }; let cols = columns.unwrap_or_else(|| lines.iter().map(|l| l.split(sep).count()).max().unwrap_or(1)).max(1); let mut rows = Vec::new(); for l in lines { let fields: Vec<&str> = l.split(sep).collect(); rows.extend(fields.chunks(cols.min(wordcraft_doc::table::MAX_COLS)).map(<[&str]>::to_vec)); } (rows, cols) } /// The cells Convert Text to Table makes of `paras`, row by row, and the column count, as /// [`text_table_cells`] makes them of their text. Each cell is its field of the source paragraph /// cut out of it, trimmed of surrounding white space, so its formatting, objects (pictures, /// fields, notes…) and tracked changes go with it. fn text_table_paras(paras: Vec, sep: Option, columns: Option) -> Result<(Vec>, usize), CmdError> { use wordcraft_doc::para::OBJ; use wordcraft_doc::table::MAX_COLS; // Each paragraph's fields as byte ranges; an object is never a separator. let fields: Vec>> = paras .iter() .map(|p| { let mut out = Vec::new(); let mut from = 0; if let Some(sep) = sep.filter(|c| *c != OBJ) { for (i, c) in p.text.char_indices().filter(|(_, c)| *c == sep) { out.push(from..i); from = i + c.len_utf8(); } } out.push(from..p.text.len()); out }) .collect(); let cols = match sep { None => columns.unwrap_or(1).clamp(1, MAX_COLS), Some(_) => columns.unwrap_or_else(|| fields.iter().map(Vec::len).max().unwrap_or(1)).max(1), }; let per_row = cols.min(MAX_COLS); let mut rows: Vec> = Vec::new(); let mut row: Vec = Vec::new(); for (mut work, ranges) in paras.into_iter().zip(fields) { // The section break stays out of the cells. work.section = None; let mut pieces = Vec::with_capacity(ranges.len()); // Last field first, so the earlier offsets stay valid. for r in ranges.into_iter().rev() { let field = work.text.get(r.clone()).unwrap_or(""); let start = r.start + (field.len() - field.trim_start().len()); let end = (r.start + field.trim_end().len()).max(start); let mut piece = work.split_off(start)?; piece.split_off(end - start)?; pieces.push(piece); } pieces.reverse(); for piece in pieces { row.push(piece); if row.len() == per_row { rows.push(std::mem::take(&mut row)); } } // With a separator, each paragraph starts a row. if sep.is_some() && !row.is_empty() { rows.push(std::mem::take(&mut row)); } } if !row.is_empty() { rows.push(row); } Ok((rows, cols)) } /// Word's largest Initial column width, 22 in, in points. const MAX_INITIAL_WIDTH: f64 = 22.0 * 72.0; fn from_text(s: &mut Session, v: &Value) -> CmdResult { let sep = match p::str(v, "separator") { None | Some("tab") => Some('\t'), Some("comma") => Some(','), Some("paragraph") => None, Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) { Some(c) if c != '\n' && c != '\r' => Some(c), _ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())), }, Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))), }; let fit = match p::str(v, "autofit") { None | Some("initial") => None, Some(m @ ("fixed" | "contents" | "window")) => Some(m), Some(x) => return Err(CmdError::Params(format!("unknown AutoFit behavior `{x}`"))), }; // Initial column width: 0 (or none) is Auto, the text width shared out; Word takes 0 to 22 in. let col_width = match v.get("width") { // Only the Initial column width uses it; AutoFit sizes the columns itself. _ if matches!(fit, Some("contents" | "window")) => 0.0, None | Some(Value::Null) => 0.0, Some(w) => match w.as_f64() { Some(w) if (0.0..=MAX_INITIAL_WIDTH).contains(&w) => w as f32, _ => return Err(CmdError::Params("`width` must be between 0 and 1584 pt (22 in)".into())), }, }; let columns = p::u64(v, "columns").map(|n| n.clamp(1, wordcraft_doc::table::MAX_COLS as u64) as usize); let (a, b) = s.sel.ordered(); // Checks the selection: only paragraphs, a table between them is refused. text_table_lines(s)?; let paras: Vec = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).cloned()).collect(); let (cells, cols) = text_table_paras(paras, sep, columns)?; // A table has at most 63 columns; a longer row would lose its last fields. if cols > wordcraft_doc::table::MAX_COLS { return Err(CmdError::Failed("a table can have at most 63 columns".into())); } if cells.len() > wordcraft_doc::table::MAX_ROWS { return Err(CmdError::Failed(format!("a table can have at most {} rows", wordcraft_doc::table::MAX_ROWS))); } let width = super::page::sect(s).text_width(); let mut t = Table::new(cells.len().max(1), cols, width); if col_width > 0.0 { // Every column gets that preferred width, even past the margins (layout fits the table // to the text width, as Word draws it); the table itself has no preferred width. t.grid.iter_mut().for_each(|w| *w = col_width); t.rows.iter_mut().flat_map(|row| row.cells.iter_mut()).for_each(|c| c.props.width = Some(col_width)); } for (r, l) in cells.into_iter().enumerate() { for (c, para) in l.into_iter().enumerate() { if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) { cl.blocks = vec![para_block(para)]; } } } for i in (a.path.last()..=b.path.last()).rev() { s.doc.remove_block(a.story, &a.path.with_last(i))?; } s.doc.insert_block(a.story, &a.path, Block::Table(t))?; let mut first = a.path.0.clone(); first.extend([0, 0, 0]); s.sel = Selection::caret(Pos { story: a.story, path: Path(first), off: 0 }); match fit { Some(mode) => autofit(s, &json!({ "mode": mode })), None => sel_result(s), } } fn quick_table(s: &mut Session, v: &Value) -> CmdResult { let kind = p::str(v, "kind").unwrap_or("tabular"); let data: Vec> = match kind { "calendar" => { let mut rows = vec![vec!["M", "T", "W", "T", "F", "S", "S"]]; let days = [ "", "", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "", "", "", ]; for ch in days.chunks(7) { rows.push(ch.to_vec()); } rows } "matrix" => vec![ vec!["", "Column A", "Column B", "Column C"], vec!["Row 1", "1", "2", "3"], vec!["Row 2", "4", "5", "6"], vec!["Row 3", "7", "8", "9"], ], _ => vec![ vec!["Item", "Needed"], vec!["Books", "1"], vec!["Magazines", "3"], vec!["Notebooks", "1"], vec!["Paper pads", "1"], vec!["Pens", "3"], vec!["Pencils", "2"], ], }; let cols = data.iter().map(Vec::len).max().unwrap_or(1); let width = super::page::sect(s).text_width(); let mut t = Table::new(data.len(), cols, width); t.props.style = Some(if kind == "calendar" { "GridTable1Light" } else { "GridTable4AccentBlue" }.into()); for (r, row) in data.iter().enumerate() { for (c, txt) in row.iter().enumerate() { if let Some(cl) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { cl.blocks = vec![para_block(Paragraph::with_text(txt, Default::default()))]; } } } if let Some(r) = t.rows.first_mut() { r.props.header = true; } let story = s.sel.focus.story; let at = super::delete_selection(s)?; let new = s.doc.split_paragraph(&at)?; s.doc.insert_block(story, &new.path, Block::Table(t))?; let mut first = new.path.0.clone(); first.extend([0, 0, 0]); s.sel = Selection::caret(Pos { story, path: Path(first), off: 0 }); let _ = StoryRef::Body; sel_result(s) }