mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user