mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06: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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b75ed0877d
commit
96aba71759
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user