From 591fc9b21ec81704c294a1b7708f9d39cf8cb2e2 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:54:49 -0500 Subject: [PATCH] Paste: cells copied from a spreadsheet paste as a table of type over gridlines Tab-separated rows with the same number of cells (quoted cells too) become a group of point type, one per cell, in columns and rows over 0.5 pt gridlines; other text pastes as before. Fixes #5296. --- crates/engine/src/cmd/clipboard.rs | 1 + crates/engine/src/cmd/clipboard/flavours.rs | 23 ++- crates/engine/src/cmd/clipboard/table.rs | 203 ++++++++++++++++++++ crates/engine/src/tests/clipflavours.rs | 35 ++++ docs/file-format-parity.md | 5 +- docs/mcp.md | 4 +- 6 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/clipboard/table.rs diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 1f2707fbe..6d1e7284c 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -11,6 +11,7 @@ mod flavours; pub(crate) mod resources; +mod table; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index f4c096d9b..1d6f2af3e 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -191,7 +191,7 @@ pub fn specs() -> Vec { "Load Text into Clipboard", [], None, - "{text, center?: [x, y]} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste", + "{text, center?: [x, y], table?: true} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste. Cells copied from a spreadsheet (tab-separated, at least two columns, every row with the same number of cells, cells in double quotes may hold tabs and line breaks; at most 1000 rows, 256 columns and 20,000 cells) become a group \"Table\": point type for each cell that holds text, in columns as wide as their widest cell and rows as tall as their tallest, over a group \"Gridlines\" of 0.5 pt black lines around every cell → {count, rows, columns}; table: false pastes them as plain text", has_doc, import_text ), @@ -491,9 +491,24 @@ fn import_text(s: &mut Session, p: &Value) -> Result { if text.trim().is_empty() { return Err(bad(C, "the text is empty")); } - let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null)); - t.system_layout = s.doc()?.doc.setup.system_layout; - crate::cmd::typecmd::refresh_bounds(&mut t); + let style = super::super::create::new_type_style(s, &Value::Null); + let system_layout = s.doc()?.doc.setup.system_layout; + let layout = |t: &mut TextObject| { + t.system_layout = system_layout; + crate::cmd::typecmd::refresh_bounds(t); + }; + let table = match p.get("table").filter(|v| !v.is_null()) { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad(C, "table must be true or false"))?, + }; + // Cells copied from a spreadsheet paste as a table of type over gridlines. + if let Some(rows) = table.then(|| super::table::parse(text)).flatten() { + let n = super::table::table(&rows, &style, layout); + let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?; + return Ok(json!({ "count": count, "rows": rows.len(), "columns": rows.first().map_or(0, Vec::len) })); + } + let mut t = TextObject::point(Point::ZERO, text, style); + layout(&mut t); let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t))); let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?; Ok(json!({ "count": count })) diff --git a/crates/engine/src/cmd/clipboard/table.rs b/crates/engine/src/cmd/clipboard/table.rs new file mode 100644 index 000000000..392be9895 --- /dev/null +++ b/crates/engine/src/cmd/clipboard/table.rs @@ -0,0 +1,203 @@ +//! Cells copied from a spreadsheet (Excel, Google Sheets, LibreOffice Calc, Numbers): the plain +//! text they put on the clipboard is tab-separated values, one row a line, every row with the +//! same number of cells. `clipboard.importText` pastes such text as a table: a group of point +//! type, one object for each cell that holds text, laid out in columns as wide as their widest +//! cell and rows as tall as their tallest, over gridlines around every cell. + +use std::sync::Arc; + +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; + +/// Most rows a pasted table takes; more pastes as plain text. +pub(crate) const MAX_ROWS: usize = 1000; +/// Most columns a pasted table takes; more pastes as plain text. +pub(crate) const MAX_COLUMNS: usize = 256; +/// Most cells (rows × columns) a pasted table takes; more pastes as plain text. +pub(crate) const MAX_CELLS: usize = 20_000; +/// Space between a cell's text and its gridlines, in points. +const PAD: f64 = 4.0; +/// The gridlines' stroke weight, in points. +const GRIDLINE: f64 = 0.5; + +/// The rows of `text` (line breaks already `\n`, trailing ones dropped) when it is a table copied +/// from a spreadsheet: tab-separated cells, at least two columns, every row with the same number +/// of cells, within the caps above. A cell in double quotes may hold tabs, line breaks and +/// doubled quotes (`""`), as the spreadsheets write them. `None` for any other text, which pastes +/// as it always has. +pub(crate) fn parse(text: &str) -> Option>> { + if !text.contains('\t') { + return None; + } + let mut rows: Vec> = vec![]; + let mut row: Vec = vec![]; + let mut cell = String::new(); + let mut chars = text.chars().peekable(); + // At the start of a cell, a quote opens a quoted cell. + let mut start = true; + let mut quoted = false; + let mut cells = 0usize; + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + chars.next(); + cell.push('"'); + } else { + quoted = false; + } + } else { + cell.push(c); + } + continue; + } + match c { + '"' if start => { + quoted = true; + start = false; + } + '\t' | '\n' => { + row.push(std::mem::take(&mut cell)); + cells += 1; + if row.len() > MAX_COLUMNS || cells > MAX_CELLS { + return None; + } + if c == '\n' { + rows.push(std::mem::take(&mut row)); + if rows.len() > MAX_ROWS { + return None; + } + } + start = true; + } + c => { + cell.push(c); + start = false; + } + } + } + // A quote left open is not a spreadsheet's. + if quoted { + return None; + } + row.push(cell); + rows.push(row); + if rows.iter().any(|r| r.len() > MAX_COLUMNS) || cells + 1 > MAX_CELLS { + return None; + } + let columns = rows.first()?.len(); + let fits = columns >= 2 && rows.len() <= MAX_ROWS && rows.iter().all(|r| r.len() == columns); + // The first and last columns hold something: text indented with tabs is not a table. + let used = |k: usize| rows.iter().any(|r| r.get(k).is_some_and(|c| !c.trim().is_empty())); + (fits && used(0) && used(columns - 1)).then_some(rows) +} + +/// `rows` laid out as a table (see the module docs) with its top left at the origin: one group +/// holding a group of gridlines and the cells' type. `style` is the type's style; `layout` +/// refreshes a type object's bounds. +pub(crate) fn table(rows: &[Vec], style: &CharStyle, layout: impl Fn(&mut TextObject)) -> Node { + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + let mut next = 0u64; + let mut id = || { + next += 1; + NodeId(next) + }; + // Each cell's type, at the origin, with its bounds. + let mut cells: Vec>> = vec![]; + for row in rows { + let mut out = vec![]; + for text in row { + if text.trim().is_empty() { + out.push(None); + continue; + } + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut t = TextObject::point(Point::ZERO, &text, style.clone()); + layout(&mut t); + let n = Node::new(id(), NodeKind::Text(Box::new(t))); + out.push(n.geometric_bounds().filter(|b| b.is_finite()).map(|b| (n, b))); + } + cells.push(out); + } + // A row with nothing in it is as tall as one line of the type. + let line = style.size.max(1.0) * 1.2; + let widths: Vec = + (0..columns).map(|k| cells.iter().filter_map(|r| r.get(k)?.as_ref()).map(|(_, b)| b.width()).fold(line, f64::max) + 2.0 * PAD).collect(); + let heights: Vec = cells.iter().map(|r| r.iter().flatten().map(|(_, b)| b.height()).fold(line, f64::max) + 2.0 * PAD).collect(); + let xs: Vec = std::iter::once(0.0) + .chain(widths.iter().scan(0.0, |x, w| { + *x += w; + Some(*x) + })) + .collect(); + let ys: Vec = std::iter::once(0.0) + .chain(heights.iter().scan(0.0, |y, h| { + *y += h; + Some(*y) + })) + .collect(); + let (right, bottom) = (xs.last().copied().unwrap_or(0.0), ys.last().copied().unwrap_or(0.0)); + let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), GRIDLINE); + let mut lines = vec![]; + for y in &ys { + lines.push(segment(id(), (0.0, *y), (right, *y), &stroke)); + } + for x in &xs { + lines.push(segment(id(), (*x, 0.0), (*x, bottom), &stroke)); + } + let mut grid = Node::group(id(), lines); + grid.name = Some("Gridlines".into()); + let mut children = vec![Arc::new(grid)]; + for (r, row) in cells.into_iter().enumerate() { + for (k, cell) in row.into_iter().enumerate() { + let (Some((mut n, b)), Some(x), Some(y)) = (cell, xs.get(k), ys.get(r)) else { continue }; + // The text's top left a pad inside the cell's. + n.transform(Affine::translate((x + PAD - b.x0, y + PAD - b.y0)), false); + children.push(Arc::new(n)); + } + } + let mut group = Node::group(id(), children); + group.name = Some("Table".into()); + group +} + +/// A straight stroked line from `a` to `b`. +fn segment(id: NodeId, a: (f64, f64), b: (f64, f64), stroke: &Appearance) -> Arc { + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + Arc::new(Node::path(id, PathData::from_bezpath(&p), stroke.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spreadsheet_cells_are_told_from_other_text() { + let rows = parse("Name\tQty\nApples\t3\nPears\t").unwrap(); + assert_eq!(rows, vec![vec!["Name", "Qty"], vec!["Apples", "3"], vec!["Pears", ""]]); + // Quoted cells hold line breaks, tabs and quotes. + let rows = parse("\"Two\nlines\"\t\"a\tb\"\n\"say \"\"hi\"\"\"\tx").unwrap(); + assert_eq!(rows, vec![vec!["Two\nlines", "a\tb"], vec!["say \"hi\"", "x"]]); + // Not tables: no tabs, ragged rows, indented text, an unclosed quote. + for t in ["plain words", "one\ntwo", "a\tb\nc", "\tindented\n\tcode", "a\t\"open", "x\t"] { + assert_eq!(parse(t), None, "{t:?}"); + } + // A quote inside a cell is just a character. + assert_eq!(parse("5\" pipe\t2").unwrap(), vec![vec!["5\" pipe", "2"]]); + } + + #[test] + fn tables_past_the_caps_paste_as_text() { + let wide = vec!["x"; MAX_COLUMNS + 1].join("\t"); + assert_eq!(parse(&wide), None); + let tall = vec!["a\tb"; MAX_ROWS + 1].join("\n"); + assert_eq!(parse(&tall), None); + let many = vec![vec!["c"; 100].join("\t"); MAX_CELLS / 100 + 1].join("\n"); + assert_eq!(parse(&many), None); + let fits = vec!["a\tb"; MAX_ROWS].join("\n"); + assert_eq!(parse(&fits).map(|r| r.len()), Some(MAX_ROWS)); + } +} diff --git a/crates/engine/src/tests/clipflavours.rs b/crates/engine/src/tests/clipflavours.rs index 313935c21..5955389ae 100644 --- a/crates/engine/src/tests/clipflavours.rs +++ b/crates/engine/src/tests/clipflavours.rs @@ -333,6 +333,41 @@ fn import_text_then_paste_gives_point_text() { } } +#[test] +fn spreadsheet_cells_paste_as_a_table_of_type_over_gridlines() { + let mut s = session(); + // As Excel puts three rows of two columns on the clipboard, an empty cell included. + let r = run(&mut s, "clipboard.importText", json!({"text": "Fruit\tQty\r\nApples\t3\r\nPears\t\r\n"})); + assert_eq!((r["count"].clone(), r["rows"].clone(), r["columns"].clone()), (json!(1), json!(3), json!(2))); + run(&mut s, "edit.paste", json!({"center": [200, 150]})); + let first = pasted(&s); + assert_eq!(first.len(), 1); + let table = &first[0]; + assert_eq!(table.name.as_deref(), Some("Table")); + let kids = table.children().unwrap(); + let grid = &kids[0]; + assert_eq!(grid.name.as_deref(), Some("Gridlines")); + assert_eq!(grid.children().unwrap().len(), 4 + 3, "four row lines, three column lines"); + // One type object a cell that holds text, each inside its own cell. + let cells: Vec<_> = kids[1..].iter().map(|n| (text_of(n).plain_text(), n.geometric_bounds().unwrap())).collect(); + let texts: Vec<&str> = cells.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(texts, ["Fruit", "Qty", "Apples", "3", "Pears"]); + let at = |t: &str| cells.iter().find(|c| c.0 == t).unwrap().1; + assert!(at("Qty").x0 > at("Apples").x1, "the second column starts past the widest of the first"); + assert!((at("Qty").x0 - at("3").x0).abs() < 1e-6 && (at("Fruit").x0 - at("Pears").x0).abs() < 1e-6, "columns line up"); + assert!(at("Apples").y0 > at("Fruit").y1 && at("Pears").y0 > at("Apples").y1, "rows go down: {cells:?}"); + let b = table.geometric_bounds().unwrap(); + assert!((b.center().x - 200.0).abs() < 1.0 && (b.center().y - 150.0).abs() < 1.0, "{b:?}"); + // table: false, and text that isn't a spreadsheet's, paste as one point type object. + for (text, table) in [("a\tb\nc\td", json!(false)), ("\tindented\n\tcode", Value::Null), ("x\ty\nz", Value::Null)] { + run(&mut s, "clipboard.importText", json!({"text": text, "table": table})); + run(&mut s, "edit.paste", json!({})); + let p = pasted(&s); + assert_eq!((p.len(), text_of(&p[0]).plain_text()), (1, text.to_string()), "{text:?}"); + } + assert!(s.execute("clipboard.importText", &json!({"text": "a\tb", "table": "yes"})).is_err()); +} + #[test] fn type_only_copies_offer_their_text() { let mut s = session(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..971dfa016 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Paste: cells copied from a spreadsheet paste as a table of point type over gridlines, #5296; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -177,7 +177,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | CSS | export (CSS Properties) | — | yes | | | OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) | | Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | -| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art | +| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) | ## PDF editable imports @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 91589c619..ee359ccdf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4308,7 +4308,9 @@ layer's objects hidden, or the page's objects when `document.open` would not use compatibility comes in from its editing data the same way with or without `editingData`), `clipboard.importEps {dataBase64}` (an EPS or other PostScript picture's objects, as `document.open` reads one, a hidden layer's objects hidden), `clipboard.importImage {dataBase64, mime?}` (an embedded image at 100% of its physical size) or -`clipboard.importText {text}` (point text in the default type style), each centred on `center` +`clipboard.importText {text, table?}` (point text in the default type style; cells copied from a spreadsheet, +tab-separated rows with the same number of cells, become a group `Table` of point type per cell over 0.5 pt +`Gridlines`, unless `table: false`), each centred on `center` (default: the first artboard); then any `edit.paste*` command pastes it. The desktop app does this itself: Copy and Cut publish the formats to the system clipboard, and a Paste outside text editing first reads what another app copied (SVG, then PDF, then text, then a bitmap; SVG markup in text counts as SVG). A background thread makes the