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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 10:52:01 +07:00
co-authored by Claude Opus 5.5
parent a560f74e17
commit 0968e34e7e
24 changed files with 1314 additions and 77 deletions
+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)) {