From b3f6edddfe6ecf2a01840ae5e3969c4771c2dc5b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 04:00:24 +0700 Subject: [PATCH] Fix #1499: PDF and EPS another app copied paste as editable vector art Paste read PDF only from the Windows clipboard and EPS nowhere, so on macOS a copied equation or drawing pasted nothing new (the last thing copied in the app came back). - macOS: the pasteboard's PDF, EPS and SVG are read, ahead of text and bitmaps. - Windows: EPS is read beside PDF. - Everywhere: an .eps file copied in a file manager pastes. - clipboard.importEps loads an EPS into the clipboard as document.open reads one. --- apps/vectorcraft/src/clipboard.rs | 139 +++++++++++++------- crates/engine/src/cmd/clipboard.rs | 2 +- crates/engine/src/cmd/clipboard/flavours.rs | 39 +++++- crates/engine/tests/import_fuzz.rs | 7 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/sysclip.rs | 5 +- crates/ui-egui/src/tests_sysclip_emf.rs | 36 ++++- docs/control-protocol.md | 3 +- docs/mcp.md | 3 +- 23 files changed, 191 insertions(+), 58 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 44451430..165c0a81 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -2,20 +2,21 @@ //! (`Services::system_clipboard`). //! //! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go; -//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that +//! Paste reads each of them and EPS, a bitmap as PNG, else as the device-independent bitmap that //! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`). //! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup), -//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF -//! as they are, as screenshots copy them). On Wayland, arboard's data-control +//! else the PNG as a bitmap; Paste reads text and bitmaps, and on macOS the pasteboard's SVG, PDF +//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on +//! Linux arboard reads no other formats). On Wayland, arboard's data-control //! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps //! copied), falling back to X11 where the compositor lacks the protocol. -//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a -//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text). +//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, +//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). use std::io::{Cursor, Read}; use std::path::PathBuf; -use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour}; +use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour}; use vectorcraft_ui_egui::SystemClipboard; /// This platform's system clipboard. @@ -27,6 +28,34 @@ pub fn system_clipboard() -> Box { cb } +/// The registered Windows clipboard formats a flavour is written under (and read from, first held +/// first). +#[cfg_attr(not(windows), allow(dead_code))] +fn windows_formats(mime: &str) -> &'static [&'static str] { + match mime { + SVG => &["image/svg+xml"], + PDF => &["Portable Document Format", "application/pdf"], + EPS => &["Encapsulated PostScript", "application/postscript"], + PNG => &["PNG"], + _ => &[], + } +} + +/// The macOS pasteboard types Paste reads for `mime` (`image/*`: any bitmap), best first, each with +/// the MIME type of its data. They are the system's uniform type identifiers (the values of +/// `NSPasteboardTypePDF`, `NSPasteboardTypePNG` and `NSPasteboardTypeTIFF` among them) and the +/// older name some apps still give PDF. Text is read through arboard. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn pasteboard_types(mime: &str) -> &'static [(&'static str, &'static str)] { + match mime { + SVG => &[("public.svg-image", SVG)], + PDF => &[("com.adobe.pdf", PDF), ("Apple PDF pasteboard type", PDF)], // brand-ok: the system's type identifier + EPS => &[("com.adobe.encapsulated-postscript", EPS)], // brand-ok: the system's type identifier + BITMAP => &[("public.png", PNG), ("public.tiff", "image/tiff")], + _ => &[], + } +} + /// The text flavour among `flavours`. fn text_of(flavours: &[Flavour]) -> Option { flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned()) @@ -127,27 +156,15 @@ mod win { use std::num::NonZeroU32; use clipboard_win::{formats, options::NoClear, raw}; - use vectorcraft_engine::cmd::clipboard::{PDF, SVG}; use super::*; - /// The registered clipboard formats a flavour is written under (and read from, first held - /// first). - fn names(mime: &str) -> &'static [&'static str] { - match mime { - SVG => &["image/svg+xml"], - PDF => &["Portable Document Format", "application/pdf"], - PNG => &["PNG"], - _ => &[], - } - } - /// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the /// other or a `CF_BITMAP` is: the system converts between them. const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB]; fn formats_of(mime: &str) -> impl Iterator { - names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get) + windows_formats(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get) } fn err(e: impl std::fmt::Display) -> String { @@ -350,27 +367,30 @@ mod portable { if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) { return Some(f); } - mimes.iter().find_map(|m| match *m { - TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }), - BITMAP => { - #[cfg(target_os = "macos")] - if let Some(f) = super::mac::bitmap() { - return Some(f); + mimes.iter().find_map(|m| { + #[cfg(target_os = "macos")] + if let Some(f) = super::mac::read(m) { + return Some(f); + } + match *m { + TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }), + BITMAP => { + let img = cb.get_image().ok()?; + let rgba = + image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?; + png_flavour(&rgba) } - let img = cb.get_image().ok()?; - let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?; - png_flavour(&rgba) + _ => None, } - _ => None, }) } - /// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask - /// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the - /// menu item waits for text. + /// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no + /// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys + /// take it, the menu item waits for text. fn has(&mut self, mimes: &[&'static str]) -> bool { #[cfg(target_os = "macos")] - if mimes.contains(&BITMAP) && super::mac::has_bitmap() { + if super::mac::has(mimes) { return true; } mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty())) @@ -378,7 +398,8 @@ mod portable { } } -/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it). +/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the +/// pictures TIFF only, decoded). #[cfg(target_os = "macos")] mod mac { use objc2::rc::autoreleasepool; @@ -387,24 +408,20 @@ mod mac { use super::*; - /// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and - /// `NSPasteboardTypeTIFF`), with their MIME types. - const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")]; - - /// Does the pasteboard hold a bitmap? Asks for its types only. - pub fn has_bitmap() -> bool { + /// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only. + pub fn has(mimes: &[&'static str]) -> bool { autoreleasepool(|_| { - let types = TYPES.map(|(t, _)| NSString::from_str(t)); + let types: Vec<_> = mimes.iter().flat_map(|m| pasteboard_types(m)).map(|(t, _)| NSString::from_str(t)).collect(); let types: Vec<&NSString> = types.iter().map(|t| &**t).collect(); - NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some() + !types.is_empty() && NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some() }) } - /// The pasteboard's bitmap, PNG before TIFF. - pub fn bitmap() -> Option { + /// The pasteboard's data for `mime`, the first of its types held ([`pasteboard_types`]). + pub fn read(mime: &str) -> Option { autoreleasepool(|_| { let pasteboard = NSPasteboard::generalPasteboard(); - TYPES.into_iter().find_map(|(t, mime)| { + pasteboard_types(mime).iter().find_map(|&(t, mime)| { let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec(); (!data.is_empty()).then_some(Flavour { mime, data }) }) @@ -414,7 +431,7 @@ mod mac { #[cfg(test)] mod tests { - use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG}; + use vectorcraft_engine::cmd::clipboard::{EMF, PASTE_ORDER}; use super::*; @@ -448,6 +465,36 @@ mod tests { assert!(copied_file(vec![], &PASTE_ORDER).is_none()); } + /// What another app copied as vector art is asked for under the names the platforms give it, + /// and read as the flavour the paste imports (#1499). + #[test] + fn vector_art_is_read_under_each_platforms_names() { + let mac = |m| pasteboard_types(m).iter().map(|t| t.0).collect::>(); + assert_eq!(mac(PDF), ["com.adobe.pdf", "Apple PDF pasteboard type"]); + assert_eq!(mac(EPS), ["com.adobe.encapsulated-postscript"]); + assert_eq!(mac(SVG), ["public.svg-image"]); + assert_eq!(pasteboard_types(BITMAP), [("public.png", PNG), ("public.tiff", "image/tiff")]); + assert!(windows_formats(EPS).contains(&"Encapsulated PostScript") && windows_formats(PDF).contains(&"Portable Document Format")); + for asked in PASTE_ORDER { + // Each type's data is the flavour asked for (a bitmap its own type): what + // `clipboard.import*` then reads. Text and metafiles come another way. + assert!(pasteboard_types(asked).iter().all(|(_, mime)| *mime == asked || asked == BITMAP), "{asked}"); + assert_eq!(pasteboard_types(asked).is_empty(), [TEXT, EMF].contains(&asked), "{asked}"); + } + // Vector art before text, text before bitmaps. + let at = |m| PASTE_ORDER.iter().position(|x| *x == m).unwrap(); + assert!(at(PDF) < at(EPS) && at(EPS) < at(TEXT) && at(TEXT) < at(BITMAP)); + } + + /// An .eps file copied in a file manager pastes as EPS. + #[test] + fn a_copied_eps_file_pastes() { + let eps = dir("eps").join("equation.eps"); + std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap(); + assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS)); + assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none()); + } + #[test] fn a_crlf_left_by_the_uri_list_is_ignored() { let red = dir("crlf").join("red.png"); diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index faaa1245..5f1822ca 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -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, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture}; +pub use flavours::{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::*; diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 2ae87825..ebb525cc 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -1,5 +1,5 @@ //! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what -//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles). +//! Paste takes from them (bitmaps, plain text, PDF, EPS and Windows metafiles). //! //! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy //! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on @@ -27,9 +27,12 @@ pub const BITMAP: &str = "image/*"; /// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it /// where the platform offers it). 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"; /// 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; 5] = [SVG, PDF, EMF, TEXT, BITMAP]; +pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP]; /// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the /// image's address as text next to the picture: then the picture is what Paste takes (#597). An /// address alone pastes as text, and nothing is fetched. @@ -67,13 +70,14 @@ fn only_images(html: &str) -> bool { 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, a Windows metafile or a bitmap (its own `image/…` type), told from its first +/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first /// [`FILE_HEAD`] bytes and its name. 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 { "svg" => (SVG, SVG), "pdf" => (PDF, PDF), + "eps" => (EPS, EPS), "emf" | "wmf" => (EMF, EMF), _ if f.raster => (BITMAP, f.mime), _ => return None, @@ -167,6 +171,15 @@ pub fn specs() -> Vec { has_doc, import_emf ), + cmd!( + query "clipboard.importEps", + "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", + has_doc, + import_eps + ), ] } @@ -345,10 +358,26 @@ fn import_emf(s: &mut Session, p: &Value) -> Result { if vectorcraft_metafile::sniff(&bytes).is_none() { return Err(bad(C, "the data isn't an EMF or WMF picture")); } - let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?; + import_picture(s, p, C, "Clipboard.emf", &bytes) +} + +fn import_eps(s: &mut Session, p: &Value) -> Result { + const C: &str = "clipboard.importEps"; + let bytes = data_param(p, C)?; + if !vectorcraft_pdf::is_postscript(&bytes) { + return Err(bad(C, "the data isn't an EPS or PostScript picture")); + } + import_picture(s, p, C, "Clipboard.eps", &bytes) +} + +/// 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 { + let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?; + loaded.doc.drop_edit_modes(); let clip = Clipboard::from_document(&loaded.doc); if clip.is_empty() { - return Err(bad(C, "the picture has no objects")); + return Err(bad(cmd, "the picture has no objects")); } let count = s.load_clipboard(clip, p)?; Ok(json!({ "count": count, "warnings": loaded.warnings })) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 9edac2d8..2978b984 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1290,6 +1290,13 @@ proptest! { survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; } + /// A damaged EPS another app copied: Paste reads it as an error or as objects. + #[test] + fn pasted_eps_never_panics(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::()), 0..12)) { + static SAMPLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + paste_from_elsewhere("clipboard.importEps", json!({"dataBase64": mutated_b64(SAMPLE.get_or_init(foreign_eps_sample).clone(), cut, &edits)}))?; + } + /// Hostile PostScript programs: operators in any order with any operands, opened and placed; /// as files in the legacy Illustrator format too (the creator line is what turns on its /// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 208e8397..3cdd3e25 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4683,3 +4683,4 @@ mark-style Japanese Japonské @msg unknown parameter `{key}`; did you mean `{near}`? neznámý parametr `{key}`; mysleli jste `{near}`? @msg unknown parameter `{key}`; the command takes no parameters neznámý parametr `{key}`; příkaz nemá žádné parametry @msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys} + Load EPS into Clipboard Načíst EPS do schránky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6b8486e5..52929231 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4206,3 +4206,4 @@ theme Appearance Erscheinungsbild @msg unknown parameter `{key}`; did you mean `{near}`? unbekannter Parameter `{key}`; meinten Sie `{near}`? @msg unknown parameter `{key}`; the command takes no parameters unbekannter Parameter `{key}`; der Befehl hat keine Parameter @msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys} + Load EPS into Clipboard EPS in die Zwischenablage laden diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1b185608..d328e9dc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4623,3 +4623,4 @@ mark-style Japanese Japonesas @msg unknown parameter `{key}`; did you mean `{near}`? parámetro desconocido `{key}`; ¿quiso decir `{near}`? @msg unknown parameter `{key}`; the command takes no parameters parámetro desconocido `{key}`; el comando no tiene parámetros @msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys} + Load EPS into Clipboard Cargar EPS en el portapapeles diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e5eb78d..ea746ae5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4682,3 +4682,4 @@ file-filter VectorCraft Template Modèle VectorCraft gradient-kind Radial Radial mark-style Japanese Japonais theme Appearance Apparence + Load EPS into Clipboard Charger un EPS dans le presse-papiers diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 86c45de5..97f989a4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4649,3 +4649,4 @@ theme Appearance Kinézet @msg unknown parameter `{key}`; did you mean `{near}`? ismeretlen paraméter: `{key}`; erre gondolt: `{near}`? @msg unknown parameter `{key}`; the command takes no parameters ismeretlen paraméter: `{key}`; a parancsnak nincs paramétere @msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys} + Load EPS into Clipboard EPS betöltése a vágólapra diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3d883f2a..2eb4bcae 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4251,3 +4251,4 @@ lighting Light Luce @msg unknown parameter `{key}`; did you mean `{near}`? parametro sconosciuto `{key}`; forse intendevate `{near}`? @msg unknown parameter `{key}`; the command takes no parameters parametro sconosciuto `{key}`; il comando non ha parametri @msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys} + Load EPS into Clipboard Carica EPS negli appunti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b650d22a..948e57fc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4245,3 +4245,4 @@ lighting Light ライト @msg unknown parameter `{key}`; did you mean `{near}`? `{key}` は不明なパラメーターです。`{near}` のことですか? @msg unknown parameter `{key}`; the command takes no parameters `{key}` は不明なパラメーターです。このコマンドにパラメーターはありません @msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys} + Load EPS into Clipboard EPS をクリップボードに読み込む diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2b115018..be651886 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4215,3 +4215,4 @@ lighting Light 광원 @msg unknown parameter `{key}`; did you mean `{near}`? 알 수 없는 매개변수 `{key}`: `{near}`을(를) 의미하셨습니까? @msg unknown parameter `{key}`; the command takes no parameters 알 수 없는 매개변수 `{key}`: 이 명령에는 매개변수가 없습니다 @msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys} + Load EPS into Clipboard EPS를 클립보드로 불러오기 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 14c3b374..418685c3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4526,3 +4526,4 @@ mark-style Japanese Japońskie @msg unknown parameter `{key}`; did you mean `{near}`? nieznany parametr `{key}`; czy chodziło o `{near}`? @msg unknown parameter `{key}`; the command takes no parameters nieznany parametr `{key}`; polecenie nie przyjmuje parametrów @msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys} + Load EPS into Clipboard Wczytaj EPS do schowka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0a08529e..d6ee969c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3692,3 +3692,4 @@ axis Both Ambos @msg unknown parameter `{key}`; did you mean `{near}`? parâmetro desconhecido `{key}`; você quis dizer `{near}`? @msg unknown parameter `{key}`; the command takes no parameters parâmetro desconhecido `{key}`; o comando não tem parâmetros @msg unknown parameter `{key}`; the command takes: {keys} parâmetro desconhecido `{key}`; o comando aceita: {keys} + Load EPS into Clipboard Carregar EPS na Área de Transferência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 14dda2d7..20449dba 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4247,3 +4247,4 @@ lighting Light Свет @msg unknown parameter `{key}`; did you mean `{near}`? неизвестный параметр `{key}`; возможно, имелся в виду `{near}`? @msg unknown parameter `{key}`; the command takes no parameters неизвестный параметр `{key}`; у команды нет параметров @msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys} + Load EPS into Clipboard Загрузить EPS в буфер обмена diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 656d63fa..3b99f28d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4231,3 +4231,4 @@ lighting Light Світло @msg unknown parameter `{key}`; did you mean `{near}`? невідомий параметр `{key}`; можливо, ви мали на увазі `{near}`? @msg unknown parameter `{key}`; the command takes no parameters невідомий параметр `{key}`; команда не має параметрів @msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys} + Load EPS into Clipboard Завантажити EPS у буфер обміну diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b2c4c833..fc3792cc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4233,3 +4233,4 @@ theme Appearance Giao diện @msg {path}: {e} {path}: {e} @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} không nằm trong nhóm thanh công cụ có nhiều công cụ + Load EPS into Clipboard Tải EPS vào bảng tạm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b049ae22..4c740c7b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3842,3 +3842,4 @@ lighting Light 光源 @msg unknown parameter `{key}`; did you mean `{near}`? 未知参数 `{key}`;您是否想输入 `{near}`? @msg unknown parameter `{key}`; the command takes no parameters 未知参数 `{key}`;该命令没有参数 @msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys} + Load EPS into Clipboard 将 EPS 载入剪贴板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2a5e7691..f34c476f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3623,3 +3623,4 @@ lighting Light 光源 @msg unknown parameter `{key}`; did you mean `{near}`? 未知的參數 `{key}`;您是指 `{near}` 嗎? @msg unknown parameter `{key}`; the command takes no parameters 未知的參數 `{key}`;此指令沒有參數 @msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys} + Load EPS into Clipboard 將 EPS 載入剪貼簿 diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index a2eb8a91..d2d9fc87 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -1,6 +1,6 @@ //! The system clipboard's formats: Copy and Cut publish every flavour the engine makes //! ([`Session::clipboard_flavours`](vectorcraft_engine::Session::clipboard_flavours): text, SVG, -//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, an EMF, text, a bitmap, +//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, EPS, an EMF, text, a bitmap, //! or a copied file that is one of them) into the internal clipboard with the `clipboard.import*` //! commands. The host installs the platform side as //! [`Services::system_clipboard`](crate::Services::system_clipboard); without it (the web) Copy @@ -8,7 +8,7 @@ //! reads from a paste itself ([`VectorcraftApp::paste_from_host`]). use serde_json::{Value, json}; -use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture}; +use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, EPS, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture}; use crate::VectorcraftApp; @@ -43,6 +43,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 })), + 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 })), } diff --git a/crates/ui-egui/src/tests_sysclip_emf.rs b/crates/ui-egui/src/tests_sysclip_emf.rs index 852e1353..426b5e12 100644 --- a/crates/ui-egui/src/tests_sysclip_emf.rs +++ b/crates/ui-egui/src/tests_sysclip_emf.rs @@ -4,7 +4,7 @@ use serde_json::json; use vectorcraft_doc::NodeKind; use vectorcraft_engine::Session; -use vectorcraft_engine::cmd::clipboard::{EMF, PNG}; +use vectorcraft_engine::cmd::clipboard::{EMF, EPS, Flavour, PASTE_ORDER, PDF, PNG, TEXT, file_flavour}; use crate::sysclip::import_command; use crate::tests_sysclip::{app, blue_png, copy_elsewhere, pasted, run}; @@ -17,7 +17,7 @@ fn an_emf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() { other.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); let emf = other.execute("document.export", &json!({"format": "emf"})).unwrap(); let emf = vectorcraft_format::base64_decode(emf["dataBase64"].as_str().unwrap()).unwrap(); - let flavour = vectorcraft_engine::cmd::clipboard::Flavour { mime: EMF, data: emf.clone() }; + let flavour = Flavour { mime: EMF, data: emf.clone() }; assert_eq!(import_command(&flavour, None).0, "clipboard.importEmf"); copy_elsewhere(&board, vec![(PNG, blue_png()), (EMF, emf)]); run(&mut app, "edit.paste", json!({})); @@ -26,3 +26,35 @@ fn an_emf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() { let b = p[0].geometric_bounds().unwrap(); assert!((b.width() - 30.0).abs() < 0.05, "{b:?}"); } + +/// A 30 pt red square, as an EPS written by hand from the EPSF specification. +const SQUARE_EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor\nnewpath 10 10 moveto 40 10 lineto 40 40 lineto 10 40 lineto closepath fill\nshowpage\n%%EOF\n"; + +/// An EPS another app copied (an equation editor's, a drawing app's) pastes as editable paths, not +/// as the bitmap or the text beside it, and not as what was copied in the app before (#1499). +#[test] +fn an_eps_from_another_app_pastes_as_vectors() { + let (mut app, board) = app(); + crate::tests_sysclip::copy_rect(&mut app); + let eps = SQUARE_EPS.as_bytes().to_vec(); + assert_eq!(import_command(&Flavour { mime: EPS, data: eps.clone() }, None).0, "clipboard.importEps"); + copy_elsewhere(&board, vec![(PNG, blue_png()), (TEXT, b"x = 1".to_vec()), (EPS, eps.clone())]); + run(&mut app, "edit.paste", json!({})); + let p = pasted(&app); + assert!(!p.is_empty(), "something pasted"); + let path = |n: &vectorcraft_doc::Node| matches!(n.kind, NodeKind::Path { .. }); + let mut paths = vec![]; + p.iter().for_each(|n| n.walk(&mut |c| paths.extend(path(c).then(|| c.geometric_bounds().unwrap())))); + assert_eq!(paths.len(), 1, "{p:?}"); + assert!((paths[0].width() - 30.0).abs() < 0.05 && (paths[0].height() - 30.0).abs() < 0.05, "{paths:?}"); + assert!(p.iter().all(|n| !matches!(n.kind, NodeKind::Image(_) | NodeKind::Text(_))), "{p:?}"); + // A PDF beside it is the better copy: the apps that offer both mean the same picture. + assert!(PASTE_ORDER.iter().position(|m| *m == PDF) < PASTE_ORDER.iter().position(|m| *m == EPS)); + // Not PostScript: refused, nothing pasted over. + let pdf = vectorcraft_format::base64_encode(b"%PDF-1.7"); + assert!(app.session.execute("clipboard.importEps", &json!({"dataBase64": pdf})).is_err()); + // An .eps file copied in a file manager is that flavour too (told by its bytes, not its name). + assert_eq!(file_flavour("equation.eps", &eps, &PASTE_ORDER), Some(EPS)); + assert_eq!(file_flavour("equation.dat", &eps, &PASTE_ORDER), Some(EPS)); + assert_eq!(file_flavour("equation.eps", &eps, &[TEXT]), None); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ef75d494..097d06ac 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -507,7 +507,8 @@ type with those options (`file.place {text: {…}}`). System clipboard formats: through the desktop app, `edit.copy` and `edit.cut` publish what `clipboard.flavours` lists to the system clipboard, and every Paste command first loads what another app copied (`clipboard.importSvg`, -`importPdf`, `importText` or `importImage`, centred in the view); an import error answers the Paste with that error +`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 copied in a file manager anywhere); 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). diff --git a/docs/mcp.md b/docs/mcp.md index 097e2e18..d2044ed3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2115,7 +2115,8 @@ area). Each one has a query command: transparent around them). What other apps copied goes into the clipboard with `clipboard.importSvg`, `clipboard.importPdf {dataBase64, page?, -password?}` (the page's objects), `clipboard.importImage {dataBase64, mime?}` (an embedded image at 100% of its +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` (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