M4.57: PNG, PDF and text clipboard flavours in and out

Copy and Cut also offer other apps a PNG of the copied objects, a PDF
with On Copy: PDF, and plain text when every copied object is type
(clipboard.flavours; clipboard.exportPng {scale?}, exportPdf,
exportText). Paste outside text editing takes what another app copied:
SVG, then PDF (clipboard.importPdf), then plain text as point text
(clipboard.importText), then a bitmap embedded at the view centre
(clipboard.importImage {dataBase64, mime?, center?}); pasting our own
copy keeps the lossless internal clipboard, and Cmd+V pastes a bitmap
that comes with no text. Services gains system_clipboard
(SystemClipboard: write, holds_ours, read, has): on Windows every
format at once, on macOS and Linux one (the text, else the PNG); the
web keeps SVG text only. With When pasting text: Keep Plain Text, text
the Type tool copied pastes without its formatting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 10:52:01 +07:00
1 parent a560f74e17
commit 0968e34e7e
24 files changed
+1314 -77

No files matched your search

Generated
+2
View File
@@ -4047,8 +4047,10 @@ name = "vectorcraft"
version = "0.1.1"
dependencies = [
"arboard",
"clipboard-win",
"eframe",
"egui",
"image",
"log",
"muda",
"rfd",
+1
View File
@@ -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]
+5 -1
View File
@@ -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 }
+264
View File
@@ -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<dyn SystemClipboard> {
#[cfg(windows)]
let cb = Box::<win::Clipboard>::default();
#[cfg(not(windows))]
let cb = Box::<portable::Clipboard>::default();
cb
}
/// The text flavour among `flavours`.
fn text_of(flavours: &[Flavour]) -> Option<String> {
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
}
fn decode_png(png: &[u8]) -> Option<image::RgbaImage> {
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<Item = u32> {
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<Vec<u8>> {
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<NonZeroU32>,
/// The text of our last write.
text: Option<String>,
}
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<Flavour> {
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<u8>) -> 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<Flavour> {
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<Vec<u8>> {
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<arboard::Clipboard>,
ours: Option<Ours>,
}
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<Flavour> {
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()))
}
}
}
+3 -2
View File
@@ -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);
+11 -18
View File
@@ -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<CommandSpec> {
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<CommandSpec> {
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<Value> {
(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<Value> {
+295
View File
@@ -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<u8>,
}
/// 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<CommandSpec> {
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<String> {
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<Option<(Vec<u8>, 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<Option<Vec<u8>>> {
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<Flavour> {
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<usize> {
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<Vec<u8>> {
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<Value> {
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<Value> {
Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) }))
}
fn export_text(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "text": s.clipboard_text() }))
}
fn flavours(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "flavours": s.clipboard_flavour_types() }))
}
fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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 }))
}
+15 -9
View File
@@ -400,6 +400,20 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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
}
+5 -1
View File
@@ -181,7 +181,11 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
/// 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
}
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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)
}
+2
View File
@@ -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;
+213
View File
@@ -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<Value> =
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<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
/// The pasted objects (the selection after a paste).
fn pasted(s: &Session) -> Vec<vectorcraft_doc::Node> {
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::<Vec<_>>();
// 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("<svg") && f[0].data == f[1].data);
assert!(f[2].data.starts_with(b"\x89PNG"));
run(&mut s, "prefs.set", json!({"key": "copyAsPdf", "value": true}));
assert_eq!(mimes(&s), [TEXT, SVG, PDF, PNG]);
assert!(s.clipboard_flavours()[2].data.starts_with(b"%PDF"));
run(&mut s, "prefs.set", json!({"values": {"copyAsSvg": false, "copyAsPdf": false}}));
assert_eq!(mimes(&s), [PNG]);
// Type only: its text is the plain text, even without SVG.
copy_text(&mut s, &["Words"]);
assert_eq!(mimes(&s), [TEXT, PNG]);
assert_eq!(s.clipboard_flavours()[0].data, b"Words");
run(&mut s, "prefs.set", json!({"key": "copyAsSvg", "value": true}));
let f = s.clipboard_flavours();
assert_eq!((f[0].data.as_slice(), f[1].mime), (b"Words".as_slice(), SVG));
}
/// The Type tool pastes the text it copied with its formatting, unless pasted text is kept plain.
fn paste_copied_range(plain: bool) -> 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");
}
+1
View File
@@ -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)) {
+56 -1
View File
@@ -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<u8> {
static PDF: std::sync::OnceLock<Vec<u8>> = 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<u8>, 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::<u8>()), 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::<u8>()), 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 }))?;
}
}
+4
View File
@@ -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,
}
}
}
+2 -2
View File
@@ -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));
}
+29 -37
View File
@@ -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<dyn FnMut(&str) -> Option<String>>;
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, 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<place::PlaceInbox>,
/// 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<Box<dyn SystemClipboard>>,
}
/// 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, &params).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) {
+2 -2
View File
@@ -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"
+43
View File
@@ -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<Key>,
/// 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!({}));
+98
View File
@@ -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<Flavour>;
/// 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, &params).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)),
}
}
}
+222
View File
@@ -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<Flavour>,
ours: bool,
}
struct Fake(Rc<RefCell<Board>>);
impl Fake {
fn find(&self, mimes: &[&'static str]) -> Option<Flavour> {
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<Flavour> {
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<RefCell<Board>>) {
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<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
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<RefCell<Board>>) -> Vec<&'static str> {
board.borrow().flavours.iter().map(|f| f.mime).collect()
}
/// The objects the last paste selected.
fn pasted(app: &VectorcraftApp) -> Vec<Node> {
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<u8> {
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<egui::Event>) {
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("<svg"));
run(&mut app, "prefs.set", json!({"key": "copyAsPdf", "value": true}));
run(&mut app, "edit.cut", json!({}));
assert_eq!(written(&board), [TEXT, SVG, PDF, PNG]);
assert!(board.borrow().flavours[2].data.starts_with(b"%PDF"));
// Nothing goes through egui's text output when the service is there.
assert!(app.clipboard_out.is_none());
}
#[test]
fn our_own_copy_pastes_the_lossless_internal_clipboard() {
let (mut app, board) = app();
copy_rect(&mut app);
// As if the clipboard held only the PNG: still ours, so the rectangle comes back as a path.
board.borrow_mut().flavours.retain(|f| f.mime == PNG);
run(&mut app, "edit.paste", json!({}));
assert!(matches!(pasted(&app)[0].kind, NodeKind::Path { .. }));
}
#[test]
fn a_bitmap_from_another_app_pastes_as_an_embedded_image_at_the_view_centre() {
let (mut app, board) = app();
copy_rect(&mut app);
copy_elsewhere(&board, vec![(PNG, blue_png())]);
run(&mut app, "edit.paste", json!({}));
let n = &pasted(&app)[0];
let NodeKind::Image(im) = &n.kind else { panic!("not an image: {:?}", n.kind) };
assert!(im.link.is_none() && (im.width, im.height) == (4, 2));
let b = n.geometric_bounds().unwrap();
assert!((b.center() - Point::new(250.0, 180.0)).hypot() < 1e-6, "{b:?}");
}
#[test]
fn text_from_another_app_pastes_as_point_text_and_svg_text_as_art() {
let (mut app, board) = app();
// Word processors offer a picture of the text too: the text wins.
copy_elsewhere(&board, vec![(TEXT, b"Pasted words".to_vec()), (PNG, blue_png())]);
run(&mut app, "edit.paste", json!({}));
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") };
assert_eq!(t.plain_text(), "Pasted words");
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="5" r="5"/><circle cx="20" cy="5" r="5"/></svg>"##;
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);
}
+9 -2
View File
@@ -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).
+30
View File
@@ -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]}}}
```