mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
a560f74e17
commit
0968e34e7e
24 files changed
+1314
-77
No files matched your search
Generated
+2
@@ -4047,8 +4047,10 @@ name = "vectorcraft"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"eframe",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"rfd",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }))?;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ¶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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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, ¶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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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]}}}
|
||||
```
|
||||
Reference in new issue
Block a user