Files
bflatasticandClaude Opus 5.5 4767ec7113 Fix #398: Linux/Wayland bitmap paste, copied files paste as art
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]>
2026-10-08 07:44:23 +07:00

237 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}