From 4a3859c69be00fa02d7ebfd11ec1ea224dadbdda Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:31:41 +0700 Subject: [PATCH] M4.26: SVG Options in the engine, a dialog and MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 1 + crates/engine/src/cmd/clipboard.rs | 5 +- crates/engine/src/cmd/fileio/encode.rs | 93 ++++-- crates/engine/src/cmd/fileio/export.rs | 54 ++-- crates/engine/src/cmd/fileio/load.rs | 12 +- crates/engine/src/cmd/fileio/mod.rs | 104 ++++-- crates/engine/src/cmd/fileio/save.rs | 52 ++- crates/engine/src/cmd/fileio/svg.rs | 130 ++++++++ crates/engine/src/cmd/fileio/tests_svg.rs | 184 +++++++++++ crates/engine/src/lib.rs | 4 + crates/mcp/src/lib.rs | 2 + crates/mcp/src/tests_svg.rs | 25 ++ crates/svg/Cargo.toml | 1 + crates/svg/src/export.rs | 137 ++++++-- crates/svg/src/import.rs | 11 +- crates/svg/src/lib.rs | 140 +++++++- crates/svg/tests/prop_svg.rs | 10 +- crates/svg/tests/roundtrip.rs | 14 +- crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/svg_options.rs | 371 ++++++++++++++++++++++ crates/ui-egui/src/io.rs | 59 +++- crates/ui-egui/src/menus.rs | 21 +- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/unsaved.rs | 2 +- docs/control-protocol.md | 1 + docs/mcp.md | 7 + 27 files changed, 1300 insertions(+), 150 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/svg.rs create mode 100644 crates/engine/src/cmd/fileio/tests_svg.rs create mode 100644 crates/mcp/src/tests_svg.rs create mode 100644 crates/ui-egui/src/dialogs/svg_options.rs diff --git a/Cargo.lock b/Cargo.lock index 9d9270f1..b1fdf719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4257,6 +4257,7 @@ dependencies = [ "kurbo 0.13.1", "proptest", "resvg", + "serde", "serde_json", "svgtypes 0.16.1", "thiserror 2.0.21", diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index e9b2903f..de8f9f05 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -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() }, + )) } } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index c36d7e03..9448f992 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -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>, 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, +/// 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, Vec)>, + /// Images to write next to the file(s) (SVG `images: "link"`). + pub linked: Vec, + /// The encoder's warnings (features approximated or left out, options not applied yet). + pub warnings: Vec, +} + +impl Encoded { + fn one(bytes: Vec) -> 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 = 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, Vec)> { + 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> { 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, Vec)> { + 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 { 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)) } diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 642f9465..5f8945a5 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -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 { 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 { 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| 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 { 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()) { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ad05470e..59266fbd 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -76,9 +76,15 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { "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)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 2a5e5044..33f180c9 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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 { "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 { "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 { "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 { + 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 { + 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 { - 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; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 8400b2c0..5e137e61 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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 { + 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. diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs new file mode 100644 index 00000000..d4703a80 --- /dev/null +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -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 {nl}", w.indent(2))); } @@ -95,10 +116,37 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec{}{nl}", xml_escape(&doc.title))); } + w.metadata(&mut out, native); out.push_str(&w.body); out.push_str(""); 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 ``: 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("""), + '%' => o.push_str("%"), + // Doubly escaped: the entity's replacement text is parsed again where it is used. + '&' => o.push_str("&#38;"), + '<' => o.push_str("&#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>, /// Features written approximately (once each). warnings: Vec, + /// Prepended to every id and class name ([`ObjectIds::Unique`]). + id_prefix: &'a str, + /// Image files the SVG links to ([`ImageMode::Link`]). + linked: Vec, } /// 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::>()))), + Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css(&props.iter().collect::>()))), Styling::InternalCss => { - let decl = css(&props.iter().collect::>()); - 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::>())); + 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() + } + } + } + + /// ``: 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, ""); + if self.opts.metadata { + line(2, ""); + line(3, ""); + line(4, "image/svg+xml"); + if !self.doc.title.is_empty() { + line(4, &format!("{}", xml_escape(&self.doc.title))); + } + line(3, ""); + line(2, ""); + } + if let Some(bytes) = native { + line(2, &format!("{}", base64_encode(bytes))); + } + line(1, ""); + } + 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, diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 7a2f1b2b..f913d7da 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -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, diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 17be8e70..da0d55cf 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -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 `` with `userSpaceOnUse`; clip groups as -//! ``; embedded images as `data:` URIs; text as ``/``). +//! * [`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 `` with `userSpaceOnUse`; clip groups as ``; images +//! embedded as `data:` URIs or linked; text as ``/`` 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 `