From 548ace24d2567b9a258ebe3d90814011eeb7296c Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:01:07 -0700 Subject: [PATCH] Clipboard: a copy with a large image no longer puts its SVG on the clipboard as text (fixes #2213) With Include SVG Code on, Copy offered the SVG markup as plain text with every placed image embedded as base64: a 13 MB photo made 17 MB of text. A paste reads the text first (eframe on every paste key; on Linux and macOS it is the one format written, and holds_ours reads it again), so each paste moved it through the system clipboard on the UI thread, and on GNOME the app stopped responding. The SVG markup now goes out as text only while the images it embeds total at most 1 MiB; above that it is offered as image/svg+xml and PNG. Type keeps its text, and vector art keeps its SVG text at any size (only embedded image bytes count). Windows 11, Ryzen 5 2600X, 13 MB PNG, real Ctrl+C: the longest UI stall fell from 823-1021 ms to 133 ms. Not run on Linux. --- crates/engine/src/cmd/clipboard/flavours.rs | 16 ++++++- crates/engine/src/tests_clipflavours.rs | 47 +++++++++++++++++++++ docs/mcp.md | 3 +- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 50a2417a5..08e4a6f4d 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -122,6 +122,13 @@ pub struct Flavour { const COPY_PNG_SIDE: f64 = 4096.0; /// Most bytes of text `clipboard.importText` takes. const MAX_TEXT: usize = 1 << 20; +/// Most bytes of images the SVG markup may embed and still go out as plain text. A paste reads the +/// clipboard's text first (eframe reads it on every paste key; on Linux and macOS it is the one +/// format the app writes, and checking that the clipboard still holds our copy reads it again), so +/// a placed photo in it made each paste move megabytes of base64 through the system clipboard on +/// the UI thread, and on a GNOME desktop the app stopped responding (#2213). A copy with more image +/// data than this offers its SVG under its own type and its PNG, not as text. +const COPY_TEXT_IMAGE_BYTES: usize = 1 << 20; pub fn specs() -> Vec { vec![ @@ -157,7 +164,7 @@ pub fn specs() -> Vec { "Clipboard Formats", [], None, - "{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut", + "{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg when the images it embeds total at most 1 MiB), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut", always, flavours ), @@ -263,12 +270,17 @@ impl Clipboard { fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some) } + /// Bytes of the encoded images the objects show (what their SVG embeds as base64). + fn image_bytes(&self) -> usize { + self.images.values().fold(0usize, |sum, b| sum.saturating_add(b.bytes.len())) + } + /// The MIME types of [`Self::flavours`], without making them. pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> { if self.is_empty() { return vec![]; } - let text = svg || self.nodes.iter().all(type_only); + let text = (svg && self.image_bytes() <= COPY_TEXT_IMAGE_BYTES) || self.nodes.iter().all(type_only); // Art without an area (a lone guide-like line) has no page or picture. let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0); [(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect() diff --git a/crates/engine/src/tests_clipflavours.rs b/crates/engine/src/tests_clipflavours.rs index 759f0f83a..b0c5ea228 100644 --- a/crates/engine/src/tests_clipflavours.rs +++ b/crates/engine/src/tests_clipflavours.rs @@ -315,6 +315,53 @@ fn copy_offers_flavours_by_the_preferences() { assert_eq!((f[0].data.as_slice(), f[1].mime), (b"Words".as_slice(), SVG)); } +/// A PNG of `side` × `side` noise pixels: noise keeps it about `side² × 3` bytes, as a photo. +fn noise_png(side: u32) -> Vec { + let mut x: u32 = 0x9e37_79b9; + let px = (0..side * side * 3) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 24) as u8 + }) + .collect(); + let mut out = vec![]; + image::RgbImage::from_raw(side, side, px).unwrap().write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// #2213: a placed photo in the plain-text flavour made every paste read megabytes of SVG back +/// through the system clipboard. Past 1 MiB of embedded images the SVG goes out under its own type +/// and the PNG only; a small picture, vector art of any size and type keep their text. +#[test] +fn a_copy_with_a_large_image_offers_no_svg_text() { + let mut s = session(); + let mimes = |s: &Session| s.clipboard_flavours().iter().map(|f| f.mime).collect::>(); + for (side, text) in [(700, false), (64, true)] { + let png = noise_png(side); + assert_eq!(png.len() > 1 << 20, !text, "{} bytes", png.len()); + run(&mut s, "clipboard.importImage", json!({"dataBase64": vectorcraft_format::base64_encode(&png)})); + run(&mut s, "edit.paste", json!({})); + run(&mut s, "edit.copy", json!({})); + let want: &[&str] = if text { &[TEXT, SVG, PNG] } else { &[SVG, PNG] }; + assert_eq!(mimes(&s), want, "{side} px"); + assert_eq!(run(&mut s, "clipboard.flavours", json!({}))["flavours"], json!(want), "{side} px"); + // The SVG still embeds the picture, for apps that read it. + let svg = s.clipboard_flavours().into_iter().find(|f| f.mime == SVG).unwrap(); + assert!(String::from_utf8_lossy(&svg.data).contains("data:image/png;base64,")); + run(&mut s, "edit.clear", json!({})); + } + // Vector art of more than 1 MiB of SVG keeps its text: only embedded images count. + let rects: String = + (0..30_000).map(|i| format!(r##""##, i % 400, i / 100, i * 977)).collect(); + run(&mut s, "clipboard.importSvg", json!({"svg": format!(r#"{rects}"#)})); + run(&mut s, "edit.paste", json!({})); + run(&mut s, "edit.copy", json!({})); + let f = s.clipboard_flavours(); + assert!(f[0].mime == TEXT && f[0].data.len() > 1 << 20, "{} bytes of text", f[0].data.len()); +} + /// The Type tool pastes the text it copied with its formatting, unless pasted text is kept plain. fn paste_copied_range(plain: bool) -> f64 { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 824babebf..d6de283bb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2882,7 +2882,8 @@ They are kept across restarts, in the desktop app and in `vectorcraft-cli` alike Besides SVG, Copy offers other apps PNG and PDF of the copied objects, and their text when they are all type. `clipboard.flavours` lists what Copy offers for the current clipboard, best first, by the Clipboard Handling -preferences: `text/plain` (the text of a type-only copy, else the SVG markup with `copyAsSvg`), `image/svg+xml` +preferences: `text/plain` (the text of a type-only copy, else the SVG markup with `copyAsSvg` when the images it +embeds total at most 1 MiB, so a paste never reads a photo back as text, #2213), `image/svg+xml` (`copyAsSvg`), `application/pdf` (`copyAsPdf`) and `image/png` (always; PDF and PNG need objects with an area). Each one has a query command: `clipboard.exportSvg`, `clipboard.exportText` (null unless every copied object is type), `clipboard.exportPdf`