diff --git a/ROADMAP.md b/ROADMAP.md
index fd17b3a1c..42ccd19dc 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
+| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it |
| 2026-11-01 | Copy, Cut, Paste and Duplicate of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text |
| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows |
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs
index 19fd48bf1..f6003dd04 100644
--- a/crates/ui-egui/src/app/clipboard.rs
+++ b/crates/ui-egui/src/app/clipboard.rs
@@ -11,6 +11,52 @@ pub(crate) fn system_clipboard_text(t: &str) -> String {
if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() }
}
+/// Whether pasted `text` (and the clipboard's `html`) only name a picture that is on the clipboard
+/// too, as a browser's Copy Image leaves them: the picture's address, perhaps followed by its alt
+/// text, or HTML that is a lone `
`. Then Ctrl+V pastes the picture, as PowerPoint does.
+/// Text with NULs is UTF-16 read as UTF-8 (Firefox's `text/x-moz-url`), never words to keep.
+pub(crate) fn names_a_picture(text: &str, html: Option<&str>) -> bool {
+ if text.contains('\0') {
+ return true;
+ }
+ if let Some(h) = html
+ && h.to_ascii_lowercase().contains("
`/`");
+ } else if rest.starts_with('<') {
+ i = skip_to(">");
+ } else {
+ let end = rest.find('<').map_or(html.len(), |e| i + e);
+ out.push_str(&html[i..end]);
+ i = end;
+ }
+ }
+ out.replace(" ", " ")
+}
+
impl SlideApp {
/// Offer the copy or cut on the system clipboard.
///
diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs
index d6903e82d..7523308c0 100644
--- a/crates/ui-egui/src/app/keys.rs
+++ b/crates/ui-egui/src/app/keys.rs
@@ -152,8 +152,10 @@ impl SlideApp {
// Text keeps its HTML (bold, italic, underline) when the host can read it.
let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty());
// egui 0.37 sends `PasteImage` directly; the services fallback stays for
- // integrations that still send an empty `Paste`. Never insert twice.
- let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
+ // integrations that still send an empty `Paste`, and for a browser's Copy
+ // Image, whose text is only the picture's link. Never insert twice.
+ let wants_picture = text.is_empty() || super::clipboard::names_a_picture(&text, html.as_deref());
+ let img = if wants_picture && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
let revision = self.session.active().map(|d| d.revision);
let r = match img {
Some(png) => self
diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs
index 98f3b85c7..47192ad36 100644
--- a/crates/ui-egui/src/tests.rs
+++ b/crates/ui-egui/src/tests.rs
@@ -378,6 +378,50 @@ fn title_bar_export_opens_the_export_dialog() {
assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export"));
}
+/// A browser's Copy Image puts the picture on the clipboard with text that only names it: the
+/// address (and the alt text), and an `
` fragment as HTML. Ctrl+V pastes the picture, as
+/// PowerPoint does, instead of a text box with the link. Without a picture the link pastes as text.
+#[test]
+fn ctrl_v_pastes_the_picture_a_browser_copied_not_its_link() {
+ let url = "https://github.com/storytold/deckcraft/raw/main/assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png";
+ let caption = format!("{url}\nDeckCraft icon: a presentation screen with a bar chart on an orange tile");
+ for (text, html, with_picture, pictures_after) in [
+ (caption.clone(), Some(format!("
")), true, 1),
+ (url.to_string(), None, true, 1),
+ // Firefox's text/x-moz-url is UTF-16; read as UTF-8 it carries NULs.
+ (url.chars().flat_map(|c| [c, '\0']).collect(), None, true, 1),
+ (url.to_string(), None, false, 0),
+ ("See https://example.com for the photos".to_string(), None, true, 0),
+ ] {
+ let session = Session::with_new();
+ let png = {
+ let d = session.active().unwrap();
+ deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0
+ };
+ let services = Services {
+ clipboard_image: Some(Box::new(move || with_picture.then(|| png.clone()))),
+ clipboard_html: Some(Box::new(move || html.clone())),
+ ..Default::default()
+ };
+ let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+ |ui, app: &mut SlideApp| {
+ app.logic(ui.ctx());
+ app.ui(ui);
+ },
+ SlideApp::new(session, services),
+ );
+ harness.run_steps(2);
+ let shapes_before = harness.state().session.active().unwrap().doc.slides[0].shapes.len();
+ harness.event(egui::Event::Paste(text.clone()));
+ harness.run_steps(2);
+ let d = harness.state().session.active().unwrap();
+ let shapes = &d.doc.slides[0].shapes;
+ let pictures = shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count();
+ assert_eq!(pictures, pictures_after, "{text:?}");
+ assert_eq!(shapes.len(), shapes_before + 1, "one shape, picture or text, for {text:?}");
+ }
+}
+
/// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and
/// swallows the key press, so only the V release reaches the app.
#[test]
diff --git a/docs/gaps.md b/docs/gaps.md
index 75a92355b..27c7ef7c7 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -161,7 +161,7 @@ Detail in [file-format-parity.md](file-format-parity.md).
- **Estimate:** 3–5 h. **Beta blocker.**
### Image paste fails on Windows (#1414)
-- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.14.1 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01. #1414 may have the same cause: re-test on Windows.
+- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.14.1 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01, as is a browser's Copy Image on Linux, which pasted its link because Ctrl+V preferred any text over the picture. #1414 may have either cause: re-test on Windows.
- **Impact:** copy-paste of screenshots is a core workflow.
- **Estimate:** 2–4 h. **Beta blocker.**