mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
b75ed0877d
commit
96aba71759
20 files changed
+202
-43
No files matched your search
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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", "{}"),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user