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-04 18:31:41 +07:00
parent 2df7edd8c9
commit 4a3859c69b
27 changed files with 1300 additions and 150 deletions

1
Cargo.lock generated
View File

@ -4257,6 +4257,7 @@ dependencies = [
"kurbo 0.13.1",
"proptest",
"resvg",
"serde",
"serde_json",
"svgtypes 0.16.1",
"thiserror 2.0.21",

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() },
))
}
}

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))
}

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()) {

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)?;

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;

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.

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)
}

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}");
}
}

View File

@ -111,6 +111,9 @@ pub struct DocState {
pub mask_view: Option<NodeId>,
/// View → Show Transparency Grid, per document (view state: not saved, not undoable).
pub transparency_grid: bool,
/// The SVG options this document was last saved with as SVG (Save reuses them; JSON as in
/// `document.save {svg}`, null when none).
pub save_options: Value,
}
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@ -134,6 +137,7 @@ impl DocState {
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
mask_view: None,
transparency_grid: false,
save_options: Value::Null,
}
}
/// Unsaved changes: the document differs from the saved one (selection changes don't count).

View File

@ -35,3 +35,5 @@ mod tests_fileio;
mod tests_freeform;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_svg;

View File

@ -0,0 +1,25 @@
//! SVG Options through the MCP `export` tool: format options pass through to the engine.
use serde_json::{Value, json};
use crate::{Backend, Headless, call_tool};
fn svg_of(h: &mut Headless, args: Value) -> String {
let r = call_tool(h, "export", &args);
assert!(!r.is_error, "{r:?}");
let v: Value = serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap();
String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
}
#[test]
fn export_tool_takes_svg_options() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50}})).unwrap();
h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap();
let plain = svg_of(&mut h, json!({"format": "svg"}));
assert!(!plain.contains("<style>"));
assert!(svg_of(&mut h, json!({"format": "svg", "options": {"styling": "css"}})).contains("<style>"));
assert!(svg_of(&mut h, json!({"format": "svg", "options": {"svg": {"styling": "css", "responsive": true}}})).contains("<style>"));
let r = call_tool(&mut h, "export", &json!({"format": "svg", "options": {"svg": {"styling": "fancy"}}}));
assert!(r.is_error, "junk options are rejected");
}

View File

