Merge pull request #5942 from mockydevs/feat/paste-spreadsheet-table

Paste: cells copied from a spreadsheet paste as a table of type over gridlines
This commit is contained in:
BFlat
2026-11-07 09:04:38 +08:00
committed by GitHub
6 changed files with 264 additions and 7 deletions
+1
View File
@@ -11,6 +11,7 @@
mod flavours;
pub(crate) mod resources;
mod table;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
+19 -4
View File
@@ -191,7 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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 }))
+203
View File
@@ -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<Vec<Vec<String>>> {
if !text.contains('\t') {
return None;
}
let mut rows: Vec<Vec<String>> = vec![];
let mut row: Vec<String> = 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<String>], 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<Option<(Node, Rect)>>> = 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<f64> =
(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<f64> = cells.iter().map(|r| r.iter().flatten().map(|(_, b)| b.height()).fold(line, f64::max) + 2.0 * PAD).collect();
let xs: Vec<f64> = std::iter::once(0.0)
.chain(widths.iter().scan(0.0, |x, w| {
*x += w;
Some(*x)
}))
.collect();
let ys: Vec<f64> = 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<Node> {
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));
}
}
+35
View File
@@ -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();
+3 -2
View File
@@ -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 |
+3 -1
View File
@@ -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