Ctrl+V pastes a picture copied in a browser, not its link

A browser's Copy Image puts the picture on the clipboard together with
text that only names it (the address and alt text, or Firefox's UTF-16
text/x-moz-url read as UTF-8) and an <img> HTML fragment. Ctrl+V only
looked for a picture when the text was empty, so it pasted a text box
with the link. When the text or HTML only names a picture, paste the
picture; with no picture on the clipboard the link still pastes as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stephan Schott-Verdugo
2026-11-01 22:38:05 +01:00
co-authored by Claude Opus 5.5
parent e1cfe8516e
commit f006b5d8ce
5 changed files with 96 additions and 3 deletions
+1
View File
@@ -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 |
+46
View File
@@ -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 `<img>`. 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("<img")
&& visible_text(h).trim().is_empty()
{
return true;
}
let mut lines = text.trim().lines();
let first = lines.next().unwrap_or("").trim();
let link = ["http://", "https://", "file://", "data:image/"].iter().any(|p| first.to_ascii_lowercase().starts_with(p));
link && !first.contains(char::is_whitespace) && lines.count() <= 1
}
/// The text of an HTML fragment outside its tags (and outside comments and `<style>`/`<script>`
/// bodies, which browsers sometimes put around a copied picture).
fn visible_text(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let mut out = String::new();
let mut i = 0;
while i < html.len() {
let rest = &lower[i..];
let skip_to = |end: &str| rest.find(end).map(|e| i + e + end.len()).unwrap_or(html.len());
if rest.starts_with("<!--") {
i = skip_to("-->");
} else if rest.starts_with("<style") {
i = skip_to("</style>");
} else if rest.starts_with("<script") {
i = skip_to("</script>");
} 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("&nbsp;", " ")
}
impl SlideApp {
/// Offer the copy or cut on the system clipboard.
///
+4 -2
View File
@@ -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
+44
View File
@@ -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 `<img>` 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!("<img src=\"{url}\" alt=\"DeckCraft icon\">")), 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]
+1 -1
View File
@@ -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.**