diff --git a/ROADMAP.md b/ROADMAP.md index b602d1dbfd..36ae93b633 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -450,6 +450,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | +| 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input | | 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows | | 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). | | 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes | diff --git a/crates/engine/src/cmd/tools/actions.rs b/crates/engine/src/cmd/tools/actions.rs index e917aa7048..28bc721c13 100644 --- a/crates/engine/src/cmd/tools/actions.rs +++ b/crates/engine/src/cmd/tools/actions.rs @@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult { } Ok(Value::Object(out)) } + +/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`. +pub(super) fn spreadsheet_rows(v: &Value) -> Result>, CmdError> { + // The CSV fallback is for an absent `values`; an explicit null is a malformed argument. + let Some(values) = v.get("values") else { + return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"))); + }; + let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?; + if list.is_empty() || list.len() > 1000 { + return Err(CmdError::Params("`values` needs 1 to 1000 rows".into())); + } + let mut rows: Vec> = Vec::with_capacity(list.len()); + for (r, row) in list.iter().enumerate() { + let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?; + if cells.is_empty() || cells.len() > 63 { + return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1))); + } + if rows.first().is_some_and(|f| f.len() != cells.len()) { + return Err(CmdError::Params(format!( + "`values` row {} has {} cells, row 1 has {}", + r + 1, + cells.len(), + rows.first().map_or(0, Vec::len) + ))); + } + let mut out = Vec::with_capacity(cells.len()); + for cell in cells { + out.push(match cell { + Value::String(x) => x.clone(), + Value::Number(x) => x.to_string(), + Value::Bool(x) => x.to_string(), + Value::Null => String::new(), + _ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))), + }); + } + rows.push(out); + } + Ok(rows) +} diff --git a/crates/engine/src/cmd/tools/specs.rs b/crates/engine/src/cmd/tools/specs.rs index f9a9684d5f..fc4daab3e4 100644 --- a/crates/engine/src/cmd/tools/specs.rs +++ b/crates/engine/src/cmd/tools/specs.rs @@ -1,5 +1,6 @@ use super::actions::{ customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar, + spreadsheet_rows, }; use super::autocorrect::autocorrect_options; use super::compare::{COMPARE_PARAMS, compare}; @@ -213,10 +214,10 @@ pub fn specs() -> Vec { }) .params(r#"{"path": string}"#), CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| { - let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"); - let rows = crate::cmd::mailings::parse_csv(csv); + let rows = spreadsheet_rows(v)?; let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63); - s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?; + let style = p::str(v, "style").unwrap_or("GridTable1Light"); + s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?; let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) }; let t = s.doc.table_mut(s.sel.focus.story, &tp)?; for (r, row) in rows.iter().enumerate() { @@ -226,9 +227,14 @@ pub fn specs() -> Vec { } } } + if p::bool(v, "header").unwrap_or(false) + && let Some(first) = t.rows.first_mut() + { + first.props.header = true; + } sel_result(s) }) - .params(r#"{"csv"?: string}"#), + .params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#), CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| { let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true); let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; diff --git a/crates/engine/src/cmd/tools/tests.rs b/crates/engine/src/cmd/tools/tests.rs index 4683817e60..3d2de2d844 100644 --- a/crates/engine/src/cmd/tools/tests.rs +++ b/crates/engine/src/cmd/tools/tests.rs @@ -255,6 +255,59 @@ fn proofing_language_options() { assert!(s.run("review.language", &json!({"lang": "