@ -18,6 +18,7 @@ vectorcraft-text = { workspace = true }
kurbo = { workspace = true }
usvg = { workspace = true }
thiserror = { workspace = true }
serde = { 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"

View File

@ -10,7 +10,7 @@ use vectorcraft_doc::{CharStyle, Justify};
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape};
use crate::{EDITING_NS, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output, Styling, base64_encode, fmt_num, fnv1a, xml_escape};
type Props = Vec<(&'static str, String)>;
@ -22,10 +22,21 @@ struct AreaLine<'t> {
hyphenated: bool,
}
pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<String>) {
pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output {
// Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result.
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
if opts.object_ids != ObjectIds::Unique {
return write(doc, opts, native, "");
}
// Unique ids: every id and class name gets a prefix hashed from the output written without
// one, so the same document always gets the same ids and different ones never share any.
let plain = write(doc, opts, native, "");
let prefix = format!("u{:010x}-", fnv1a(plain.svg.as_bytes()) & 0xff_ffff_ffff);
write(doc, opts, native, &prefix)
}
fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: &str) -> Output {
let rect = opts
.artboard
.and_then(|i| doc.artboards.get(i))
@ -50,6 +61,8 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<Strin
anonymous: false,
knockout_filter: None,
warnings: vec![],
id_prefix,
linked: Vec::new(),
};
w.assign_name_ids();
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
@ -70,20 +83,28 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<Strin
if !opts.minify {
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
}
if opts.styling == Styling::StyleEntities && !w.classes.is_empty() {
out.push_str("<!DOCTYPE svg [");
for (i, c) in w.classes.iter().enumerate() {
out.push_str(&format!("{nl}{}<!ENTITY st{} \"{}\">", w.indent(1), i + 1, entity_value(c)));
}
out.push_str(&format!("{nl}]>{nl}"));
}
let (ww, hh) = (w.num(rect.width()), w.num(rect.height()));
out.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"");
if !opts.responsive {
out.push_str(&format!(" width=\"{ww}\" height=\"{hh}\""));
}
out.push_str(&format!(" viewBox=\"0 0 {ww} {hh}\">{nl}"));
if !w.defs.is_empty() || !w.classes.is_empty() {
let css = opts.styling == Styling::InternalCss && !w.classes.is_empty();
if !w.defs.is_empty() || css {
out.push_str(&w.indent(1));
out.push_str(&format!("<defs>{nl}"));
if !w.classes.is_empty() {
if css {
out.push_str(&w.indent(2));
out.push_str("<style>");
for (i, c) in w.classes.iter().enumerate() {
out.push_str(&format!("{nl}{}.cls-{}{{{}}}", w.indent(3), i + 1, xml_escape(c)));
out.push_str(&format!("{nl}{}.{id_prefix}cls-{}{{{}}}", w.indent(3), i + 1, xml_escape(c)));
}
out.push_str(&format!("{nl}{}</style>{nl}", w.indent(2)));
}
@ -95,10 +116,37 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<Strin
out.push_str(&w.indent(1));
out.push_str(&format!("<title>{}</title>{nl}", xml_escape(&doc.title)));
}
w.metadata(&mut out, native);
out.push_str(&w.body);
out.push_str("</svg>");
out.push_str(nl);
(out, w.warnings)
Output { svg: out, linked: w.linked, warnings: w.warnings }
}
/// A CSS declaration block as the literal value of an `<!ENTITY>`: quotes, `%` and markup
/// characters become references that still read as themselves inside the `style` attribute.
fn entity_value(decl: &str) -> String {
let mut o = String::with_capacity(decl.len());
for c in decl.chars() {
match c {
'"' => o.push_str("&#34;"),
'%' => o.push_str("&#37;"),
// Doubly escaped: the entity's replacement text is parsed again where it is used.
'&' => o.push_str("&#38;#38;"),
'<' => o.push_str("&#38;#60;"),
_ => o.push(c),
}
}
o
}
/// The usual file extension of an image MIME type.
fn image_ext(mime: &str) -> &str {
match mime {
"image/jpeg" => "jpg",
"image/svg+xml" => "svg",
m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"),
}
}
struct Writer<'a> {
@ -126,6 +174,10 @@ struct Writer<'a> {
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Features written approximately (once each).
warnings: Vec<String>,
/// Prepended to every id and class name ([`ObjectIds::Unique`]).
id_prefix: &'a str,
/// Image files the SVG links to ([`ImageMode::Link`]).
linked: Vec<LinkedImage>,
}
/// How one stroke is written.
@ -200,7 +252,7 @@ impl Writer<'_> {
fn fresh_id(&mut self, prefix: &str) -> String {
let mut i = 1;
loop {
let id = format!("{prefix}-{i}");
let id = format!("{}{prefix}-{i}", self.id_prefix);
if self.used_ids.insert(id.clone()) {
return id;
}
@ -209,13 +261,13 @@ impl Writer<'_> {
}
/// Reserve ids for every named object up front so generated def ids never collide with them.
fn assign_name_ids(&mut self) {
if !self.opts.object_ids {
if self.opts.object_ids == ObjectIds::Minimal {
return;
}
let mut named: Vec<(NodeId, String)> = Vec::new();
self.doc.walk(|n| {
if let Some(name) = &n.name {
named.push((n.id, sanitize_id(name)));
named.push((n.id, format!("{}{}", self.id_prefix, sanitize_id(name))));
}
});
for (id, base) in named {
@ -257,20 +309,55 @@ impl Writer<'_> {
s
}
Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css(&props.iter().collect::<Vec<_>>()))),
Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css(&props.iter().collect::<Vec<_>>()))),
Styling::InternalCss => {
let decl = css(&props.iter().collect::<Vec<_>>());
let i = match self.classes.iter().position(|c| *c == decl) {
Some(i) => i,
None => {
self.classes.push(decl);
self.classes.len() - 1
}
};
format!(" class=\"cls-{}\"", i + 1)
let i = self.class(css(&props.iter().collect::<Vec<_>>()));
format!(" class=\"{}cls-{i}\"", self.id_prefix)
}
}
}
/// The 1-based number of a distinct declaration block (a CSS class or a style entity).
fn class(&mut self, decl: String) -> usize {
match self.classes.iter().position(|c| *c == decl) {
Some(i) => i + 1,
None => {
self.classes.push(decl);
self.classes.len()
}
}
}
/// `<metadata>`: the Dublin Core title and format ([`ExportOptions::metadata`]) and the native
/// document ([`ExportOptions::preserve_editing`]).
fn metadata(&self, out: &mut String, native: Option<&[u8]>) {
let native = native.filter(|_| self.opts.preserve_editing);
if !self.opts.metadata && native.is_none() {
return;
}
let nl = if self.opts.minify { "" } else { "\n" };
let mut line = |depth: usize, s: &str| {
out.push_str(&self.indent(depth));
out.push_str(s);
out.push_str(nl);
};
line(1, "<metadata>");
if self.opts.metadata {
line(2, "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
line(3, "<rdf:Description rdf:about=\"\">");
line(4, "<dc:format>image/svg+xml</dc:format>");
if !self.doc.title.is_empty() {
line(4, &format!("<dc:title>{}</dc:title>", xml_escape(&self.doc.title)));
}
line(3, "</rdf:Description>");
line(2, "</rdf:RDF>");
}
if let Some(bytes) = native {
line(2, &format!("<vectorcraft:document xmlns:vectorcraft=\"{EDITING_NS}\">{}</vectorcraft:document>", base64_encode(bytes)));
}
line(1, "</metadata>");
}
fn node_props(n: &Node) -> Props {
let mut p = Props::new();
if n.opacity < 1.0 {
@ -912,8 +999,18 @@ impl Writer<'_> {
}
NodeKind::Text(t) => self.text_node(n, t),
NodeKind::Image(im) => {
let href = match self.doc.images.get(&im.key) {
Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
let link = self.opts.images == ImageMode::Link;
let href = match (im.link.as_ref().filter(|_| link), self.doc.images.get(&im.key)) {
(Some(l), _) => l.clone(),
(None, Some(b)) if !b.bytes.is_empty() && link => {
// Named after the bytes: image keys ("raster-1"…) repeat across documents.
let name = format!("{}.{}", b.content_key(), image_ext(&b.mime));
if !self.linked.iter().any(|l| l.name == name) {
self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() });
}
name
}
(None, Some(b)) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
_ => match &im.link {
Some(l) => l.clone(),
None => return,

View File

@ -12,7 +12,7 @@ use vectorcraft_doc::{
};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes};
use crate::SvgError;
use crate::{SvgError, fnv1a};
use css::XNode;
use text::TextSlots;
@ -257,15 +257,6 @@ fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath {
bp
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
fn rule(r: usvg::FillRule) -> FillRule {
match r {
usvg::FillRule::NonZero => FillRule::NonZero,

View File

@ -1,8 +1,9 @@
//! VectorCraft SVG import and export.
//!
//! * [`export`] writes a [`Document`] as SVG 1.1 with our own writer (presentation attributes,
//! inline styles or internal CSS classes; gradients in `<defs>` with `userSpaceOnUse`; clip groups as
//! `<clipPath>`; embedded images as `data:` URIs; text as `<text>`/`<tspan>`).
//! * [`export`] / [`export_full`] write a [`Document`] as SVG 1.1 with our own writer, as set by
//! [`ExportOptions`] (presentation attributes, inline styles, style entities or internal CSS
//! classes; gradients in `<defs>` with `userSpaceOnUse`; clip groups as `<clipPath>`; images
//! embedded as `data:` URIs or linked; text as `<text>`/`<tspan>` or outlines).
//! * [`import`] / [`import_with_report`] parse SVG with `usvg` and convert its normalized tree into
//! document nodes with all transforms baked into the geometry.
//!
@ -54,37 +55,85 @@
mod export;
mod import;
use serde::{Deserialize, Serialize};
pub use vectorcraft_doc::Document;
pub use vectorcraft_doc::TextObject;
/// How style properties are written (the "Styling" export option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// How style properties are written (SVG Options → Styling).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Styling {
/// `fill="#ff0000"` attributes.
#[default]
#[serde(rename = "presentation")]
PresentationAttributes,
/// `style="fill:#ff0000"`.
#[serde(rename = "style")]
InlineStyle,
/// `style="&st1;"`, each distinct declaration block an XML entity declared in the DOCTYPE.
#[serde(rename = "entities")]
StyleEntities,
/// `class="cls-1"` with a `<style>` element in `<defs>`.
#[serde(rename = "css")]
InternalCss,
}
/// SVG export options (Export As → SVG).
#[derive(Clone, Debug, PartialEq)]
/// Which `id` attributes objects get (SVG Options → Object IDs). Definitions other elements
/// reference (gradients, clip paths, masks, patterns, filters) always have ids.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ObjectIds {
/// Readable ids from layer and object names.
#[default]
LayerNames,
/// Only the ids that are referenced.
Minimal,
/// Every id carries a prefix derived from the content, so SVGs inlined in one page never
/// share an id.
Unique,
}
/// How raster images are written (SVG Options → Images).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ImageMode {
/// `data:` URIs inside the SVG.
#[default]
Embed,
/// `href` to an image file: a linked image's own file, an embedded one written next to the
/// SVG (see [`Output::linked`]).
Link,
}
/// SVG export options (SVG Options). Deserializes from camelCase JSON with every field optional;
/// unknown fields are rejected.
///
/// These defaults are the library's and the engine's (`document.export` without options):
/// presentation attributes and a fixed size, the most portable output. The SVG Options dialog
/// starts from Internal CSS and Responsive instead, like the reference app's Export As.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct ExportOptions {
/// Artboard index to export (sets the viewBox); `None` = the bounds of all art.
/// Artboard index to export (sets the viewBox); `None` = the bounds of all art. Callers pick
/// it (the engine's `artboard`/`range`/`useArtboards` params), so it is not deserialized.
#[serde(skip)]
pub artboard: Option<usize>,
pub styling: Styling,
/// Decimal places for coordinates (1–7 in the dialog; default 3).
pub decimals: u8,
/// Write `id` attributes from layer and object names.
pub object_ids: bool,
pub object_ids: ObjectIds,
pub images: ImageMode,
/// No indentation or newlines, no XML declaration.
pub minify: bool,
/// Omit `width`/`height` so the SVG scales to its container.
pub responsive: bool,
/// Fonts → Convert to Outlines: text becomes paths (portable, no font needed to view it).
pub outline_text: bool,
/// Embed the native document (passed to [`export_full`]) in `<metadata>` so VectorCraft
/// reopens the SVG with nothing lost (see [`editing_data`]).
pub preserve_editing: bool,
/// Write `<metadata>` with the document's Dublin Core title and format.
pub metadata: bool,
}
impl Default for ExportOptions {
@ -93,28 +142,86 @@ impl Default for ExportOptions {
artboard: Some(0),
styling: Styling::PresentationAttributes,
decimals: 3,
object_ids: true,
object_ids: ObjectIds::LayerNames,
images: ImageMode::Embed,
minify: false,
responsive: false,
outline_text: false,
preserve_editing: false,
metadata: false,
}
}
}
/// Decimal places the options accept.
pub const DECIMALS: std::ops::RangeInclusive<u8> = 1..=7;
impl ExportOptions {
/// Values outside what the options allow (the decimals range).
pub fn check(&self) -> Result<(), String> {
if DECIMALS.contains(&self.decimals) {
Ok(())
} else {
Err(format!("decimals must be {}–{} (got {})", DECIMALS.start(), DECIMALS.end(), self.decimals))
}
}
}
/// An export: the SVG text and the image files it links to.
#[derive(Clone, Debug, Default)]
pub struct Output {
pub svg: String,
/// [`ImageMode::Link`]: embedded images written as files next to the SVG, named after their
/// content (`img….png`), so exports of different documents into one folder never clash.
pub linked: Vec<LinkedImage>,
/// Features the export approximates.
pub warnings: Vec<String>,
}
/// An image file an exported SVG links to by `name`.
#[derive(Clone, Debug)]
pub struct LinkedImage {
pub name: String,
pub bytes: std::sync::Arc<Vec<u8>>,
}
#[derive(Debug, thiserror::Error)]
pub enum SvgError {
#[error("SVG parse error: {0}")]
Parse(String),
}
/// Export a document as an SVG string.
/// Export a document as an SVG string (linked images are not returned: see [`export_full`]).
pub fn export(doc: &Document, opts: &ExportOptions) -> String {
export::export(doc, opts).0
export_full(doc, opts, None).svg
}
/// Export a document as an SVG string, also returning warnings about approximated features.
pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec<String>) {
export::export(doc, opts)
let o = export_full(doc, opts, None);
(o.svg, o.warnings)
}
/// Export a document as SVG plus the image files it links to. `native`: the document in the
/// native format, embedded when [`ExportOptions::preserve_editing`] is on.
pub fn export_full(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output {
export::export(doc, opts, native)
}
/// The XML namespace of the editing data [`ExportOptions::preserve_editing`] embeds.
pub const EDITING_NS: &str = "urn:vectorcraft:editing";
/// The native document an SVG carries (base64, as written with
/// [`ExportOptions::preserve_editing`]), if any.
pub fn editing_data(svg: &str) -> Option<String> {
if !svg.contains(EDITING_NS) {
return None;
}
let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let xml = usvg::roxmltree::Document::parse_with_options(svg, opts).ok()?;
let el = xml.descendants().find(|n| n.tag_name().namespace() == Some(EDITING_NS) && n.tag_name().name() == "document")?;
let data: String = el.children().filter_map(|c| c.text()).flat_map(|t| t.chars().filter(|c| !c.is_whitespace())).collect();
Some(data).filter(|d| !d.is_empty())
}
/// Import an SVG document.
@ -127,6 +234,11 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
import::import(svg)
}
/// FNV-1a: a stable content hash (image keys, unique id prefixes).
pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf29ce484222325, |h, b| (h ^ *b as u64).wrapping_mul(0x100000001b3))
}
/// Standard base64 (RFC 4648, with padding).
pub fn base64_encode(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

View File

@ -7,7 +7,7 @@ use proptest::prelude::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{PathData, Rect, shapes};
use vectorcraft_svg::{ExportOptions, Styling, export, import};
use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import};
use vectorcraft_testkit::fixtures::{self, DocBuilder, art_nodes};
use vectorcraft_testkit::invariants::check_document;
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
@ -15,9 +15,11 @@ use vectorcraft_testkit::strategies::{arb_closed_shape, arb_ops, arb_path_data};
fn all_options() -> Vec<ExportOptions> {
let mut v = vec![];
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {
for (minify, decimals, object_ids, responsive) in [(false, 3, true, false), (true, 1, false, true), (false, 7, true, true)] {
v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive, outline_text: false });
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
for (minify, decimals, object_ids, responsive) in
[(false, 3, ObjectIds::LayerNames, false), (true, 1, ObjectIds::Minimal, true), (false, 7, ObjectIds::Unique, true)]
{
v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive, ..Default::default() });
}
}
v.push(ExportOptions { artboard: None, ..Default::default() });

