mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge pull request #2665 from dotCooCoo/feat/paste-clipboard-image
Paste: images on the system clipboard paste as embedded images
This commit is contained in:
16 files changed
+841
-24
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<ClipboardImage> {
|
||||
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<T: Send + 'static>(busy: &'static AtomicBool, wait: Duration, read: impl FnOnce() -> Option<T> + Send + 'static) -> Option<T> {
|
||||
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> {
|
||||
i64::try_from(objc2_app_kit::NSPasteboard::generalPasteboard().changeCount()).ok()
|
||||
}
|
||||
|
||||
fn read_now() -> Option<ClipboardImage> {
|
||||
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<ClipboardImage> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
@@ -380,5 +382,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_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;
|
||||
@@ -0,0 +1,318 @@
|
||||
//! 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<CommandSpec> {
|
||||
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 one point per pixel, as File › Place sizes an image, 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(())
|
||||
}
|
||||
|
||||
/// 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<u8>) -> std::result::Result<Vec<u8>, 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<u8>)> {
|
||||
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<Value> {
|
||||
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")),
|
||||
};
|
||||
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(),
|
||||
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))?;
|
||||
// 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,
|
||||
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 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"))?;
|
||||
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: 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;
|
||||
let spread = p.get("spread").cloned().unwrap_or(Value::Null);
|
||||
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<Value> {
|
||||
let journal = s.journal.len();
|
||||
let r = s.execute("file.place", params);
|
||||
s.journal.truncate(journal);
|
||||
r
|
||||
}
|
||||
|
||||
#[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<u8> {
|
||||
let rgba: Vec<u8> = (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<u8> {
|
||||
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"<svg xmlns='http://www.w3.org/2000/svg'/>")}), "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 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();
|
||||
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::<Vec<_>>(), [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();
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<u8>) -> Option<Vec<u8>> {
|
||||
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) {
|
||||
|
||||
@@ -114,6 +114,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,
|
||||
|
||||
@@ -1,28 +1,65 @@
|
||||
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);
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
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");
|
||||
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(|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);
|
||||
}
|
||||
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 +69,54 @@ impl DesignApp {
|
||||
o.entry("spread").or_insert(spread);
|
||||
o.entry("at").or_insert(json!([at.x, at.y]));
|
||||
}
|
||||
params
|
||||
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. 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<Value, String> {
|
||||
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())
|
||||
} 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.
|
||||
|
||||
@@ -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,18 @@ 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());
|
||||
// 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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<egui::Event>, 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;
|
||||
|
||||
@@ -1051,6 +1051,183 @@ 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<String>,
|
||||
image: Option<(u32, u32)>,
|
||||
change: i64,
|
||||
}
|
||||
|
||||
type FakeClipboard = std::rc::Rc<std::cell::RefCell<FakeClip>>;
|
||||
|
||||
/// 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)));
|
||||
// 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);
|
||||
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");
|
||||
// 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]
|
||||
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());
|
||||
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 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] {
|
||||
|
||||
@@ -272,11 +272,27 @@ 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)) => {
|
||||
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");
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,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};
|
||||
|
||||
@@ -260,6 +263,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<String>,
|
||||
/// 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<i64>,
|
||||
/// 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<String>,
|
||||
|
||||
@@ -11,6 +11,37 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
|
||||
pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>;
|
||||
|
||||
/// 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<u8> },
|
||||
/// An image file copied in a file manager: its name and contents.
|
||||
File { name: String, bytes: Vec<u8> },
|
||||
}
|
||||
|
||||
impl ClipboardImage {
|
||||
/// 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 [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 }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<dyn FnMut() -> Option<ClipboardImage>>;
|
||||
|
||||
/// 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<dyn FnMut() -> Option<i64>>;
|
||||
|
||||
/// Keeps the window's frame as the app needs it; called once per frame, before it is laid out.
|
||||
pub type WindowFrameFn = Box<dyn FnMut()>;
|
||||
|
||||
@@ -40,6 +71,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<ClipboardFn>,
|
||||
/// 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<ClipboardImageFn>,
|
||||
/// 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<ClipboardChangeFn>,
|
||||
/// 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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user