diff --git a/Cargo.lock b/Cargo.lock index 1a37aae6..ac5f2cb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4047,8 +4047,10 @@ name = "vectorcraft" version = "0.1.1" dependencies = [ "arboard", + "clipboard-win", "eframe", "egui", + "image", "log", "muda", "rfd", diff --git a/Cargo.toml b/Cargo.toml index 2209c307..baf03ed0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" arboard = { version = "3.6", default-features = false } +clipboard-win = "5.4" webbrowser = "1" [workspace.lints.rust] diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 97731d61..5820e2e5 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -19,9 +19,13 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } -arboard = { workspace = true } +arboard = { workspace = true, features = ["image-data"] } +image = { workspace = true } webbrowser = { workspace = true } +[target.'cfg(windows)'.dependencies] +clipboard-win = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs new file mode 100644 index 00000000..7bf53252 --- /dev/null +++ b/apps/vectorcraft/src/clipboard.rs @@ -0,0 +1,264 @@ +//! The system clipboard with every format Copy offers and Paste reads +//! (`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. +//! - 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. + +use std::io::Cursor; + +use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PNG, TEXT}; +use vectorcraft_ui_egui::SystemClipboard; + +/// This platform's system clipboard. +pub fn system_clipboard() -> Box { + #[cfg(windows)] + let cb = Box::::default(); + #[cfg(not(windows))] + let cb = Box::::default(); + cb +} + +/// 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()) +} + +fn decode_png(png: &[u8]) -> Option { + Some(image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?.to_rgba8()) +} + +#[cfg(windows)] +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"], + _ => &[], + } + } + + fn formats_of(mime: &str) -> impl Iterator { + names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get) + } + + fn err(e: impl std::fmt::Display) -> String { + format!("{e}") + } + + /// `png` over white, as a BMP file (what the bitmap format takes). + fn opaque_bmp(png: &[u8]) -> Option> { + let img = decode_png(png)?; + let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8; + let mut rgb = Vec::with_capacity(img.as_raw().len() / 4 * 3); + for p in img.pixels() { + let [r, g, b, a] = p.0; + rgb.extend([over(r, a), over(g, a), over(b, a)]); + } + let rgb = image::RgbImage::from_raw(img.width(), img.height(), rgb)?; + let mut bmp = vec![]; + rgb.write_to(&mut Cursor::new(&mut bmp), image::ImageFormat::Bmp).ok()?; + Some(bmp) + } + + #[derive(Default)] + pub struct Clipboard { + /// The clipboard's sequence number right after our last write. + seq: Option, + /// The text of our last write. + text: Option, + } + + impl SystemClipboard for Clipboard { + /// Writes every flavour it can; the first failure is reported. + fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let mut result = Ok(()); + { + let _open = clipboard_win::Clipboard::new_attempts(10).map_err(err)?; + raw::empty().map_err(err)?; + let mut set = |r: clipboard_win::SysResult<()>| { + if result.is_ok() { + result = r.map_err(err); + } + }; + for f in flavours { + if f.mime == TEXT { + set(raw::set_string_with(&String::from_utf8_lossy(&f.data), NoClear)); + continue; + } + for id in formats_of(f.mime) { + set(raw::set_without_clear(id, &f.data)); + } + if f.mime == PNG + && let Some(bmp) = opaque_bmp(&f.data) + { + set(raw::set_bitmap_with(&bmp, NoClear)); + } + } + } + self.seq = raw::seq_num(); + self.text = text_of(flavours); + result + } + + fn holds_ours(&mut self) -> bool { + if self.seq.is_some() && raw::seq_num() == self.seq { + return true; + } + // A clipboard manager may have copied our data again as its own. + let Some(ours) = self.text.as_deref() else { return false }; + let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false }; + read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes()) + } + + fn read(&mut self, mimes: &[&'static str]) -> Option { + let _open = clipboard_win::Clipboard::new_attempts(10).ok()?; + mimes.iter().find_map(|m| read_one(m)) + } + + fn has(&mut self, mimes: &[&'static str]) -> bool { + mimes.iter().any(|m| match *m { + TEXT => raw::is_format_avail(formats::CF_UNICODETEXT), + BITMAP => raw::is_format_avail(formats::CF_BITMAP) || formats_of(PNG).any(raw::is_format_avail), + m => formats_of(m).any(raw::is_format_avail), + }) + } + } + + /// Read the first registered format of `mime` the clipboard holds into `data`. + fn read_registered(mime: &str, data: &mut Vec) -> bool { + formats_of(mime).filter(|id| raw::is_format_avail(*id)).any(|id| { + data.clear(); + raw::get_vec(id, data).is_ok() && !data.is_empty() + }) + } + + /// One flavour, while the clipboard is open. + fn read_one(mime: &'static str) -> Option { + let mut data = vec![]; + let mime = match mime { + BITMAP if read_registered(PNG, &mut data) => PNG, + BITMAP => { + data.clear(); + raw::get_bitmap(&mut data).ok()?; + "image/bmp" + } + TEXT => { + raw::get_string(&mut data).ok()?; + TEXT + } + m if read_registered(m, &mut data) => m, + _ => return None, + }; + // Memory blocks can be longer than the data: SVG text ends at its first NUL. + if mime == SVG + && let Some(end) = data.iter().position(|b| *b == 0) + { + data.truncate(end); + } + (!data.is_empty()).then_some(Flavour { mime, data }) + } +} + +/// arboard: one format at a time. Built everywhere so it is checked everywhere. +#[cfg_attr(windows, allow(dead_code))] +mod portable { + use std::borrow::Cow; + + use super::*; + + /// `img` as PNG bytes. + fn encode_png(img: &image::RgbaImage) -> Option> { + let mut out = vec![]; + img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) + } + + /// What our last write put on the clipboard. + enum Ours { + Text(String), + Image(usize, usize), + } + + #[derive(Default)] + pub struct Clipboard { + /// Kept open: on Linux the copied data lives as long as the handle does. + cb: Option, + ours: Option, + } + + impl Clipboard { + fn cb(&mut self) -> Result<&mut arboard::Clipboard, String> { + if self.cb.is_none() { + self.cb = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?); + } + self.cb.as_mut().ok_or_else(|| "no system clipboard".to_string()) + } + } + + impl SystemClipboard for Clipboard { + fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let text = text_of(flavours); + let image = match &text { + Some(_) => None, + None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)), + }; + let cb = self.cb()?; + let ours = match (text, image) { + (Some(t), _) => { + cb.set_text(t.as_str()).map_err(|e| e.to_string())?; + Some(Ours::Text(t)) + } + (None, Some(img)) => { + let (width, height) = (img.width() as usize, img.height() as usize); + cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) }).map_err(|e| e.to_string())?; + Some(Ours::Image(width, height)) + } + (None, None) => { + cb.clear().map_err(|e| e.to_string())?; + None + } + }; + self.ours = ours; + Ok(()) + } + + fn holds_ours(&mut self) -> bool { + let Some(cb) = self.cb.as_mut() else { return false }; + match &self.ours { + Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t), + Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)), + None => false, + } + } + + fn read(&mut self, mimes: &[&'static str]) -> Option { + let cb = self.cb().ok()?; + 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 => { + 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())?; + encode_png(&rgba).map(|data| Flavour { mime: PNG, data }) + } + _ => None, + }) + } + + /// Text only: reading a bitmap costs as much as pasting it. + fn has(&mut self, mimes: &[&'static str]) -> bool { + mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty())) + } + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 79d7c269..928885f2 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -6,6 +6,7 @@ //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `vectorcraft_ui_egui::control` for the methods. +mod clipboard; mod control_server; #[cfg(target_os = "macos")] mod native_menu; @@ -123,8 +124,8 @@ fn services() -> Services { })), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - // Menu-bar Paste never sees egui's Paste event, so read the clipboard directly. - clipboard_read: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())), + // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). + system_clipboard: Some(clipboard::system_clipboard()), // Help → Discord / website / GitHub, the Discord button, About and Home links. open_url: Some(Box::new(|url: &str| { let _ = webbrowser::open(url); diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 4b41f59c..12e05c8a 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -6,8 +6,10 @@ //! System clipboard: the copied objects as SVG markup (what other apps paste), and SVG markup from //! other apps turned into clipboard objects (pasted with the Paste commands). The UI owns the //! platform clipboard; these commands only convert. Within VectorCraft the internal clipboard stays -//! lossless (live effects, masks, symbols); SVG is the outside format. +//! lossless (live effects, masks, symbols); SVG is the outside format, with PNG, PDF and plain +//! text besides it (see `flavours`). +mod flavours; mod resources; use std::collections::BTreeMap; @@ -17,15 +19,16 @@ use serde_json::{Value, json}; use vectorcraft_brush::Brush; use vectorcraft_color::Swatch; use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef}; -use vectorcraft_geom::{Affine, Rect}; +use vectorcraft_geom::Rect; +pub use flavours::{BITMAP, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT}; pub(crate) use resources::SwatchChoices; use super::*; use crate::DocState; pub fn specs() -> Vec { - vec![ + let mut v = vec![ cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg), cmd!( query "clipboard.importSvg", @@ -45,7 +48,9 @@ pub fn specs() -> Vec { has_doc, conflicts ), - ] + ]; + v.extend(flavours::specs()); + v } /// Is `text` SVG markup (as other apps put on the clipboard)? @@ -184,23 +189,11 @@ fn import_svg(s: &mut Session, p: &Value) -> Result { (None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")), }; let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, e.to_string()))?; - let mut clip = Clipboard::from_document(&src); + let clip = Clipboard::from_document(&src); if clip.is_empty() { return Err(bad(C, "the SVG has no drawable objects")); } - let center = match point_param(p, "center") { - Some(c) => c, - None => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(), - }; - if let Some(b) = clip.bounds() { - let xf = Affine::translate(center - b.center()); - for n in &mut clip.nodes { - n.transform(xf, false); - } - } - let count = clip.nodes.len(); - s.clipboard = clip; - Ok(json!({ "count": count })) + Ok(json!({ "count": s.load_clipboard(clip, p)? })) } fn conflicts(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs new file mode 100644 index 00000000..b04dad02 --- /dev/null +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -0,0 +1,295 @@ +//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what +//! Paste takes from them (bitmaps, plain text and PDF). +//! +//! 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 +//! the system clipboard into the internal clipboard with the `clipboard.import*` commands. These +//! commands only convert, so agents can drive them headless. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, ImageObject, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, Point}; + +use super::super::fileio; +use super::super::place::pt_per_px; +use super::*; + +/// Plain text: a type-only copy's text, else the SVG markup (Include SVG Code). +pub const TEXT: &str = "text/plain"; +/// SVG markup (Include SVG Code). +pub const SVG: &str = "image/svg+xml"; +/// A PDF of the copied objects (On Copy: PDF). +pub const PDF: &str = "application/pdf"; +/// A PNG of the copied objects (always offered). +pub const PNG: &str = "image/png"; +/// Any bitmap (PNG, BMP…), in what Paste asks the system clipboard for. +pub const BITMAP: &str = "image/*"; +/// 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; 4] = [SVG, PDF, TEXT, BITMAP]; + +/// One representation of the clipboard's contents. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Flavour { + pub mime: &'static str, + pub data: Vec, +} + +/// Longest side (px) of the PNG Copy offers: larger art is scaled down to fit. +const COPY_PNG_SIDE: f64 = 4096.0; +/// Most bytes of text `clipboard.importText` takes. +const MAX_TEXT: usize = 1 << 20; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "clipboard.exportPng", + "Clipboard as PNG", + [], + None, + "{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)", + always, + export_png + ), + cmd!( + query "clipboard.exportPdf", + "Clipboard as PDF", + [], + None, + "{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)", + always, + export_pdf + ), + cmd!( + query "clipboard.exportText", + "Clipboard as Text", + [], + None, + "{} → {text} the copied type objects' text, one object per line (null unless every copied object is type, or a group of type)", + always, + export_text + ), + cmd!( + query "clipboard.flavours", + "Clipboard Formats", + [], + None, + "{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut", + always, + flavours + ), + cmd!( + query "clipboard.importImage", + "Load Image into Clipboard", + [], + None, + "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste", + has_doc, + import_image + ), + cmd!( + query "clipboard.importText", + "Load Text into Clipboard", + [], + None, + "{text, center?: [x, y]} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste", + has_doc, + import_text + ), + cmd!( + query "clipboard.importPdf", + "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", + has_doc, + import_pdf + ), + ] +} + +/// Is every object type (or a group of type only)? +fn type_only(n: &Node) -> bool { + match &n.kind { + NodeKind::Text(_) => true, + NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| type_only(c)), + _ => false, + } +} + +impl Session { + /// The copied type objects' text, one object per line (`None` unless the clipboard holds type + /// only). + pub fn clipboard_text(&self) -> Option { + let nodes = &self.clipboard.nodes; + if nodes.is_empty() || !nodes.iter().all(type_only) { + return None; + } + let mut lines = vec![]; + for n in nodes { + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + lines.push(t.plain_text()); + } + }); + } + Some(lines.join("\n")) + } + + /// The clipboard as a document whose one artboard is the objects' visual bounds (`None` when + /// it is empty or has no extent). + fn clipboard_cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> { + let b = self.clipboard.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?; + Some((fileio::with_single_artboard(self.clipboard.to_document(), b, "Clipboard"), b)) + } + + /// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None` + /// when the clipboard is empty. + pub fn clipboard_png(&self, scale: f64) -> Result, u32, u32)>> { + const C: &str = "clipboard.exportPng"; + let Some((doc, b)) = self.clipboard_cropped() else { return Ok(None) }; + let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?; + let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?; + Ok(Some((png, w, h))) + } + + /// A one-page PDF of the copied objects (without the native document inside); `None` when the + /// clipboard is empty. + pub fn clipboard_pdf(&self) -> Result>> { + let Some((doc, _)) = self.clipboard_cropped() else { return Ok(None) }; + fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some) + } + + /// The MIME types of [`Self::clipboard_flavours`], without making them. + pub fn clipboard_flavour_types(&self) -> Vec<&'static str> { + if self.clipboard.is_empty() { + return vec![]; + } + let (svg, pdf) = (self.prefs.copy_as_svg, self.prefs.copy_as_pdf); + let text = svg || self.clipboard.nodes.iter().all(type_only); + // Art without an area (a lone guide-like line) has no page or picture. + let area = self.clipboard.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0); + [(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect() + } + + /// What Copy offers other apps, best first (see `clipboard.flavours`). A flavour that can't be + /// made (art too large to render) is left out; the PNG is scaled down to at most + /// [`COPY_PNG_SIDE`] pixels a side. + pub fn clipboard_flavours(&self) -> Vec { + let types = self.clipboard_flavour_types(); + let svg = types.contains(&SVG).then(|| self.clipboard_svg()).flatten(); + let mut out = vec![]; + for mime in types { + let data = match mime { + TEXT => self.clipboard_text().or_else(|| svg.clone()).map(String::into_bytes), + SVG => svg.clone().map(String::into_bytes), + PDF => self.clipboard_pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(), + _ => { + let side = self.clipboard.bounds().map_or(1.0, |b| b.width().max(b.height())); + let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0); + self.clipboard_png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png) + } + }; + out.extend(data.map(|data| Flavour { mime, data })); + } + out + } + + /// Replace the clipboard with `clip`, centred on `p`'s `center` (default: the first + /// artboard's centre) → the number of objects. + pub(super) fn load_clipboard(&mut self, mut clip: Clipboard, p: &Value) -> Result { + let center = match point_param(p, "center") { + Some(c) => c, + None => self.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(), + }; + if let Some(b) = clip.bounds() { + let xf = Affine::translate(center - b.center()); + for n in &mut clip.nodes { + n.transform(xf, false); + } + } + let count = clip.nodes.len(); + self.clipboard = clip; + Ok(count) + } +} + +/// The bytes of `p`'s `dataBase64`. +fn data_param(p: &Value, cmd: &str) -> Result> { + let b64 = str_param(p, "dataBase64").ok_or_else(|| bad(cmd, "give dataBase64"))?; + vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad base64")) +} + +fn export_png(s: &mut Session, p: &Value) -> Result { + let scale = f64_or(p, "scale", 1.0); + if !(scale > 0.0 && scale <= 64.0) { + return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64")); + } + Ok(match s.clipboard_png(scale)? { + Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }), + None => json!({ "dataBase64": null }), + }) +} + +fn export_pdf(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) })) +} + +fn export_text(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "text": s.clipboard_text() })) +} + +fn flavours(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "flavours": s.clipboard_flavour_types() })) +} + +fn import_image(s: &mut Session, p: &Value) -> Result { + const C: &str = "clipboard.importImage"; + if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) { + return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText"))); + } + let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?; + let (sx, sy) = pt_per_px(img.ppi); + let (w, h) = (img.width, img.height); + let image = ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None }; + let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() }; + clip.images.insert(img.key, img.blob); + let count = s.load_clipboard(clip, p)?; + Ok(json!({ "count": count, "width": w as f64 * sx, "height": h as f64 * sy, "pixelWidth": w, "pixelHeight": h })) +} + +fn import_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "clipboard.importText"; + let raw = str_param(p, "text").ok_or_else(|| bad(C, "give text"))?; + if raw.len() > MAX_TEXT { + return Err(bad(C, format!("the text is over {} MB", MAX_TEXT >> 20))); + } + let text = raw.replace("\r\n", "\n").replace('\r', "\n"); + let text = text.trim_end_matches('\n'); + if text.trim().is_empty() { + return Err(bad(C, "the text is empty")); + } + let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null)); + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t))); + let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?; + Ok(json!({ "count": count })) +} + +fn import_pdf(s: &mut Session, p: &Value) -> Result { + const C: &str = "clipboard.importPdf"; + let bytes = data_param(p, C)?; + let page = match p.get("page") { + 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 opts = fileio::LoadOptions { password: str_param(p, "password").map(str::to_string), ..Default::default() }; + 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); + if clip.is_empty() { + return Err(bad(C, format!("page {page} of the PDF has no objects"))); + } + let count = s.load_clipboard(clip, p)?; + Ok(json!({ "count": count, "warnings": warnings })) +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 7c0e3f01..5597406a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -400,6 +400,20 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let x = f64_req(p, "x", "text.create")?; let y = f64_req(p, "y", "text.create")?; let text = str_param(p, "text").unwrap_or(""); + let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + if let Some(a) = p.get("area") { + let w = f64_or(a, "width", 200.0); + let h = f64_or(a, "height", 100.0); + t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + t.cached_bounds = Some(lay.bounds); + add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None) +} + +/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the +/// defaults, filled with the current fill unless that is None or white (black then). +pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { let mut style = CharStyle::default(); if let Some(sz) = p.get("size").and_then(Value::as_f64) { style.size = sz.clamp(0.1, 1296.0); @@ -415,13 +429,5 @@ fn text_create(s: &mut Session, p: &Value) -> Result { None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(), None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), }; - let mut t = TextObject::point(Point::new(x, y), text, style); - if let Some(a) = p.get("area") { - let w = f64_or(a, "width", 200.0); - let h = f64_or(a, "height", 100.0); - t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; - } - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - t.cached_bounds = Some(lay.bounds); - add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None) + style } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index c8a1e8b3..98645c9f 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -181,7 +181,11 @@ fn use_artboards(p: &Value) -> Result> { /// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds). pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document { - let mut d = doc.clone(); + with_single_artboard(doc.clone(), rect, name) +} + +/// `d` with one artboard, `rect`. +pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document { d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }]; d } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 7bad9e58..0cdb4177 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -26,7 +26,7 @@ mod svg; use serde_json::{Value, json}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; -pub(crate) use encode::{anti_alias, background}; +pub(crate) use encode::{anti_alias, background, with_single_artboard}; use load::err; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image}; diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index c6d1e1a1..52afd11d 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -110,7 +110,7 @@ struct Loaded { } /// Points per pixel of a raster image at the resolution it declares (72 ppi when none). -fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { +pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { let (x, y) = ppi.unwrap_or((72.0, 72.0)); (72.0 / x, 72.0 / y) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 01eb31a2..d8d61e17 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -910,6 +910,8 @@ mod tests_clip; #[cfg(test)] mod tests_clipboard; #[cfg(test)] +mod tests_clipflavours; +#[cfg(test)] mod tests_clippaint; #[cfg(test)] mod tests_cmdsplit; diff --git a/crates/engine/src/tests_clipflavours.rs b/crates/engine/src/tests_clipflavours.rs new file mode 100644 index 00000000..41204857 --- /dev/null +++ b/crates/engine/src/tests_clipflavours.rs @@ -0,0 +1,213 @@ +//! Clipboard flavours (M4.57): PNG, PDF and plain text out; bitmaps, plain text and PDF in; the +//! Clipboard Handling preferences. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeKind, TextObject}; +use vectorcraft_geom::Point; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; +use crate::cmd::clipboard::{PDF, PNG, SVG, TEXT}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A red 40 × 40 pt square, copied. +fn copy_red_square(s: &mut Session) { + let id = run(s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 40}))["id"].clone(); + run(s, "paint.setFill", json!({"color": "#ff0000"})); + run(s, "paint.setStroke", json!({"none": true})); + run(s, "select.set", json!({"ids": [id]})); + run(s, "edit.copy", json!({})); +} + +fn copy_text(s: &mut Session, texts: &[&str]) { + let ids: Vec = + texts.iter().enumerate().map(|(i, t)| run(s, "text.create", json!({"x": 20, "y": 40 + 30 * i, "text": t}))["id"].clone()).collect(); + run(s, "select.set", json!({ "ids": ids })); + run(s, "edit.copy", json!({})); +} + +fn decode(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// The pasted objects (the selection after a paste). +fn pasted(s: &Session) -> Vec { + let st = s.doc().unwrap(); + st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() +} + +fn text_of(n: &vectorcraft_doc::Node) -> &TextObject { + match &n.kind { + NodeKind::Text(t) => t, + k => panic!("not type: {k:?}"), + } +} + +#[test] +fn exported_png_of_a_red_square_is_red() { + let mut s = session(); + assert!(run(&mut s, "clipboard.exportPng", json!({}))["dataBase64"].is_null(), "nothing copied"); + copy_red_square(&mut s); + let r = run(&mut s, "clipboard.exportPng", json!({"scale": 2})); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(80), Some(80))); + let img = image::load_from_memory(&decode(&r)).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (80, 80)); + assert_eq!(img.get_pixel(40, 40).0, [255, 0, 0, 255]); + assert_eq!(img.get_pixel(2, 77).0, [255, 0, 0, 255], "cropped to the square"); + for bad in [json!({"scale": 0}), json!({"scale": -1}), json!({"scale": 65})] { + assert!(s.execute("clipboard.exportPng", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn exported_pdf_pastes_back_as_vectors() { + let mut s = session(); + assert!(run(&mut s, "clipboard.exportPdf", json!({}))["dataBase64"].is_null()); + copy_red_square(&mut s); + let pdf = run(&mut s, "clipboard.exportPdf", json!({})); + assert!(decode(&pdf).starts_with(b"%PDF")); + // Plain PDF: no native document inside (the default PDF preset embeds one). + let has_native = |b: &[u8]| b.windows(13).any(|w| w == b"/EmbeddedFile"); + assert!(!has_native(&decode(&pdf))); + let saved = run(&mut s, "document.exportPdf", json!({})); + assert!(has_native(&decode(&saved)), "the check finds the native document"); + let mut t = session(); + let r = run(&mut t, "clipboard.importPdf", json!({"dataBase64": pdf["dataBase64"], "center": [200, 150]})); + assert!(r["count"].as_u64().unwrap() >= 1, "{r}"); + run(&mut t, "edit.pasteInPlace", json!({})); + let st = t.doc().unwrap(); + let b = st.doc.bounds_of(&st.selection.in_paint_order(&st.doc), false).unwrap(); + assert!((b.center().x - 200.0).abs() < 0.5 && (b.center().y - 150.0).abs() < 0.5, "{b:?}"); + assert!((b.width() - 40.0).abs() < 0.5 && (b.height() - 40.0).abs() < 0.5, "{b:?}"); + let mut red = false; + for n in pasted(&t) { + n.walk(&mut |c| red |= c.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000")); + } + assert!(red, "the square keeps its red fill"); + assert!(t.execute("clipboard.importPdf", &json!({"dataBase64": vectorcraft_format::base64_encode(b"not a pdf")})).is_err()); + assert!(t.execute("clipboard.importPdf", &json!({})).is_err()); +} + +#[test] +fn import_image_then_paste_gives_an_image() { + let mut s = session(); + copy_red_square(&mut s); + let png = run(&mut s, "clipboard.exportPng", json!({})); + let r = run(&mut s, "clipboard.importImage", json!({"dataBase64": png["dataBase64"], "mime": "image/png", "center": [100, 100]})); + assert_eq!((r["count"].as_u64(), r["pixelWidth"].as_u64(), r["width"].as_f64()), (Some(1), Some(40), Some(40.0))); + let r = run(&mut s, "edit.paste", json!({"center": [300, 200]})); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + let n = &pasted(&s)[0]; + let NodeKind::Image(im) = &n.kind else { panic!("not an image: {:?}", n.kind) }; + assert!(im.link.is_none(), "embedded"); + assert!(s.doc().unwrap().doc.images.contains_key(&im.key), "the blob came along"); + let b = n.geometric_bounds().unwrap(); + assert!((b.center().x - 300.0).abs() < 1e-6 && (b.center().y - 200.0).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6, "{b:?}"); + // Not a bitmap. + assert!(s.execute("clipboard.importImage", &json!({"dataBase64": png["dataBase64"], "mime": "image/svg+xml"})).is_err()); + assert!(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(b"hello")})).is_err()); + assert!(s.execute("clipboard.importImage", &json!({"dataBase64": "!!"})).is_err()); +} + +#[test] +fn import_text_then_paste_gives_point_text() { + let mut s = session(); + let r = run(&mut s, "clipboard.importText", json!({"text": "Hello\r\nworld\r\n"})); + assert_eq!(r["count"], 1); + run(&mut s, "edit.paste", json!({"center": [200, 150]})); + let n = &pasted(&s)[0]; + let t = text_of(n); + assert!(matches!(t.kind, vectorcraft_doc::TextKind::Point)); + assert_eq!(t.plain_text(), "Hello\nworld"); + let b = n.geometric_bounds().unwrap(); + assert!((b.center().x - 200.0).abs() < 1.0 && (b.center().y - 150.0).abs() < 1.0, "{b:?}"); + for bad in [json!({}), json!({"text": " \n\n"}), json!({"text": "x".repeat((1 << 20) + 1)})] { + assert!(s.execute("clipboard.importText", &bad).is_err()); + } +} + +#[test] +fn type_only_copies_offer_their_text() { + let mut s = session(); + assert!(run(&mut s, "clipboard.exportText", json!({}))["text"].is_null()); + copy_text(&mut s, &["One", "Two"]); + assert_eq!(run(&mut s, "clipboard.exportText", json!({}))["text"], "One\nTwo"); + // A group of type is type. + run(&mut s, "object.group", json!({})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(run(&mut s, "clipboard.exportText", json!({}))["text"], "One\nTwo"); + // Type with a path is not. + copy_red_square(&mut s); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + assert!(run(&mut s, "clipboard.exportText", json!({}))["text"].is_null()); +} + +#[test] +fn copy_offers_flavours_by_the_preferences() { + let mut s = session(); + assert!(s.clipboard_flavours().is_empty()); + assert_eq!(run(&mut s, "clipboard.flavours", json!({}))["flavours"], json!([])); + copy_red_square(&mut s); + let mimes = |s: &Session| s.clipboard_flavours().iter().map(|f| f.mime).collect::>(); + // Defaults: Include SVG Code on, PDF off. + assert_eq!(mimes(&s), [TEXT, SVG, PNG]); + assert_eq!(run(&mut s, "clipboard.flavours", json!({}))["flavours"], json!([TEXT, SVG, PNG])); + let f = s.clipboard_flavours(); + assert!(String::from_utf8_lossy(&f[0].data).contains(" f64 { + let mut s = session(); + let v = ViewInfo::default(); + let id = run(&mut s, "text.create", json!({"x": 100, "y": 100, "text": "Big small", "size": 10}))["id"].clone(); + run(&mut s, "text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "size": 40})); + if plain { + run(&mut s, "prefs.set", json!({"key": "pasteTextFormatting", "value": "plain"})); + } + s.select_tool("type", v).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!() }; + let at = t.xf * Point::new(30.0, -3.0); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap(); + } + s.set_tool_option("select", &json!({"start": 0, "end": 3})); + let r = run(&mut s, "text.getRange", json!({"id": id, "start": 0, "end": 3})); + s.set_tool_option("copy", &r["runs"]); + s.tool_key(ToolKey::End, Mods::default(), v).unwrap(); + s.tool_text("Big", v).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone(); + let t = text_of(&n); + assert_eq!(t.plain_text(), "Big smallBig"); + t.runs.last().unwrap().style.size +} + +#[test] +fn keep_plain_text_preference_drops_pasted_formatting() { + assert_eq!(paste_copied_range(false), 40.0, "Keep Formatting"); + assert_eq!(paste_copied_range(true), 10.0, "Keep Plain Text"); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 923b004b..01d6ed61 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -88,6 +88,7 @@ impl Session { preview_bounds: self.prefs.use_preview_bounds, unit, stroke_unit, + paste_plain_text: self.prefs.paste_text_formatting == "plain", }; let tool = &mut self.tool; match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) { diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 7e4a9b8a..3ff50dc8 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,7 +1,7 @@ //! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input, //! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, must load as an error or as a document that then renders and -//! exports, without a panic. +//! exports, without a panic; nor may bitmaps, PDF and text pasted from other apps. //! //! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). @@ -590,3 +590,58 @@ proptest! { pdf_presets("mutated pdf presets", &mutate_text(&text, cut, &edits))?; } } + +// ---------- what Paste takes from other apps ---------- + +/// Load `p` into the clipboard with `cmd`, then paste it into a new document: neither may panic. +fn paste_from_elsewhere(cmd: &str, p: Value) -> Result<(), TestCaseError> { + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + if s.execute(cmd, &p).is_ok() { + s.execute("edit.paste", &json!({"center": [50, 50]})).unwrap(); + } + }); + prop_assert!(r.is_ok(), "{cmd}: panicked: {:?}", r.err()); + Ok(()) +} + +/// [`rich_pdf`], made once. +fn rich_pdf_once() -> Vec { + static PDF: std::sync::OnceLock> = std::sync::OnceLock::new(); + PDF.get_or_init(rich_pdf).clone() +} + +/// `bytes` with `edits` (positions wrap around) cut to `cut` bytes, as base64. +fn mutated_b64(mut bytes: Vec, cut: usize, edits: &[(usize, u8)]) -> String { + let n = bytes.len().max(1); + for (at, b) in edits { + if let Some(x) = bytes.get_mut(at % n) { + *x = *b; + } + } + bytes.truncate(cut.max(8)); + vectorcraft_format::base64_encode(&bytes) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn pasted_bitmaps_never_panic(which in 0usize..4, cut in 0usize..400, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { + let (_, image) = raster_samples().swap_remove(which); + paste_from_elsewhere("clipboard.importImage", json!({"dataBase64": mutated_b64(image, cut, &edits), "mime": "image/png"}))?; + } + + #[test] + fn pasted_pdf_never_panics(cut in 0usize..40_000, edits in prop::collection::vec((0usize..40_000, any::()), 0..12)) { + paste_from_elsewhere("clipboard.importPdf", json!({"dataBase64": mutated_b64(rich_pdf_once(), cut, &edits)}))?; + } + + // ASCII with control characters and line breaks, a mark and multi-byte characters (characters + // no font has would send every layout to the slow font fallback). + #[test] + fn pasted_text_never_panics(text in r"[\x00-\x7f\u{300}\u{feff}\u{fffd}é€]{0,200}") { + paste_from_elsewhere("clipboard.importText", json!({ "text": text }))?; + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 19de5eea..70002c16 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -206,6 +206,9 @@ pub struct ToolContext<'a> { pub unit: Unit, /// Units ▸ Stroke: the unit measurement labels show stroke widths in. pub stroke_unit: Unit, + /// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes + /// the style at the caret, even the text the Type tool copied with its formatting. + pub paste_plain_text: bool, } impl ToolContext<'_> { @@ -405,6 +408,7 @@ pub(crate) mod testutil { preview_bounds: false, unit: Unit::Points, stroke_unit: Unit::Points, + paste_plain_text: false, } } } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e21e6dfa..f8c77e00 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -365,9 +365,9 @@ impl Tool for TypeTool { s }; self.clicks = (None, 0); - // Pasting what we copied keeps its formatting. + // Pasting what we copied keeps its formatting (unless pasted text is kept plain). let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - if s.len() > 1 && s == clip { + if s.len() > 1 && s == clip && !cx.paste_plain_text { let runs = self.clipboard.clone(); return self.replace(cx, "", Some(runs)); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 06e265c8..bf693816 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod render_worker; pub mod shortcut_editor; pub mod shortcuts; pub mod state; +pub mod sysclip; pub mod theme; pub mod titlebar; pub mod toolbar; @@ -54,6 +55,8 @@ mod tests_svg; #[cfg(test)] mod tests_svgsave; #[cfg(test)] +mod tests_sysclip; +#[cfg(test)] mod tests_transparencygrid; use std::sync::mpsc::{Receiver, Sender}; @@ -64,6 +67,7 @@ use vectorcraft_engine::{Session, ViewInfo}; pub use control::{ControlRequest, ControlResponse}; pub use state::{UiState, View}; +pub use sysclip::SystemClipboard; pub type PickSave = Box Option>; pub type ReadFn = Box Result, String>>; @@ -101,6 +105,10 @@ pub struct Services { /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the /// canvas). pub place_inbox: Option, + /// The system clipboard with all its formats (desktop): Copy offers text, SVG, PDF and PNG, + /// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through + /// egui and `clipboard_read`). + pub system_clipboard: Option>, } /// Cached canvas raster. @@ -178,14 +186,17 @@ pub struct VectorcraftApp { pub custom_titlebar: bool, /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, - /// The Paste commands can paste from the system clipboard alone: it holds SVG while the - /// internal clipboard is empty. It enables the Paste menu items. - pub(crate) system_svg: bool, - /// When `system_svg` was last checked (app time, s; at most once per frame). - system_svg_at: f64, + /// The Paste commands can paste from the system clipboard alone: it holds something to paste + /// (SVG without a `system_clipboard`) while the internal clipboard is empty. It enables the + /// Paste menu items. + pub(crate) system_paste: bool, + /// When `system_paste` was last checked (app time, s; at most once per frame). + system_paste_at: f64, + /// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]). + pub(crate) paste_chord: shortcuts::PasteChord, } -/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_svg`]. +/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; impl VectorcraftApp { @@ -226,8 +237,9 @@ impl VectorcraftApp { hover_doc: None, custom_titlebar: false, place: Default::default(), - system_svg: false, - system_svg_at: f64::NEG_INFINITY, + system_paste: false, + system_paste_at: f64::NEG_INFINITY, + paste_chord: Default::default(), } } @@ -274,7 +286,10 @@ impl VectorcraftApp { } let mut params = params; if id.starts_with("edit.paste") { - self.adopt_system_clipboard(); + if let Err(e) = self.adopt_system_clipboard() { + self.ui.status = e.clone(); + return Err(e); + } // Paste (also without formatting) goes to the centre of the view. if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") && ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none()) @@ -288,9 +303,8 @@ impl VectorcraftApp { } } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); - if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") && self.session.prefs.copy_as_svg { - self.clipboard_out = self.session.clipboard_svg(); - self.clipboard_published = self.clipboard_out.clone(); + if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { + self.publish_clipboard(); } self.sync_views(); match &r { @@ -316,21 +330,6 @@ impl VectorcraftApp { r } - /// Before a paste: SVG that another app put on the system clipboard replaces the internal - /// clipboard (centred in the view). Our own published SVG keeps the lossless internal copy. - fn adopt_system_clipboard(&mut self) { - let text = self.clipboard_in.take().or_else(|| self.services.clipboard_read.as_mut().and_then(|f| f())); - let Some(text) = text.filter(|t| vectorcraft_engine::cmd::clipboard::looks_like_svg(t)) else { return }; - if self.clipboard_published.as_deref() == Some(text.as_str()) { - return; - } - let center = self.view().map(|v| [v.center.x, v.center.y]); - match self.session.execute("clipboard.importSvg", &serde_json::json!({ "svg": text, "center": center })) { - Ok(_) => self.clipboard_published = Some(text), - Err(e) => self.ui.status = format!("Couldn't paste SVG: {e}"), - } - } - /// Open a link in the browser (Help → Discord, website, GitHub…). pub fn open_url(&mut self, url: &str) { match self.services.open_url.as_mut() { @@ -499,16 +498,9 @@ impl VectorcraftApp { self.sync_views(); // Read the system clipboard only when that alone decides whether Paste is enabled, and at // most a few times a second (opening it locks it against other apps on some systems). - if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_svg_at)) { - self.system_svg_at = now; - self.system_svg = self.session.clipboard.is_empty() - && self.session.active().is_some() - && self - .services - .clipboard_read - .as_mut() - .and_then(|read| read()) - .is_some_and(|t| vectorcraft_engine::cmd::clipboard::looks_like_svg(&t)); + if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) { + self.system_paste_at = now; + self.system_paste = self.session.clipboard.is_empty() && self.session.active().is_some() && self.system_clipboard_pasteable(); } // The window's close button (or the system quitting the app) asks about unsaved documents. if ctx.input(|i| i.viewport().close_requested()) && unsaved::any_dirty(self) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 106e18f4..e9cf27b0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -940,8 +940,8 @@ pub fn shortcut_of(id: &str) -> Option<&'static str> { /// Is a command currently enabled? pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { if let Some(c) = vectorcraft_engine::find_command(id) { - // SVG on the system clipboard can be pasted with an empty internal clipboard. - return (c.enabled)(&app.session).is_ok() || app.system_svg && id.starts_with("edit.paste"); + // The system clipboard's contents can be pasted with an empty internal clipboard. + return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); } match id { "file.save" diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index fbe2e235..c98b8d61 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -69,7 +69,46 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json v } +/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) +const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste]; + +/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and +/// sends a Paste event only when the clipboard holds text, so a paste key released without its +/// press (or a Paste event) reported was a paste of a bitmap or a PDF. +#[derive(Default)] +pub(crate) struct PasteChord { + /// Paste keys whose press was reported (typed, not pasting). + pressed: Vec, + /// A Paste event came since the last paste key was released. + pasted: bool, +} + +impl PasteChord { + /// Follow one frame's `events` → whether they hold a paste egui sent no event for. + fn textless_paste(&mut self, events: &[egui::Event]) -> bool { + let mut fire = false; + for e in events { + match e { + egui::Event::Paste(_) => self.pasted = true, + // A release that comes while the window is away is never seen. + egui::Event::WindowFocused(false) => *self = Self::default(), + egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key), + egui::Event::Key { key, pressed: false, .. } if PASTE_KEYS.contains(key) => match self.pressed.iter().position(|k| k == key) { + Some(i) => { + self.pressed.swap_remove(i); + } + None => fire |= !std::mem::take(&mut self.pasted), + }, + _ => {} + } + } + fire + } +} + pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { + // Followed before anything returns, so a key typed in a field isn't taken for a paste. + let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events)); if app.ui.dialog.is_some() || app.ui.palette_open { if crate::shortcut_editor::is_recording(app) { return; @@ -139,6 +178,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { false }) }); + // Only the system clipboard service reads what isn't text (the native menu has its own Paste). + if textless_paste && app.services.system_clipboard.is_some() && !app.native_shortcuts.contains("edit.paste") { + clip.push(("edit.paste", None)); + } for (id, text) in clip { app.clipboard_in = text; crate::menus::invoke(app, id, json!({})); diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs new file mode 100644 index 00000000..f346095e --- /dev/null +++ b/crates/ui-egui/src/sysclip.rs @@ -0,0 +1,98 @@ +//! 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, text, a bitmap) 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 +//! publishes SVG text through egui and Paste takes SVG text only. + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::clipboard::{Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg}; + +use crate::VectorcraftApp; + +/// The platform clipboard, with every format it carries. +pub trait SystemClipboard { + /// Replace the clipboard's contents with `flavours` (best first; none empties it). + fn write(&mut self, flavours: &[Flavour]) -> Result<(), String>; + /// Does the clipboard still hold what [`Self::write`] last put there (nothing copied since)? + fn holds_ours(&mut self) -> bool; + /// The first of `mimes` the clipboard holds; `image/*` is any bitmap (returned as PNG, BMP…). + fn read(&mut self, mimes: &[&'static str]) -> Option; + /// Does the clipboard hold one of `mimes`? Cheap enough to ask a few times a second. + fn has(&mut self, mimes: &[&'static str]) -> bool; +} + +/// The command (and its params) that loads `f` into the internal clipboard, centred on `center`. +pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static str, Value) { + let b64 = || vectorcraft_format::base64_encode(&f.data); + match f.mime { + TEXT => { + let text = String::from_utf8_lossy(&f.data); + if looks_like_svg(&text) { + ("clipboard.importSvg", json!({ "svg": text, "center": center })) + } else { + ("clipboard.importText", json!({ "text": text, "center": center })) + } + } + SVG => ("clipboard.importSvg", json!({ "dataBase64": b64(), "center": center })), + PDF => ("clipboard.importPdf", json!({ "dataBase64": b64(), "center": center })), + mime => ("clipboard.importImage", json!({ "dataBase64": b64(), "mime": mime, "center": center })), + } +} + +impl VectorcraftApp { + /// After Copy or Cut: offer the copied objects to other apps. + pub(crate) fn publish_clipboard(&mut self) { + match self.services.system_clipboard.as_mut() { + Some(cb) => { + if let Err(e) = cb.write(&self.session.clipboard_flavours()) { + self.ui.status = format!("Couldn't copy to the system clipboard: {e}"); + } + } + None if self.session.prefs.copy_as_svg => { + self.clipboard_out = self.session.clipboard_svg(); + self.clipboard_published = self.clipboard_out.clone(); + } + None => {} + } + } + + /// Before a paste: what another app put on the system clipboard replaces the internal + /// clipboard (centred in the view). What we published ourselves keeps the lossless internal + /// copy. An error means nothing should be pasted. + pub(crate) fn adopt_system_clipboard(&mut self) -> Result<(), String> { + let event_text = self.clipboard_in.take(); + let center = self.view().map(|v| [v.center.x, v.center.y]); + let (cmd, params) = match self.services.system_clipboard.as_mut() { + Some(cb) => { + if cb.holds_ours() { + return Ok(()); + } + let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) }; + import_command(&f, center) + } + // Text only: SVG markup is art, other text is left alone. + None => { + let text = event_text.or_else(|| self.services.clipboard_read.as_mut().and_then(|f| f())); + let Some(text) = text.filter(|t| looks_like_svg(t)) else { return Ok(()) }; + if self.clipboard_published.as_deref() == Some(text.as_str()) { + return Ok(()); + } + self.clipboard_published = Some(text.clone()); + ("clipboard.importSvg", json!({ "svg": text, "center": center })) + } + }; + self.session.execute(cmd, ¶ms).map(drop).map_err(|e| { + self.clipboard_published = None; + format!("Couldn't paste from the clipboard: {e}") + }) + } + + /// Does the system clipboard hold something Paste can take (with nothing copied in the app)? + pub(crate) fn system_clipboard_pasteable(&mut self) -> bool { + match self.services.system_clipboard.as_mut() { + Some(cb) => cb.has(&PASTE_ORDER), + None => self.services.clipboard_read.as_mut().and_then(|read| read()).is_some_and(|t| looks_like_svg(&t)), + } + } +} diff --git a/crates/ui-egui/src/tests_sysclip.rs b/crates/ui-egui/src/tests_sysclip.rs new file mode 100644 index 00000000..ae149257 --- /dev/null +++ b/crates/ui-egui/src/tests_sysclip.rs @@ -0,0 +1,222 @@ +//! The system clipboard's formats through a headless fake service (M4.57): what Copy publishes, +//! what Paste takes from other apps (bitmaps, PDF, text), the Paste menu and keyboard paste. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PDF, PNG, SVG, TEXT}; +use vectorcraft_geom::Point; + +use crate::{Services, SystemClipboard, VectorcraftApp, menus}; + +/// The fake clipboard's contents: what the app wrote, or what "another app" copied. +#[derive(Default)] +struct Board { + flavours: Vec, + ours: bool, +} + +struct Fake(Rc>); + +impl Fake { + fn find(&self, mimes: &[&'static str]) -> Option { + let b = self.0.borrow(); + let matches = |m: &str, f: &Flavour| f.mime == m || (m == BITMAP && f.mime.starts_with("image/") && f.mime != SVG); + mimes.iter().find_map(|m| b.flavours.iter().find(|f| matches(m, f)).cloned()) + } +} + +impl SystemClipboard for Fake { + fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + *self.0.borrow_mut() = Board { flavours: flavours.to_vec(), ours: true }; + Ok(()) + } + fn holds_ours(&mut self) -> bool { + self.0.borrow().ours + } + fn read(&mut self, mimes: &[&'static str]) -> Option { + self.find(mimes) + } + fn has(&mut self, mimes: &[&'static str]) -> bool { + self.find(mimes).is_some() + } +} + +/// An app with a document and a fake system clipboard, the view centred on (250, 180). +fn app() -> (VectorcraftApp, Rc>) { + let board = Rc::new(RefCell::new(Board::default())); + let services = Services { system_clipboard: Some(Box::new(Fake(board.clone()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + run(&mut app, "file.new", json!({"width": 400, "height": 300})); + let v = app.view_mut().unwrap(); + (v.center, v.fitted) = (Point::new(250.0, 180.0), true); + (app, board) +} + +/// Another app copies `flavours`. +fn copy_elsewhere(board: &Rc>, flavours: Vec<(&'static str, Vec)>) { + let flavours = flavours.into_iter().map(|(mime, data)| Flavour { mime, data }).collect(); + *board.borrow_mut() = Board { flavours, ours: false }; +} + +fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { + app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn copy_rect(app: &mut VectorcraftApp) { + let id = run(app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20}))["id"].clone(); + run(app, "paint.setFill", json!({"color": "#ff0000"})); + run(app, "select.set", json!({"ids": [id]})); + run(app, "edit.copy", json!({})); +} + +fn written(board: &Rc>) -> Vec<&'static str> { + board.borrow().flavours.iter().map(|f| f.mime).collect() +} + +/// The objects the last paste selected. +fn pasted(app: &VectorcraftApp) -> Vec { + let st = app.session.active().unwrap(); + st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() +} + +fn count(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().doc.layers[0].children().unwrap().len() +} + +/// A 4 × 2 px blue PNG. +fn blue_png() -> Vec { + let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png +} + +/// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`. +fn frame(app: &mut VectorcraftApp, events: Vec) { + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx())).textures_delta.clear(); +} + +fn key(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +#[test] +fn copy_publishes_pdf_to_the_service_only_with_the_preference_on() { + let (mut app, board) = app(); + copy_rect(&mut app); + assert_eq!(written(&board), [TEXT, SVG, PNG]); + assert!(String::from_utf8_lossy(&board.borrow().flavours[0].data).contains(""##; + copy_elsewhere(&board, vec![(TEXT, svg.as_bytes().to_vec())]); + run(&mut app, "edit.paste", json!({})); + assert_eq!(pasted(&app).len(), 2); + assert!(pasted(&app).iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // Text that can't be pasted pastes nothing (not the stale internal clipboard). + let before = count(&app); + copy_elsewhere(&board, vec![(TEXT, b" \r\n".to_vec())]); + assert!(app.run("edit.paste", json!({})).is_err()); + assert!(app.ui.status.starts_with("Couldn't paste"), "{}", app.ui.status); + assert_eq!(count(&app), before); +} + +#[test] +fn a_pdf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() { + let (mut app, board) = app(); + let mut other = Session::new(); + other.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = other.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 30, "height": 30})).unwrap()["id"].clone(); + other.execute("select.set", &json!({"ids": [id]})).unwrap(); + other.execute("edit.copy", &json!({})).unwrap(); + let pdf = other.clipboard_pdf().unwrap().unwrap(); + copy_elsewhere(&board, vec![(PNG, blue_png()), (PDF, pdf)]); + run(&mut app, "edit.paste", json!({})); + let p = pasted(&app); + assert!(!p.is_empty() && p.iter().all(|n| !matches!(n.kind, NodeKind::Image(_))), "{p:?}"); +} + +#[test] +fn a_bitmap_on_the_system_clipboard_enables_paste() { + let (mut app, board) = app(); + copy_elsewhere(&board, vec![(PNG, blue_png())]); + assert!(!menus::enabled(&app, "edit.paste"), "not looked at before a frame"); + frame(&mut app, vec![]); + assert!(menus::enabled(&app, "edit.paste") && menus::enabled(&app, "edit.pasteInPlace")); + menus::invoke(&mut app, "edit.paste", json!({})); + assert!(matches!(pasted(&app)[0].kind, NodeKind::Image(_))); +} + +#[test] +fn keyboard_paste_of_a_bitmap_without_a_paste_event() { + let (mut app, board) = app(); + let (cmd, none) = (egui::Modifiers::COMMAND, egui::Modifiers::NONE); + copy_elsewhere(&board, vec![(PNG, blue_png())]); + frame(&mut app, vec![]); + // Cmd+V: egui swallows the press and sends no Paste event (no text); only the release arrives. + frame(&mut app, vec![key(egui::Key::V, false, cmd)]); + assert_eq!(count(&app), 1); + assert!(matches!(pasted(&app)[0].kind, NodeKind::Image(_))); + // A V typed (its press reported) is no paste, whatever is released with it. + frame(&mut app, vec![key(egui::Key::V, true, none)]); + frame(&mut app, vec![key(egui::Key::V, false, cmd)]); + assert_eq!(count(&app), 1); + // Text on the clipboard: egui's Paste event pastes once, its release adds nothing. + copy_elsewhere(&board, vec![(TEXT, b"words".to_vec())]); + frame(&mut app, vec![egui::Event::Paste("words".into())]); + frame(&mut app, vec![key(egui::Key::V, false, cmd)]); + assert_eq!(count(&app), 2); +} + +#[test] +fn without_the_service_plain_text_is_still_not_pasted() { + // The web: SVG text through egui only (see shortcuts' copy_and_paste_events_use_the_system_clipboard). + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + frame(&mut app, vec![]); + frame(&mut app, vec![key(egui::Key::V, false, egui::Modifiers::COMMAND)]); + frame(&mut app, vec![egui::Event::Paste("hello".into())]); + assert_eq!(count(&app), 0); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dcf67f2e..2f33e97a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -244,8 +244,9 @@ far). `ui.dialog.confirm` records the answer and asks about the next conflict (w the same); the last one runs the paste with `swatchConflict`. `ui.dialog.cancel` pastes nothing. Paste placement: through the app, `edit.paste` and `edit.pasteWithoutFormatting` without `center`, `dx` or `dy` -paste at the centre of the view. The Paste menu items are enabled while the system clipboard holds SVG, even with -nothing copied in the app (looked at up to four times a second; `ui.menu.list` shows it). The Layers panel menu (≡ on +paste at the centre of the view. The Paste menu items are enabled while the system clipboard holds something to +paste (SVG, PDF, text or a bitmap on the desktop; SVG on the web), even with nothing copied in the app (looked at up +to four times a second; `ui.menu.list` shows it). The Layers panel menu (≡ on the dock's tab strip while Layers shows) lists the layer commands and Paste Remembers Layers (`layer.pasteRemembersLayers`, checked when on). @@ -276,3 +277,9 @@ Text Import Options: placing a `.txt` file through the app (`file.place` with a dialog, a drop) opens the `textImport` dialog (fields `platform`: `windows`/`mac`, `characterSet`: `unicode`/`ansi`, `removeLineReturns`, `removeParagraphReturns`, `replaceSpaces`, `spaces`). `ui.dialog.confirm` places the file as area 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 +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). diff --git a/docs/mcp.md b/docs/mcp.md index 51c06e8f..33b007eb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -976,3 +976,33 @@ in `document.exportPdf`, `document.pdfSettings`, `document.export`/`serialize` w {"name":"run_command","arguments":{"command":"pdf.preset.save","params":{"name":"Web","preset":"Smallest File Size","compatibility":"1.5"}}} {"name":"run_command","arguments":{"command":"document.exportPdf","params":{"preset":"Web","path":"/tmp/web.pdf"}}} ``` + +## Clipboard formats + +Besides SVG, Copy offers other apps PNG and PDF of the copied objects, and their text when they are all type. +`clipboard.flavours` lists what Copy offers for the current clipboard, best first, by the Clipboard Handling +preferences: `text/plain` (the text of a type-only copy, else the SVG markup with `copyAsSvg`), `image/svg+xml` +(`copyAsSvg`), `application/pdf` (`copyAsPdf`) and `image/png` (always; PDF and PNG need objects with an +area). Each one has a query command: +`clipboard.exportSvg`, `clipboard.exportText` (null unless every copied object is type), `clipboard.exportPdf` +(a one-page PDF, the page the objects' visual bounds) and `clipboard.exportPng {scale?}` (cropped to the objects, +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 +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). Pasting what VectorCraft copied +keeps the lossless internal clipboard. Windows carries every format both ways; macOS and Linux carry one format +(the text, else the PNG) and paste text and bitmaps. On the web only SVG text goes through the system clipboard. + +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. + +```json +{"name":"run_command","arguments":{"command":"clipboard.flavours","params":{}}} +{"name":"run_command","arguments":{"command":"clipboard.exportPng","params":{"scale":2}}} +{"name":"run_command","arguments":{"command":"clipboard.importImage","params":{"dataBase64":"iVBORw0KGgo…","center":[300,200]}}} +{"name":"run_command","arguments":{"command":"edit.paste","params":{"center":[300,200]}}} +```