M4.26: SVG Options in the engine, a dialog and MCP

document.export, serialize and save, and the Save As / Save a Copy UI
commands, take the SVG options flat or as svg: {…}: styling
(presentation, style, entities, css), outlineText, images (embed or
link; linked files are written next to the SVG), objectIds (layerNames,
minimal, unique), decimals 1–7, minify, responsive, useArtboards and
range "all" (one SVG per artboard), preserveEditing (the SVG reopens as
the full document) and metadata. Unknown keys are rejected and
document.formats lists every option with its default. Results carry the
SVG writer's warnings.

Save writes SVG to an .svg path and Save again reuses its options.
Export As SVG and Save As / Save a Copy to .svg open SVG Options (first
use: Internal CSS and Responsive, then the last-used choices) with a
read-only code view. Save a Copy no longer retargets the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 07:55:59 +07:00
co-authored by Claude Opus 5.5
parent 2df7edd8c9
commit 4a3859c69b
27 changed files with 1300 additions and 150 deletions
+4 -1
View File
@@ -46,7 +46,10 @@ impl Session {
*ch = self.clipboard.iter().cloned().map(Arc::new).collect();
}
d.layers = vec![Arc::new(layer)];
Some(vectorcraft_svg::export(&d, &vectorcraft_svg::ExportOptions { artboard: None, object_ids: false, ..Default::default() }))
Some(vectorcraft_svg::export(
&d,
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
))
}
}
+71 -22
View File
@@ -14,8 +14,8 @@ const C: &str = "document.export";
/// The params that pick artboards (the fields of [`ArtboardPick`]).
pub const ARTBOARD_PARAMS: [&str; 3] = ["artboard", "artboards", "range"];
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based) wins over `artboards`
/// (0-based), which wins over `artboard`.
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based, or `"all"`) wins over
/// `artboards` (0-based), which wins over `artboard`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ArtboardPick {
@@ -28,6 +28,7 @@ impl ArtboardPick {
/// The named artboards (`None`: none named), checked against the document's `count`.
pub fn resolve(&self, count: usize) -> std::result::Result<Option<Vec<usize>>, String> {
let v = match (&self.range, &self.artboards, self.artboard) {
(Some(r), _, _) if r.trim().eq_ignore_ascii_case("all") => (0..count).collect(),
(Some(r), _, _) => parse_range(r, count)?,
(None, Some(a), _) => a.clone(),
(None, None, Some(a)) => vec![a],
@@ -53,13 +54,58 @@ impl ArtboardPick {
}
}
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct SvgOptions {
#[serde(flatten)]
boards: ArtboardPick,
/// Text as glyph outlines (SVG Options → Fonts → Convert to Outlines).
outline_text: Option<bool>,
/// What an export writes: its files (one, or one per artboard for an SVG of several artboards)
/// and the image files an SVG links to.
#[derive(Default)]
pub struct Encoded {
/// `(artboard, bytes)`; the artboard names the file when there are several.
pub files: Vec<(Option<usize>, Vec<u8>)>,
/// Images to write next to the file(s) (SVG `images: "link"`).
pub linked: Vec<vectorcraft_svg::LinkedImage>,
/// The encoder's warnings (features approximated or left out, options not applied yet).
pub warnings: Vec<String>,
}
impl Encoded {
fn one(bytes: Vec<u8>) -> Self {
Self { files: vec![(None, bytes)], ..Self::default() }
}
/// Every file to write for the destination `path` (a path or a file name): the file itself
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images
/// beside it under their own names.
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, &'a [u8])> {
// Siblings keep the path's own separators (this runs on every platform and on the web).
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
let sibling = |name: &str| format!("{dir}{name}");
let mut out: Vec<(String, &[u8])> = match self.files.as_slice() {
[(_, bytes)] => vec![(path.to_string(), bytes.as_slice())],
files => {
let boards: Vec<usize> = files.iter().map(|(b, _)| b.unwrap_or(0)).collect();
let file = std::path::Path::new(&path[dir.len()..]);
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
super::artboard_file_names(doc, &boards)
.into_iter()
.zip(files)
.map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), bytes.as_slice()))
.collect()
}
};
out.extend(self.linked.iter().map(|l| (sibling(&l.name), l.bytes.as_slice())));
out
}
/// The one file and the warnings, when the export wrote nothing else.
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<String>)> {
if !self.linked.is_empty() {
return Err(bad(C, format!("{} with linked images writes several files: use document.export or document.save", f.label)));
}
match <[_; 1]>::try_from(self.files) {
Ok([(_, bytes)]) => Ok((bytes, self.warnings)),
Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))),
}
}
}
#[derive(Default, Deserialize)]
@@ -98,28 +144,31 @@ fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> {
}
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
/// options from `p` (see `document.formats`). Raster formats leave template layers out, and no
/// format writes the opacity-mask editing layer.
/// options from `p` (see `document.formats`) into one file. Raster formats leave template layers
/// out, and no format writes the opacity-mask editing layer. Exports that write several files (an
/// SVG per artboard, linked images) go through [`encode_all`].
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
encode_with_warnings(doc, format, p).map(|(bytes, _)| bytes)
}
/// Like [`encode`], also returning the encoder's warnings (PDF: options not applied yet, features
/// approximated or left out; the other formats have none).
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
/// features approximated or left out; the other formats have none).
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
let f = super::writable(C, Some(format), None)?;
encode_all(doc, f.id, p)?.single(f)
}
/// [`encode`], with every file the export writes.
pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
if f.id == "pdf" {
return super::pdf::encode(C, doc, p);
}
let bytes = match f.id {
"vectorcraft" => vectorcraft_format::save_file(doc),
"svg" => {
let o: SvgOptions = options(f, p)?;
let artboard = if n == 0 { None } else { Some(boards(o.boards.one(n))?) };
let opts = vectorcraft_svg::ExportOptions { artboard, outline_text: o.outline_text.unwrap_or(false), ..Default::default() };
vectorcraft_svg::export(doc, &opts).into_bytes()
"svg" => return super::svg::encode(doc, p).map_err(|e| bad(C, e)),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
}
"png" | "jpg" | "webp" => {
let o: RasterOptions = options(f, p)?;
@@ -137,5 +186,5 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(
}
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
};
Ok((bytes, vec![]))
Ok(Encoded::one(bytes))
}
+30 -24
View File
@@ -8,30 +8,49 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use super::super::*;
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, encode_with_warnings, writable, write_file, write_or_return};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, default_name, encode, encode_all, merge, writable, write_encoded,
write_file, write_or_return,
};
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let (bytes, warnings) = encode_with_warnings(&s.doc()?.doc, f.id, p)?;
if f.id == "svg" {
return Ok(json!({ "text": String::from_utf8_lossy(&bytes), "warnings": warnings }));
let doc = &s.doc()?.doc;
let enc = encode_all(doc, f.id, p)?;
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text.
let data = |bytes: &[u8]| match f.id {
"svg" => json!({ "text": String::from_utf8_lossy(bytes) }),
_ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }),
};
let mut out = merge(data(main.first().map_or(&[][..], |m| m.1)), json!({ "warnings": enc.warnings }));
if main.len() > 1 {
out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect();
}
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "warnings": warnings }))
if !linked.is_empty() {
out["linked"] = linked.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
}
Ok(out)
}
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
let f = writable("document.export", str_param(p, "format"), path)?;
let (bytes, warnings) = encode_with_warnings(&s.doc()?.doc, f.id, p)?;
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings }))
let doc = &s.doc()?.doc;
let enc = encode_all(doc, f.id, p)?;
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
}
/// `p` without its artboard choice (for documents made of one synthetic artboard).
/// `p` without its artboard choice, also inside its SVG options (for documents made of one
/// synthetic artboard, and for callers that pick the artboard themselves).
fn without_artboards(p: &Value) -> Value {
let strip = |o: &mut serde_json::Map<String, Value>| ARTBOARD_PARAMS.iter().for_each(|k| _ = o.remove(*k));
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
for k in ARTBOARD_PARAMS {
o.remove(k);
strip(o);
if let Some(svg) = o.get_mut("svg").and_then(Value::as_object_mut) {
strip(svg);
}
}
q
@@ -119,22 +138,9 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(dir) = folder {
create_dir(dir)?;
}
// Names are compared without case: `Icon` and `icon` are one file on most desktop file systems.
let mut names = HashSet::new();
let mut written = HashSet::new();
let mut files = vec![];
for b in boards {
let mut base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() {
base = format!("Artboard-{}", b + 1);
}
let mut name = base.clone();
for i in 2.. {
if names.insert(name.to_lowercase()) {
break;
}
name = format!("{base}-{i}");
}
for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
for sf in &formats {
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
if !written.insert(file.to_lowercase()) {
+9 -3
View File
@@ -76,9 +76,15 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
"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 (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?;
warnings = w;
d
// 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)) {
Some(native) => vectorcraft_format::load(&native).map_err(err)?,
None => {
let (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?;
warnings = w;
d
}
}
}
"pdf" | "ai" | "ait" => {
let r = vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(err)?;
+82 -22
View File
@@ -3,6 +3,7 @@
//!
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
//! - `save`: `document.save`, `file.saveAsTemplate`.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
@@ -16,11 +17,14 @@ mod export;
mod load;
pub mod pdf;
mod save;
mod svg;
use serde_json::{Value, json};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, encode, encode_with_warnings};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image};
pub use save::{save_encoding, save_format};
pub use svg::options_map as svg_options;
use super::*;
use crate::EngineError;
@@ -41,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?} native .vectorcraft (default: the document's path) → {path}; a never-saved document without path → {dataBase64} (stays modified)",
"{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)",
has_doc,
save::save
),
@@ -50,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)} → {text, warnings} for svg, else {dataBase64, warnings}",
"{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}]",
has_doc,
export::serialize
),
@@ -59,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\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg), …the PDF options of document.exportPdf} → {path, format, bytes, warnings}; no path → {dataBase64, format, bytes, warnings}. Never changes the document's path",
"{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} (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
),
@@ -167,12 +171,10 @@ const RANGE: FormatOption = FormatOption {
name: "range",
ty: "string",
default: "null",
description: "1-based artboards such as \"1-3, 5\" (wins over artboards and artboard)",
description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)",
};
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" };
const OUTLINE_TEXT: FormatOption =
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "text as glyph outlines (viewable without the fonts)" };
/// A format `document.open` reads but nothing writes yet.
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
@@ -191,16 +193,7 @@ pub const FORMATS: &[Format] = &[
raster: false,
options: &[],
},
Format {
id: "svg",
label: "SVG",
extensions: &["svg"],
mime: "image/svg+xml",
read: true,
write: true,
raster: false,
options: &[ARTBOARD, OUTLINE_TEXT],
},
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: "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),
@@ -322,22 +315,89 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
Err(no_fs(path))
}
/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
let mut taken = std::collections::HashSet::new();
boards
.iter()
.map(|&b| {
let name = doc.artboards.get(b).map_or("", |a| a.name.as_str());
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() {
base = format!("Artboard-{}", b + 1);
}
let mut name = base.clone();
for i in 2.. {
if taken.insert(name.to_lowercase()) {
break;
}
name = format!("{base}-{i}");
}
name
})
.collect()
}
/// Write an export's files for the destination `path` → `{path, bytes, files?, linked?, …extra}`
/// (`files`: every file when there are several; `linked`: the images it links to); with no path
/// → the same with `dataBase64` and `{name, dataBase64}` rows, named after `name`.
fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document, enc: &Encoded, extra: Value) -> Result<Value> {
let files = enc.named(doc, path.unwrap_or(name));
let (main, linked) = files.split_at(enc.files.len());
let row = |(name, bytes): &(String, &[u8])| match path {
Some(_) => json!(name),
None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }),
};
if path.is_some() {
for (p, bytes) in &files {
write_file(p, bytes)?;
}
}
let Some((first, bytes)) = main.first() else { return Err(EngineError::Other("the export wrote no file".into())) };
let mut out = match path {
Some(_) => json!({ "path": first, "bytes": bytes.len() }),
None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }),
};
if main.len() > 1 {
out["files"] = main.iter().map(row).collect();
}
if !linked.is_empty() {
out["linked"] = linked.iter().map(row).collect();
}
Ok(merge(out, extra))
}
/// `a` with the fields of `b`.
fn merge(mut a: Value, b: Value) -> Value {
if let (Some(a), Value::Object(b)) = (a.as_object_mut(), b) {
a.extend(b);
}
a
}
/// A file name for bytes handed back without a path: the document's title with `ext`.
fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
format!("{}.{ext}", stem.as_deref().unwrap_or("Untitled"))
}
/// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`.
fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
let mut out = match path {
let out = match path {
Some(path) => {
write_file(path, bytes)?;
json!({ "path": path, "bytes": bytes.len() })
}
None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }),
};
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
o.extend(e);
}
Ok(out)
Ok(merge(out, extra))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_pdf;
#[cfg(test)]
mod tests_svg;
+46 -6
View File
@@ -1,22 +1,62 @@
//! Native saves: `document.save` and `file.saveAsTemplate`.
//! Saves: `document.save` (native or SVG) and `file.saveAsTemplate`.
use serde_json::{Value, json};
use super::super::*;
use super::write_or_return;
use super::{Encoded, Format, default_name, encode_all, format_for_name, writable_format, write_encoded, write_or_return};
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.
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)?,
(None, Some(f)) => f,
(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)),
}
}
/// What Save writes for `st` in format `f` (see [`save_format`]) → the encoded file and the
/// options to remember for the next Save. An SVG save takes the SVG options in `p`, else the ones
/// the document was last saved with.
pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, Value)> {
const C: &str = "document.save";
if f.id == "vectorcraft" {
return Ok((encode_all(&st.doc, f.id, p)?, Value::Null));
}
let given = super::svg_options(p).map_err(|e| bad(C, e))?;
let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) };
let enc = encode_all(&st.doc, f.id, &json!({ "svg": opts }))?;
if enc.files.len() != 1 {
return Err(bad(C, "Save writes one artboard: name one, or export several with document.export"));
}
Ok((enc, opts))
}
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.save";
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?;
let st = s.doc()?;
let (enc, opts) = save_encoding(st, f, p)?;
let Some(path) = path else {
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
return write_or_return(None, &bytes, json!({}));
return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, json!({}));
};
super::write_file(&path, &bytes)?;
let out = write_encoded(Some(&path), &path, &st.doc, &enc, json!({}))?;
let st = s.doc_mut()?;
st.path = Some(path.clone());
st.save_options = opts;
st.mark_saved();
Ok(json!({ "path": path }))
let mut r = json!({ "path": path });
if let Some(linked) = out.get("linked") {
r["linked"] = linked.clone();
}
Ok(r)
}
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
+130
View File
@@ -0,0 +1,130 @@
//! SVG Options: the SVG settings of `document.export`, `serialize` and `save`, read from the
//! params' top level or from an `svg` object (which wins), and the files an SVG export writes
//! (one per artboard, plus the images it links to).
use serde::Deserialize;
use serde_json::{Map, Value};
use vectorcraft_doc::Document;
use vectorcraft_svg::ExportOptions;
use super::encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded};
use super::{ARTBOARD, ARTBOARDS, FormatOption, RANGE};
/// The options `document.formats` lists for SVG (the defaults are [`ExportOptions::default`]'s).
pub const OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
FormatOption {
name: "useArtboards",
ty: "boolean",
default: "true",
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each)",
},
FormatOption {
name: "styling",
ty: "string",
default: "\"presentation\"",
description: "presentation (attributes) | style (style attributes) | entities (style attributes as DOCTYPE entities) | css (internal <style> classes)",
},
FormatOption {
name: "outlineText",
ty: "boolean",
default: "false",
description: "Fonts: text as glyph outlines (viewable without the fonts) instead of <text>",
},
FormatOption {
name: "images",
ty: "string",
default: "\"embed\"",
description: "embed (data: URIs) | link (linked images keep their file; embedded ones are written next to the SVG, or returned as `linked`)",
},
FormatOption {
name: "objectIds",
ty: "string",
default: "\"layerNames\"",
description: "layerNames (readable ids from names) | minimal (only referenced ids) | unique (content-hashed prefix on every id and class)",
},
FormatOption { name: "decimals", ty: "integer", default: "3", description: "coordinate precision, 1–7 decimal places" },
FormatOption { name: "minify", ty: "boolean", default: "false", description: "no XML declaration, indentation or line breaks" },
FormatOption { name: "responsive", ty: "boolean", default: "false", description: "no width/height: the SVG scales to its container" },
FormatOption {
name: "preserveEditing",
ty: "boolean",
default: "false",
description: "embed the native document in <metadata> so VectorCraft reopens the SVG with nothing lost",
},
FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write <metadata> with the title and format (Dublin Core)" },
FormatOption {
name: "svg",
ty: "object",
default: "null",
description: "these options as one object, e.g. {styling: \"css\", decimals: 2}; it wins over top-level keys and rejects unknown ones",
},
];
/// Picks the artboards of an SVG export (beside [`ArtboardPick`]).
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Boards {
#[serde(flatten)]
pick: ArtboardPick,
use_artboards: Option<bool>,
}
/// The SVG options in `p`: its top-level option keys overlaid with its `svg` object. Empty when
/// `p` names none.
pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
let mut m = Map::new();
let Some(o) = p.as_object() else { return Ok(m) };
for opt in OPTIONS.iter().filter(|opt| opt.name != "svg") {
if let Some(v) = o.get(opt.name).filter(|v| !v.is_null()) {
m.insert(opt.name.into(), v.clone());
}
}
match o.get("svg") {
None | Some(Value::Null) => {}
Some(Value::Object(svg)) => m.extend(svg.clone()),
Some(_) => return Err("`svg` must be an object of SVG options (see document.formats)".into()),
}
Ok(m)
}
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
let mut m = options_map(p)?;
let picks: Map<String, Value> = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).filter_map(|k| m.remove_entry(*k)).collect();
let boards = Boards::deserialize(Value::Object(picks)).map_err(|e| format!("SVG options: {e}"))?;
let opts = ExportOptions::deserialize(Value::Object(m)).map_err(|e| format!("SVG options: {e}"))?;
opts.check().map_err(|e| format!("SVG options: {e}"))?;
let n = doc.artboards.len();
if n == 0 || boards.use_artboards == Some(false) {
return Ok((opts, vec![None]));
}
let picked = boards.pick.resolve(n)?.unwrap_or_else(|| vec![0]);
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> {
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));
let mut enc = Encoded::default();
for artboard in boards {
let out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
for l in out.linked {
if !enc.linked.iter().any(|e| e.name == l.name) {
enc.linked.push(l);
}
}
for w in out.warnings {
if !enc.warnings.contains(&w) {
enc.warnings.push(w);
}
}
enc.files.push((artboard, out.svg.into_bytes()));
}
Ok(enc)
}
+184
View File
@@ -0,0 +1,184 @@
//! SVG Options through the engine: every option changes the output, artboards write one file
//! each, junk is rejected, and Save writes SVG with remembered options.
use serde_json::{Value, json};
use super::*;
fn session(artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": artboards})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10.123456, "y": 10, "width": 50, "height": 40})).unwrap();
s
}
fn svg(s: &mut Session, p: Value) -> String {
let mut p = p;
p["format"] = json!("svg");
s.execute("document.serialize", &p).unwrap_or_else(|e| panic!("{p}: {e}"))["text"].as_str().unwrap().to_string()
}
fn tmp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-svgopts-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A session whose document holds one embedded 2×2 PNG.
fn image_session() -> Session {
let mut png = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 200, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({"name": "dot.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s
}
#[test]
fn every_option_changes_the_output() {
let mut s = session(1);
let plain = svg(&mut s, json!({}));
assert!(plain.contains("fill=\"#") && plain.contains(" width=\"200\"") && plain.contains("id=\"Layer_1\""), "{plain}");
let changed = |s: &mut Session, p: Value, check: &dyn Fn(&str) -> bool| {
let out = svg(s, p.clone());
assert_ne!(out, plain, "{p} changed nothing");
assert!(check(&out), "{p}:\n{out}");
};
changed(&mut s, json!({"styling": "style"}), &|o| o.contains("style=\"fill:#"));
changed(&mut s, json!({"styling": "entities"}), &|o| o.contains("<!ENTITY st1 ") && o.contains("style=\"&st1;\""));
changed(&mut s, json!({"svg": {"styling": "css"}}), &|o| o.contains("<style>") && o.contains("class=\"cls-1\""));
changed(&mut s, json!({"objectIds": "minimal"}), &|o| !o.contains(" id=\""));
changed(&mut s, json!({"objectIds": "unique"}), &|o| o.contains(" id=\"u") && o.contains("-Layer_1\""));
changed(&mut s, json!({"decimals": 1}), &|o| o.contains("M10.1 ") && !o.contains("10.123"));
changed(&mut s, json!({"decimals": 6}), &|o| o.contains("10.123456"));
changed(&mut s, json!({"minify": true}), &|o| !o.trim_end().contains('\n') && !o.contains("<?xml"));
changed(&mut s, json!({"responsive": true}), &|o| !o.contains(" width=\"200\"") && o.contains("viewBox=\"0 0 200 100\""));
changed(&mut s, json!({"useArtboards": false}), &|o| !o.contains("viewBox=\"0 0 200 100\""));
changed(&mut s, json!({"metadata": true}), &|o| o.contains("<metadata>") && o.contains("<dc:format>image/svg+xml</dc:format>"));
changed(&mut s, json!({"preserveEditing": true}), &|o| o.contains(vectorcraft_svg::EDITING_NS));
s.execute("text.create", &json!({"x": 20, "y": 80, "text": "Hi"})).unwrap();
let with_text = svg(&mut s, json!({}));
assert!(with_text.contains("<text"));
assert!(!svg(&mut s, json!({"outlineText": true})).contains("<text"), "Fonts → outlines");
// The nested object wins over the top level.
assert!(svg(&mut s, json!({"styling": "style", "svg": {"styling": "css"}})).contains("<style>"));
let mut s = image_session();
assert!(svg(&mut s, json!({})).contains("href=\"data:image/png;base64,"));
let r = s.execute("document.serialize", &json!({"format": "svg", "images": "link"})).unwrap();
let name = r["linked"][0]["name"].as_str().unwrap().to_string();
assert!(name.ends_with(".png") && r["text"].as_str().unwrap().contains(&format!("href=\"{name}\"")), "{r}");
}
#[test]
fn junk_options_are_rejected() {
let mut s = session(1);
for p in [
json!({"styling": "fancy"}),
json!({"objectIds": true}),
json!({"decimals": 0}),
json!({"decimals": 8}),
json!({"svg": {"bogus": 1}}),
json!({"svg": "css"}),
json!({"images": "inline"}),
json!({"useArtboards": "yes"}),
] {
let mut q = p.clone();
q["format"] = json!("svg");
assert!(s.execute("document.export", &q).is_err(), "{p} was accepted");
}
// Other formats ignore the SVG options.
assert!(s.execute("document.export", &json!({"format": "png", "styling": "fancy"})).is_ok());
}
#[test]
fn several_artboards_write_one_file_each() {
let mut s = session(3);
s.execute("artboard.setProps", &json!({"index": 0, "name": "Cover"})).unwrap();
let dir = tmp_dir("boards");
let path = dir.join("art.svg").to_string_lossy().to_string();
let r = s.execute("document.export", &json!({"path": path, "range": "all"})).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
let names: Vec<&str> = files.iter().map(|f| f.rsplit(['/', '\\']).next().unwrap()).collect();
assert_eq!(names, ["art-Cover.svg", "art-Artboard-2.svg", "art-Artboard-3.svg"]);
for f in &files {
assert!(std::fs::read_to_string(f).unwrap().contains("viewBox=\"0 0 200 100\""), "{f}");
}
let r = s.execute("document.export", &json!({"format": "svg", "svg": {"range": "2-3"}})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
let r = s.execute("document.serialize", &json!({"format": "svg", "range": "all"})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 3);
assert!(r["files"][2]["text"].as_str().unwrap().starts_with("<?xml"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn linked_images_are_written_next_to_the_svg() {
let mut s = image_session();
let dir = tmp_dir("linked");
let path = dir.join("pic.svg").to_string_lossy().to_string();
let r = s.execute("document.export", &json!({"path": path, "images": "link"})).unwrap();
let linked = r["linked"][0].as_str().unwrap();
assert!(image::load_from_memory(&std::fs::read(linked).unwrap()).is_ok(), "{linked}");
let text = std::fs::read_to_string(&path).unwrap();
let name = std::path::Path::new(linked).file_name().unwrap().to_string_lossy().to_string();
assert!(text.contains(&format!("href=\"{name}\"")), "{text}");
assert!(s.execute("document.exportSelection", &json!({"format": "svg", "images": "link"})).is_err(), "nowhere to put the images");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn preserve_editing_reopens_the_native_document() {
let mut s = session(2);
s.execute("artboard.setProps", &json!({"index": 1, "name": "Back"})).unwrap();
let text = svg(&mut s, json!({"preserveEditing": true}));
let r = s.execute("document.open", &json!({"name": "both.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
assert_eq!(r["format"], "svg");
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 2, "both artboards came back");
assert_eq!(doc.artboards[1].name, "Back");
assert_eq!(doc.title, "both.svg");
// Without it the SVG is imported (one artboard).
let text = svg(&mut s, json!({}));
s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
}
#[test]
fn save_writes_svg_and_remembers_its_options() {
let mut s = session(1);
let dir = tmp_dir("save");
let path = dir.join("doc.svg").to_string_lossy().to_string();
let r = s.execute("document.save", &json!({"path": path, "svg": {"styling": "css", "decimals": 2}})).unwrap();
assert_eq!(r["path"], json!(path));
assert!(std::fs::read_to_string(&path).unwrap().contains("<style>"));
let st = s.doc().unwrap();
assert_eq!(st.path.as_deref(), Some(path.as_str()));
assert!(!st.is_dirty());
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("document.save", &json!({})).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("<style>") && text.matches("<path").count() == 2, "Save reuses the options: {text}");
assert!(s.execute("document.save", &json!({"path": dir.join("x.png").to_string_lossy()})).is_err(), "PNG is an export");
// A native save switches back.
let native = dir.join("doc.vectorcraft").to_string_lossy().to_string();
s.execute("document.save", &json!({"path": native})).unwrap();
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()));
assert_eq!(s.doc().unwrap().save_options, Value::Null);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn formats_list_the_svg_options_with_their_defaults() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let svg = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "svg").unwrap();
let defaults = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).unwrap();
for (k, v) in defaults.as_object().unwrap() {
assert_eq!(&svg["options"][k]["default"], v, "{k}");
}
for k in ["useArtboards", "range", "svg"] {
assert!(svg["options"].get(k).is_some(), "{k}");
}
}