Paste pictures from the system clipboard (screenshots, Copy Image, copied files) (#45)

egui's paste event only carries text, so a clipboard holding just a picture
sent no paste at all on Ctrl+V, and the ribbon/context-menu Paste only knew
WordCraft's own copy ("the clipboard is empty").

Desktop: the app reads the clipboard's picture through a new
Services::clipboard_picture (arboard, already in the build via egui-winit):
copied files first, else image pixels. Mod+V whose release arrives without a
paste event, and Paste from the ribbon or menus, insert that picture through
insert.picture (one undo step); text naming copied picture files inserts the
pictures. Pixel sizes are hostile: at most 16,000 px a side and 50 Mpx, the
buffer must match, files at most 200 MB.

Web: a document paste listener reads image files from the paste event when
there's no text and delivers them through the inbox like dropped pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-10 19:08:01 +02:00
parent 40c5d51bf9
commit 26e2b313f1
10 changed files with 269 additions and 4 deletions

1
Cargo.lock generated
View File

@ -4865,6 +4865,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
name = "wordcraft"
version = "0.4.0"
dependencies = [
"arboard",
"eframe",
"egui",
"fmv-macos-events",

View File

@ -28,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] }

View File

@ -44,6 +44,9 @@ pub fn start() {
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let inbox: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
log::error!("pasting pictures is unavailable: {e}");
}
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty));
app.autosave = false;
@ -100,6 +103,52 @@ impl eframe::App for WebShell {
}
}
/// Largest picture pasted from the browser's clipboard (as `insert.picture` allows).
const MAX_PASTED_BYTES: f64 = (200u32 << 20) as f64;
/// Most pictures one paste inserts.
const MAX_PASTED_PICTURES: u32 = 20;
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
/// the inbox like dropped ones. A clipboard with text is left to eframe.
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
let Some(data) = e.clipboard_data() else { return };
if !data.get_data("text").unwrap_or_default().is_empty() {
return;
}
let Some(files) = data.files() else { return };
for i in 0..files.length().min(MAX_PASTED_PICTURES) {
let Some(file) = files.get(i) else { continue };
if !file.type_().starts_with("image/") {
continue;
}
if file.size() > MAX_PASTED_BYTES {
log::warn!("pasted picture is larger than 200 MB; not inserted");
continue;
}
let inbox = inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => {
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read the pasted picture: {e:?}"),
}
});
}
});
document.add_event_listener_with_callback("paste", on_paste.as_ref().unchecked_ref()).map_err(|e| format!("{e:?}"))?;
// The listener lives as long as the page.
on_paste.forget();
Ok(())
}
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
let open_inbox = inbox.clone();

View File

@ -37,5 +37,8 @@ rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
pollster = "0.4"
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
# in the build through egui-winit, with the same features.
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"

View File

@ -20,6 +20,7 @@ mod logging;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
paste_picture::ClipboardPicture,
window_geometry::{WindowGeometry, take_rescue},
};
@ -118,9 +119,23 @@ fn save_prefs(app: &WordApp) {
}
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94).
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), and pictures on
/// the system clipboard (#45).
fn services(file_dialog: file_dialogs::Hook) -> Services {
Services { file_dialog: Some(file_dialog), ..Default::default() }
Services { file_dialog: Some(file_dialog), clipboard_picture: Some(Box::new(clipboard_picture)), ..Default::default() }
}
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
/// sizes before inserting anything.
fn clipboard_picture() -> Option<ClipboardPicture> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
let image = clipboard.get_image().ok()?;
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Window, Dock and taskbar icon.

View File

@ -20,6 +20,8 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Pictures pasted from the system clipboard arrive as pixels and are inserted as PNG (#45).
image = { workspace = true }
# The system's preferred interface languages (#8). Native: OS APIs; wasm: none (English).
sys-locale = "0.3"
@ -29,7 +31,6 @@ js-sys = "0.3"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
image = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }

View File

@ -46,6 +46,8 @@ pub struct CanvasState {
pub(crate) obj_drag: Option<crate::objects::ObjectDrag>,
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
pub cols: usize,
/// A paste event arrived since the last Mod+V release (see `keys::canvas_events`).
pub(crate) pasted: bool,
}
impl CanvasState {
@ -78,6 +80,7 @@ impl Default for CanvasState {
obj_drag: None,
context_menu_open: false,
mini_anchor: None,
pasted: false,
cols: 1,
}
}

View File

