mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Under Wayland the window is native but arboard was built without its data-control backend, so it read the X11 clipboard through XWayland, which only holds what X11 apps copied: Paste found nothing and reported an empty clipboard (egui's text paste into fields uses smithay-clipboard, which is why that worked). arboard now has `wayland-data-control`, the same as PhotoCraft; it falls back to X11 where the compositor lacks the protocol (GNOME's Mutter). The reporter's clipboard also held text/uri-list: with a file copied in a file manager, Paste would have taken the path as text before the bitmap. Copied files now paste first, as the first one that is art (SVG, PDF, EMF/WMF or a bitmap), told from its first 4 KiB by the engine's format detection (`clipboard::file_flavour`): text/uri-list through arboard on Linux and macOS (a trailing CR from CRLF lists trimmed), CF_HDROP on Windows, read with the clipboard closed again. Dropping files on the window can't work under Wayland yet: winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop (winit#1881, added in 0.31), so no dropped_files arrive. docs/development.md documents it and the workarounds (copy and paste the files, File > Place, or run under XWayland with `WAYLAND_DISPLAY=`). Co-Authored-By: Claude Opus 5.5 <[email protected]>
237 lines
11 KiB
Rust
237 lines
11 KiB
Rust
//! 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::{BITMAP, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
||
|
||
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 copied_files_paste_by_their_content() {
|
||
let mut s = session();
|
||
copy_red_square(&mut s);
|
||
let png = decode(&run(&mut s, "clipboard.exportPng", json!({})));
|
||
let pdf = decode(&run(&mut s, "clipboard.exportPdf", json!({})));
|
||
let mut jpeg = vec![];
|
||
image::RgbImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
|
||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"4\" height=\"4\"/></svg>";
|
||
let of = |name: &str, bytes: &[u8]| file_flavour(name, &bytes[..bytes.len().min(FILE_HEAD)], &PASTE_ORDER);
|
||
assert_eq!(of("red.png", &png), Some(PNG));
|
||
// The content tells, whatever the name says; bitmaps keep their own type.
|
||
assert_eq!(of("photo.txt", &jpeg), Some("image/jpeg"));
|
||
assert_eq!(of("art.svg", svg), Some(SVG));
|
||
assert_eq!(of("doc.pdf", &pdf), Some(PDF));
|
||
// Files that aren't art paste as nothing (not as their path).
|
||
assert_eq!(of("notes.txt", b"hello"), None);
|
||
assert_eq!(of("archive.zip", b"PK\x03\x04"), None);
|
||
// Only what was asked for.
|
||
assert_eq!(file_flavour("red.png", &png, &[TEXT, SVG]), None);
|
||
assert_eq!(file_flavour("art.svg", svg, &[BITMAP]), None);
|
||
}
|
||
|
||
#[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");
|
||
}
|