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`