View File

@ -8,7 +8,7 @@ use vectorcraft_doc::{
StrokeLayer, TextKind, TextObject,
};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, Styling, export, import, import_with_report};
use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import, import_with_report};
fn doc_with(nodes: Vec<Node>) -> Document {
let mut d = Document::new(200.0, 200.0);
@ -400,7 +400,7 @@ fn text_on_path_roundtrip() {
#[test]
fn styling_modes_roundtrip() {
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
let mut d = Document::new(200.0, 200.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
let b = rect_node(&mut d, Rect::new(20.0, 0.0, 30.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
@ -412,6 +412,10 @@ fn styling_modes_roundtrip() {
match styling {
Styling::PresentationAttributes => assert!(s.contains("fill=\"#abcdef\"")),
Styling::InlineStyle => assert!(s.contains("style=\"fill:#abcdef")),
Styling::StyleEntities => {
assert!(s.contains("<!DOCTYPE svg [") && s.contains("<!ENTITY st1 \"fill:#abcdef") && s.contains("style=\"&st1;\""), "{s}");
assert_eq!(s.matches("<!ENTITY").count(), 2, "{s}");
}
Styling::InternalCss => {
assert!(s.contains("<style>") && s.contains("class=\"cls-1\""));
assert_eq!(s.matches(".cls-").count(), 2, "{s}");
@ -445,7 +449,7 @@ fn options_decimals_minify_responsive_artboard() {
// All art bounds: the rectangle and half its 1 pt stroke (right-angle miters stay inside).
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
assert!(s.contains("viewBox=\"0 0 40.877 31\""), "{s}");
let s = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
let s = export(&d, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
assert!(!s.contains("id=\"Layer_1\""));
}
@ -760,8 +764,8 @@ fn full_document_roundtrip_is_stable() {
let d = doc_with(vec![g]);
let s1 = export(&d, &ExportOptions::default());
let r = import(&s1).unwrap();
let s2 = export(&r, &ExportOptions { object_ids: false, ..Default::default() });
let s1b = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
let s2 = export(&r, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
let s1b = export(&d, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
let strip = |s: &str| s.lines().filter(|l| !l.contains("<title>")).collect::<Vec<_>>().join("\n");
assert_eq!(strip(&s1b), strip(&s2));
let _ = PathData::default();

View File

@ -15,7 +15,7 @@
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
//! - `app.open {path}` (any readable format) / `app.save {path?}` / `app.quit`
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
@ -302,7 +302,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
}
}
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.save" => wrap(app.run("file.save", if p.is_object() { p.clone() } else { json!({}) })),
"app.export" => match s("path") {
Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p).map(|p| json!({"path": p}))),
// No save dialog for an agent: the bytes come back, as in headless mode.

View File

@ -36,6 +36,7 @@ pub mod save_style_library;
pub mod save_swatch_library;
mod shapes;
pub mod spot_colors;
pub(crate) mod svg_options;
pub mod swatch_options;
pub mod tile_edge_color;
mod tools;
@ -174,6 +175,7 @@ registry! {
TransformEach: [transform_each::KIND] => transform_each::SPEC,
WidthPoint: [width_point::KIND] => width_point::SPEC,
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).

View File

@ -0,0 +1,371 @@
//! SVG Options (Export As SVG, Save As / Save a Copy to .svg): styling, fonts, images, object ids,
//! decimals, minify, responsive, artboards (export) or editing data (save), and a read-only view
//! of the code. OK remembers the choices for next time and runs the export or save command with
//! them as `svg: {…}`.
use std::sync::Arc;
use serde_json::{Map, Value, json};
use super::DialogSpec;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "SVG Options".into(), body, confirm, ok: Some("OK"), min_width: 380.0, max_width: Some(560.0), ..DialogSpec::FORM };
/// `Dialog::kind` of this dialog.
pub const KIND: &str = "svgOptions";
/// What OK does: Export As SVG, Save As or Save a Copy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Export,
Save,
SaveCopy,
}
impl Mode {
fn id(self) -> &'static str {
match self {
Mode::Export => "export",
Mode::Save => "save",
Mode::SaveCopy => "copy",
}
}
fn of(d: &Dialog) -> Self {
match d.str("mode").as_str() {
"save" => Mode::Save,
"copy" => Mode::SaveCopy,
_ => Mode::Export,
}
}
}
/// (option value, label) for each choice list.
const STYLING: [(&str, &str); 4] = [
("css", "Internal CSS"),
("presentation", "Presentation Attributes"),
("style", "Style Attributes"),
("entities", "Style Attributes (Entity References)"),
];
const FONTS: [(bool, &str); 2] = [(false, "SVG"), (true, "Convert To Outlines")];
const IMAGES: [(&str, &str); 2] = [("embed", "Embed"), ("link", "Link")];
const OBJECT_IDS: [(&str, &str); 3] = [("layerNames", "Layer Names"), ("minimal", "Minimal"), ("unique", "Unique")];
/// Dialog fields that are not SVG options.
const UI_KEYS: [&str; 5] = ["mode", "path", "showCode", "allArtboards", "range"];
/// The options a first SVG Options dialog starts from: the engine's defaults with Internal CSS
/// and Responsive on (as the reference app's Export As starts).
fn first_use() -> Map<String, Value> {
let mut m = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).ok().and_then(|v| v.as_object().cloned()).unwrap_or_default();
m.insert("styling".into(), json!("css"));
m.insert("responsive".into(), json!(true));
m.insert("useArtboards".into(), json!(true));
m
}
/// Open SVG Options for `mode`, filled with the options used last (a save starts from the ones
/// the document was last saved with). `path`: where a save goes.
pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) {
let mut fields = first_use();
let saved = app.session.active().map(|st| st.save_options.clone()).filter(|_| mode != Mode::Export);
for last in [&app.ui.svg_options].into_iter().chain(saved.as_ref()) {
if let Some(o) = last.as_object() {
fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
}
}
fields.insert("mode".into(), json!(mode.id()));
fields.insert("allArtboards".into(), json!(true));
fields.insert("range".into(), json!(""));
if let Some(p) = path {
fields.insert("path".into(), json!(p));
}
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
}
/// The SVG options the dialog's fields describe (artboards only when exporting, editing data
/// only when saving).
fn options(d: &Dialog) -> Value {
let mode = Mode::of(d);
let mut o: Map<String, Value> = d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
if mode == Mode::Export {
o.remove("preserveEditing");
if d.bool("useArtboards") {
let range = d.str("range");
let range = range.trim();
o.insert("range".into(), json!(if d.bool("allArtboards") || range.is_empty() { "all" } else { range }));
}
} else {
o.remove("useArtboards");
}
Value::Object(o)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let opts = options(d);
// Remembered for next time, without the document's artboard range.
let mut last = opts.clone();
if let Some(o) = last.as_object_mut() {
o.remove("range");
}
app.ui.svg_options = last;
app.ui.dialog = None;
let mut params = json!({ "svg": opts });
if let Some(p) = d.fields.get("path").filter(|p| p.is_string()) {
params["path"] = p.clone();
}
let id = match Mode::of(d) {
Mode::Export => "file.export.svg",
Mode::Save => "file.saveAs",
Mode::SaveCopy => "file.saveCopy",
};
app.run(id, params)
}
/// One labelled choice row: the dropdown sets `d.fields[key]` to the chosen value.
fn choice<V: Into<Value> + Copy>(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, choices: &[(V, &str)]) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
let current = d.fields.get(key).cloned().unwrap_or(Value::Null);
let shown = choices.iter().find(|(v, _)| (*v).into() == current).or(choices.first()).map_or("", |(_, l)| *l);
let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect();
if let Some((v, _)) = widgets::dropdown(ui, key, shown, &labels, 250.0).and_then(|i| choices.get(i)) {
d.fields.insert(key.into(), (*v).into());
}
ui.end_row();
}
/// A checkbox bound to `d.fields[key]`.
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: bool) {
if widgets::check(ui, label, d.bool(key), enabled) {
d.fields.insert(key.into(), json!(!d.bool(key)));
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let mode = Mode::of(d);
egui::Grid::new("svg-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
choice(ui, d, "styling", "Styling:", &STYLING);
choice(ui, d, "outlineText", "Font:", &FONTS);
choice(ui, d, "images", "Images:", &IMAGES);
choice(ui, d, "objectIds", "Object IDs:", &OBJECT_IDS);
ui.label(egui::RichText::new("Decimal:").color(t.text_dim));
let decimals = d.f64("decimals", 3.0);
if let Some(v) = widgets::spin_plain(ui, "svg-decimals", decimals, "", 0, 70.0, 1.0, 1.0, &[]) {
let (lo, hi) = (*vectorcraft_svg::DECIMALS.start() as f64, *vectorcraft_svg::DECIMALS.end() as f64);
d.fields.insert("decimals".into(), json!(v.round().clamp(lo, hi) as u8));
}
ui.end_row();
});
ui.add_space(6.0);
ui.horizontal(|ui| {
check(ui, d, "minify", "Minify", true);
ui.add_space(12.0);
check(ui, d, "responsive", "Responsive", true);
});
check(ui, d, "metadata", "Include Metadata", true);
if mode == Mode::Export {
artboards(app, ui, d);
} else {
check(ui, d, "preserveEditing", "Preserve Editing Capabilities", true);
}
ui.add_space(10.0);
let show = d.bool("showCode");
if widgets::secondary_button(ui, if show { "Hide Code" } else { "Show Code" }).clicked() {
d.fields.insert("showCode".into(), json!(!show));
}
if show {
ui.add_space(6.0);
code_view(app, ui, d);
}
false
}
/// Use Artboards: all of them or a range; off = the bounds of all art.
fn artboards(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let count = app.session.active().map_or(0, |st| st.doc.artboards.len());
ui.add_space(4.0);
check(ui, d, "useArtboards", "Use Artboards", count > 0);
let on = d.bool("useArtboards") && count > 0;
ui.add_enabled_ui(on, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
let all = d.bool("allArtboards");
if ui.radio(all, "All").clicked() {
d.fields.insert("allArtboards".into(), json!(true));
}
if ui.radio(!all, "Range:").clicked() {
d.fields.insert("allArtboards".into(), json!(false));
}
let mut range = d.str("range");
let edit = egui::TextEdit::singleline(&mut range).hint_text(format!("1-{count}")).desired_width(90.0);
if ui.add_enabled(!all, edit).changed() {
d.fields.insert("range".into(), json!(range));
}
});
});
}
/// The SVG the current options write for the first chosen artboard (read-only, selectable),
/// encoded again only when the options or the document change.
fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let mut opts = options(d);
if let Some(o) = opts.as_object_mut()
&& let Some(r) = o.remove("range")
{
// The first artboard of the range (or the first one).
let first = r.as_str().and_then(|r| r.split([',', '-', '\u{2013}']).next()).and_then(|n| n.trim().parse::<usize>().ok());
o.insert("artboard".into(), json!(first.map_or(0, |n| n.saturating_sub(1))));
}
// One cached text, replaced when the document or the options change.
let key = egui::Id::new("svg-code");
let sig = egui::Id::new((st.uid, st.revision, opts.to_string())).value();
let code: Arc<String> = match ui.ctx().data(|m| m.get_temp::<(u64, Arc<String>)>(key)).filter(|(s, _)| *s == sig) {
Some((_, c)) => c,
None => {
const MAX: usize = 200_000;
let mut text = match vectorcraft_engine::cmd::fileio::encode_all(&st.doc, "svg", &json!({ "svg": opts })) {
Ok(enc) => enc.files.into_iter().next().map(|(_, b)| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default(),
Err(e) => e.to_string(),
};
if text.len() > MAX {
let cut = (0..=MAX).rev().find(|i| text.is_char_boundary(*i)).unwrap_or(0);
text.truncate(cut);
text.push_str("\n…");
}
let c = Arc::new(text);
ui.ctx().data_mut(|m| m.insert_temp(key, (sig, c.clone())));
c
}
};
widgets::list_box(ui, |ui| {
egui::ScrollArea::both().max_height(260.0).max_width(520.0).auto_shrink([false, true]).show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut code.as_str())
.font(theme::mono(11.5))
.text_color(t.text)
.frame(egui::Frame::NONE)
.desired_width(f32::INFINITY)
.desired_rows(12),
);
});
});
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use vectorcraft_engine::Session;
use super::*;
use crate::Services;
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with two artboards and a rectangle whose save dialog answers `picked` and whose
/// writer records what it is given.
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": 120, "height": 80, "artboards": 2})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
(app, written)
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn text(w: &Written, i: usize) -> String {
String::from_utf8(w.borrow()[i].1.clone()).unwrap()
}
#[test]
fn export_as_svg_opens_the_options_and_remembers_them() {
let (mut app, written) = app("/tmp/out.svg");
app.run("file.export.svg", Value::Null).unwrap();
let d = app.ui.dialog.clone().expect("SVG Options opened");
assert_eq!(d.kind, KIND);
assert_eq!((d.str("styling"), d.bool("responsive"), d.str("mode")), ("css".into(), true, "export".into()), "first-use defaults");
frame(&mut app);
// Show Code draws the SVG the options write.
app.ui.dialog.as_mut().unwrap().fields.insert("showCode".into(), json!(true));
frame(&mut app);
assert!(app.ui.dialog.is_some());
let f = &mut app.ui.dialog.as_mut().unwrap().fields;
f.insert("decimals".into(), json!(1));
f.insert("allArtboards".into(), json!(false));
f.insert("range".into(), json!("2"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let svg = text(&written, 0);
assert_eq!(written.borrow()[0].0, "/tmp/out.svg");
assert!(svg.contains("<style>") && !svg.contains(" width=\"120\""), "{svg}");
assert_eq!(app.ui.svg_options["decimals"], 1);
assert!(app.ui.svg_options.get("range").is_none(), "the range belongs to the document");
// Next time the dialog starts from these choices.
app.run("file.export.svg", Value::Null).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("decimals", 0.0), 1.0);
}
#[test]
fn all_artboards_export_one_file_each() {
let (mut app, written) = app("/tmp/set.svg");
app.run("file.export.svg", Value::Null).unwrap();
super::super::confirm(&mut app).unwrap();
let names: Vec<String> = written.borrow().iter().map(|(p, _)| p.clone()).collect();
assert_eq!(names, ["/tmp/set-Artboard-1.svg", "/tmp/set-Artboard-2.svg"]);
}
#[test]
fn save_as_svg_asks_for_options_then_save_reuses_them() {
let (mut app, written) = app("/tmp/doc.svg");
let r = app.run("file.saveAs", Value::Null).unwrap();
assert_eq!(r["dialog"], KIND);
let d = app.ui.dialog.clone().unwrap();
assert_eq!((d.str("mode"), d.str("path")), ("save".into(), "/tmp/doc.svg".into()));
frame(&mut app);
app.ui.dialog.as_mut().unwrap().fields.insert("styling".into(), json!("style"));
super::super::confirm(&mut app).unwrap();
assert!(text(&written, 0).contains("style=\"fill:"));
let st = app.session.active().unwrap();
assert_eq!(st.path.as_deref(), Some("/tmp/doc.svg"));
assert!(!st.is_dirty());
// Save writes SVG again with the same options, no dialog.
app.run("shape.ellipse", json!({"x": 60, "y": 10, "width": 20, "height": 20})).unwrap();
app.run("file.save", Value::Null).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(written.borrow()[1].0, "/tmp/doc.svg");
assert!(text(&written, 1).contains("style=\"fill:") && text(&written, 1).matches("<path").count() == 2);
// Save a Copy (options given) leaves the document's path alone.
app.run("file.saveCopy", json!({"path": "/tmp/copy.svg", "svg": {"minify": true}})).unwrap();
assert!(!text(&written, 2).trim_end().contains('\n') && !text(&written, 2).contains("<?xml"), "minified");
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.svg"));
// A native Save As needs no options.
app.run("file.saveAs", json!({"path": "/tmp/doc.vectorcraft"})).unwrap();
assert!(vectorcraft_format::sniff(&written.borrow()[3].1));
}
}

