mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
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>
223 lines
9.4 KiB
Rust
223 lines
9.4 KiB
Rust
//! 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)]
|
||
pub(super) 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).
|
||
pub(super) 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`.
|
||
pub(super) 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 };
|
||
}
|
||
|
||
pub(super) 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.
|
||
pub(super) 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.
|
||
pub(super) 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);
|
||
}
|