Merge pull request #2575 from 00xJS/fix/2213-large-image-clipboard-text

Fix #2213: a copy with a large image no longer puts its SVG on the clipboard as text
This commit is contained in:
BFlat
2026-10-20 07:18:59 +07:00
committed by GitHub
3 changed files with 63 additions and 3 deletions
+14 -2
View File
@@ -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<CommandSpec> {
vec![
@@ -157,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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()
+47
View File
@@ -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<u8> {
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::<Vec<_>>();
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##"<rect x="{}.25" y="{}.75" width="3.5" height="3.5" fill="#{:06x}"/>"##, i % 400, i / 100, i * 977)).collect();
run(&mut s, "clipboard.importSvg", json!({"svg": format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">{rects}</svg>"#)}));
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();
+2 -1
View File
@@ -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`