@ -140,6 +140,24 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
}
let events = ctx.input(|i| i.events.clone());
for e in events {
if let egui::Event::Paste(t) = &e {
app.canvas.pasted = true;
// Copied picture files arrive as their paths: insert the pictures (#45).
if app.session.math.is_none() && !ctx.input(|i| i.modifiers.alt) && crate::paste_picture::paste_named_files(app, t) {
continue;
}
}
// Desktop: Mod+V with no text on the clipboard sends no paste event, only the key's
// release; paste the clipboard's picture then (#45). A paste event before the release
// means it was text. (The web reads pasted pictures itself.)
if let egui::Event::Key { key: key @ (Key::V | Key::Paste), pressed: false, modifiers, .. } = &e {
let paste_keys = (modifiers.command || *key == Key::Paste) && !modifiers.alt;
let pasted = std::mem::take(&mut app.canvas.pasted);
if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() {
let _ = app.run("edit.paste", json!({}));
}
continue;
}
// Editing an equation: text and editing keys go into it.
if app.session.math.is_some() {
match &e {

View File

@ -29,6 +29,7 @@ pub mod keytips;
pub mod mini_toolbar;
pub mod objects;
pub mod panes;
pub mod paste_picture;
pub mod previews;
pub mod read_aloud;
pub mod ribbon;
@ -69,6 +70,9 @@ pub struct Services {
/// Web: hand PDF bytes to the browser's print flow directly (no download, no intermediate
/// file) — opens the system print dialog on that PDF. An error means no dialog was opened.
pub print: Option<Box<dyn Fn(&[u8]) -> Result<(), String>>>,
/// Desktop: the picture on the system clipboard, if any — egui's paste only carries text
/// (#45). Read when Paste finds no text; see [`paste_picture`].
pub clipboard_picture: Option<Box<dyn Fn() -> Option<paste_picture::ClipboardPicture>>>,
}
/// Files delivered asynchronously.
@ -302,6 +306,14 @@ impl WordApp {
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() });
return Ok(json!({"pending": "saveChanges"}));
}
// Paste without system clipboard text: a picture there is newer than our own copy (copying
// here replaces it with text), so it wins (#45).
if id == "edit.paste"
&& params.get("text").is_none()
&& let Some(r) = paste_picture::paste(self)
{
return r;
}
let r = self.execute(id, params);
// Match Fields and Check for Errors show what they found.
if let Ok(v) = &r

View File

@ -0,0 +1,163 @@
//! Pasting a picture from the system clipboard (#45).
//!
//! egui's paste event only carries text, so a clipboard holding just a picture (a screenshot, a
//! browser's Copy Image) or a copied picture file used to paste nothing. The host reads the
//! clipboard through [`crate::Services::clipboard_picture`]; this module checks what it got — the
//! clipboard comes from other programs, so sizes are hostile — and inserts it with
//! `insert.picture`, one undoable step like any inserted picture.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::WordApp;
/// What the host found on the system clipboard.
pub enum ClipboardPicture {
/// Straight (not premultiplied) RGBA8 pixels, row by row.
Pixels { width: usize, height: usize, rgba: Vec<u8> },
/// Copied files (a file manager's Copy); the pictures among them are inserted.
Files(Vec<PathBuf>),
}
/// Longest side of a pasted picture, in pixels.
pub const MAX_SIDE: usize = 16_000;
/// Most pixels in a pasted picture (200 MB of RGBA).
pub const MAX_PIXELS: usize = 50_000_000;
/// Largest picture file pasted (as `insert.picture` allows).
pub const MAX_FILE_BYTES: u64 = 200 << 20;
/// Most picture files one paste inserts.
const MAX_FILES: usize = 20;
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
/// PNG bytes for clipboard pixels, or why they can't be pasted.
pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
if width == 0 || height == 0 {
return Err("the clipboard picture is empty".into());
}
let pixels = width.checked_mul(height).filter(|&n| width <= MAX_SIDE && height <= MAX_SIDE && n <= MAX_PIXELS);
let Some(pixels) = pixels else {
return Err(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"));
};
if Some(rgba.len()) != pixels.checked_mul(4) {
return Err("the clipboard picture is damaged (its size doesn't match its pixels)".into());
}
let (Ok(w), Ok(h)) = (u32::try_from(width), u32::try_from(height)) else {
return Err("the clipboard picture is too large to paste".into());
};
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or("the clipboard picture is damaged")?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| format!("couldn't read the clipboard picture: {e}"))?;
Ok(png)
}
fn is_picture_file(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// Pasted text that is only paths or `file://` addresses of picture files — what file managers
/// put on the clipboard as text when files are copied.
pub fn names_picture_files(text: &str) -> bool {
if text.len() > 64 << 10 {
return false;
}
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).peekable();
lines.peek().is_some()
&& lines.take(MAX_FILES + 1).enumerate().all(|(i, l)| {
let path = l.strip_prefix("file://").unwrap_or(l);
i < MAX_FILES && (l.starts_with("file://") || Path::new(path).is_absolute()) && is_picture_file(Path::new(path))
})
}
/// Paste the picture on the system clipboard. `None` when there is none (or no clipboard
/// service): the caller pastes as usual.
pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
let picture = app.services.clipboard_picture.as_ref()?()?;
let r = match picture {
ClipboardPicture::Pixels { width, height, rgba } => match png_from_rgba(width, height, rgba) {
Ok(png) => app.execute("insert.picture", json!({"data": wordcraft_engine::cmd::insert::base64_encode(&png)})),
Err(e) => {
log::warn!("clipboard picture: {e}");
app.status(e.clone());
Err(e)
}
},
ClipboardPicture::Files(files) => {
let files: Vec<PathBuf> = files.into_iter().filter(|p| is_picture_file(p)).take(MAX_FILES).collect();
if files.is_empty() {
return None;
}
let mut r = Err(String::new());
for path in files {
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
r = if size > MAX_FILE_BYTES {
let e = format!("{} is larger than 200 MB", path.display());
app.status(e.clone());
Err(e)
} else {
app.execute("insert.picture", json!({"path": path.to_string_lossy()}))
};
if r.is_err() {
break;
}
}
r
}
};
Some(r)
}
/// Paste text naming copied picture files: insert the pictures when the clipboard holds those
/// files. False leaves the text to the usual paste.
pub(crate) fn paste_named_files(app: &mut WordApp, text: &str) -> bool {
names_picture_files(text) && paste(app).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use wordcraft_engine::Session;
fn app_with(picture: impl Fn() -> Option<ClipboardPicture> + 'static) -> WordApp {
let services = Services { clipboard_picture: Some(Box::new(picture)), ..Default::default() };
WordApp::new(Session::new(wordcraft_doc::Document::new()), services)
}
fn pictures(a: &mut WordApp) -> usize {
a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0)
}
/// Paste with only a picture on the clipboard inserts it at its natural size, one undo step.
#[test]
fn paste_inserts_clipboard_pixels() {
let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 40, height: 20, rgba: vec![200; 40 * 20 * 4] }));
let r = a.run("edit.paste", json!({})).unwrap();
// 96 ppi: 40 × 20 px is 30 × 15 pt.
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(30.0), Some(15.0)));
assert_eq!(pictures(&mut a), 1);
let media = a.session.doc.media.values().next().unwrap();
assert_eq!(wordcraft_render::image_size(media), Some((40, 20)));
a.run("edit.undo", json!({})).unwrap();
assert_eq!(pictures(&mut a), 0);
// Pasted text that names picture files only becomes pictures when files were copied.
assert!(names_picture_files("file:///home/me/shot%201.png\n/tmp/b.JPG\n"));
assert!(!names_picture_files("see /tmp/b.png") && !names_picture_files("notes.txt") && !names_picture_files(""));
}
/// Hostile clipboard sizes are refused without inserting anything (or allocating for them).
#[test]
fn hostile_clipboard_pictures_are_refused() {
assert!(png_from_rgba(100_000, 1, vec![0; 400_000]).is_err());
assert!(png_from_rgba(usize::MAX, 2, Vec::new()).is_err());
assert!(png_from_rgba(10_000, 10_000, Vec::new()).is_err());
assert!(png_from_rgba(4, 4, vec![0; 63]).is_err());
assert!(png_from_rgba(0, 4, Vec::new()).is_err());
let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 1 << 40, height: 1 << 40, rgba: Vec::new() }));
assert!(a.run("edit.paste", json!({})).is_err());
assert_eq!(pictures(&mut a), 0);
// Copied files that aren't pictures leave Paste to the usual clipboard.
let mut a = app_with(|| Some(ClipboardPicture::Files(vec![PathBuf::from("/tmp/notes.txt")])));
assert_eq!(a.run("edit.paste", json!({})).unwrap_err(), "the clipboard is empty");
}
}