Clipboard: a .svgz file copied in a file manager pastes as its art

Paste takes a file copied in a file manager by its flavor, which
file_flavour tells from the file's first 4,096 bytes through
fileio::detect. detect names gzip data SVGZ unless it is a compressed
native file, and file_flavour gave no flavor for the format id svgz.
So a copied .svgz file did not paste as its art, although
clipboard.importSvg, the SVG flavor's command, reads SVGZ data.

A copied file whose gzip data unpacks to SVG markup now pastes through
the SVG flavor. file_flavour unpacks the head it is given with the
gzip decoder the SVGZ reader uses, up to where the head ends, and
checks the first 4,096 unpacked bytes with looks_like_svg, as detect
checks an uncompressed file, reading them as UTF-16 after a UTF-16
byte order mark. Other gzip files get no flavor, as before.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 03:36:20 +07:00
committed by bflatastic
parent c5a1504e99
commit c246fc2f0e
3 changed files with 83 additions and 0 deletions
@@ -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 {
+60
View File
@@ -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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><rect width=\"40\" height=\"40\" fill=\"#ff0000\"/></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("<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\">");
for _ in 0..2000 {
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
big += &format!("<circle cx=\"{}\" cy=\"{}\" r=\"{}\"/>", seed >> 20, (seed >> 8) & 0xfff, seed & 0xff);
}
big += "</svg>";
// 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<u8> { 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", &notes), None);
assert_eq!(of("notes.svgz", &notes), 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();