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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 07:58:10 +07:00
1 parent b75ed0877d
commit 96aba71759
20 files changed
+202 -43

No files matched your search

Generated
+1 -1
View File
@@ -4130,7 +4130,6 @@ dependencies = [
name = "vectorcraft-engine"
version = "0.1.1"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"log",
@@ -4253,6 +4252,7 @@ dependencies = [
name = "vectorcraft-svg"
version = "0.1.1"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"proptest",
-1
View File
@@ -22,7 +22,6 @@ vectorcraft-svg = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-pdf = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+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());
}
+1 -1
View File
@@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() {
let tools = tool_definitions();
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
let props = &export["inputSchema"]["properties"];
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]));
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]));
for k in ["artboard", "range", "options"] {
assert!(props.get(k).is_some(), "export takes {k}");
}
+2
View File
@@ -19,6 +19,8 @@ kurbo = { workspace = true }
usvg = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
# SVGZ (gzip, pure-Rust backend: builds for wasm).
flate2 = { workspace = true }
image = { workspace = true }
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
svgtypes = "0.16"
+35
View File
@@ -228,6 +228,41 @@ pub fn editing_data(svg: &str) -> Option<String> {
Some(data).filter(|d| !d.is_empty())
}
/// Does `bytes` start like gzip data (an SVGZ file)?
pub fn is_svgz(bytes: &[u8]) -> bool {
bytes.starts_with(&[0x1f, 0x8b])
}
/// Compress SVG text as SVGZ (gzip).
pub fn compress(svg: &str) -> Vec<u8> {
use std::io::Write as _;
let mut gz = flate2::write::GzEncoder::new(Vec::with_capacity(svg.len() / 4), flate2::Compression::default());
// Writing to a Vec cannot fail.
let _ = gz.write_all(svg.as_bytes());
gz.finish().unwrap_or_default()
}
/// Most bytes an SVGZ file may unpack to (a guard against decompression bombs).
const MAX_SVGZ: u64 = 512 << 20;
/// SVG text from SVG or SVGZ bytes.
pub fn text_of(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, SvgError> {
use std::io::Read as _;
let not_utf8 = |_| SvgError::Parse("SVG is not UTF-8".into());
if !is_svgz(bytes) {
return std::str::from_utf8(bytes).map(Into::into).map_err(not_utf8);
}
let mut raw = Vec::new();
flate2::read::GzDecoder::new(bytes)
.take(MAX_SVGZ + 1)
.read_to_end(&mut raw)
.map_err(|e| SvgError::Parse(format!("not a valid SVGZ file: {e}")))?;
if raw.len() as u64 > MAX_SVGZ {
return Err(SvgError::Parse(format!("the SVGZ file unpacks to more than {} MB", MAX_SVGZ >> 20)));
}
String::from_utf8(raw).map(Into::into).map_err(|e| not_utf8(e.utf8_error()))
}
/// Import an SVG document.
pub fn import(svg: &str) -> Result<Document, SvgError> {
import_with_report(svg).map(|(d, _)| d)
+5
View File
@@ -151,6 +151,11 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
let f = fileio::writable_format(format, path.as_deref())?;
let doc = app.session.active().ok_or("no document")?.doc.clone();
let path = target_path(app, path, f.extensions[0])?;
// An SVG given a .svgz name is written compressed.
let f = match fileio::format_for_name(&path) {
Some(z) if f.id == "svg" && z.id == "svgz" => z,
_ => f,
};
let enc = fileio::encode_all(&doc, f.id, params).map_err(|e| e.to_string())?;
write_encoded(app, &doc, &path, &enc)?;
app.status(format!("Exported {path}"));
+2
View File
@@ -41,6 +41,8 @@ mod tests_paintchips;
#[cfg(test)]
mod tests_recolor;
#[cfg(test)]
mod tests_svg;
#[cfg(test)]
mod tests_transparencygrid;
use std::sync::mpsc::{Receiver, Sender};
+7 -2
View File
@@ -53,7 +53,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.saveAs",
"Save As…",
"Cmd+Shift+S",
"{path?, svg?: {…SVG options}} .vectorcraft, or .svg (a .svg path without svg options opens SVG Options)",
"{path?, svg?: {…SVG options}} .vectorcraft, or .svg/.svgz (such a path without svg options opens SVG Options)",
),
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?, svg?: {…SVG options}} like Save As, but the document keeps its path"),
("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"),
@@ -92,7 +92,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.clearRecent", "Clear Recent Files", "", "{}"),
("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"),
("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"),
("file.export.svg", "Export As SVG…", "", "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…}"),
(
"file.export.svg",
"Export As SVG…",
"",
"{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)",
),
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1, artboard?} (document.export options)"),
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"),
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
+57
View File
@@ -0,0 +1,57 @@
//! SVGZ in the app: Place takes it, and a .svgz name (Export As SVG, Save As) writes it.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use crate::{Services, VectorcraftApp, io};
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with a rectangle whose save dialog answers `picked`; the writer records its files.
fn app(picked: &str) -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let picked = picked.to_string();
let services = Services {
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
(app, written)
}
#[test]
fn svgz_places_like_svg() {
let (mut app, _) = app("/tmp/unused.svg");
let svg = app.session.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
io::place_bytes(&mut app, "art.svgz", &vectorcraft_svg::compress(&svg)).unwrap();
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the rectangle was placed");
}
#[test]
fn a_svgz_name_writes_compressed_svg() {
let (mut app, written) = app("/tmp/art.svgz");
// Export As SVG, named .svgz in the save dialog.
app.run("file.export.svg", Value::Null).unwrap();
crate::dialogs::confirm(&mut app).unwrap();
let (path, bytes) = written.borrow()[0].clone();
assert_eq!(path, "/tmp/art.svgz");
assert!(vectorcraft_svg::text_of(&bytes).unwrap().contains("<style>"), "the SVG Options apply");
assert!(vectorcraft_svg::is_svgz(&bytes));
// Save As .svgz asks for SVG Options, then saves compressed and keeps the path.
let r = app.run("file.saveAs", Value::Null).unwrap();
assert_eq!(r["path"], "/tmp/art.svgz");
crate::dialogs::confirm(&mut app).unwrap();
assert!(vectorcraft_svg::is_svgz(&written.borrow()[1].1));
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/art.svgz"));
}
+1 -1
View File
@@ -21,7 +21,7 @@
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
+2 -1
View File
@@ -127,7 +127,8 @@ SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}
SVG, or returned as `linked`), `objectIds` (`layerNames`, `minimal`, `unique`), `decimals` (1–7), `minify`,
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`), `preserveEditing` (the SVG
reopens as the full document), `metadata` and `fewerTspans` (one `<tspan>` per line of type). Unknown keys inside `svg` are rejected; `run_command document.formats`
lists every option with its default. `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG.
lists every option with its default. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open,
place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`).
## Resources