mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -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"));
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
use crate::cmd::clipboard::{AI, BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -241,7 +241,8 @@ fn copied_ai_files_paste_as_the_pdf_or_postscript_inside() {
|
||||
let ps = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 40 40\n%%EndComments\n1 0 0 setrgbcolor\nnewpath 0 0 moveto 40 0 lineto 40 40 lineto closepath fill\nshowpage\n";
|
||||
let of = |name: &str, bytes: &[u8]| file_flavour(name, &bytes[..bytes.len().min(FILE_HEAD)], &PASTE_ORDER);
|
||||
for name in ["art.ai", "art.ait", "ART.AI"] {
|
||||
assert_eq!(of(name, &pdf), Some(PDF), "{name}");
|
||||
// Asked for as PDF; Paste reads it from its editing data (tests_aiimport).
|
||||
assert_eq!(of(name, &pdf), Some(AI), "{name}");
|
||||
assert_eq!(of(name, ps), Some(EPS), "{name}");
|
||||
}
|
||||
// Neither PDF nor PostScript: nothing to paste, whatever the name says.
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, EPS, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
|
||||
use vectorcraft_engine::cmd::clipboard::{AI, BITMAP, EMF, EPS, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::background::{self, Writer};
|
||||
@@ -59,6 +59,7 @@ pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static
|
||||
}
|
||||
SVG => ("clipboard.importSvg", json!({ "dataBase64": b64(), "center": center })),
|
||||
PDF => ("clipboard.importPdf", json!({ "dataBase64": b64(), "center": center })),
|
||||
AI => ("clipboard.importPdf", json!({ "dataBase64": b64(), "editingData": true, "center": center })),
|
||||
EPS => ("clipboard.importEps", json!({ "dataBase64": b64(), "center": center })),
|
||||
EMF => ("clipboard.importEmf", json!({ "dataBase64": b64(), "center": center })),
|
||||
mime => ("clipboard.importImage", json!({ "dataBase64": b64(), "mime": mime, "center": center })),
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, EPS, Flavour, PASTE_ORDER, PDF, PNG, TEXT, file_flavour};
|
||||
use vectorcraft_engine::cmd::clipboard::{AI, EMF, EPS, Flavour, PASTE_ORDER, PDF, PNG, TEXT, file_flavour};
|
||||
use vectorcraft_testkit::ai;
|
||||
|
||||
use crate::sysclip::import_command;
|
||||
use crate::tests_sysclip::{app, blue_png, copy_elsewhere, pasted, run};
|
||||
@@ -58,3 +59,17 @@ fn an_eps_from_another_app_pastes_as_vectors() {
|
||||
assert_eq!(file_flavour("equation.dat", &eps, &PASTE_ORDER), Some(EPS));
|
||||
assert_eq!(file_flavour("equation.eps", &eps, &[TEXT]), None);
|
||||
}
|
||||
|
||||
/// A `.ai` copied in a file manager pastes the objects of its editing data, named and in their
|
||||
/// layers' stacking order, not its page's.
|
||||
#[test]
|
||||
fn a_copied_ai_file_pastes_from_its_editing_data() {
|
||||
let file = ai::ai(&ai::sample_data(), ai::page_pdf());
|
||||
assert_eq!(file_flavour("art.ai", &file, &PASTE_ORDER), Some(AI));
|
||||
let (cmd, params) = import_command(&Flavour { mime: AI, data: file.clone() }, None);
|
||||
assert_eq!((cmd, ¶ms["editingData"]), ("clipboard.importPdf", &json!(true)));
|
||||
let (mut app, _) = app();
|
||||
app.paste_from_host(&egui::Context::default(), Flavour { mime: AI, data: file }, egui::Modifiers::COMMAND);
|
||||
let names: Vec<String> = pasted(&app).iter().map(|n| n.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ System clipboard formats: through the desktop app, `edit.copy` and `edit.cut` pu
|
||||
lists to the system clipboard (once they are written, the status bar shows the first of the PNG's and PDF's
|
||||
`warnings`, such as an image that couldn't be decoded), and every Paste command first loads what another app copied (`clipboard.importSvg`,
|
||||
`importPdf`, `importEps`, `importText` or `importImage`, centred in the view; the desktop app reads PDF and EPS from
|
||||
the Windows clipboard and, with SVG, from the macOS pasteboard, and an `.eps` file, or a `.ai` or `.ait` file that holds a PDF or PostScript, copied in a file manager anywhere); an import error answers the Paste with that error
|
||||
the Windows clipboard and, with SVG, from the macOS pasteboard, and an `.eps` file, or a `.ai` or `.ait` file that holds a PDF or PostScript, copied in a file manager anywhere; a copied `.ai` or `.ait` file that holds a PDF loads with `importPdf {editingData: true}`, which reads its editing data); an import error answers the Paste with that error
|
||||
and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no
|
||||
Paste event for it). Screenshots paste on every platform: Windows reads `PNG`, else `CF_DIBV5`/`CF_DIB`, macOS the
|
||||
pasteboard's PNG or TIFF, Linux `image/png`, and the web the picture the browser's paste event carries (#457).
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On
|
||||
|
||||
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. egui's own text paste into fields goes through smithay-clipboard and works either way.
|
||||
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
|
||||
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text).
|
||||
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text).
|
||||
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own.
|
||||
- **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (Preferences › Performance › Linux Display Server › X11 (XWayland); X11 (XWayland) When a Pen Is Attached (`x11WithPen`), which opens the window through XWayland only when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and logs `pen found: opening the window through XWayland`; `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
|
||||
|
||||
|
||||
+9
-4
@@ -2913,9 +2913,12 @@ transparent around them). As in `document.export`, the PDF's and the PNG's repli
|
||||
shows the first note of the PDF and PNG it offers in the status bar.
|
||||
|
||||
What other apps copied goes into the clipboard with `clipboard.importSvg`, `clipboard.importPdf {dataBase64, page?,
|
||||
password?}` (the page's objects), `clipboard.importEps {dataBase64}` (an EPS or other PostScript picture's objects,
|
||||
as `document.open` reads one), `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`
|
||||
password?, editingData?}` (the page's objects; with `editingData: true` the data is a `.ai` or `.ait` file, and every
|
||||
object of its editing data comes in as `document.open` reads it, each layer's objects in stacking order and a hidden
|
||||
layer's objects hidden, or the page's objects when `document.open` would not use that copy), `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`
|
||||
(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
|
||||
@@ -2927,7 +2930,9 @@ them. Windows carries every format both ways and pastes a bitmap from PNG, else
|
||||
from the device-independent bitmap screenshots copy (`CF_DIBV5`, then `CF_DIB`; alpha kept, opaque when it is zero
|
||||
throughout, at most 32768 px a side); macOS and Linux carry one format (the text, else the PNG) and paste text and
|
||||
bitmaps (macOS the pasteboard's PNG, else TIFF, as screenshots copy them). On the web Copy publishes SVG text, and
|
||||
Paste takes SVG text and the pictures and files a paste carries (a screenshot pastes as an embedded image).
|
||||
Paste takes SVG text and the pictures and files a paste carries (a screenshot pastes as an embedded image). A `.ai`
|
||||
or `.ait` file that holds a PDF, copied in a file manager or carried by a paste, is loaded with `clipboard.importPdf
|
||||
{editingData: true}`, and its objects go into the active layer.
|
||||
|
||||
The `pasteTextFormatting` preference (`keep` or `plain`): with `plain`, text the Type tool copied pastes into type
|
||||
without its formatting, taking the style at the caret.
|
||||
|
||||
@@ -762,7 +762,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one.
|
||||
- Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
|
||||
- Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499).
|
||||
- A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes as a copied `.pdf` file does, and one saved as PostScript pastes as a copied `.eps` file does.
|
||||
- A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes the objects of its editing data, and one saved as PostScript pastes as a copied `.eps` file does.
|
||||
- A copied `.ai` or `.ait` file that holds a PDF pastes the objects of its editing data as File › Open reads them: every layer's objects go into the active layer in their stacking order, with their names, hidden objects and type objects, instead of the PDF page's art with its type as outlines. When the editing data can't be read, the file pastes its page as before (`clipboard.importPdf {editingData}`). The objects of a hidden layer come in hidden, from a pasted EPS too.
|
||||
- Import and export, later 2026-10-13 merges: SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); the warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); the pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); the ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); a zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613).
|
||||
- Files and untrusted input, 2026-10-14 merges: EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773).
|
||||
- **Fields, search and window, 2026-10-14 merges:** Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation).
|
||||
|
||||
Reference in New Issue
Block a user