From 96aba71759751febb28bbb4d5c64f4a9689e0f14 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:57:23 +0700 Subject: [PATCH] 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 --- Cargo.lock | 2 +- crates/engine/Cargo.toml | 1 - crates/engine/src/cmd/clipboard.rs | 14 ++++-- crates/engine/src/cmd/fileio/encode.rs | 2 +- crates/engine/src/cmd/fileio/export.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 18 ++----- crates/engine/src/cmd/fileio/mod.rs | 21 ++++++--- crates/engine/src/cmd/fileio/save.rs | 6 +-- crates/engine/src/cmd/fileio/svg.rs | 8 ++-- crates/engine/src/cmd/fileio/tests.rs | 8 ++-- crates/engine/src/cmd/fileio/tests_svg.rs | 46 ++++++++++++++++++ crates/mcp/src/tests_fileio.rs | 2 +- crates/svg/Cargo.toml | 2 + crates/svg/src/lib.rs | 35 ++++++++++++++ crates/ui-egui/src/io.rs | 5 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 9 +++- crates/ui-egui/src/tests_svg.rs | 57 +++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/mcp.md | 3 +- 20 files changed, 202 insertions(+), 43 deletions(-) create mode 100644 crates/ui-egui/src/tests_svg.rs diff --git a/Cargo.lock b/Cargo.lock index b1fdf719..445484e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 06901e21..e4f0f055 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index de8f9f05..947c2b57 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "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 { } fn import_svg(s: &mut Session, p: &Value) -> Result { - 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 = 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")); diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 9448f992..f8c67488 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -165,7 +165,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { 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) }); diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 5f8945a5..200c6488 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -98,7 +98,7 @@ fn screen_format(row: &Value) -> Result { } 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). diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 59266fbd..50313a25 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -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 { 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 { d.insert(Some(layer), 0, n).map_err(err)?; Ok(d) } - -fn gunzip(bytes: &[u8]) -> Result> { - 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) -} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index e48266a5..6c5a412d 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "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 { "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 { "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 { "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 { "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), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 5e137e61..69c52bcb 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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)), } } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 7db8108c..db203277 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -111,9 +111,9 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec>) 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 { +/// 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 { 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 { 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) } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index d38a8638..e56fb656 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -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::>(); - 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); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 89a10d19..f0037b7f 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -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(" fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 6e4ebaa1..f3ab14dc 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -228,6 +228,41 @@ pub fn editing_data(svg: &str) -> Option { 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 { + 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, 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 { import_with_report(svg).map(|(d, _)| d) diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 10159565..d1d07512 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -151,6 +151,11 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 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}")); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index edcfc22a..f994844c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 64ca9dbb..9e2d0496 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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", "{}"), diff --git a/crates/ui-egui/src/tests_svg.rs b/crates/ui-egui/src/tests_svg.rs new file mode 100644 index 00000000..d4f4624f --- /dev/null +++ b/crates/ui-egui/src/tests_svg.rs @@ -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)>>>; + +/// 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("