From c246fc2f0e5d8a90ca6522388e2cc37d507e2b3f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 03:31:04 -0700 Subject: [PATCH] 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. --- crates/engine/src/cmd/clipboard/flavours.rs | 22 ++++++++ crates/engine/src/tests_clipflavours.rs | 60 +++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 83 insertions(+) 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"); + for _ in 0..2000 { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + big += &format!("", seed >> 20, (seed >> 8) & 0xfff, seed & 0xff); + } + big += ""; + // 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 `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `