M4.30: SVGZ read and write

svgz is a writable format with the SVG options (document.export,
serialize, save, Export for Screens): exactly the SVG export, gzipped.
SVGZ is recognised by its extension or the gzip magic bytes and opens,
places and pastes like SVG (clipboard.importSvg takes dataBase64 of SVG
or SVGZ); Export As SVG or Save As to a .svgz name writes it
compressed. Gzip lives in the svg crate (pure-Rust flate2, builds for
wasm) and unpacking stops at 512 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 07:58:10 +07:00
co-authored by Claude Opus 5.5
parent b75ed0877d
commit 96aba71759
20 changed files with 202 additions and 43 deletions
+11 -3
View File
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load SVG into Clipboard",
[],
None,
"{svg, center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
"{svg | dataBase64 (SVG or SVGZ bytes), center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
has_doc,
import_svg
),
@@ -58,8 +58,16 @@ fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
}
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
let svg = str_param(p, "svg").ok_or_else(|| bad("clipboard.importSvg", "missing svg"))?;
let src = vectorcraft_svg::import(svg).map_err(|e| bad("clipboard.importSvg", e.to_string()))?;
const C: &str = "clipboard.importSvg";
let svg = match (str_param(p, "svg"), str_param(p, "dataBase64")) {
(Some(svg), _) => std::borrow::Cow::Borrowed(svg),
(None, Some(b64)) => {
let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad base64"))?;
vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, e.to_string()))?.into_owned().into()
}
(None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")),
};
let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, e.to_string()))?;
let mut nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
if nodes.is_empty() {
return Err(bad("clipboard.importSvg", "the SVG has no drawable objects"));
+1 -1
View File
@@ -165,7 +165,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let n = doc.artboards.len();
let bytes = match f.id {
"vectorcraft" => vectorcraft_format::save_file(doc),
"svg" => return super::svg::encode(doc, p).map_err(|e| bad(C, e)),
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
+1 -1
View File
@@ -98,7 +98,7 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if format.id == "vectorcraft" {
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf"));
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf"));
}
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
// left over from a raster row switched to SVG or PDF).
+5 -13
View File
@@ -1,7 +1,6 @@
//! `document.open`: every readable format into a new document.
use std::borrow::Cow;
use std::io::{Cursor, Read as _};
use std::io::Cursor;
use std::sync::Arc;
use serde_json::{Value, json};
@@ -45,7 +44,7 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if vectorcraft_format::sniff(bytes) {
return format("vectorcraft");
}
if bytes.starts_with(&[0x1f, 0x8b]) {
if vectorcraft_svg::is_svgz(bytes) {
return format("svgz");
}
if bytes.starts_with(b"%PDF") {
@@ -74,13 +73,12 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
let mut doc = match format.id {
"vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?,
"svg" | "svgz" => {
let text: Cow<[u8]> = if format.id == "svgz" { Cow::Owned(gunzip(bytes)?) } else { Cow::Borrowed(bytes) };
let text = std::str::from_utf8(&text).map_err(|_| err("SVG is not UTF-8"))?;
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
// An SVG saved with Preserve Editing carries the native document: open that.
match vectorcraft_svg::editing_data(text).and_then(|b64| vectorcraft_format::base64_decode(&b64)) {
match vectorcraft_svg::editing_data(&text).and_then(|b64| vectorcraft_format::base64_decode(&b64)) {
Some(native) => vectorcraft_format::load(&native).map_err(err)?,
None => {
let (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?;
let (d, w) = vectorcraft_svg::import_with_report(&text).map_err(err)?;
warnings = w;
d
}
@@ -165,9 +163,3 @@ fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
d.insert(Some(layer), 0, n).map_err(err)?;
Ok(d)
}
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?;
Ok(out)
}
+15 -6
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|svg (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}} (default path: the document's) → {path, linked?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with. A never-saved document without path → {dataBase64} (stays modified)",
"{path?, format?: vectorcraft|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}} (default path: the document's) → {path, linked?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with. A never-saved document without path → {dataBase64} (stays modified)",
has_doc,
save::save
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|svg|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
has_selection,
export::export_selection
),
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; svg/pdf drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
has_doc,
export::export_for_screens
),
@@ -194,7 +194,16 @@ pub const FORMATS: &[Format] = &[
options: &[],
},
Format { id: "svg", label: "SVG", extensions: &["svg"], mime: "image/svg+xml", read: true, write: true, raster: false, options: svg::OPTIONS },
reader("svgz", "SVG Compressed", &["svgz"], "image/svg+xml", false),
Format {
id: "svgz",
label: "SVG Compressed",
extensions: &["svgz"],
mime: "image/svg+xml",
read: true,
write: true,
raster: false,
options: svg::OPTIONS,
},
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS },
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
+3 -3
View File
@@ -7,7 +7,7 @@ use super::{Encoded, Format, default_name, encode_all, format_for_name, writable
use crate::DocState;
/// The format Save writes: `format` (an id or extension), else the format the path's extension
/// names, else native. Save writes .vectorcraft and SVG; other formats are exports.
/// names, else native. Save writes .vectorcraft, SVG and SVGZ; other formats are exports.
pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
let f = match (format, path.and_then(format_for_name)) {
(Some(_), _) => writable_format(format, None)?,
@@ -15,8 +15,8 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
(None, None) => super::format("vectorcraft").ok_or("no native format")?,
};
match f.id {
"vectorcraft" | "svg" => Ok(f),
_ => Err(format!("Save writes .{} or .svg files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)),
"vectorcraft" | "svg" | "svgz" => Ok(f),
_ => Err(format!("Save writes .{}, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)),
}
}
+4 -4
View File
@@ -111,9 +111,9 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>)
Ok((opts, picked.into_iter().map(Some).collect()))
}
/// Encode `doc` as SVG: one file per chosen artboard, with the images they link to and the
/// writer's warnings.
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded, String> {
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
/// link to and the writer's warnings.
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
let (opts, boards) = plan(doc, p)?;
// Preserve editing embeds the native document (once, shared by every file).
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
@@ -130,7 +130,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded, String> {
enc.warnings.push(w);
}
}
enc.files.push((artboard, out.svg.into_bytes()));
enc.files.push((artboard, if compressed { vectorcraft_svg::compress(&out.svg) } else { out.svg.into_bytes() }));
}
Ok(enc)
}
+3 -5
View File
@@ -1,4 +1,4 @@
use std::io::{Cursor, Write as _};
use std::io::Cursor;
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
@@ -55,9 +55,7 @@ fn opens_every_readable_format() {
let native = b64(&s.execute("document.serialize", &json!({})).unwrap());
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(svg.as_bytes()).unwrap();
let svgz = gz.finish().unwrap();
let svgz = vectorcraft_svg::compress(&svg);
for (name, bytes, format) in [
("a.vectorcraft", native.clone(), "vectorcraft"),
@@ -156,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
assert_eq!(ids("writable"), ["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]);
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]);
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert_eq!(png["options"]["scale"]["default"], 1);
+46
View File
@@ -192,3 +192,49 @@ fn fewer_tspans_reaches_the_writer() {
assert_eq!(placed(&svg(&mut s, json!({}))), 3, "a tspan per style run");
assert_eq!(placed(&svg(&mut s, json!({"fewerTspans": true}))), 1, "a tspan per line");
}
/// Gunzip SVGZ bytes.
fn gunzip(bytes: &[u8]) -> String {
assert!(vectorcraft_svg::is_svgz(bytes), "gzip magic");
vectorcraft_svg::text_of(bytes).unwrap().into_owned()
}
#[test]
fn svgz_is_the_svg_export_gzipped() {
let mut s = session(2);
let opts = json!({"styling": "css", "range": "2"});
let mut p = opts.clone();
p["format"] = json!("svgz");
let svgz = vectorcraft_format::base64_decode(s.execute("document.serialize", &p).unwrap()["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(gunzip(&svgz), svg(&mut s, opts));
// The format comes from the extension too, and Save writes it.
let dir = tmp_dir("svgz");
let path = dir.join("art.svgz").to_string_lossy().to_string();
s.execute("document.export", &json!({"path": path, "range": "all"})).unwrap();
assert!(gunzip(&std::fs::read(dir.join("art-Artboard-1.svgz")).unwrap()).contains("<svg"));
let saved = dir.join("saved.svgz").to_string_lossy().to_string();
s.execute("document.save", &json!({"path": saved, "svg": {}})).unwrap();
assert!(gunzip(&std::fs::read(&saved).unwrap()).contains("<svg"));
assert_eq!(s.doc().unwrap().path.as_deref(), Some(saved.as_str()));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn svgz_opens_and_pastes_like_svg() {
let mut s = session(1);
s.execute("shape.ellipse", &json!({"x": 100, "y": 20, "width": 40, "height": 30})).unwrap();
let text = svg(&mut s, json!({}));
let open = |s: &mut Session, name: &str, bytes: &[u8]| {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
let mut doc = (*s.doc().unwrap().doc).clone();
doc.title.clear();
vectorcraft_format::save(&doc, false)
};
let plain = open(&mut s, "a.svg", text.as_bytes());
// Detected by the magic bytes whatever the name says.
assert_eq!(open(&mut s, "a.svg", &vectorcraft_svg::compress(&text)), plain);
assert_eq!(open(&mut s, "a.svgz", &vectorcraft_svg::compress(&text)), plain);
let r = s.execute("clipboard.importSvg", &json!({"dataBase64": vectorcraft_format::base64_encode(&vectorcraft_svg::compress(&text))})).unwrap();
assert_eq!(r["count"], 2);
assert!(s.execute("document.open", &json!({"name": "bad.svgz", "dataBase64": vectorcraft_format::base64_encode(&[0x1f, 0x8b, 1, 2])})).is_err());
}