mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Clipboard: a copied .ai or .ait file pastes the objects of its editing data (#2408)
This commit is contained in:
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -30,6 +30,10 @@ pub const EMF: &str = "image/emf";
|
||||
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
|
||||
/// beside, or instead of, a PDF (#1499).
|
||||
pub const EPS: &str = "application/postscript";
|
||||
/// A `.ai` or `.ait` file whose bytes are a PDF, copied in a file manager ([`file_flavour`]): Paste
|
||||
/// reads it as `document.open` does, from its editing data. The type is VectorCraft's own: no
|
||||
/// system clipboard carries it, and Paste asks for [`PDF`] to get it.
|
||||
pub const AI: &str = "application/x-ai";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
|
||||
@@ -71,8 +75,9 @@ pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
|
||||
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file is [`AI`] when its bytes are a PDF (it
|
||||
/// pastes when PDF is asked for) and EPS when they are PostScript. The file's bytes are that
|
||||
/// flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
@@ -83,7 +88,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
"pdf" => (PDF, PDF),
|
||||
"ps" => (EPS, EPS),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, AI),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
@@ -191,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load PDF into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), center?: [x, y]} replace the clipboard with the objects of one PDF page (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_pdf
|
||||
),
|
||||
@@ -209,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load EPS into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use; a hidden layer's objects hidden), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_eps
|
||||
),
|
||||
@@ -464,8 +469,19 @@ fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
|
||||
/// opened (command `cmd`).
|
||||
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
import_loaded(s, p, cmd, loaded)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with every object of `loaded`, a file read as `document.open` reads it
|
||||
/// (command `cmd`). The layers are left out, and the objects of a hidden layer come in hidden.
|
||||
fn import_loaded(s: &mut Session, p: &Value, cmd: &str, mut loaded: fileio::Loaded) -> Result<Value> {
|
||||
loaded.doc.drop_edit_modes();
|
||||
for layer in loaded.doc.layers.iter_mut().filter(|l| !l.visible) {
|
||||
for object in std::sync::Arc::make_mut(layer).children_mut().into_iter().flatten() {
|
||||
std::sync::Arc::make_mut(object).visible = false;
|
||||
}
|
||||
}
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(cmd, "the picture has no objects"));
|
||||
@@ -481,7 +497,19 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(C, "page must be a whole number from 1"))? as usize,
|
||||
};
|
||||
let editing = match p.get("editingData").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "editingData must be true or false"))?,
|
||||
};
|
||||
let opts = fileio::LoadOptions { password: str_param(p, "password").map(str::to_string), ..Default::default() };
|
||||
// A copied .ai file pastes every object of its editing data, as document.open reads the file.
|
||||
// When document.open would not use that copy, the file pastes its page, as a PDF does.
|
||||
if editing
|
||||
&& let Ok(loaded) = fileio::load_with("Clipboard.ai", &bytes, &opts)
|
||||
&& (loaded.editing_data || loaded.restored)
|
||||
{
|
||||
return import_loaded(s, p, C, loaded);
|
||||
}
|
||||
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Loaded {
|
||||
/// Only a stand-in picture of the file (an Affinity document whose native data couldn't be
|
||||
/// read): Open shows it with its warning; Place, templates and libraries refuse it.
|
||||
pub preview_only: bool,
|
||||
/// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art
|
||||
/// that it carries (its layers), not from its PDF part alone.
|
||||
pub editing_data: bool,
|
||||
}
|
||||
|
||||
impl Loaded {
|
||||
@@ -156,17 +159,17 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
|
||||
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
|
||||
/// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its
|
||||
/// first page's `UserUnit`).
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>) {
|
||||
/// first page's `UserUnit`) → (document, warnings, whether its layers were read).
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>, bool) {
|
||||
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
|
||||
return (doc, warnings);
|
||||
return (doc, warnings, false);
|
||||
}
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings, false) };
|
||||
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
|
||||
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit);
|
||||
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
return (doc, warnings, false);
|
||||
}
|
||||
// The layers' text objects hold type and the stroked paths around it, which can include stroked
|
||||
// glyphs the PDF part kept as outlines: without any such glyph in the document, the warnings on
|
||||
@@ -177,7 +180,7 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
|
||||
// to the page.
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
|
||||
(doc, warnings)
|
||||
(doc, warnings, true)
|
||||
}
|
||||
|
||||
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
|
||||
@@ -209,6 +212,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let mut editing_data = false;
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
@@ -243,13 +247,20 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// Pages of text alone: the editing copy alone where it reads, as for a file
|
||||
// saved without its PDF part (#1387: else the page, which is the art).
|
||||
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
|
||||
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
|
||||
let (doc, warnings, read) = match alone {
|
||||
Some((doc, warnings)) => (doc, warnings, true),
|
||||
None => through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit),
|
||||
};
|
||||
editing_data = read;
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has (`pages` that
|
||||
// name every page read it as the whole file).
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
let whole = LoadOptions { pages: None, ..opts.clone() };
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }).unwrap_or(Err(e))
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts })
|
||||
.unwrap_or(Err(e))
|
||||
.inspect(|_| editing_data = true)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})?
|
||||
@@ -295,7 +306,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
doc.title = title;
|
||||
}
|
||||
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
|
||||
}
|
||||
|
||||
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
|
||||
|
||||
@@ -103,6 +103,87 @@ fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
|
||||
assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}");
|
||||
}
|
||||
|
||||
/// A page of a square and a line of type, as a `.ai`'s PDF part draws them.
|
||||
const SQUARE_AND_TYPE: &str = "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET";
|
||||
|
||||
/// `file` loaded with `clipboard.importPdf` (`editingData` as given) and pasted → the command's
|
||||
/// reply and the objects the paste selected, in stacking order.
|
||||
fn paste(s: &mut Session, file: &[u8], editing_data: bool) -> (Value, Vec<Node>) {
|
||||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(file), "editingData": editing_data});
|
||||
let r = s.execute("clipboard.importPdf", &p).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
(r, st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id).cloned()).collect())
|
||||
}
|
||||
|
||||
/// Is any of `nodes` (or an object inside one) type?
|
||||
fn has_type(nodes: &[Node]) -> bool {
|
||||
let mut found = false;
|
||||
nodes.iter().for_each(|n| n.walk(&mut |c| found |= matches!(c.kind, NodeKind::Text(_))));
|
||||
found
|
||||
}
|
||||
|
||||
/// A `.ai` copied in a file manager pastes the objects of its editing data, as `document.open`
|
||||
/// reads it: both layers' objects in their stacking order, into the active layer, with their names
|
||||
/// and hidden members, and its type as type. Without `editingData`, as for a PDF, its page pastes:
|
||||
/// the shown layer's art without names.
|
||||
#[test]
|
||||
fn a_copied_ai_file_pastes_from_its_editing_data() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
|
||||
let (r, pasted) = paste(&mut s, &file, true);
|
||||
assert_eq!(r["count"], 7, "{r}");
|
||||
let pasted_names: Vec<String> = pasted.iter().map(|n| n.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(pasted_names, ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
|
||||
let pair = pasted[0].children().unwrap();
|
||||
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
|
||||
assert!(matches!(pasted[5].kind, NodeKind::Layer { .. }) && pasted[5].locked, "the sublayer comes as it is");
|
||||
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
|
||||
let layers = &s.doc().unwrap().doc.layers;
|
||||
assert_eq!(layers.len(), 1, "no layer is added");
|
||||
assert!(pasted.iter().all(|n| layers[0].children().unwrap().iter().any(|c| c.id == n.id)), "into the active layer");
|
||||
// The same editing data in an EPS: the same objects, the hidden layer's objects hidden.
|
||||
let eps = ai::eps(&ai::sample_data(), ai::page_ps());
|
||||
s.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(&eps)})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let shown = |nodes: Vec<&Node>| nodes.iter().map(|n| (n.name.clone().unwrap_or_default(), n.visible)).collect::<Vec<_>>();
|
||||
assert_eq!(shown(st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id)).collect()), shown(pasted.iter().collect()));
|
||||
let (r, page) = paste(&mut s, &file, false);
|
||||
assert!(page.iter().all(|n| n.name.is_none()), "{r}");
|
||||
// A page of text only, or only a placeholder (saved without PDF compatibility): the editing
|
||||
// data's objects all the same.
|
||||
for (page, boxed) in [(TEXT_ART, "/ArtBox [0 0 200 100]"), (PLACEHOLDER, "")] {
|
||||
let (r, pasted) = paste(&mut s, &ai::ai_with(&compressed(&ai::sample_data()), page, boxed), true);
|
||||
assert_eq!(pasted.first().and_then(|n| n.name.as_deref()), Some("Pair"), "{page}: {r}");
|
||||
assert_eq!(r["count"], 7, "{page}: {r}");
|
||||
}
|
||||
// Type: a text object from the editing data, outlines from the page.
|
||||
let data = ai::editing_data(
|
||||
200.0,
|
||||
100.0,
|
||||
&ai::layer("Art", &format!("0 g\n{}f\n/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n", ai::rect(10.0, 10.0, 50.0, 50.0))),
|
||||
);
|
||||
let typed = ai::ai(&compressed(data.as_bytes()), SQUARE_AND_TYPE);
|
||||
// (The page's type comes in as type too now that PDF import keeps it, so only this side is told apart.)
|
||||
let (r, pasted) = paste(&mut s, &typed, true);
|
||||
assert!(has_type(&pasted), "{r}: {pasted:?}");
|
||||
// A `.ai` VectorCraft wrote: the document it carries, its hidden square too.
|
||||
let mut other = Session::new();
|
||||
other.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
for x in [10, 100] {
|
||||
other.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap();
|
||||
}
|
||||
other.execute("object.hide", &json!({})).unwrap();
|
||||
let saved = other.execute("document.export", &json!({"format": "ai"})).unwrap();
|
||||
let saved = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let (r, pasted) = paste(&mut s, &saved, true);
|
||||
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, false], "{r}");
|
||||
let (r, page) = paste(&mut s, &saved, false);
|
||||
assert!(page.iter().all(|n| n.visible), "{r}");
|
||||
}
|
||||
|
||||
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
|
||||
#[test]
|
||||
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
|
||||
@@ -173,6 +254,29 @@ fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE, unread]), "{r}");
|
||||
}
|
||||
|
||||
/// A copied `.ai` whose editing data can't be read, or isn't there, pastes its page as a PDF
|
||||
/// does: the same objects as without `editingData`.
|
||||
#[test]
|
||||
fn a_copied_ai_file_whose_editing_data_cant_be_read_pastes_its_page() {
|
||||
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
for (what, private) in
|
||||
[("damaged", b"%AI12_CompressedData not zlib".to_vec()), ("unread layers", compressed(symbols.as_bytes())), ("none", vec![])]
|
||||
{
|
||||
let file = ai::ai(&private, SQUARE_AND_TYPE);
|
||||
let (r, pasted) = paste(&mut s, &file, true);
|
||||
let (want, page) = paste(&mut s, &file, false);
|
||||
assert_eq!((&r["count"], &r["warnings"]), (&want["count"], &want["warnings"]), "{what}");
|
||||
let kinds = |nodes: &[Node]| nodes.iter().map(|n| std::mem::discriminant(&n.kind)).collect::<Vec<_>>();
|
||||
assert_eq!(kinds(&pasted), kinds(&page), "{what}");
|
||||
assert!(!pasted.is_empty(), "{what}: {r}");
|
||||
}
|
||||
// Editing data that reads and draws as the page does: its objects, not the page's.
|
||||
let (r, _) = paste(&mut s, &ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), true);
|
||||
assert_eq!(r["count"], 7, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
|
||||
|
||||
Reference in New Issue
Block a user