View File

@ -1,12 +1,13 @@
//! File I/O through the injected services (pick, read, write, download); the engine's `fileio`
//! decodes and encodes every format.
use serde_json::Value;
use serde_json::{Value, json};
use vectorcraft_doc::{ImageObject, Node, NodeKind};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_geom::Affine;
use crate::VectorcraftApp;
use crate::dialogs::svg_options;
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
/// graphic style library files open in the library panel and flattener presets files are imported.
@ -81,16 +82,32 @@ fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Res
}
}
/// File → Save / Save As (native format).
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> {
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<(), String> {
for (p, bytes) in enc.named(doc, path) {
write_out(app, &p, bytes)?;
}
Ok(())
}
fn format_param(p: &Value) -> Option<&str> {
p.get("format").and_then(Value::as_str)
}
/// File → Save / Save As: native, or SVG for an .svg path (with the SVG options in `params`, else
/// the ones the document was last saved with).
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool, params: &Value) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?;
let existing = if save_as { None } else { st.path.clone() };
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
let doc = app.session.active().ok_or("no document")?.doc.clone();
let bytes = fileio::encode(&doc, "vectorcraft", &Value::Null).map_err(|e| e.to_string())?;
write_out(app, &path, &bytes)?;
let f = fileio::save_format(format_param(params), Some(&path))?;
let st = app.session.active().ok_or("no document")?;
let (enc, opts) = fileio::save_encoding(st, f, params).map_err(|e| e.to_string())?;
let doc = st.doc.clone();
write_encoded(app, &doc, &path, &enc)?;
if let Some(st) = app.session.active_mut() {
st.path = Some(path.clone());
st.save_options = opts;
st.mark_saved();
}
app.status(format!("Saved {path}"));
@ -98,6 +115,27 @@ pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Re
Ok(path)
}
/// File → Save As / Save a Copy: the path (asked for when missing); an SVG path without SVG
/// options in `p` opens SVG Options, whose OK saves. A copy leaves the document's path alone.
pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result<Value, String> {
let path = target_path(app, p.get("path").and_then(Value::as_str).map(str::to_string), vectorcraft_format::EXTENSION)?;
let f = fileio::save_format(format_param(p), Some(&path))?;
let given = p.get("svg").is_some_and(|v| !v.is_null()) || !fileio::svg_options(p)?.is_empty();
if f.id != "vectorcraft" && !given {
let mode = if copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save };
svg_options::open(app, mode, Some(&path));
return Ok(json!({ "dialog": svg_options::KIND, "path": path }));
}
if !copy {
return save(app, Some(path), true, p).map(|p| json!({ "path": p }));
}
let doc = app.session.active().ok_or("no document")?.doc.clone();
let enc = fileio::encode_all(&doc, f.id, p).map_err(|e| e.to_string())?;
write_encoded(app, &doc, &path, &enc)?;
app.status(format!("Saved a copy as {path}"));
Ok(json!({ "path": path }))
}
/// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files).
pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
let r = &mut app.ui.recent_files;
@ -107,13 +145,14 @@ pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
}
/// Export the active document in `format` (default: the path's extension, else PNG) with the
/// `document.export` options in `params` (artboard, range, scale…). The document keeps its path.
/// `document.export` options in `params` (artboard, range, scale, SVG options…): every file it
/// writes (an SVG per artboard, linked images) goes next to `path`. The document keeps its path.
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<String, String> {
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])?;
let bytes = fileio::encode(&doc, f.id, params).map_err(|e| e.to_string())?;
write_out(app, &path, &bytes)?;
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}"));
Ok(path)
}
@ -204,7 +243,7 @@ pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
app.session.clipboard = nodes;
// Straight to the engine: `app.run` would let the system clipboard replace these nodes.
app.session.execute("edit.pasteInPlace", &serde_json::json!({})).map_err(|e| e.to_string())?;
app.session.execute("edit.pasteInPlace", &json!({})).map_err(|e| e.to_string())?;
app.sync_views();
return Ok(());
}

