From 630b5dfc43d32b15181afaee13a2bf6b95dd7cd0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:50:24 -0800 Subject: [PATCH 1/7] Paste: images on the system clipboard paste as embedded images (#2204) Paste ignored a bitmap on the system clipboard: a screenshot or an image copied in a browser had to be saved and placed. Paste now places it. - edit.pasteImage {base64, name?, spread?, at?} embeds the image in the selected empty frame, or in a new frame centered on `at` at its stored resolution (72 ppi when none) and at most 60% of the page width, as File > Place sizes it. Save and reopen and IDML export keep the pixels. Images over 64 million pixels and names without an image extension are refused. - Ctrl/Cmd+V pastes the image egui delivers with no text, and Edit > Paste reads the clipboard's bitmap, or the first image file of a copied file list, when there is no text or the text is only an address. - The menu read waits at most 1.5 s on its own thread, with one read in flight at a time. - With a native menu, objects copied here still paste as objects until the pasteboard's change count shows a newer copy. - ui.paste takes {image} for the control channel. --- apps/designcraft/Cargo.toml | 8 +- apps/designcraft/src/clipboard_image.rs | 135 +++++++++++++ apps/designcraft/src/main.rs | 4 + crates/engine/src/cmd.rs | 3 + crates/engine/src/cmd/paste_image.rs | 251 ++++++++++++++++++++++++ crates/images/src/lib.rs | 9 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/clipboard.rs | 89 ++++++++- crates/ui-egui/src/app/commands.rs | 12 +- crates/ui-egui/src/canvas/input.rs | 16 +- crates/ui-egui/src/canvas/tests.rs | 121 ++++++++++++ crates/ui-egui/src/control.rs | 21 +- crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/services.rs | 33 ++++ docs/control-protocol.md | 2 +- 15 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 apps/designcraft/src/clipboard_image.rs create mode 100644 crates/engine/src/cmd/paste_image.rs diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index b17258acd..b66041a04 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -27,7 +27,8 @@ winresource = "0.1" # Not called here: craft-dialogs shows the dialogs. rfd's default features keep its Wayland # support on, so portal dialogs get the window as their parent. rfd = { workspace = true } -arboard = { workspace = true } +# `image-data`: Edit › Paste reads bitmaps from the clipboard (src/clipboard_image.rs). +arboard = { workspace = true, features = ["image-data"] } [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] # The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs. @@ -46,9 +47,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"] } winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } # The input context, to drop the IME's marked text when the Type tool ends a composition. The -# versions muda uses, through safe methods. +# versions muda uses, through safe methods. `NSPasteboard`: the pasteboard's change count, which +# tells Paste whether anything was copied since an object Copy (src/clipboard_image.rs). objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSResponder", "NSScreen", "NSTextInputContext"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSPasteboard", "NSResponder", "NSScreen", "NSTextInputContext"] } # Files dragged in from the Finder: the pointer's place on the screen (src/pointer.rs). objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] } # The window's `movable` flag follows the application bar's controls. diff --git a/apps/designcraft/src/clipboard_image.rs b/apps/designcraft/src/clipboard_image.rs new file mode 100644 index 000000000..0e8339ff4 --- /dev/null +++ b/apps/designcraft/src/clipboard_image.rs @@ -0,0 +1,135 @@ +//! The system clipboard's image for Edit › Paste (`Services::clipboard_image`): a bitmap +//! (Windows `CF_DIBV5` or `PNG`, the macOS pasteboard's TIFF or PNG, `image/png` on X11), else the +//! first image file of files copied in a file manager. The read runs on its own thread, and Paste +//! waits for it at most [`WAIT`], so an application that is slow to hand over the clipboard does +//! not freeze the window. While a read that timed out is still waiting, Paste starts no other. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::Duration; + +use designcraft_ui_egui::ClipboardImage; + +/// How long Paste waits for the clipboard's image. +const WAIT: Duration = Duration::from_millis(1500); + +/// The largest image file Paste reads from a copied file list, in bytes. +const MAX_FILE_BYTES: u64 = 256 << 20; + +/// A clipboard read is running. +static READING: AtomicBool = AtomicBool::new(false); + +/// The clipboard's image, or `None` when it holds none or does not answer within [`WAIT`]. +pub fn read() -> Option { + within(&READING, WAIT, read_now) +} + +/// Clears the in-flight flag when the read's thread ends. +struct Reading(&'static AtomicBool); + +impl Drop for Reading { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +/// `read`'s result when it arrives within `wait`. A read that takes longer finishes on its own +/// thread and is dropped; until it does, `busy` stays set and no other read starts. +fn within(busy: &'static AtomicBool, wait: Duration, read: impl FnOnce() -> Option + Send + 'static) -> Option { + if busy.swap(true, Ordering::AcqRel) { + log::warn!("clipboard image: the previous read has not finished"); + return None; + } + let (tx, rx) = mpsc::channel(); + let spawned = std::thread::Builder::new().name("clipboard image".into()).spawn(move || { + let _reading = Reading(busy); + let _ = tx.send(read()); + }); + if let Err(e) = spawned { + busy.store(false, Ordering::Release); + log::warn!("clipboard image: no thread to read it: {e}"); + return None; + } + match rx.recv_timeout(wait) { + Ok(image) => image, + Err(mpsc::RecvTimeoutError::Timeout) => { + log::warn!("clipboard image: the clipboard did not answer within {} ms", wait.as_millis()); + None + } + Err(mpsc::RecvTimeoutError::Disconnected) => None, + } +} + +/// The general pasteboard's change count, which AppKit raises with every copy. +#[cfg(target_os = "macos")] +pub fn change_count() -> Option { + i64::try_from(objc2_app_kit::NSPasteboard::generalPasteboard().changeCount()).ok() +} + +fn read_now() -> Option { + let mut clipboard = arboard::Clipboard::new().ok()?; + if let Ok(image) = clipboard.get_image() { + let width = u32::try_from(image.width).ok()?; + let height = u32::try_from(image.height).ok()?; + return Some(ClipboardImage::Pixels { width, height, rgba: image.bytes.into_owned() }); + } + first_image_file(&clipboard.get().file_list().ok()?) +} + +/// The first of `paths` that is an image file no larger than [`MAX_FILE_BYTES`], with its name. +fn first_image_file(paths: &[PathBuf]) -> Option { + paths.iter().find_map(|path| { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + if !designcraft_engine::cmd::IMAGE_EXTENSIONS.contains(&ext.as_str()) || std::fs::metadata(path).ok()?.len() > MAX_FILE_BYTES { + return None; + } + // At most one byte past the limit is read, should the file grow after the size check. + let bytes = designcraft_engine::io::read_capped(path, MAX_FILE_BYTES).ok().filter(|b| b.len() as u64 <= MAX_FILE_BYTES)?; + Some(ClipboardImage::File { name: file_name(path), bytes }) + }) +} + +fn file_name(path: &Path) -> String { + path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_read_that_does_not_answer_is_given_up_and_no_second_one_starts_meanwhile() { + static BUSY: AtomicBool = AtomicBool::new(false); + let (release, blocked) = mpsc::channel::<()>(); + // The read waits for a message that is only sent after Paste has given up on it. + assert_eq!(within(&BUSY, Duration::ZERO, move || blocked.recv().ok().map(|()| 1)), None); + let (started, ran) = mpsc::channel::<()>(); + let second = within(&BUSY, Duration::from_secs(60), move || started.send(()).ok().map(|()| 2)); + assert_eq!(second, None, "refused while the first read waits"); + assert!(ran.try_recv().is_err(), "the second read never ran"); + let _ = release.send(()); + while BUSY.load(Ordering::Acquire) { + std::thread::yield_now(); + } + assert_eq!(within(&BUSY, Duration::from_secs(60), || Some(3)), Some(3), "an answer is taken as soon as it comes"); + } + + #[test] + fn a_copied_file_list_pastes_its_first_image_file() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-clipboard-files-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let (text, missing, png) = (dir.join("notes.txt"), dir.join("gone.png"), dir.join("Photo.PNG")); + std::fs::write(&text, b"text").unwrap(); + std::fs::write(&png, b"png bytes").unwrap(); + let picked = first_image_file(&[text, missing, png.clone()]); + let none = first_image_file(&[dir.join("a.txt")]); + let _ = std::fs::remove_dir_all(&dir); + match picked { + Some(ClipboardImage::File { name, bytes }) => assert_eq!((name.as_str(), bytes.as_slice()), ("Photo.PNG", b"png bytes".as_slice())), + _ => panic!("the image file is picked"), + } + assert!(none.is_none()); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index ab6970332..5f465e46d 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -15,6 +15,7 @@ #[cfg(target_os = "macos")] mod apple_events; +mod clipboard_image; mod control_server; mod gpu; mod gpu_limits; @@ -227,6 +228,9 @@ fn services() -> Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), + clipboard_image: Some(Box::new(clipboard_image::read)), + #[cfg(target_os = "macos")] + clipboard_change: Some(Box::new(clipboard_image::change_count)), #[cfg(target_os = "macos")] pointer: Some(Box::new(pointer::screen_position)), ..Default::default() diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index dc1acaf15..20260a5ad 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -44,6 +44,7 @@ mod object; mod overrides; mod package; pub mod params; +mod paste_image; mod paste_svg; mod path_type; mod paths; @@ -179,6 +180,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(export::specs()); v.extend(export_batch::specs()); v.extend(edit::specs()); + v.extend(paste_image::specs()); v.extend(paste_svg::specs()); v.extend(endnotes::specs()); v.extend(editorial::specs()); @@ -376,5 +378,6 @@ pub use file::{ DOCUMENT_SNIFF_BYTES, RecentPreview, base64_decode, base64_encode, from_bytes, is_document, recent_preview, to_bytes, with_document_extension, }; pub use layout::chapter_numbering; +pub use paste_image::{IMAGE_EXTENSIONS, MAX_PASTED_PIXELS, PASTED_IMAGE_NAME, check_pasted_size, pasted_pixels, pasted_png}; pub use paste_svg::is_svg_markup; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/cmd/paste_image.rs b/crates/engine/src/cmd/paste_image.rs new file mode 100644 index 000000000..d5197a90c --- /dev/null +++ b/crates/engine/src/cmd/paste_image.rs @@ -0,0 +1,251 @@ +//! Paste a bitmap from the system clipboard (a screenshot, an image copied in a web browser or an +//! image editor, an image file copied in a file manager) as a placed image. The pixels are +//! embedded in the document, so a save and reopen or an IDML export keeps them. + +use designcraft_doc::Content; +use designcraft_geom::Point; +use serde_json::{Value, json}; + +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, spread_param}; + +const ID: &str = "edit.pasteImage"; + +/// The most pixels a pasted image may have (8,000 × 8,000). +pub const MAX_PASTED_PIXELS: u64 = 64_000_000; + +/// The largest pasted image file, in bytes. +const MAX_PASTED_BYTES: usize = 256 << 20; + +/// The extensions a pasted image's name may end in. +pub const IMAGE_EXTENSIONS: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; + +/// The name a pasted bitmap gets in the Links panel when it comes with none. +pub const PASTED_IMAGE_NAME: &str = "Pasted image.png"; + +pub fn specs() -> Vec { + vec![cmd!( + "edit.pasteImage", + "Paste Image", + [], + None, + "{base64: an image file (PNG/JPEG/GIF/WebP/TIFF/BMP) as base64, name?: its name in the Links panel ending in an image extension (default \"Pasted image.png\"), spread?, at?: [x, y] (spread coords of the image's center; default the first page's center)} → {id, asset} — embedded; a selected empty frame takes the image, otherwise a new frame holds it at its stored resolution (72 ppi when none) and at most 60% of the page width", + has_doc, + paste_image + )] +} + +/// Whether a `width × height` bitmap is small enough to paste; the error says why not. +pub fn check_pasted_size(width: u32, height: u32) -> std::result::Result<(), String> { + if width == 0 || height == 0 { + return Err("the image on the clipboard has no pixels".into()); + } + if u64::from(width) * u64::from(height) > MAX_PASTED_PIXELS { + return Err(format!( + "the image on the clipboard is {width} × {height} pixels; images of up to 64 million pixels can be pasted (save it and use File › Place)" + )); + } + Ok(()) +} + +/// Unpremultiplied RGBA pixels from the clipboard, rows top to bottom, as a PNG file for +/// [`edit.pasteImage`](specs). +pub fn pasted_png(width: u32, height: u32, rgba: Vec) -> std::result::Result, String> { + check_pasted_size(width, height)?; + if (rgba.len() as u64) != u64::from(width) * u64::from(height) * 4 { + return Err("the image on the clipboard has the wrong number of pixels".into()); + } + designcraft_images::png_from_rgba(width, height, rgba).ok_or_else(|| "the image on the clipboard can't be stored".to_string()) +} + +/// An image file's pixels: (width, height, unpremultiplied RGBA). `None` when it is not a raster +/// DesignCraft reads or has more pixels than [`MAX_PASTED_PIXELS`]. +pub fn pasted_pixels(bytes: &[u8]) -> Option<(u32, u32, Vec)> { + let (w, h) = raster_size(bytes)?; + check_pasted_size(w, h).ok()?; + let img = designcraft_images::decode_rgba(bytes)?; + Some((img.width(), img.height(), img.into_raw())) +} + +/// A raster's pixel size; `None` for vector formats (PDF, SVG, EPS) and unreadable data. +fn raster_size(bytes: &[u8]) -> Option<(u32, u32)> { + if designcraft_images::is_pdf(bytes) || designcraft_images::is_svg(bytes) || designcraft_images::is_eps(bytes) { + return None; + } + designcraft_images::pixel_size(bytes) +} + +fn paste_image(s: &mut Session, p: &Value) -> Result { + let b64 = match p.get("base64") { + Some(Value::String(b)) if !b.is_empty() => b, + Some(_) => return Err(bad(ID, "`base64` must be a non-empty string")), + None => return Err(bad(ID, "missing base64")), + }; + if b64.len() / 4 * 3 > MAX_PASTED_BYTES { + return Err(bad(ID, format!("the image is larger than {} MB", MAX_PASTED_BYTES >> 20))); + } + let name = match p.get("name") { + None | Some(Value::Null) => PASTED_IMAGE_NAME, + Some(Value::String(n)) if !n.trim().is_empty() => n.as_str(), + Some(_) => return Err(bad(ID, "`name` must be a non-empty string")), + }; + // File › Place picks text and layout import by the name's extension: keep it an image's. + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); + if !IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(bad(ID, format!("`name` must end in an image extension ({})", IMAGE_EXTENSIONS.join(", ")))); + } + let bytes = super::base64_decode(b64); + let (pw, ph) = raster_size(&bytes).ok_or_else(|| bad(ID, "the clipboard doesn't hold an image DesignCraft can read"))?; + check_pasted_size(pw, ph).map_err(|e| bad(ID, e))?; + let sr = spread_param(p, ID)?; + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(_) => { + let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "`at` must be [x, y]"))?; + if !(at.x.is_finite() && at.y.is_finite()) { + return Err(bad(ID, "`at` must be finite")); + } + Some(at) + } + }; + // A selected empty frame takes the image, as File › Place fills it. + let st = s.doc()?; + let empty_frame = match st.selection.items.as_slice() { + [only] if st.doc.item(*only).is_some_and(|it| !it.locked && it.path.encloses_area() && matches!(it.content, Content::Unassigned)) => { + Some(only.0) + } + _ => None, + }; + if let Some(frame) = empty_frame { + return s.execute("file.place", &json!({"base64": b64, "name": name, "frame": frame})); + } + let d = &s.doc()?.doc; + let sp = d.spread(sr).ok_or_else(|| bad(ID, "no such spread"))?; + let center = match at { + Some(at) => at, + None => sp.pages.first().map_or(Point::ZERO, |pg| pg.bounds().center()), + }; + // The size File › Place gives it: the stored resolution (72 ppi when none), at most 60% of + // the page width. + let (nw, nh) = designcraft_render::natural_size(&bytes, 0).unwrap_or((f64::from(pw), f64::from(ph))); + let w = nw.min(d.settings.page_width * 0.6); + let h = nh * w / nw; + let spread = p.get("spread").cloned().unwrap_or(Value::Null); + s.execute("file.place", &json!({"base64": b64, "name": name, "spread": spread, "x": center.x - w / 2.0, "y": center.y - h / 2.0})) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::{base64_decode, base64_encode}; + use designcraft_doc::{ItemId, SpreadRef}; + + /// A `w × h` PNG whose pixels count up, so a changed pixel shows. + fn png(w: u32, h: u32) -> Vec { + let rgba: Vec = (0..w * h * 4).map(|i| (i % 251) as u8).collect(); + pasted_png(w, h, rgba).unwrap() + } + + fn doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s + } + + fn placed_png(s: &Session, id: ItemId) -> Vec { + let d = &s.doc().unwrap().doc; + let Content::Graphic(g) = &d.item(id).unwrap().content else { panic!("not a graphic") }; + let a = &d.assets[&g.asset]; + assert_eq!(a.link, None, "embedded, not linked"); + a.data.to_vec() + } + + #[test] + fn a_pasted_bitmap_is_embedded_at_the_center_and_kept_by_save_and_idml() { + let mut s = doc(); + let png = png(40, 30); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(ID, &json!({"base64": base64_encode(&png), "at": [300, 400]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let id = ItemId(r["id"].as_u64().unwrap()); + let st = s.doc().unwrap(); + assert_eq!(st.selection.items, [id], "the pasted image is selected"); + let b = st.doc.item(id).unwrap().bounds(); + assert!( + (b.x0 - 280.0).abs() < 1e-9 && (b.y0 - 385.0).abs() < 1e-9 && (b.width() - 40.0).abs() < 1e-9 && (b.height() - 30.0).abs() < 1e-9, + "{b:?}" + ); + assert_eq!(placed_png(&s, id), png); + assert_eq!(s.doc().unwrap().doc.assets[&designcraft_doc::AssetId(r["asset"].as_u64().unwrap())].name, PASTED_IMAGE_NAME); + + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": "a.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(placed_png(&t, id), png, "saved with the document"); + + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let imported = designcraft_idml::import_idml(&base64_decode(idml["base64"].as_str().unwrap())).unwrap(); + assert!(imported.assets.values().any(|a| *a.data == png), "the IDML package carries the pixels"); + } + + #[test] + fn without_a_point_the_image_lands_in_the_middle_of_the_first_page_and_shrinks_to_the_page() { + let mut s = doc(); + let r = s.execute(ID, &json!({"base64": base64_encode(&png(1000, 10))})).unwrap(); + let d = &s.doc().unwrap().doc; + let b = d.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); + let page = d.spread(SpreadRef::Doc(0)).unwrap().pages[0].bounds(); + let w = d.settings.page_width * 0.6; + assert!((b.width() - w).abs() < 1e-9 && (b.height() - w / 100.0).abs() < 1e-9, "{b:?}"); + assert!((b.center().x - page.center().x).abs() < 1e-9 && (b.center().y - page.center().y).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn a_selected_empty_frame_takes_the_image() { + let mut s = doc(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [frame]})).unwrap(); + let items = s.doc().unwrap().doc.spreads[0].items.len(); + let r = s.execute(ID, &json!({"base64": base64_encode(&png(10, 10)), "at": [500, 500]})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(frame)); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), items, "no new frame"); + // A frame that already holds a graphic gets a new frame beside it instead. + let r = s.execute(ID, &json!({"base64": base64_encode(&png(10, 10)), "at": [500, 500]})).unwrap(); + assert_ne!(r["id"].as_u64(), Some(frame)); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), items + 1); + } + + #[test] + fn bad_input_is_refused_and_leaves_no_undo_step() { + let mut s = doc(); + let ok = base64_encode(&png(2, 2)); + let undo = s.doc().unwrap().history.undo.len(); + for (p, want) in [ + (json!({}), "missing base64"), + (json!({"base64": 5}), "`base64` must be a non-empty string"), + (json!({"base64": ""}), "`base64` must be a non-empty string"), + (json!({"base64": base64_encode(b"just text")}), "doesn't hold an image"), + (json!({"base64": base64_encode(b"")}), "doesn't hold an image"), + (json!({"base64": ok, "name": ""}), "`name` must be a non-empty string"), + (json!({"base64": ok, "name": 3}), "`name` must be a non-empty string"), + (json!({"base64": ok, "name": "notes.txt"}), "`name` must end in an image extension"), + (json!({"base64": ok, "name": "page.idml"}), "`name` must end in an image extension"), + (json!({"base64": ok, "name": "png"}), "`name` must end in an image extension"), + (json!({"base64": ok, "at": [1]}), "`at` must be [x, y]"), + (json!({"base64": ok, "spread": 9}), "no such spread"), + ] { + let e = s.execute(ID, &p).unwrap_err().to_string(); + assert!(e.contains(want), "{p}: {e}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed"); + } + + #[test] + fn images_beyond_the_pixel_limit_are_refused_before_they_are_encoded() { + let e = pasted_png(9000, 8000, Vec::new()).unwrap_err(); + assert!(e.contains("9000 × 8000 pixels") && e.contains("64 million"), "{e}"); + assert!(pasted_png(0, 5, Vec::new()).is_err()); + assert!(pasted_png(2, 2, vec![0; 15]).unwrap_err().contains("wrong number of pixels")); + assert_eq!(pasted_pixels(&png(3, 2)).map(|(w, h, px)| (w, h, px.len())), Some((3, 2, 24))); + assert_eq!(pasted_pixels(b"%PDF-1.4"), None, "vector files are not pasted as pixels"); + } +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 83c540e6e..02f978b6a 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -373,6 +373,15 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { Some((w as f64, h as f64)) } +/// Unpremultiplied RGBA pixels, rows top to bottom, as a PNG file with no resolution stored. +/// `None` when `rgba` doesn't hold exactly `width × height` pixels. +pub fn png_from_rgba(width: u32, height: u32, rgba: Vec) -> Option> { + let img = image::RgbaImage::from_raw(width, height, rgba)?; + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + /// Pixel dimensions of a raster. pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { if let Some(p) = palette_tiff(bytes) { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 88bd48c58..8355e236f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -113,6 +113,7 @@ impl DesignApp { egui_ctx: None, in_control: false, object_clip_text: None, + object_clip_change: None, ime_marked: None, ime_discard: false, canvas_ime: false, diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 4b813dd5c..bff5156f7 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -1,13 +1,35 @@ use super::{DesignApp, Value, canvas, json}; +use crate::services::ClipboardImage; + +/// What Paste takes from the system clipboard. +pub(super) enum Pasted { + /// The command's params, with the clipboard's text when it has some. + Params(Value), + /// An image, pasted as a placed image instead. + Image(ClipboardImage), +} + +/// Text that is only the address of a file or a web page: what a browser puts beside a copied +/// image. Paste prefers the image to such text. +fn is_bare_address(text: &str) -> bool { + let t = text.trim(); + !t.is_empty() + && !t.contains(char::is_whitespace) + && ["http://", "https://", "file://", "data:image/"] + .iter() + .any(|scheme| t.get(..scheme.len()).is_some_and(|s| s.eq_ignore_ascii_case(scheme))) +} impl DesignApp { /// Paste without the clipboard's text (a menu, not the paste keys): read it here. With no /// insertion point, text that doesn't stand for copied objects becomes a new text frame - /// centred in the view. - pub(super) fn with_system_clipboard(&mut self, id: &str, mut params: Value) -> Value { + /// centered in the view, and an image on the clipboard (when there is no text, or the text is + /// only an address) becomes a placed image, unless nothing was copied to the clipboard since + /// objects were last copied here. + pub(super) fn with_system_clipboard(&mut self, id: &str, mut params: Value) -> Pasted { let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); if !matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") || !(typing || id == "edit.paste") { - return params; + return Pasted::Params(params); } if params.get("text").is_none() && let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f()) @@ -15,14 +37,33 @@ impl DesignApp { { o.insert("text".into(), Value::String(text)); } + // Nothing was copied to the clipboard since objects were copied here (native menu, where + // the system raises a change count with every copy). + let unchanged = match self.object_clip_change { + Some(at_copy) => self.services.clipboard_change.as_mut().and_then(|f| f()) == Some(at_copy), + None => true, + }; // Still what an object copy put there: paste those objects, not the placeholder text. let norm = |t: &str| t.replace("\r\n", "\n"); - if let Some(copied) = &self.object_clip_text - && params.get("text").and_then(Value::as_str).is_some_and(|t| norm(t) == norm(copied)) - && let Some(o) = params.as_object_mut() - { + let ours = unchanged + && self + .object_clip_text + .as_ref() + .is_some_and(|copied| params.get("text").and_then(Value::as_str).is_some_and(|t| norm(t) == norm(copied))); + if ours && let Some(o) = params.as_object_mut() { o.remove("text"); } + let text = params.get("text").and_then(Value::as_str); + let copied_here = + text.is_none() && self.session.clipboard.is_some() && self.object_clip_text.is_none() && self.object_clip_change.is_some() && unchanged; + if !typing + && !ours + && !copied_here + && text.is_none_or(is_bare_address) + && let Some(image) = self.services.clipboard_image.as_mut().and_then(|f| f()) + { + return Pasted::Image(image); + } if !typing && params.get("text").and_then(Value::as_str).is_some_and(|t| !t.is_empty()) && let Some((spread, at)) = canvas::view_centre(self) @@ -32,7 +73,39 @@ impl DesignApp { o.entry("spread").or_insert(spread); o.entry("at").or_insert(json!([at.x, at.y])); } - params + Pasted::Params(params) + } + + /// Paste an image from the system clipboard as a placed image (`edit.pasteImage`), centered + /// in the view or in the selected empty frame. Text being edited takes no image. + pub(crate) fn paste_clipboard_image(&mut self, image: ClipboardImage) -> Result { + let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); + let file = if typing { + Err("To paste the image, stop editing the text first (Esc).".to_string()) + } else { + match image { + ClipboardImage::Pixels { width, height, rgba } => { + designcraft_engine::cmd::pasted_png(width, height, rgba).map(|png| (png, designcraft_engine::cmd::PASTED_IMAGE_NAME.to_string())) + } + ClipboardImage::File { name, bytes } => Ok((bytes, name)), + } + }; + let (bytes, name) = match file { + Ok(file) => file, + Err(e) => { + self.status(e.clone()); + return Err(e); + } + }; + let mut params = json!({"base64": designcraft_engine::cmd::base64_encode(&bytes), "name": name}); + if let Some((spread, at)) = canvas::view_centre(self) + && let Ok(spread) = serde_json::to_value(spread) + && let Some(o) = params.as_object_mut() + { + o.insert("spread".into(), spread); + o.insert("at".into(), json!([at.x, at.y])); + } + self.run("edit.pasteImage", params) } /// Keep widget clipboard output private while a queued control event is running. diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index bb75c102e..dc0326172 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -8,7 +8,14 @@ impl DesignApp { } // Only user-initiated runs touch the system clipboard, never the control channel's. let system_clipboard = !self.in_control && !self.control_input_frame.load(std::sync::atomic::Ordering::Relaxed); - let mut params = if system_clipboard { self.with_system_clipboard(id, params) } else { params }; + let mut params = if system_clipboard { + match self.with_system_clipboard(id, params) { + super::clipboard::Pasted::Params(params) => params, + super::clipboard::Pasted::Image(image) => return self.paste_clipboard_image(image), + } + } else { + params + }; // Vector art copied in another app (Illustrator, VectorCraft, Figma) is SVG markup on the // system clipboard: with no insertion point it pastes as editable paths where text would go. let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); @@ -37,14 +44,17 @@ impl DesignApp { let text = match v.get("text").and_then(Value::as_str) { Some(t) => { self.object_clip_text = None; + self.object_clip_change = None; Some(t.to_string()) } None if !self.native_menu => { self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session)); + self.object_clip_change = None; self.object_clip_text.clone() } None => { self.object_clip_text = self.services.clipboard_text.as_mut().and_then(|f| f()); + self.object_clip_change = self.services.clipboard_change.as_mut().and_then(|f| f()); None } }; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 590b0f8c6..d9102e266 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -435,7 +435,13 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui:: let _ = app.run("text.insert", json!({"text": t})); } // The IME owns the keyboard until it commits: no typing, clipboard or editing keys. - egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Key { .. } if composing => {} + egui::Event::Text(_) + | egui::Event::Paste(_) + | egui::Event::PasteImage(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Key { .. } + if composing => {} egui::Event::Paste(t) if wants_text => { // Formatted when the system clipboard still holds what was copied here; // Shift (⇧⌘V) pastes without formatting. @@ -443,6 +449,10 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui:: let id = if plain { "edit.pasteWithoutFormatting" } else { "edit.paste" }; let _ = app.run(id, json!({"text": t})); } + // A clipboard image with no text (a screenshot, an image copied in a browser). + egui::Event::PasteImage(image) => { + let _ = app.paste_clipboard_image(crate::ClipboardImage::from_color_image(&image)); + } egui::Event::Copy | egui::Event::Cut if wants_text => { // `run` puts the copied text on the system clipboard. let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; @@ -525,6 +535,10 @@ pub(crate) fn forwarded_keys(app: &mut DesignApp, events: Vec, modi (egui::Event::Copy, _) if !app.native_menu => ("edit.copy", serde_json::Value::Null), (egui::Event::Cut, _) if !app.native_menu => ("edit.cut", serde_json::Value::Null), (egui::Event::Paste(t), _) if !app.native_menu => ("edit.paste", json!({"text": t})), + (egui::Event::PasteImage(image), _) => { + let _ = app.paste_clipboard_image(crate::ClipboardImage::from_color_image(&image)); + continue; + } (egui::Event::Key { key, pressed: true, modifiers, .. }, _) => { tool_key(app, key, modifiers, vi); continue; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 62064ea79..0937904d9 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1051,6 +1051,127 @@ fn edit_paste_from_the_menu_pastes_svg_on_the_system_clipboard_as_paths() { assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items, "no paths added"); } +/// A fake system clipboard: its text, a bitmap of a given size and the change count that every +/// copy raises. +#[derive(Default)] +struct FakeClip { + text: Option, + image: Option<(u32, u32)>, + change: i64, +} + +type FakeClipboard = std::rc::Rc>; + +/// Another application copies `text` and an image of `image` pixels. +fn copy_elsewhere(clip: &FakeClipboard, text: Option<&str>, image: Option<(u32, u32)>) { + let mut c = clip.borrow_mut(); + (c.text, c.image, c.change) = (text.map(str::to_string), image, c.change + 1); +} + +fn fake_clipboard(text: Option<&str>, image: Option<(u32, u32)>) -> (FakeClipboard, crate::Services) { + let clip = FakeClipboard::default(); + copy_elsewhere(&clip, text, image); + let (t, i, c) = (clip.clone(), clip.clone(), clip.clone()); + let services = crate::Services { + clipboard_text: Some(Box::new(move || t.borrow().text.clone())), + clipboard_image: Some(Box::new(move || { + let (width, height) = i.borrow().image?; + // Sizes past the paste limit are refused before their pixels are read. + let len = if u64::from(width) * u64::from(height) > designcraft_engine::cmd::MAX_PASTED_PIXELS { 0 } else { width * height * 4 }; + Some(crate::ClipboardImage::Pixels { width, height, rgba: vec![90; len as usize] }) + })), + clipboard_change: Some(Box::new(move || Some(c.borrow().change))), + ..Default::default() + }; + (clip, services) +} + +/// The embedded image the selected frame shows: its pixel size and the asset's name. +fn pasted_image(app: &DesignApp) -> Option<((u32, u32), String)> { + let st = app.session.doc().unwrap(); + let id = *st.selection.items.first()?; + let designcraft_doc::Content::Graphic(g) = &st.doc.item(id)?.content else { return None }; + let asset = &st.doc.assets[&g.asset]; + assert_eq!(asset.link, None, "embedded"); + Some((asset.pixels?, asset.name.clone())) +} + +#[test] +fn edit_paste_from_the_menu_places_the_clipboard_image() { + let (clip, services) = fake_clipboard(None, Some((40, 30))); + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.native_menu = true; + app.run("file.new", json!({})).unwrap(); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), Some(((40, 30), designcraft_engine::cmd::PASTED_IMAGE_NAME.to_string()))); + // A browser puts the image's address beside it: the image wins. + copy_elsewhere(&clip, Some("https://example.com/cat.png"), Some((8, 6))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((8, 6))); + // Other text wins over a picture of it. + copy_elsewhere(&clip, Some("Some words"), Some((8, 6))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), None); + assert!(app.session.doc().unwrap().selection.text.is_none(), "a text frame, selected as an object"); + // Too many pixels: refused with a message, and nothing is added. + copy_elsewhere(&clip, None, Some((9000, 8000))); + let items = app.session.doc().unwrap().doc.spreads[0].items.len(); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items); + assert!(app.ui.status.contains("64 million"), "{}", app.ui.status); +} + +#[test] +fn objects_copied_here_still_paste_as_objects_until_another_image_is_copied() { + // A native menu: Copy leaves the system clipboard (here an older image) alone. + let (clip, services) = fake_clipboard(None, Some((40, 30))); + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.native_menu = true; + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200], "content": "text"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + crate::menus::activate(&mut app, "edit.copy", &Value::Null); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), None, "the copied text frame"); + assert_ne!(app.session.doc().unwrap().selection.items.first().map(|i| i.0), id.as_u64()); + // The same image copied again in another application is a new copy. + copy_elsewhere(&clip, None, Some((40, 30))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((40, 30)), "an image copied since then"); + // The same goes for an image whose address was on the clipboard at the copy. + let url = "https://example.com/cat.png"; + copy_elsewhere(&clip, Some(url), Some((5, 5))); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + crate::menus::activate(&mut app, "edit.copy", &Value::Null); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), None, "the copied text frame again"); + copy_elsewhere(&clip, Some(url), Some((5, 5))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((5, 5)), "the image copied again with the same address"); +} + +#[test] +fn the_paste_keys_place_an_image_in_the_selected_empty_frame_but_not_in_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [100, 100, 300, 250]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [frame]})).unwrap(); + let mut h = harness(app); + let image = || egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([20, 10], egui::Color32::RED))); + h.input_mut().events.push(image()); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().selection.items.first().map(|i| i.0), Some(frame), "the frame took it"); + assert_eq!(pasted_image(h.state()).map(|(px, _)| px), Some((20, 10))); + // While text is edited, the image is not pasted and the status bar says why. + let story = h.state_mut().run("frame.create", json!({"rect": [10, 400, 300, 600], "content": "text"})).unwrap()["story"].clone(); + h.state_mut().run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); + let items = h.state().session.doc().unwrap().doc.spreads[0].items.len(); + h.input_mut().events.push(image()); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().doc.spreads[0].items.len(), items); + assert!(h.state().ui.status.contains("stop editing the text"), "{}", h.state().ui.status); +} + #[test] fn selected_merged_owners_paint_the_same_row_and_column_targets() { for column in [true, false] { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d70a79695..9a8e23900 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -272,11 +272,24 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest ctx.request_repaint(); ok(Value::Null) } - // What Ctrl/Cmd+V delivers from the system clipboard: text, or SVG markup copied in an - // illustration app (pasted as editable paths when no text is being edited). + // What Ctrl/Cmd+V delivers from the system clipboard: text, SVG markup copied in an + // illustration app (pasted as editable paths when no text is being edited), or an image + // (`image`: an image file as base64, pasted as a placed image). "ui.paste" => { - let Some(text) = s("text") else { return err("missing `text`") }; - app.control_synthetic.push(egui::Event::Paste(text.to_string())); + let event = match (s("text"), s("image")) { + (Some(text), None) => egui::Event::Paste(text.to_string()), + (None, Some(image)) => { + let bytes = designcraft_engine::cmd::base64_decode(image); + let Some((w, h, rgba)) = designcraft_engine::cmd::pasted_pixels(&bytes) else { + return err("`image` is not an image file that can be pasted"); + }; + let size = [w, h].map(|n| usize::try_from(n).unwrap_or(0)); + egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied(size, &rgba))) + } + (Some(_), Some(_)) => return err("give `text` or `image`, not both"), + (None, None) => return err("missing `text` or `image`"), + }; + app.control_synthetic.push(event); ctx.request_repaint(); ok(Value::Null) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 40bdfbcb3..f441e2f1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -74,7 +74,10 @@ use app::browser_import_tests; pub use app::opens_as_document; pub use clock::now_ms; pub use key_names::{drop_key_name_text, is_key_name}; -pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, PointerFn, ReadFn, Services, WindowFrameFn, WriteFn}; +pub use services::{ + ClipboardChangeFn, ClipboardFn, ClipboardImage, ClipboardImageFn, DownloadFn, Inbox, OpenAsyncFn, PointerFn, ReadFn, Services, WindowFrameFn, + WriteFn, +}; pub(crate) use state::second_window_viewport; pub use state::{CanvasCache, HiddenPanels, ImportRequest, ImportedFile, PdfExportSettings, Perf, SavedWorkspace, ScreenMode, Shown, UiState, View}; @@ -257,6 +260,9 @@ pub struct DesignApp { /// (no native menu: without it the paste keys produce no paste event), or what it held then /// (native menu). It is never pasted as text: Paste with it pastes the copied objects. object_clip_text: Option, + /// The clipboard's change count at the last object copy (native menu). While it is unchanged, + /// Paste pastes the copied objects rather than an image the clipboard held before the copy. + object_clip_change: Option, /// The marked text the system IME last sent (`None` once it commits or clears). When the /// composition ends some other way (a click, a tool switch), the IME is told to drop it. pub(crate) ime_marked: Option, diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 384a75b4b..fd529ac42 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -11,6 +11,32 @@ pub type DownloadFn = Box Result<(), String>>; pub type ClipboardFn = Box Option>; +/// An image on the system clipboard. +pub enum ClipboardImage { + /// A bitmap (a screenshot, an image copied in a browser): unpremultiplied RGBA, rows top to + /// bottom. + Pixels { width: u32, height: u32, rgba: Vec }, + /// An image file copied in a file manager: its name and contents. + File { name: String, bytes: Vec }, +} + +impl ClipboardImage { + /// An image egui delivered with Ctrl/Cmd+V (`Event::PasteImage`). + pub fn from_color_image(image: &egui::ColorImage) -> Self { + let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); + let [w, h] = image.size.map(|n| u32::try_from(n).unwrap_or(u32::MAX)); + ClipboardImage::Pixels { width: w, height: h, rgba } + } +} + +/// The system clipboard's image, for Paste chosen from a menu. It returns within a short bound +/// even when the application that owns the clipboard is slow to answer (`None` then). +pub type ClipboardImageFn = Box Option>; + +/// A number the system raises with every copy to the clipboard (the macOS pasteboard's change +/// count), read without waiting for the clipboard's owner. +pub type ClipboardChangeFn = Box Option>; + /// Keeps the window's frame as the app needs it; called once per frame, before it is laid out. pub type WindowFrameFn = Box; @@ -40,6 +66,13 @@ pub struct Services { /// The system clipboard's text, for Paste chosen from a menu (egui only delivers it with the /// paste keys, which a native menu bar takes first). pub clipboard_text: Option, + /// The system clipboard's image (a bitmap, else the first image file of a copied file list), + /// for Paste chosen from a menu when the clipboard holds no text or only an image's address. + pub clipboard_image: Option, + /// The clipboard's change count, so Paste can tell an image copied after an object Copy from + /// one that was there before it. Needed where Copy leaves the system clipboard alone (a + /// native menu bar); `None` elsewhere. + pub clipboard_change: Option, /// Keeps the undecorated window borderless, so the content and the pointer stay aligned /// (Windows custom title bar, #1295). `None` when the window is decorated or the platform /// needs no fixing. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e886c07ab..706c1fc43 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -42,7 +42,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | | `ui.ime` | `{preedit?, active?: [start, end], commit?}` | Input method events as the system IME sends them: marked text (shown in place, `active` is the clause being converted, in characters) and the committed text (typed as one `text.insert`) | -| `ui.paste` | `{text}` | What Ctrl/Cmd+V delivers from the system clipboard: text into the text being edited, or SVG markup (vector art copied in Illustrator, VectorCraft or Figma) as editable paths in the middle of the view | +| `ui.paste` | `{text}` or `{image}` | What Ctrl/Cmd+V delivers from the system clipboard: text into the text being edited, SVG markup (vector art copied in Illustrator, VectorCraft or Figma) as editable paths in the middle of the view, or an image (`image`: an image file as base64) as an embedded image in the selected empty frame or in the middle of the view (`edit.pasteImage`) | | `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: 1–255` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, documentGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | From 6b9c47fdb2a514a1a00829e17f24e8699cf7d6ae Mon Sep 17 00:00:00 2001 From: "autocraft-bot[bot]" <354215646+autocraft-bot[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:37:36 +0000 Subject: [PATCH 2/7] Apply review fixes - crates/ui-egui/src/app/clipboard.rs - docs/egui-037-migration-plan.md --- crates/ui-egui/src/app/clipboard.rs | 2 +- docs/egui-037-migration-plan.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index bff5156f7..a50aff456 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -59,7 +59,7 @@ impl DesignApp { if !typing && !ours && !copied_here - && text.is_none_or(is_bare_address) + && text.is_none_or(|t| t.trim().is_empty() || is_bare_address(t)) && let Some(image) = self.services.clipboard_image.as_mut().and_then(|f| f()) { return Pasted::Image(image); diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 165db7892..dc77c55e7 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -494,9 +494,8 @@ Clipboard: freshness, owner/seat changes, empty/absent offers, self-paste, canceled/slow transfers, password exclusion and no unchanged-selection reclaim on wlroots/KWin/Mutter. Native capability evidence is still required; compile-only is not acceptance. -- `Event::PasteImage` is new but DesignCraft has no image-paste contract. Leave it - unhandled rather than inventing a placement path. Add a test that it does not panic and - does not leak into text paste. +- `Event::PasteImage` (an image on the clipboard with no text) pastes through `edit.pasteImage`: in the + selected empty frame, else centered in the view, and never into text being edited. Keyboard layouts: after the fork pin, `RawInput::keyboard_layout_is_latin` and `RawInput::latin_layout_chars` (E19) must preserve tool and command shortcuts on Russian, From 8463cef4a9c5837dd07fe1073941112cac6434f8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:59:10 -0800 Subject: [PATCH 3/7] Paste: refuse damaged clipboard images and journal one entry per paste - edit.pasteImage decodes the whole raster before it stores the image, so a file whose pixels are cut off after a whole header is refused. - edit.pasteImage is the only journal entry for a paste; the file.place it runs no longer adds a second one, so a replay places the image once. - Empty clipboard text beside an image counts as no text, so Edit > Paste pastes the image. - With a native menu, a pasted image event is ignored while the clipboard still holds what it held when objects were copied here. --- crates/engine/src/cmd/paste_image.rs | 52 ++++++++++++++++++++++++++-- crates/ui-egui/src/app/clipboard.rs | 27 ++++++++++----- crates/ui-egui/src/canvas/tests.rs | 28 +++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/paste_image.rs b/crates/engine/src/cmd/paste_image.rs index d5197a90c..b5e1003ac 100644 --- a/crates/engine/src/cmd/paste_image.rs +++ b/crates/engine/src/cmd/paste_image.rs @@ -96,6 +96,11 @@ fn paste_image(s: &mut Session, p: &Value) -> Result { let bytes = super::base64_decode(b64); let (pw, ph) = raster_size(&bytes).ok_or_else(|| bad(ID, "the clipboard doesn't hold an image DesignCraft can read"))?; check_pasted_size(pw, ph).map_err(|e| bad(ID, e))?; + // The header can be whole while the pixels after it are cut off: decode the whole raster so + // a damaged image is refused instead of stored. + if designcraft_images::decode_rgba(&bytes).is_none() { + return Err(bad(ID, "the image on the clipboard is damaged and can't be read")); + } let sr = spread_param(p, ID)?; let at = match p.get("at") { None | Some(Value::Null) => None, @@ -116,7 +121,7 @@ fn paste_image(s: &mut Session, p: &Value) -> Result { _ => None, }; if let Some(frame) = empty_frame { - return s.execute("file.place", &json!({"base64": b64, "name": name, "frame": frame})); + return place(s, &json!({"base64": b64, "name": name, "frame": frame})); } let d = &s.doc()?.doc; let sp = d.spread(sr).ok_or_else(|| bad(ID, "no such spread"))?; @@ -130,7 +135,16 @@ fn paste_image(s: &mut Session, p: &Value) -> Result { let w = nw.min(d.settings.page_width * 0.6); let h = nh * w / nw; let spread = p.get("spread").cloned().unwrap_or(Value::Null); - s.execute("file.place", &json!({"base64": b64, "name": name, "spread": spread, "x": center.x - w / 2.0, "y": center.y - h / 2.0})) + place(s, &json!({"base64": b64, "name": name, "spread": spread, "x": center.x - w / 2.0, "y": center.y - h / 2.0})) +} + +/// Runs `file.place` inside this command. Only `edit.pasteImage` stays in the journal, so a +/// replay of the journal places the image once. +fn place(s: &mut Session, params: &Value) -> Result { + let journal = s.journal.len(); + let r = s.execute("file.place", params); + s.journal.truncate(journal); + r } #[cfg(test)] @@ -239,6 +253,40 @@ mod tests { assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed"); } + #[test] + fn a_damaged_image_with_a_whole_header_is_refused() { + let mut s = doc(); + let whole = png(40, 30); + let cut = &whole[..whole.len() / 2]; + assert_eq!(raster_size(cut), Some((40, 30)), "the header still reads"); + let (undo, assets) = (s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.assets.len()); + let e = s.execute(ID, &json!({"base64": base64_encode(cut)})).unwrap_err().to_string(); + assert!(e.contains("damaged"), "{e}"); + let st = s.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.doc.assets.len()), (undo, assets), "nothing stored"); + } + + #[test] + fn each_paste_is_one_journal_entry_and_replays_once() { + let mut s = doc(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [frame]})).unwrap(); + let start = s.journal.len(); + let b64 = base64_encode(&png(10, 10)); + s.execute(ID, &json!({"base64": b64, "at": [500, 500]})).unwrap(); + s.execute(ID, &json!({"base64": b64, "at": [500, 500]})).unwrap(); + let entries = s.journal[start..].to_vec(); + assert_eq!(entries.iter().map(|(id, _)| id.as_str()).collect::>(), [ID, ID], "the frame and the new-frame paths"); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + let mut t = doc(); + t.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap(); + t.execute("selection.set", &json!({"ids": [frame]})).unwrap(); + for (id, p) in &entries { + t.execute(id, p).unwrap(); + } + assert_eq!(items(&t), items(&s), "the replay places each image once"); + } + #[test] fn images_beyond_the_pixel_limit_are_refused_before_they_are_encoded() { let e = pasted_png(9000, 8000, Vec::new()).unwrap_err(); diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index bff5156f7..faddd6f5b 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -31,18 +31,14 @@ impl DesignApp { if !matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") || !(typing || id == "edit.paste") { return Pasted::Params(params); } + // Empty text counts as no text, so an image beside it still pastes. if params.get("text").is_none() - && let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f()) + && let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f()).filter(|t| !t.is_empty()) && let Some(o) = params.as_object_mut() { o.insert("text".into(), Value::String(text)); } - // Nothing was copied to the clipboard since objects were copied here (native menu, where - // the system raises a change count with every copy). - let unchanged = match self.object_clip_change { - Some(at_copy) => self.services.clipboard_change.as_mut().and_then(|f| f()) == Some(at_copy), - None => true, - }; + let unchanged = self.clipboard_unchanged_since_object_copy(); // Still what an object copy put there: paste those objects, not the placeholder text. let norm = |t: &str| t.replace("\r\n", "\n"); let ours = unchanged @@ -76,9 +72,24 @@ impl DesignApp { Pasted::Params(params) } + /// Nothing was copied to the clipboard since objects were copied here (native menu, where + /// the system raises a change count with every copy). + fn clipboard_unchanged_since_object_copy(&mut self) -> bool { + match self.object_clip_change { + Some(at_copy) => self.services.clipboard_change.as_mut().and_then(|f| f()) == Some(at_copy), + None => true, + } + } + /// Paste an image from the system clipboard as a placed image (`edit.pasteImage`), centered - /// in the view or in the selected empty frame. Text being edited takes no image. + /// in the view or in the selected empty frame. Text being edited takes no image. An image the + /// clipboard held before objects were copied here (native menu) is not pasted: the menu's + /// Paste pastes those objects. pub(crate) fn paste_clipboard_image(&mut self, image: ClipboardImage) -> Result { + let user = !self.in_control && !self.control_input_frame.load(std::sync::atomic::Ordering::Relaxed); + if user && self.session.clipboard.is_some() && self.object_clip_change.is_some() && self.clipboard_unchanged_since_object_copy() { + return Ok(Value::Null); + } let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); let file = if typing { Err("To paste the image, stop editing the text first (Esc).".to_string()) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 0937904d9..5b3f796dc 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1108,6 +1108,10 @@ fn edit_paste_from_the_menu_places_the_clipboard_image() { copy_elsewhere(&clip, Some("https://example.com/cat.png"), Some((8, 6))); crate::menus::activate(&mut app, "edit.paste", &Value::Null); assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((8, 6))); + // Empty text beside the image counts as no text. + copy_elsewhere(&clip, Some(""), Some((12, 9))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((12, 9))); // Other text wins over a picture of it. copy_elsewhere(&clip, Some("Some words"), Some((8, 6))); crate::menus::activate(&mut app, "edit.paste", &Value::Null); @@ -1150,6 +1154,30 @@ fn objects_copied_here_still_paste_as_objects_until_another_image_is_copied() { assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((5, 5)), "the image copied again with the same address"); } +#[test] +fn a_paste_image_event_after_an_object_copy_does_not_paste_the_older_image() { + // A native menu: Copy leaves the older image on the system clipboard. + let (clip, services) = fake_clipboard(None, Some((40, 30))); + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.native_menu = true; + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200], "content": "text"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + crate::menus::activate(&mut app, "edit.copy", &Value::Null); + app.run("selection.set", json!({"ids": []})).unwrap(); + let mut h = harness(app); + let image = |w, h| egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([w, h], egui::Color32::RED))); + let items = h.state().session.doc().unwrap().doc.spreads[0].items.len(); + h.input_mut().events.push(image(40, 30)); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().doc.spreads[0].items.len(), items, "the older image is not pasted"); + // An image copied since then pastes. + copy_elsewhere(&clip, None, Some((20, 10))); + h.input_mut().events.push(image(20, 10)); + h.run_steps(2); + assert_eq!(pasted_image(h.state()).map(|(px, _)| px), Some((20, 10))); +} + #[test] fn the_paste_keys_place_an_image_in_the_selected_empty_frame_but_not_in_text() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From b1a514fc51606ba4b26390400f38b6abeff97973 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:05:16 -0800 Subject: [PATCH 4/7] Paste: ui.paste checks the image's size before decoding it ui.paste {image} refuses base64 longer than the 256 MB file limit before it decodes the data, with the same check edit.pasteImage uses. --- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/paste_image.rs | 25 ++++++++++++++++++++++--- crates/ui-egui/src/control.rs | 3 +++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 20260a5ad..739072d78 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -378,6 +378,6 @@ pub use file::{ DOCUMENT_SNIFF_BYTES, RecentPreview, base64_decode, base64_encode, from_bytes, is_document, recent_preview, to_bytes, with_document_extension, }; pub use layout::chapter_numbering; -pub use paste_image::{IMAGE_EXTENSIONS, MAX_PASTED_PIXELS, PASTED_IMAGE_NAME, check_pasted_size, pasted_pixels, pasted_png}; +pub use paste_image::{IMAGE_EXTENSIONS, MAX_PASTED_PIXELS, PASTED_IMAGE_NAME, check_pasted_base64, check_pasted_size, pasted_pixels, pasted_png}; pub use paste_svg::is_svg_markup; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/cmd/paste_image.rs b/crates/engine/src/cmd/paste_image.rs index b5e1003ac..ec29d78b0 100644 --- a/crates/engine/src/cmd/paste_image.rs +++ b/crates/engine/src/cmd/paste_image.rs @@ -47,6 +47,19 @@ pub fn check_pasted_size(width: u32, height: u32) -> std::result::Result<(), Str Ok(()) } +/// Whether an image file given as base64 is small enough to paste, checked before it is +/// decoded; the error says why not. +pub fn check_pasted_base64(b64: &str) -> std::result::Result<(), String> { + check_pasted_base64_len(b64.len()) +} + +fn check_pasted_base64_len(len: usize) -> std::result::Result<(), String> { + if len / 4 * 3 > MAX_PASTED_BYTES { + return Err(format!("the image is larger than {} MB", MAX_PASTED_BYTES >> 20)); + } + Ok(()) +} + /// Unpremultiplied RGBA pixels from the clipboard, rows top to bottom, as a PNG file for /// [`edit.pasteImage`](specs). pub fn pasted_png(width: u32, height: u32, rgba: Vec) -> std::result::Result, String> { @@ -80,9 +93,7 @@ fn paste_image(s: &mut Session, p: &Value) -> Result { Some(_) => return Err(bad(ID, "`base64` must be a non-empty string")), None => return Err(bad(ID, "missing base64")), }; - if b64.len() / 4 * 3 > MAX_PASTED_BYTES { - return Err(bad(ID, format!("the image is larger than {} MB", MAX_PASTED_BYTES >> 20))); - } + check_pasted_base64(b64).map_err(|e| bad(ID, e))?; let name = match p.get("name") { None | Some(Value::Null) => PASTED_IMAGE_NAME, Some(Value::String(n)) if !n.trim().is_empty() => n.as_str(), @@ -253,6 +264,14 @@ mod tests { assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed"); } + #[test] + fn base64_past_the_file_limit_is_refused_by_its_length() { + let limit = MAX_PASTED_BYTES / 3 * 4; + assert!(check_pasted_base64_len(limit).is_ok()); + let e = check_pasted_base64_len(limit + 4).unwrap_err(); + assert!(e.contains("larger than 256 MB"), "{e}"); + } + #[test] fn a_damaged_image_with_a_whole_header_is_refused() { let mut s = doc(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9a8e23900..0b8fbb6d5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -279,6 +279,9 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest let event = match (s("text"), s("image")) { (Some(text), None) => egui::Event::Paste(text.to_string()), (None, Some(image)) => { + if let Err(e) = designcraft_engine::cmd::check_pasted_base64(image) { + return err(format!("`image`: {e}")); + } let bytes = designcraft_engine::cmd::base64_decode(image); let Some((w, h, rgba)) = designcraft_engine::cmd::pasted_pixels(&bytes) else { return err("`image` is not an image file that can be pasted"); From 915be8561e804b9a4dd278cd7a47ca19ad5eb054 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:05:58 -0800 Subject: [PATCH 5/7] Paste: an image past the size limit is refused before its pixels are copied ClipboardImage::from_color_image checks the size before it copies the pixels, so an image egui delivers with Ctrl/Cmd+V that is over 64 million pixels is not copied before the paste refuses it. --- crates/ui-egui/src/canvas/tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/services.rs | 9 +++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 5b3f796dc..88a526d9d 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1200,6 +1200,26 @@ fn the_paste_keys_place_an_image_in_the_selected_empty_frame_but_not_in_text() { assert!(h.state().ui.status.contains("stop editing the text"), "{}", h.state().ui.status); } +#[test] +fn a_paste_keys_image_past_the_size_limit_is_refused_without_copying_its_pixels() { + // The size says 72 million pixels; the four pixels it holds are not copied. + let big = egui::ColorImage { size: [9000, 8000], source_size: egui::vec2(9000.0, 8000.0), pixels: vec![egui::Color32::RED; 4] }; + let image = crate::ClipboardImage::from_color_image(&big); + let crate::ClipboardImage::Pixels { width, height, rgba } = &image else { panic!("a bitmap") }; + assert_eq!((*width, *height, rgba.len()), (9000, 8000, 0)); + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let items = app.session.doc().unwrap().doc.spreads[0].items.len(); + assert!(app.paste_clipboard_image(image).is_err()); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items); + assert!(app.ui.status.contains("64 million"), "{}", app.ui.status); + // An image within the limit keeps its pixels. + let small = crate::ClipboardImage::from_color_image(&egui::ColorImage::filled([3, 2], egui::Color32::RED)); + let crate::ClipboardImage::Pixels { width, height, rgba } = small else { panic!("a bitmap") }; + assert_eq!((width, height), (3, 2)); + assert_eq!(rgba, [255, 0, 0, 255].repeat(6)); +} + #[test] fn selected_merged_owners_paint_the_same_row_and_column_targets() { for column in [true, false] { diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index fd529ac42..c2a181be1 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -21,10 +21,15 @@ pub enum ClipboardImage { } impl ClipboardImage { - /// An image egui delivered with Ctrl/Cmd+V (`Event::PasteImage`). + /// An image egui delivered with Ctrl/Cmd+V (`Event::PasteImage`). The pixels of an image + /// past the paste limit are not copied, and pasting it is refused by its size. pub fn from_color_image(image: &egui::ColorImage) -> Self { - let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); let [w, h] = image.size.map(|n| u32::try_from(n).unwrap_or(u32::MAX)); + let rgba = if designcraft_engine::cmd::check_pasted_size(w, h).is_ok() { + image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect() + } else { + Vec::new() + }; ClipboardImage::Pixels { width: w, height: h, rgba } } } From 346a0b24a291b41c4d68f8aa35fda234fd1e630e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:12:21 -0800 Subject: [PATCH 6/7] Paste: objects copied over empty clipboard text paste from the menu With a native menu, Copy saved the clipboard's empty text as the copy's text, so the next Paste took the older image, which the paste then skipped, and nothing was pasted. Empty text now counts as no text at the copy, as it does at the paste. --- crates/ui-egui/src/app/commands.rs | 3 ++- crates/ui-egui/src/canvas/tests.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index dc0326172..0255bd189 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -53,7 +53,8 @@ impl DesignApp { self.object_clip_text.clone() } None => { - self.object_clip_text = self.services.clipboard_text.as_mut().and_then(|f| f()); + // Empty text counts as no text, as Paste reads it. + self.object_clip_text = self.services.clipboard_text.as_mut().and_then(|f| f()).filter(|t| !t.is_empty()); self.object_clip_change = self.services.clipboard_change.as_mut().and_then(|f| f()); None } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 88a526d9d..7bca97710 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1152,6 +1152,14 @@ fn objects_copied_here_still_paste_as_objects_until_another_image_is_copied() { copy_elsewhere(&clip, Some(url), Some((5, 5))); crate::menus::activate(&mut app, "edit.paste", &Value::Null); assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((5, 5)), "the image copied again with the same address"); + // Empty text beside the older image counts as no text at the copy too. + copy_elsewhere(&clip, Some(""), Some((7, 7))); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + crate::menus::activate(&mut app, "edit.copy", &Value::Null); + let items = app.session.doc().unwrap().doc.spreads[0].items.len(); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items + 1, "the copied text frame is pasted"); + assert_eq!(pasted_image(&app), None); } #[test] From ed9980fec243e286368011f7fff16c9a55dd6bd2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:39:11 -0800 Subject: [PATCH 7/7] Paste: params text gives the pasted image's size as one point per pixel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edit.pasteImage sizes a new frame as File › Place does, which reads a raster at one point per pixel and does not read a stored resolution. The params text and the comment said otherwise. --- crates/engine/src/cmd/paste_image.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/paste_image.rs b/crates/engine/src/cmd/paste_image.rs index ec29d78b0..6040d1b9e 100644 --- a/crates/engine/src/cmd/paste_image.rs +++ b/crates/engine/src/cmd/paste_image.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Paste Image", [], None, - "{base64: an image file (PNG/JPEG/GIF/WebP/TIFF/BMP) as base64, name?: its name in the Links panel ending in an image extension (default \"Pasted image.png\"), spread?, at?: [x, y] (spread coords of the image's center; default the first page's center)} → {id, asset} — embedded; a selected empty frame takes the image, otherwise a new frame holds it at its stored resolution (72 ppi when none) and at most 60% of the page width", + "{base64: an image file (PNG/JPEG/GIF/WebP/TIFF/BMP) as base64, name?: its name in the Links panel ending in an image extension (default \"Pasted image.png\"), spread?, at?: [x, y] (spread coords of the image's center; default the first page's center)} → {id, asset} — embedded; a selected empty frame takes the image, otherwise a new frame holds it at one point per pixel, as File › Place sizes an image, and at most 60% of the page width", has_doc, paste_image )] @@ -140,8 +140,8 @@ fn paste_image(s: &mut Session, p: &Value) -> Result { Some(at) => at, None => sp.pages.first().map_or(Point::ZERO, |pg| pg.bounds().center()), }; - // The size File › Place gives it: the stored resolution (72 ppi when none), at most 60% of - // the page width. + // The size File › Place gives it: one point per pixel (`designcraft_images::natural_size`), at + // most 60% of the page width. let (nw, nh) = designcraft_render::natural_size(&bytes, 0).unwrap_or((f64::from(pw), f64::from(ph))); let w = nw.min(d.settings.page_width * 0.6); let h = nh * w / nw;