Files
vectorcraft/crates/engine/src/cmd/clipboard.rs
T
bflatasticandClaude Opus 5.5 961ca75a28 M4.95: EMF and WMF open, place and paste as vector objects
crates/metafile import: EMF and WMF (placeable or not) records play on a
small device-context model (objects numbered as GDI does, saved DCs, world
transforms and mapping modes, path brackets, the current position) into one
artboard (the picture's frame) and one layer: paths with fills and strokes
(pens with caps, joins, dashes), clipping groups for clips, images from DIBs
(StretchDIBits, BitBlt, StretchBlt, AlphaBlend, StretchDIB, DIBBitBlt) and
point type in the font the file names. Lines drawn one after another join
into one path; a clip around the whole picture is dropped; objects far
outside it are left out; unknown records are skipped with one warning.

document.open and file.place read .emf and .wmf through the shared loader
(Open and Place filters list them). clipboard.importEmf loads a picture into
the clipboard, and Paste takes image/emf from a system clipboard service
ahead of text and bitmaps. The import fuzzer covers mutated and random-record
metafiles opened, placed and pasted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:27 +07:00

270 lines
12 KiB
Rust

//! The internal clipboard and system clipboard interchange.
//!
//! Copy keeps the objects together with the document resources they use ([`Clipboard`]), so
//! pasting into any document brings those along (see `resources`).
//!
//! 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, with PNG, PDF and plain
//! text besides it (see `flavours`).
mod flavours;
mod resources;
use std::collections::BTreeMap;
use std::sync::Arc;
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::Rect;
pub use flavours::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT};
pub(crate) use resources::SwatchChoices;
use super::*;
use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
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",
"Load SVG into Clipboard",
[],
None,
"{svg | dataBase64 (SVG or SVGZ bytes), center?: [x, y]} replace the clipboard with the SVG's objects (and the images, patterns and symbols they use), centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
has_doc,
import_svg
),
cmd!(
query "clipboard.conflicts",
"Swatch Conflicts",
[],
None,
"{} → {swatches: [{name, document, clipboard, spot}]} the global or spot swatches the copied objects use whose name the active document gives another colour (`document`/`clipboard`: #rrggbb): Paste asks Merge or Add for each (pass the answer as edit.paste* {swatchConflict}). Pasting back into the document the objects came from raises none",
has_doc,
conflicts
),
];
v.extend(flavours::specs());
v
}
/// Is `text` SVG markup (as other apps put on the clipboard)?
pub fn looks_like_svg(text: &str) -> bool {
let t = text.trim_start_matches('\u{feff}').trim_start();
(t.starts_with("<svg") || t.starts_with("<?xml") || t.starts_with("<!--") || t.starts_with("<!DOCTYPE svg")) && t.contains("<svg")
}
/// What Copy puts on the internal clipboard: the objects and every document resource they use.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Clipboard {
/// The copied objects in paint order (document coordinates).
pub nodes: Vec<Node>,
/// Blobs of the images the objects show, by key.
pub images: BTreeMap<String, ImageBlob>,
/// Symbols the objects place (also inside other symbols and patterns).
pub symbols: Vec<Symbol>,
/// Patterns the objects paint with (pasting makes their swatches again).
pub patterns: Vec<PatternDef>,
/// Global and spot swatches the objects' colours link to, the tint swatches of the tints they
/// use and the gradient swatches their gradients came from. Never the built-in [Registration]
/// swatch, which every document has.
pub swatches: Vec<Swatch>,
/// Graphic styles the objects are linked to.
pub graphic_styles: Vec<GraphicStyle>,
pub char_styles: Vec<TextStyleDef>,
pub para_styles: Vec<TextStyleDef>,
/// Brushes the objects' strokes use.
pub brushes: Vec<Brush>,
/// The artboard the objects were copied from: Paste on All Artboards keeps their offset to it.
pub source_artboard: Option<Rect>,
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
pub source_layers: Vec<Option<String>>,
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
/// own resources as they are now.
pub source_doc: Option<u64>,
}
impl Clipboard {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
pub(crate) fn copy(st: &DocState, roots: &[NodeId]) -> Self {
let d = &*st.doc;
let mut nodes = vec![];
let mut source_layers = vec![];
for id in roots {
let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue };
nodes.push(n.clone());
// The nearest enclosing layer (or sublayer).
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
}
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
let board = bounds.and_then(|b| d.artboard_at(b.center())).unwrap_or(0);
Self { source_artboard: d.artboards.get(board).map(|a| a.rect), source_layers, source_doc: Some(st.uid), ..Self::gather(d, nodes) }
}
/// Every object of `src` (each top-level layer's contents) with its resources, for Place and
/// pasting outside SVG. Their layers don't count for Paste Remembers Layers.
pub fn from_document(src: &Document) -> Self {
let nodes = src.layers.iter().flat_map(|l| l.children().into_iter().flatten()).map(|n| (**n).clone()).collect();
Self::gather(src, nodes)
}
/// Union of the objects' visual bounds.
pub fn bounds(&self) -> Option<Rect> {
self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b))
}
/// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none
/// back in the document the objects came from.
pub fn swatch_conflicts<'a>(&'a self, st: &'a DocState) -> Vec<(&'a Swatch, &'a Swatch)> {
if self.source_doc == Some(st.uid) {
return vec![];
}
self.conflicts(&st.doc, &self.used())
}
/// A standalone document holding the objects and their resources (SVG export).
pub fn to_document(&self) -> Document {
let mut d = Document::new(100.0, 100.0);
d.images = self.images.clone();
d.symbols = self.symbols.clone();
d.patterns = self.patterns.clone();
d.swatches.extend(self.swatches.iter().cloned());
d.graphic_styles.extend(self.graphic_styles.iter().cloned());
d.char_styles = self.char_styles.clone();
d.para_styles = self.para_styles.clone();
if !self.brushes.is_empty() {
let mut lib = vectorcraft_brush::library(&d);
for b in &self.brushes {
match lib.iter_mut().find(|x| x.name == b.name) {
Some(x) => *x = b.clone(),
None => lib.push(b.clone()),
}
}
vectorcraft_brush::store(&mut d, &lib);
}
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = self.nodes.iter().cloned().map(Arc::new).collect();
}
d.layers = vec![Arc::new(layer)];
d
}
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
if self.clipboard.is_empty() {
return None;
}
let d = self.clipboard.to_document();
Some(vectorcraft_svg::export(
&d,
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
))
}
}
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "svg": s.clipboard_svg() }))
}
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importSvg";
let svg = match (str_param(p, "svg"), str_param(p, "dataBase64")) {
(Some(svg), _) => std::borrow::Cow::Borrowed(svg),
(None, Some(b64)) => {
let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad base64"))?;
vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, e.to_string()))?.into_owned().into()
}
(None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")),
};
let (src, _) = super::fileio::import_svg(&svg, None).map_err(|e| bad(C, e.to_string()))?;
let clip = Clipboard::from_document(&src);
if clip.is_empty() {
return Err(bad(C, "the SVG has no drawable objects"));
}
Ok(json!({ "count": s.load_clipboard(clip, p)? }))
}
fn conflicts(s: &mut Session, _: &Value) -> Result<Value> {
let hex = |w: &Swatch| w.paint.color().map(|c| c.to_hex());
let list: Vec<Value> = s
.clipboard
.swatch_conflicts(s.doc()?)
.into_iter()
.map(|(c, t)| json!({"name": c.name, "document": hex(t), "clipboard": hex(c), "spot": c.spot}))
.collect();
Ok(json!({ "swatches": list }))
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
#[test]
fn copy_exports_svg_that_round_trips_through_paste() {
let mut s = session();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
assert!(s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].is_null() || !s.clipboard.is_empty());
s.execute("edit.copy", &json!({})).unwrap();
let svg = s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].as_str().unwrap().to_string();
assert!(looks_like_svg(&svg) && svg.contains("#12ab34"), "{svg}");
// As if pasted into another VectorCraft window.
let mut t = session();
assert_eq!(t.execute("clipboard.importSvg", &json!({"svg": svg, "center": [200, 150]})).unwrap()["count"], 1);
t.execute("edit.pasteInPlace", &json!({})).unwrap();
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() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}");
assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6);
}
#[test]
fn paste_never_lands_in_an_object_that_reused_an_undone_layers_id() {
// Found by the model-based test: undo restores the id counter, so a compound path made
// after undoing New Layer gets the dead layer's id, which was still the active layer.
let mut s = session();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
assert_eq!(c, layer, "the scenario needs the id to be reused");
s.execute("edit.paste", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let compound = d.node(NodeId(c)).unwrap();
assert!(compound.children().unwrap().iter().all(|ch| matches!(ch.kind, vectorcraft_doc::NodeKind::Path { .. })));
}
#[test]
fn foreign_svg_and_non_svg_text() {
let mut s = session();
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="red"/><rect width="2" height="2"/></svg>"##;
assert!(looks_like_svg(svg) && looks_like_svg(&format!("<?xml version=\"1.0\"?>\n{svg}")));
assert!(!looks_like_svg("hello <svg> world") && !looks_like_svg("plain text"));
assert_eq!(s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap()["count"], 2);
assert!(s.execute("clipboard.importSvg", &json!({"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\"/>"})).is_err());
assert!(s.execute("clipboard.importSvg", &json!({"svg": "not svg"})).is_err());
}
}