diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs index 95ec1a35c0..daf6d2b49d 100644 --- a/apps/wordcraft/src/clipboard.rs +++ b/apps/wordcraft/src/clipboard.rs @@ -68,7 +68,10 @@ fn set_html_text(html: &str, text: &str) -> bool { } let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) }); match set { - Some(Ok(())) => true, + Some(Ok(())) => { + platform::record_own(); + true + } Some(Err(e)) => { log::warn!("clipboard: {e}"); *slot = None; @@ -79,6 +82,50 @@ fn set_html_text(html: &str, text: &str) -> bool { }) } +/// Put a copied picture on the clipboard: its pixels, with HTML and text beside it where the +/// platform takes them. `false` when it couldn't be set. +pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool { + if platform::set_image(image, html, text) { + return true; + } + if !platform::can_set() { + return false; + } + let set = set_image_arboard(image); + if set { + platform::after_set_image(image, html, text); + } + set +} + +fn set_image_arboard(image: &wordcraft_ui_egui::system_clipboard::CopyImage) -> bool { + WRITER.with(|w| { + let Ok(mut slot) = w.try_borrow_mut() else { return false }; + if slot.is_none() { + *slot = open(); + } + let img = arboard::ImageData { width: image.width, height: image.height, bytes: std::borrow::Cow::Borrowed(&image.rgba) }; + let set = slot.as_mut().map(|c| c.set().image(img)); + match set { + Some(Ok(())) => { + platform::record_own(); + true + } + Some(Err(e)) => { + log::warn!("clipboard image: {e}"); + *slot = None; + false + } + None => false, + } + }) +} + +/// Whether the system clipboard still holds WordCraft's last copy. +pub fn is_own() -> bool { + platform::is_own() +} + /// The clipboard's text. pub fn text() -> Option { open()?.get_text().ok() @@ -180,6 +227,36 @@ mod platform { get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default() } + const OWNER_MIME: &str = "application/x-wordcraft-owner"; + + fn owner_token() -> [u8; 16] { + static TOKEN: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new(); + *TOKEN.get_or_init(|| { + let mut b = [0u8; 16]; + let _ = getrandom::fill(&mut b); + b + }) + } + + static X11_OWNER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + + pub fn record_own() { + if !wayland() { + let win = x11::owner_window().unwrap_or(0); + X11_OWNER.store(win, std::sync::atomic::Ordering::Relaxed); + } + } + + pub fn is_own() -> bool { + if wayland() { + if formats().iter().any(|m| m == OWNER_MIME) { read(OWNER_MIME, 16).is_some_and(|b| b == owner_token()) } else { false } + } else { + let current = x11::owner_window().unwrap_or(0); + let recorded = X11_OWNER.load(std::sync::atomic::Ordering::Relaxed); + recorded != 0 && current == recorded + } + } + /// Whether arboard sets the clipboard natively: X11, or Wayland with data-control. pub fn can_set() -> bool { !wayland() @@ -206,6 +283,7 @@ mod platform { sources.push(offer(mime, data)); } } + sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) }); match Options::new().copy_multi(sources) { Ok(()) => true, Err(e) => { @@ -215,6 +293,35 @@ mod platform { } } + pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool { + use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source}; + + if !wayland() { + return false; + } + let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) else { + return false; + }; + let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) }; + let mut sources = vec![MimeSource { source: Source::Bytes(png.into()), mime_type: MimeType::Specific("image/png".into()) }]; + if !text.is_empty() { + sources.push(offer("text/plain;charset=utf-8", text)); + } + if !html.is_empty() { + sources.push(offer("text/html", html)); + } + sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) }); + match Options::new().copy_multi(sources) { + Ok(()) => true, + Err(e) => { + log::warn!("clipboard image: {e}"); + false + } + } + } + + pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {} + /// Wayland copies carry their RTF already; X11 ones can't (see the module docs). pub fn add_rtf(_rtf: &str) {} @@ -341,6 +448,13 @@ mod platform { let reply = r.convert(target, cap, true)?; (reply.type_ != incr).then_some(reply.value) } + + pub fn owner_window() -> Option { + let (conn, _) = x11rb::connect(None).ok()?; + let clipboard = atom(&conn, "CLIPBOARD")?; + let reply = conn.get_selection_owner(clipboard).ok()?.reply().ok()?; + (reply.owner != x11rb::NONE).then_some(reply.owner) + } } #[cfg(test)] @@ -415,6 +529,20 @@ mod platform { Ok(out) } + const OWNER_FORMAT: &str = "WordCraft Copy Owner"; + + pub fn record_own() { + let Some(format) = raw::register_format(OWNER_FORMAT) else { return }; + let Ok(_open) = Clipboard::new_attempts(10) else { return }; + let _ = raw::set_without_clear(format.get(), &[1u8]); + } + + pub fn is_own() -> bool { + let Some(format) = raw::register_format(OWNER_FORMAT) else { return false }; + let Ok(_open) = Clipboard::new_attempts(10) else { return false }; + raw::is_format_avail(format.get()) + } + pub fn can_set() -> bool { true } @@ -424,6 +552,28 @@ mod platform { false } + pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool { + false + } + + pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) { + if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) { + if let Some(format) = raw::register_format("PNG") { + if let Ok(_open) = Clipboard::new_attempts(10) { + let _ = raw::set_without_clear(format.get(), &png); + } + } + } + if !html.is_empty() { + if let Some(format) = raw::register_format("HTML Format") { + if let Ok(_open) = Clipboard::new_attempts(10) { + let _ = raw::set_without_clear(format.get(), html.as_bytes()); + } + } + } + record_own(); + } + /// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual. pub fn add_rtf(rtf: &str) { let Some(format) = raw::register_format("Rich Text Format") else { return }; @@ -479,6 +629,19 @@ mod platform { None } + static LAST_CHANGE_COUNT: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(-1); + + pub fn record_own() { + let count = NSPasteboard::generalPasteboard().changeCount(); + LAST_CHANGE_COUNT.store(count, std::sync::atomic::Ordering::Relaxed); + } + + pub fn is_own() -> bool { + let count = NSPasteboard::generalPasteboard().changeCount(); + let recorded = LAST_CHANGE_COUNT.load(std::sync::atomic::Ordering::Relaxed); + recorded >= 0 && count == recorded + } + pub fn can_set() -> bool { true } @@ -488,6 +651,21 @@ mod platform { false } + pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool { + false + } + + pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) { + let board = NSPasteboard::generalPasteboard(); + if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) { + let _ = board.setData_forType(Some(&NSData::with_bytes(&png)), &NSString::from_str("public.png")); + } + if !html.is_empty() { + let _ = board.setString_forType(&NSString::from_str(html), &NSString::from_str("public.html")); + } + record_own(); + } + /// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first). pub fn add_rtf(rtf: &str) { if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) { @@ -530,10 +708,22 @@ mod platform { true } + pub fn record_own() {} + + pub fn is_own() -> bool { + false + } + pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool { false } + pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool { + false + } + + pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {} + pub fn add_rtf(_rtf: &str) {} pub fn add_mathml(_mathml: &str) {} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2360a2e510..347f76089a 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -352,6 +352,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { clipboard_text: Some(Box::new(clipboard::text)), clipboard_html: Some(Box::new(clipboard::html)), set_clipboard_rich: Some(Box::new(clipboard::set_rich)), + set_clipboard_image: Some(Box::new(clipboard::set_image)), + clipboard_is_own: Some(Box::new(clipboard::is_own)), clipboard_rtf: Some(Box::new(clipboard::rtf)), clipboard_formats: Some(Box::new(clipboard::formats)), save_prefs: Some(Box::new(try_save_prefs)), diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index fc8fa2bb26..ce3fbd2b8b 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -20,6 +20,9 @@ pub struct Fragment { /// gives each box a new story made from these. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub parts: BTreeMap, + /// Media items referenced by objects in this fragment. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "crate::byte_store")] + pub media: BTreeMap>>, } impl Fragment { @@ -30,6 +33,8 @@ impl Fragment { pub fn to_document(&self) -> Document { let mut d = Document::new(); d.body = self.blocks.iter().cloned().map(Arc::new).collect(); + d.parts = self.parts.clone(); + d.media = self.media.clone(); d } /// Whether the fragment holds an equation (in tables too), which a copy also puts on the @@ -974,7 +979,8 @@ impl Document { // Half a content control (one marker without the other) isn't copied. crate::control::balance_blocks(&mut blocks); let parts = self.text_box_parts(&blocks); - Fragment { blocks, parts } + let media = self.media_of_blocks(&blocks); + Fragment { blocks, parts, media } } /// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes. @@ -996,7 +1002,8 @@ impl Document { fn fragment_of(&self, mut blocks: Vec) -> Fragment { crate::control::balance_blocks(&mut blocks); let parts = self.text_box_parts(&blocks); - Fragment { blocks, parts } + let media = self.media_of_blocks(&blocks); + Fragment { blocks, parts, media } } /// The table whose whole text `a..b` covers when the range runs across its cells: from the @@ -1038,7 +1045,8 @@ impl Document { pub fn body_fragment(&self) -> Fragment { let blocks: Vec = self.body.iter().map(|b| (**b).clone()).collect(); let parts = self.text_box_parts(&blocks); - Fragment { blocks, parts } + let media = self.media_of_blocks(&blocks); + Fragment { blocks, parts, media } } /// Another document's body as a fragment of this one, to insert: the media, lists, styles, @@ -1207,7 +1215,7 @@ impl Document { } let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); blocks.iter_mut().for_each(|b| link.block(b)); - Fragment { blocks, parts } + Fragment { blocks, parts, media: self.media.clone() } } /// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its @@ -1215,7 +1223,7 @@ impl Document { /// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past /// the adopted ones. pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec { - let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms); + let Fragment { mut blocks, parts, .. } = self.adopt_in(src, keep_styles, perms); let mut budget = BoxBudget::new(); self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); blocks @@ -1241,6 +1249,33 @@ impl Document { out } + /// Media items referenced by objects in `blocks`, nested text box stories included. + pub fn media_of_blocks(&self, blocks: &[Block]) -> BTreeMap>> { + let mut keys = std::collections::BTreeSet::new(); + let mut collect_para = |p: &Paragraph| { + for o in &p.objects { + if let InlineObject::Image { media, .. } = o { + keys.insert(media.clone()); + } + } + }; + for b in blocks { + each_para(b, 0, &mut collect_para); + } + for part in self.text_box_parts(blocks).values() { + for b in &part.blocks { + each_para(b, 0, &mut collect_para); + } + } + let mut out = BTreeMap::new(); + for k in keys { + if let Some(data) = self.media.get(&k) { + out.insert(k, data.clone()); + } + } + out + } + fn copy_blocks(&self, a: &Pos, b: &Pos) -> Vec { let (a, b) = order(a, b); let mut out = Vec::new(); @@ -1389,6 +1424,11 @@ impl Document { /// Insert a fragment at `pos`; returns the position after the pasted content. pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { + for (k, v) in &frag.media { + if !self.media.contains_key(k) { + self.media.insert(k.clone(), v.clone()); + } + } // Tables that would nest deeper than the limit here become their paragraphs (#1596). let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth()); let mut blocks = Vec::with_capacity(frag.blocks.len()); diff --git a/crates/doc/src/edit/tests.rs b/crates/doc/src/edit/tests.rs index ae7f7d64af..fd8946bf7c 100644 --- a/crates/doc/src/edit/tests.rs +++ b/crates/doc/src/edit/tests.rs @@ -326,7 +326,7 @@ fn fan_out(story: Vec>, refs: usize) -> Fragment { } let mut parts = BTreeMap::new(); parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() }); - Fragment { blocks: vec![Block::Para(p)], parts } + Fragment { blocks: vec![Block::Para(p)], parts, ..Default::default() } } #[test] diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index 6d6c83b805..8801d2d9e7 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -232,7 +232,8 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult { blocks.push(Block::Para(para)); } s.clipboard_text = rows.join("\n"); - s.clipboard = Some(Fragment { blocks, parts }); + let media = s.doc.media_of_blocks(&blocks); + s.clipboard = Some(Fragment { blocks, parts, media }); Ok(super::edit::copy_result(s)) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a55ce7571e..35c64fbbbc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -204,7 +204,10 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult { pub(crate) fn copy_result(s: &mut Session) -> Value { (s.clipboard_html, s.clipboard_rtf) = match &s.clipboard { Some(f) => { - let d = f.to_document(); + let mut d = f.to_document(); + if d.media.is_empty() { + d.media = s.doc.media.clone(); + } (wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d)) } None => (String::new(), String::new()), diff --git a/crates/engine/src/cmd/tools/actions.rs b/crates/engine/src/cmd/tools/actions.rs index 28bc721c13..50c492e921 100644 --- a/crates/engine/src/cmd/tools/actions.rs +++ b/crates/engine/src/cmd/tools/actions.rs @@ -131,7 +131,9 @@ pub(super) fn quick_parts(s: &mut Session, v: &Value, gallery: &str) -> CmdResul None => { let e = document_entry(&s.doc, name, gallery).and_then(|i| s.doc.glossary.get(i)); let e = e.ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?; - wordcraft_doc::edit::Fragment { blocks: e.blocks.iter().map(|b| (**b).clone()).collect(), parts: Default::default() } + let blocks: Vec<_> = e.blocks.iter().map(|b| (**b).clone()).collect(); + let media = s.doc.media_of_blocks(&blocks); + wordcraft_doc::edit::Fragment { blocks, parts: Default::default(), media } } }; let at = delete_selection(s)?; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b2533e5f1f..b3926c3716 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -100,6 +100,7 @@ impl WordApp { ..UiState::default() }, services, + clipboard_published: false, printing: Default::default(), print_feedback_seen: None, integrated_titlebar: false, diff --git a/crates/ui-egui/src/keys/canvas.rs b/crates/ui-egui/src/keys/canvas.rs index f5890f382c..775aaebc44 100644 --- a/crates/ui-egui/src/keys/canvas.rs +++ b/crates/ui-egui/src/keys/canvas.rs @@ -269,12 +269,16 @@ fn paste_release(app: &mut WordApp, e: &egui::Event) -> bool { let modifiers = &paste_release_modifiers(*modifiers, press_seen, app.canvas.paste_mods.take()); let paste_keys = is_paste_release(*key, *modifiers); let pasted = std::mem::take(&mut app.canvas.pasted); - if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() { + if !pasted && paste_keys && app.session.math.is_none() && (app.services.clipboard_picture.is_some() || app.session.clipboard.is_some()) { let _ = app.run("edit.paste", json!({})); } // Mod+Alt+V with no text on the clipboard: Paste Special still opens, listing what // is there (a picture) or saying what it holds (#687, #833). - if !pasted && is_paste_special_release(*key, *modifiers) && app.session.math.is_none() && app.services.clipboard_picture.is_some() { + if !pasted + && is_paste_special_release(*key, *modifiers) + && app.session.math.is_none() + && (app.services.clipboard_picture.is_some() || app.session.clipboard.is_some()) + { let _ = app.run("edit.pasteSpecial", json!({})); } return true; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 516852fd6c..1d2e37b7c8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -614,6 +614,8 @@ pub struct WordApp { pub session: Session, pub ui: UiState, pub services: Services, + /// Only a successfully published copy may reuse this window's private fragment. + pub clipboard_published: bool, pub printing: printing::State, print_feedback_seen: Option, pub canvas: canvas::CanvasState, diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d9359b973b..5228e0783b 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,5 +1,5 @@ use super::state::DefaultApp; -use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing}; +use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing, system_clipboard}; /// Platform services injected by the host (file dialogs, file I/O). #[derive(Default)] @@ -73,6 +73,10 @@ pub struct Services { /// pasted equation from (#1297). `true` when the text and HTML are there; otherwise the copy's /// text goes through egui as usual. pub set_clipboard_rich: Option bool>>, + /// Desktop: publish one picture's pixels, HTML and text together without clearing formats. + pub set_clipboard_image: Option bool>>, + /// Whether the clipboard still contains this process's last copy. + pub clipboard_is_own: Option bool>>, /// Desktop: the RTF on the system clipboard, where the platform offers it (#687). pub clipboard_rtf: Option Option>>, /// Desktop: the names of the formats on the system clipboard (MIME types, Windows format diff --git a/crates/ui-egui/src/system_clipboard.rs b/crates/ui-egui/src/system_clipboard.rs index bbb9525f53..87c815a2ff 100644 --- a/crates/ui-egui/src/system_clipboard.rs +++ b/crates/ui-egui/src/system_clipboard.rs @@ -11,6 +11,14 @@ use wordcraft_engine::cmd::paste::{MAX_PASTE_BYTES, MAX_PICTURE_BYTES}; use crate::{WordApp, paste_picture}; +/// Straight RGBA8 pixels for a native image copy. Decode limits are applied before allocation. +#[derive(Clone, Debug)] +pub struct CopyImage { + pub width: usize, + pub height: usize, + pub rgba: Vec, +} + /// The commands that paste the clipboard. pub(crate) fn is_paste(id: &str) -> bool { matches!(id, "edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial") @@ -30,6 +38,10 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option Option Option bool { if text.is_empty() { // A copied picture or shape has no text, only HTML (#2276): empty the clipboard's text, // else Paste would find the text copied before it and paste that instead of our copy. if !html.is_empty() { + if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) { + return true; + } ctx.copy_text(String::new()); } - return; + return false; } if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) { - return; + return true; } ctx.copy_text(text.to_string()); + false } /// Put what Copy or Cut returned on the system clipboard (see [`put`]). -pub(crate) fn put_result(app: &WordApp, ctx: &egui::Context, r: &Value) { +pub(crate) fn put_result(app: &mut WordApp, ctx: &egui::Context, r: &Value) { let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or(""); - put(app, ctx, s("text"), s("html"), s("rtf"), s("mathml")); + app.clipboard_published = false; + if app.services.set_clipboard_image.is_some() + && let Some(res) = copied_image(app) + { + match res { + Ok(image) => { + let set = app.services.set_clipboard_image.as_ref().map(|f| f(&image, s("html"), s("text"))).unwrap_or(false); + if set { + app.clipboard_published = true; + return; + } + } + Err(e) => { + app.status(format!("Couldn't copy the picture: {e}")); + } + } + } + app.clipboard_published = put(app, ctx, s("text"), s("html"), s("rtf"), s("mathml")); +} + +fn copied_image(app: &WordApp) -> Option> { + use wordcraft_doc::{Block, para::InlineObject}; + let fragment = app.session.clipboard.as_ref()?; + let [Block::Para(para)] = fragment.blocks.as_slice() else { return None }; + let [InlineObject::Image { media, .. }] = para.objects.as_slice() else { return None }; + if para.text != wordcraft_doc::para::OBJ.to_string() { + return None; + } + let bytes = fragment.media.get(media).or_else(|| app.session.doc.media.get(media))?; + Some(match wordcraft_render::metafile_size(bytes) { + Some((w, h, _)) => { + let rect = wordcraft_geom::Rect::new(0.0, 0.0, w, h); + let items = [wordcraft_layout::display::Draw::Image { + rect, + media: media.clone(), + crop: [0.0; 4], + alpha: 1.0, + alt: String::new(), + fx: Default::default(), + decorative: false, + }]; + let image = wordcraft_render::render_items(&app.session.doc, &items, rect, 96.0 / 72.0, &Default::default()); + Ok(CopyImage { width: image.width as usize, height: image.height as usize, rgba: image.to_straight() }) + } + None => decode_image(bytes), + }) +} + +fn decode_image(bytes: &[u8]) -> Result { + if bytes.len() as u64 > paste_picture::MAX_FILE_BYTES { + return Err("the picture file is larger than 200 MB".into()); + } + let reader = || image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| e.to_string()); + let (w, h) = reader()?.into_dimensions().map_err(|e| e.to_string())?; + let pixels = (w as usize).checked_mul(h as usize); + if w == 0 + || h == 0 + || w as usize > paste_picture::MAX_SIDE + || h as usize > paste_picture::MAX_SIDE + || pixels.is_none_or(|n| n > paste_picture::MAX_PIXELS) + { + return Err(format!("the picture is too large to copy ({w} × {h} pixels)")); + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(paste_picture::MAX_SIDE as u32); + limits.max_image_height = Some(paste_picture::MAX_SIDE as u32); + limits.max_alloc = Some((paste_picture::MAX_PIXELS as u64).saturating_mul(4)); + let mut reader = reader()?; + reader.limits(limits); + let rgba = reader.decode().map_err(|e| e.to_string())?.into_rgba8(); + Ok(CopyImage { width: w as usize, height: h as usize, rgba: rgba.into_raw() }) } /// Format names worth showing: not the clipboard protocol's own bookkeeping targets. @@ -265,7 +356,7 @@ mod tests { a.run("select.all", json!({})).unwrap(); let ctx = egui::Context::default(); let r = a.run("edit.copy", json!({})).unwrap(); - put_result(&a, &ctx, &r); + put_result(&mut a, &ctx, &r); let got_now = got.borrow().clone(); assert_eq!(got_now.len(), 1); assert!(got_now[0].0.contains("") && got_now[2].1 == "y=2x_s", "{got_now:?}"); } + + #[test] + fn copying_an_image_publishes_image_and_html_to_system_clipboard() { + let got_image = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(usize, usize, String, String)>::new())); + let sink = got_image.clone(); + let services = Services { + set_clipboard_image: Some(Box::new(move |img, html, text| { + sink.borrow_mut().push((img.width, img.height, html.to_string(), text.to_string())); + true + })), + ..Default::default() + }; + let mut a = app(services); + let png = paste_picture::png_from_rgba(2, 2, vec![255; 16]).unwrap(); + let data = wordcraft_engine::cmd::insert::base64_encode(&png); + a.run("insert.picture", json!({"data": data})).unwrap(); + a.run("select.all", json!({})).unwrap(); + let ctx = egui::Context::default(); + let r = a.run("edit.copy", json!({})).unwrap(); + put_result(&mut a, &ctx, &r); + assert!(a.clipboard_published); + let got = got_image.borrow().clone(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].0, 2); + assert_eq!(got[0].1, 2); + assert!(got[0].2.contains("