View File

@ -48,9 +48,14 @@ use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.open", "Open…", "Cmd+O", "{path?}"),
("file.save", "Save", "Cmd+S", "{path?}"),
("file.saveAs", "Save As…", "Cmd+Shift+S", "{path?}"),
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?}"),
("file.save", "Save", "Cmd+S", "{path?, svg?: {…SVG options}} (a document saved as .svg saves as SVG again, with the same options)"),
(
"file.saveAs",
"Save As…",
"Cmd+Shift+S",
"{path?, svg?: {…SVG options}} .vectorcraft, or .svg (a .svg 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"),
("file.revert", "Revert", "F12", "{}"),
("file.place", "Place…", "Cmd+Shift+P", "{path?}"),
@ -87,7 +92,7 @@ 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…", "", "{path?, artboard?, outlineText?} (document.export options)"),
("file.export.svg", "Export As SVG…", "", "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…}"),
("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", "{}"),
@ -385,8 +390,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
None => io::open_dialog(app).map(|_| Value::Null),
},
"file.save" => io::save(app, s("path"), false).map(|p| json!({"path": p})),
"file.saveAs" | "file.saveCopy" => io::save(app, s("path"), true).map(|p| json!({"path": p})),
"file.save" => io::save(app, s("path"), false, p).map(|p| json!({"path": p})),
"file.saveAs" | "file.saveCopy" => io::save_as(app, id == "file.saveCopy", p),
"document.exportSelection" => {
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
}
@ -438,6 +443,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
None => Err("cancelled".into()),
}
}
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None);
Ok(Value::Null)
}
"file.export.svg" => io::export(app, Some("svg"), s("path"), p).map(|p| json!({"path": p})),
"file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => {
let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);

