diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs
index e8a99816d..864af3b56 100644
--- a/crates/engine/src/cmd/clipboard/flavours.rs
+++ b/crates/engine/src/cmd/clipboard/flavours.rs
@@ -77,6 +77,9 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
let f = fileio::detect(name, head)?;
let (asked, mime) = match f.id {
"svg" => (SVG, SVG),
+ // `detect` names gzip data SVGZ (a compressed native file aside); `clipboard.importSvg`
+ // reads SVGZ data.
+ "svgz" if unpacks_to_svg(head) => (SVG, SVG),
"pdf" => (PDF, PDF),
"eps" => (EPS, EPS),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
@@ -88,6 +91,25 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
mimes.contains(&asked).then_some(mime)
}
+/// Does gzip data `head` (a file's first bytes, often a stream cut short) unpack to SVG markup?
+/// The first [`FILE_HEAD`] bytes it unpacks to are read as UTF-16 after a UTF-16 byte order mark,
+/// else as UTF-8, and checked with [`looks_like_svg`].
+fn unpacks_to_svg(head: &[u8]) -> bool {
+ use std::io::Read as _;
+ let mut raw = Vec::with_capacity(FILE_HEAD);
+ // A stream cut short ends in an error after the bytes that unpacked: judge those.
+ let _ = flate2::read::GzDecoder::new(head).take(FILE_HEAD as u64).read_to_end(&mut raw);
+ let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| -> String {
+ char::decode_utf16(rest.as_chunks::<2>().0.iter().map(|u| unit(*u))).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
+ };
+ let text = match raw.as_slice() {
+ [0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
+ [0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
+ _ => String::from_utf8_lossy(&raw).into_owned(),
+ };
+ looks_like_svg(&text)
+}
+
/// One representation of the clipboard's contents.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Flavour {
diff --git a/crates/engine/src/tests_clipflavours.rs b/crates/engine/src/tests_clipflavours.rs
index 72446e2c2..7e727ec23 100644
--- a/crates/engine/src/tests_clipflavours.rs
+++ b/crates/engine/src/tests_clipflavours.rs
@@ -118,6 +118,66 @@ fn import_image_then_paste_gives_an_image() {
assert!(s.execute("clipboard.importImage", &json!({"dataBase64": "!!"})).is_err());
}
+#[test]
+fn copied_svgz_files_paste_as_svg() {
+ let svg = "";
+ let svgz = vectorcraft_svg::compress(svg);
+ let of = |name: &str, bytes: &[u8]| file_flavour(name, &bytes[..bytes.len().min(FILE_HEAD)], &PASTE_ORDER);
+ assert_eq!(of("art.svgz", &svgz), Some(SVG));
+ assert_eq!(of("art.gz", &svgz), Some(SVG), "the content tells");
+ assert_eq!(file_flavour("art.svgz", &svgz, &[BITMAP]), None);
+ // A file larger than FILE_HEAD: the head is a gzip stream cut short.
+ let mut seed = 1u32;
+ let mut big = String::from("\n";
+ // UTF-16 after a byte order mark, as SVG Options' UTF-16 encoding writes it.
+ let utf16 =
+ |text: &str, unit: fn(u16) -> [u8; 2], bom: [u8; 2]| -> Vec { bom.into_iter().chain(text.encode_utf16().flat_map(unit)).collect() };
+ assert_eq!(of("be.svgz", &vectorcraft_svg::compress_bytes(&utf16(svg, u16::to_be_bytes, [0xfe, 0xff]))), Some(SVG));
+ assert_eq!(of("le.svgz", &vectorcraft_svg::compress_bytes(&utf16(svg, u16::to_le_bytes, [0xff, 0xfe]))), Some(SVG));
+ // Stored without compression, the head unpacks to fewer than FILE_HEAD bytes (an odd number)
+ // before it ends.
+ let stored = |bytes: &[u8]| {
+ let mut gz = flate2::write::GzEncoder::new(vec![], flate2::Compression::none());
+ std::io::Write::write_all(&mut gz, bytes).unwrap();
+ gz.finish().unwrap()
+ };
+ assert_eq!(of("stored.svgz", &stored(big.as_bytes())), Some(SVG));
+ assert_eq!(of("stored16.svgz", &stored(&utf16(&big, u16::to_be_bytes, [0xfe, 0xff]))), Some(SVG));
+ let big = vectorcraft_svg::compress(&big);
+ assert!(big.len() > 2 * FILE_HEAD, "{}", big.len());
+ assert_eq!(of("big.svgz", &big), Some(SVG));
+ // Other gzip data, whole or cut short, pastes as nothing.
+ let notes = vectorcraft_svg::compress("plain text, not SVG");
+ assert_eq!(of("notes.txt.gz", ¬es), None);
+ assert_eq!(of("notes.svgz", ¬es), None);
+ let mut text = String::new();
+ for i in 0..3000 {
+ seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
+ text += &format!("line {i}: {seed}\n");
+ }
+ let text = vectorcraft_svg::compress(&text);
+ assert!(text.len() > FILE_HEAD, "{}", text.len());
+ assert_eq!(of("log.gz", &text), None);
+ // Cut within the gzip header (10 bytes) or at its end: nothing unpacks.
+ for cut in [2, 5, 10] {
+ assert_eq!(of("art.svgz", &svgz[..cut]), None, "{cut} bytes");
+ }
+ // The SVG flavor's command reads the SVGZ bytes: the red square pastes.
+ let mut s = session();
+ let r = run(&mut s, "clipboard.importSvg", json!({"dataBase64": vectorcraft_format::base64_encode(&svgz), "center": [200, 150]}));
+ assert_eq!(r["count"], 1, "{r}");
+ run(&mut s, "edit.paste", json!({}));
+ let n = &pasted(&s)[0];
+ assert_eq!(n.appearance.fill_paint().color().map(|c| c.to_hex()).as_deref(), Some("#ff0000"));
+ let b = n.geometric_bounds().unwrap();
+ assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}");
+}
+
#[test]
fn copied_files_paste_by_their_content() {
let mut s = session();
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 36614b72d..750e6220e 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -541,6 +541,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `