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 `