View File

@ -260,6 +260,9 @@ pub struct UiState {
/// swatches) or "" (none) (`ui.colorGuideLimit`).
#[serde(default)]
pub color_guide_limit: String,
/// The SVG Options chosen last (`svg` object of the export/save commands; null: never used).
#[serde(default)]
pub svg_options: Value,
}
impl UiState {
@ -315,6 +318,7 @@ impl Default for UiState {
library_panel: None,
flattener_preview: Default::default(),
color_guide_limit: String::new(),
svg_options: Value::Null,
}
}
}

View File

@ -71,7 +71,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
let i = i.ok_or("no such document")?;
if !d.bool("discard") {
app.session.set_active(i);
io::save(app, None, false)?;
io::save(app, None, false, &Value::Null)?;
}
close_now(app, i)?;
match d.str("then").as_str() {

View File

@ -21,6 +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`, `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,

View File

@ -122,6 +122,13 @@ colour group (one undo step).
{"name":"run_command","arguments":{"command":"colorTheme.addToSwatches","params":{"name":"Harbor"}}}
```
SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}`: `styling` (`presentation`,
`style`, `entities`, `css`), `outlineText`, `images` (`embed`, or `link`: embedded images are written next to the
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) and `metadata`. 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.
## Resources
| URI | Content |