mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
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:
@@ -11,6 +11,7 @@
|
||||
|
||||
mod flavours;
|
||||
pub(crate) mod resources;
|
||||
mod table;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user