diff --git a/Cargo.lock b/Cargo.lock index 9d9270f1..445484e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4130,7 +4130,6 @@ dependencies = [ name = "vectorcraft-engine" version = "0.1.1" dependencies = [ - "flate2", "image", "kurbo 0.13.1", "log", @@ -4253,10 +4252,12 @@ dependencies = [ name = "vectorcraft-svg" version = "0.1.1" dependencies = [ + "flate2", "image", "kurbo 0.13.1", "proptest", "resvg", + "serde", "serde_json", "svgtypes 0.16.1", "thiserror 2.0.21", diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c28ac48f..3f910872 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -117,18 +117,19 @@ impl Gradient { } /// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints). pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> { - let mut out = Vec::new(); - for (i, s) in self.stops.iter().enumerate() { - out.push((s.offset, s.color, s.opacity)); - if let Some(n) = self.stops.get(i + 1) - && (s.midpoint - 0.5).abs() > 1e-3 - { + self.expanded().map(|(t, c, o, _)| (t, c, o)).collect() + } + /// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None` + /// on the gradient's own stops). + pub fn expanded(&self) -> impl Iterator)> + '_ { + self.stops.iter().enumerate().flat_map(move |(i, s)| { + let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { let t = s.offset + (n.offset - s.offset) * s.midpoint; let (c, o) = self.sample(t); - out.push((t, c, o)); - } - } - out + (t, c, o, Some(s.midpoint)) + }); + std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) + }) } pub fn reverse(&mut self) { self.stops.reverse(); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 06901e21..e4f0f055 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,7 +22,6 @@ vectorcraft-svg = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-pdf = { workspace = true } image = { workspace = true } -flate2 = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index e9b2903f..947c2b57 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Load SVG into Clipboard", [], None, - "{svg, center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace", + "{svg | dataBase64 (SVG or SVGZ bytes), center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace", has_doc, import_svg ), @@ -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() }, + )) } } @@ -55,8 +58,16 @@ fn export_svg(s: &mut Session, _: &Value) -> Result { } fn import_svg(s: &mut Session, p: &Value) -> Result { - let svg = str_param(p, "svg").ok_or_else(|| bad("clipboard.importSvg", "missing svg"))?; - let src = vectorcraft_svg::import(svg).map_err(|e| bad("clipboard.importSvg", e.to_string()))?; + const C: &str = "clipboard.importSvg"; + let svg = match (str_param(p, "svg"), str_param(p, "dataBase64")) { + (Some(svg), _) => std::borrow::Cow::Borrowed(svg), + (None, Some(b64)) => { + let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad base64"))?; + vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, e.to_string()))?.into_owned().into() + } + (None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")), + }; + let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, e.to_string()))?; let mut nodes: Vec = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect(); if nodes.is_empty() { return Err(bad("clipboard.importSvg", "the SVG has no drawable objects")); diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index c36d7e03..f8c67488 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" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), + "pdf" => { + let (bytes, warnings) = super::pdf::encode(C, doc, p)?; + return Ok(Encoded { warnings, ..Encoded::one(bytes) }); } "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..200c6488 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 @@ -79,7 +98,7 @@ fn screen_format(row: &Value) -> Result { } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; if format.id == "vectorcraft" { - return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf")); + return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf")); } // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one // left over from a raster row switched to SVG or PDF). @@ -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..50313a25 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -1,7 +1,6 @@ //! `document.open`: every readable format into a new document. -use std::borrow::Cow; -use std::io::{Cursor, Read as _}; +use std::io::Cursor; use std::sync::Arc; use serde_json::{Value, json}; @@ -45,7 +44,7 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if vectorcraft_format::sniff(bytes) { return format("vectorcraft"); } - if bytes.starts_with(&[0x1f, 0x8b]) { + if vectorcraft_svg::is_svgz(bytes) { return format("svgz"); } if bytes.starts_with(b"%PDF") { @@ -74,11 +73,16 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { let mut doc = match format.id { "vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?, "svg" | "svgz" => { - let text: Cow<[u8]> = if format.id == "svgz" { Cow::Owned(gunzip(bytes)?) } else { Cow::Borrowed(bytes) }; - let text = std::str::from_utf8(&text).map_err(|_| err("SVG is not UTF-8"))?; - let (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?; - warnings = w; - d + let text = vectorcraft_svg::text_of(bytes).map_err(err)?; + // An SVG saved with Preserve Editing carries the native document: open that. + match vectorcraft_svg::editing_data(&text).and_then(|b64| vectorcraft_format::base64_decode(&b64)) { + 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)?; @@ -159,9 +163,3 @@ fn raster_doc(name: &str, bytes: &[u8]) -> Result { d.insert(Some(layer), 0, n).map_err(err)?; Ok(d) } - -fn gunzip(bytes: &[u8]) -> Result> { - let mut out = Vec::new(); - flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?; - Ok(out) -} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 2a5e5044..6c5a412d 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|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}} (default path: the document's) → {path, linked?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with. A never-saved document without path → {dataBase64} (stays modified)", has_doc, save::save ), @@ -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|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -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|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -68,7 +72,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})", + "{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})", has_selection, export::export_selection ), @@ -86,7 +90,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; svg/pdf drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", + "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", has_doc, export::export_for_screens ), @@ -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,17 +193,17 @@ 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: svg::OPTIONS }, Format { - id: "svg", - label: "SVG", - extensions: &["svg"], + id: "svgz", + label: "SVG Compressed", + extensions: &["svgz"], mime: "image/svg+xml", read: true, write: true, raster: false, - options: &[ARTBOARD, OUTLINE_TEXT], + 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), reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false), @@ -322,22 +324,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..69c52bcb 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, SVG and SVGZ; other formats are exports. +pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { + let f = match (format, path.and_then(format_for_name)) { + (Some(_), _) => writable_format(format, None)?, + (None, Some(f)) => f, + (None, None) => super::format("vectorcraft").ok_or("no native format")?, + }; + match f.id { + "vectorcraft" | "svg" | "svgz" => Ok(f), + _ => Err(format!("Save writes .{}, .svg or .svgz 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..db203277 --- /dev/null +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -0,0 +1,136 @@ +//! 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 +109,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> { @@ -115,6 +156,8 @@ struct Writer<'a> { depth: usize, /// `` def ids by pattern name + placement. patterns: HashMap, + /// Gradient def ids by content (everything but the id). + gradients: HashMap, pattern_nest: u32, /// Whether the group being written is a knockout group (what its neutral children inherit). knockout: bool, @@ -126,6 +169,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,16 +247,29 @@ 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; } i += 1; } } + /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… + fn unique_id(&mut self, base: &str) -> String { + let base = format!("{}{base}", self.id_prefix); + let mut cand = base.clone(); + for i in 2.. { + if self.used_ids.insert(cand.clone()) { + break; + } + cand = format!("{base}-{i}"); + } + cand + } + /// 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(); @@ -219,20 +279,21 @@ impl Writer<'_> { } }); for (id, base) in named { - let mut cand = base.clone(); - let mut i = 2; - while !self.used_ids.insert(cand.clone()) { - cand = format!("{base}-{i}"); - i += 1; - } + let cand = self.unique_id(&base); self.names.insert(id, cand); } } + /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to + /// differ from it (spaces, punctuation, duplicates, the unique prefix). fn id_attr(&self, n: &Node) -> String { if self.anonymous { return String::new(); } - self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default() + let Some(id) = self.names.get(&n.id) else { return String::new() }; + match n.name.as_deref() { + Some(name) if name != id => format!(" id=\"{}\" data-name=\"{}\"", xml_escape(id), xml_escape(name)), + _ => format!(" id=\"{}\"", xml_escape(id)), + } } fn matrix(&self, m: Affine) -> String { let c = m.as_coeffs(); @@ -249,7 +310,7 @@ impl Writer<'_> { Styling::PresentationAttributes => { // CSS-only properties go in a style attribute. let (style, attrs): (Vec<_>, Vec<_>) = - props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings")); + props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-kerning")); let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); if !style.is_empty() { s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style)))); @@ -257,20 +318,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 { @@ -319,6 +415,8 @@ impl Writer<'_> { s } + /// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space + /// [`Self::xf`] maps into user space, as the renderer does). fn paint(&mut self, p: &Paint, bounds: Option) -> String { match p { Paint::None => "none".into(), @@ -372,63 +470,57 @@ impl Writer<'_> { Some(id) } + /// The id of a ``/`` def for `g` resolved against `bounds`. + /// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its + /// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint` + /// (what import turns back into a midpoint). fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { let mut geom = g.resolve(bounds); geom.transform(self.xf, g.gradient.kind); let (s, e) = (geom.start, geom.end); - let head = match g.gradient.kind { + // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. + let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y)); + let (tag, prefix, attrs) = match g.gradient.kind { GradientKind::Radial => { - let id = self.fresh_id("radial-gradient"); - let r = (e - s).hypot(); - // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. - let focal = |f: Point| format!(" fx=\"{}\" fy=\"{}\"", self.num(f.x), self.num(f.y)); - if (geom.aspect - 1.0).abs() < 1e-9 { - ( - id.clone(), - format!( - "", - self.num(s.x), - self.num(s.y), - self.num(r), - geom.focal.map(focal).unwrap_or_default() - ), - "radialGradient", - ) + let r = self.num((e - s).hypot()); + let attrs = if (geom.aspect - 1.0).abs() < 1e-9 { + let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default(); + format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y)) } else { let v = e - s; let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect); - ( - id.clone(), - format!( - "", - self.num(r), - geom.focal.map(|f| focal(m.inverse() * f)).unwrap_or_default(), - self.matrix(m) - ), - "radialGradient", - ) - } - } - GradientKind::Linear | GradientKind::Freeform => { - let id = self.fresh_id("linear-gradient"); - ( - id.clone(), - format!( - "", - self.num(s.x), - self.num(s.y), - self.num(e.x), - self.num(e.y) - ), - "linearGradient", - ) + let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default(); + format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m)) + }; + ("radialGradient", "radial-gradient", attrs) } + GradientKind::Linear | GradientKind::Freeform => ( + "linearGradient", + "linear-gradient", + format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), + ), }; - let (id, open, tag) = head; - self.def(1, &open); - for (off, c, o) in g.gradient.expanded_stops() { - let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; - self.def(2, &format!("", fmt_num(off as f64, 4), c.to_hex())); + let stops: Vec = g + .gradient + .expanded() + .map(|(offset, c, o, mid)| { + let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; + let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default(); + format!("", fmt_num(offset as f64, 4), c.to_hex()) + }) + .collect(); + let key = format!("{tag}{attrs}{}", stops.concat()); + if let Some(id) = self.gradients.get(&key) { + return id.clone(); + } + let id = match &g.swatch { + Some(name) => self.unique_id(&sanitize_id(name)), + None => self.fresh_id(prefix), + }; + self.gradients.insert(key, id.clone()); + self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">")); + for stop in &stops { + self.def(2, stop); } self.def(1, &format!("")); id @@ -912,8 +1004,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, @@ -1034,12 +1136,16 @@ impl Writer<'_> { } } - fn char_props(&mut self, st: &CharStyle) -> Props { + /// The ``/`` properties of a character style. `space`: the text's layout bounds + /// and the map from text space to user space, which character gradients resolve against + /// (as on the canvas). + fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { let mut p = Props::new(); let fam = if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() }; p.push(("font-family", fam)); - p.push(("font-size", self.num(st.size))); + // Vertical scale is the font size; horizontal scale stretches it (`textLength`). + p.push(("font-size", self.num(st.size * st.v_scale / 100.0))); let fs = st.font_style.to_ascii_lowercase(); if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") { p.push(("font-weight", "bold".into())); @@ -1047,22 +1153,26 @@ impl Writer<'_> { if fs.contains("italic") || fs.contains("oblique") { p.push(("font-style", "italic".into())); } - let fill = match &st.fill { - Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()), - p => self.paint(p, None), - }; + // Character paints resolve in the text's space (`to_user` maps it into user space). + let (bounds, to_user) = space; + let saved = std::mem::replace(&mut self.xf, to_user); + let fill = self.paint(&st.fill, Some(bounds)); p.push(("fill", fill)); if st.has_stroke() { - // Weight, cap, join, miter limit and dashes as for object strokes (a gradient: its - // first colour, as for the fill). - let mut layer = st.stroke_layer(); - if let Paint::Gradient(g) = &layer.paint { - layer.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color)); - } - self.stroke_props(&layer, layer.width, None, &mut p); + // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans + // the text grown by half the weight, as on the canvas. + let layer = st.stroke_layer(); + self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); } - if st.tracking != 0.0 { - p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size))); + self.xf = saved; + // Tracking and manual kerning both add space after every character. + let spacing = st.tracking + st.kerning.unwrap_or(0.0); + if spacing != 0.0 { + p.push(("letter-spacing", self.num(spacing / 1000.0 * st.size))); + } + if st.kerning.is_some() { + // Manual kerning replaces the font's pair kerning. + p.push(("font-kerning", "none".into())); } if !st.features.is_empty() { let v: Vec = st @@ -1118,7 +1228,8 @@ impl Writer<'_> { self.line(""); } - /// Text as glyph outlines: one compound path per run, painted like the run. + /// Text as glyph outlines: one compound path per run, painted like the run (gradients span + /// the whole text, as on the canvas). fn text_outlines(&mut self, n: &Node, t: &TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; @@ -1132,34 +1243,127 @@ impl Writer<'_> { let a = self.attrs(&Self::node_props(n)); self.line(&format!("")); self.depth += 1; - for (r, mut bp) in runs { + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (r, bp) in runs { let Some(run) = t.runs.get(r) else { continue }; - bp.apply_affine(t.xf); let pd = PathData::from_bezpath(&bp); // An unnamed id: the pieces carry no id attribute (the group has it). let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); let d = self.path_d(&pd, self.xf); - self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, pd.bounds()); + self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); } + self.xf = saved; self.depth -= 1; self.line(""); } + /// Point and area type, every laid-out line at its own position: wraps, indents, tabs and + /// spacing come out as on the canvas. Centred and right-aligned lines are anchored + /// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font + /// differs; other lines are placed where each style run (and, justified, each word) starts. fn text(&mut self, n: &Node, t: &TextObject) { - let first = t.first_style(); - let lead = first.effective_leading(); + if let TextKind::OnPath { path, start } = &t.kind { + return self.text_on_path(n, t, path, *start); + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let lines = text_lines(t, &lay, self.opts.fewer_tspans); + // A tab starts a new chunk at its stop: only lines without tabs can be anchored. + let anchor = match t.para.justify { + Justify::Center => Some(("middle", 0.5)), + Justify::Right => Some(("end", 1.0)), + _ => None, + } + .filter(|_| !lines.iter().flatten().any(|s| s.brk)); + // The element's user space is text space. + let space = (lay.bounds, Affine::IDENTITY); + let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: anchor.is_some() }; + let mut props = base.props.clone(); + if let Some((a, _)) = anchor { + props.push(("text-anchor", a.into())); + } + props.extend(Self::node_props(n)); let id = self.id_attr(n); - let (m, on_path) = match &t.kind { - // Area type lines are positioned in text space (see `area_lines`). - TextKind::Point | TextKind::Area { .. } => (self.xf * t.xf, None), - TextKind::OnPath { path, start } => { - let pid = self.fresh_id("text-path"); - let d = self.path_d(path, self.xf * t.xf); - self.def(1, &format!("")); - (Affine::IDENTITY, Some((pid, *start))) - } + let a = self.attrs(&props); + let m = self.xf * t.xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + let starts: Vec> = lines.iter().map(|l| line_start(l, anchor.map(|a| a.1))).collect(); + // The `` carries the first line's position too, so readers that place text by its + // own x/y start where the first line does. + let at = match starts.iter().flatten().next() { + Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)), + None => String::new(), }; - let mut props = self.char_props(&first); + let mut s = format!(""); + for (line, start) in lines.iter().zip(starts) { + if let Some(start) = start { + self.text_line(&mut s, t, line, start, &base); + } + } + s.push_str(""); + self.line(&s); + } + + /// One line of laid-out text starting at `start`. Default: a positioned `` per segment + /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with + /// the style changes nested in it, positioned again only after tabs and justified word spaces. + /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. + fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase) { + let fewer = self.opts.fewer_tspans; + if fewer { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + } + let mut place = !fewer; + let mut first = true; + // The baseline shift the current text position carries. + let mut shift = 0.0; + for seg in line { + let Some(st) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let mut attrs = String::new(); + if place { + let (x, y) = if first { start } else { (seg.x, seg.y) }; + attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y))); + shift = 0.0; + } + first = false; + if st.baseline_shift != shift { + attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); + shift = st.baseline_shift; + } + // After a tab or a justified word space the next segment is placed. + place = seg.brk || (!fewer && !base.anchored); + if (st.h_scale - st.v_scale).abs() > 1e-9 { + attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance))); + } + if st.rotation != 0.0 { + attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); + } + let diff = run_diff(&base.props, self.char_props(st, base.space)); + attrs.push_str(&self.attrs(&diff)); + let text = xml_escape(&seg.text); + if attrs.is_empty() && fewer { + s.push_str(&text); + } else { + s.push_str(&format!("{text}")); + } + } + if fewer { + s.push_str(""); + } + } + + /// Type on a path: a `` along the path (a def). + fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) { + let id = self.id_attr(n); + let pid = self.fresh_id("text-path"); + let d = self.path_d(path, self.xf * t.xf); + self.def(1, &format!("")); + // The element's user space is the document's; gradients span the laid-out text. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let space = (lay.bounds, self.xf * t.xf); + let base = self.char_props(&t.first_style(), space); + let mut props = base.clone(); match t.para.justify { Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), @@ -1167,100 +1371,16 @@ impl Writer<'_> { } props.extend(Self::node_props(n)); let a = self.attrs(&props); - let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - // Area type: laid-out lines (the `` carries the first line's position too, so readers - // that place text by its own x/y start where the first line does). - let lines = matches!(t.kind, TextKind::Area { .. }).then(|| self.area_lines(t)); - let at = match lines.as_ref().and_then(|l| l.first()) { - Some(l) => format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)), - None => String::new(), - }; - let mut s = format!(""); - if let Some((pid, start)) = &on_path { - s.push_str(&format!("", fmt_num(start * 100.0, 3))); + let offset = fmt_num(start * 100.0, 3); + let mut s = format!(""); + for run in &t.runs { + let diff = run_diff(&base, self.char_props(&run.style, space)); + let a = self.attrs(&diff); + s.push_str(&format!("{}", xml_escape(&run.text.replace('\n', " ")))); } - let base = self.char_props(&first); - if let Some(lines) = lines { - for l in lines { - let last = l.pieces.len() - 1; - for (k, (ri, text)) in l.pieces.into_iter().enumerate() { - let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() }; - let diff = run_diff(&base, self.char_props(&t.runs[ri].style)); - let a = self.attrs(&diff); - let hy = if k == last && l.hyphenated { "-" } else { "" }; - s.push_str(&format!("{}{hy}", xml_escape(text))); - } - } - } else { - let mut line = 0usize; - let mut pending = false; - for run in &t.runs { - let diff = run_diff(&base, self.char_props(&run.style)); - for (i, piece) in run.text.split('\n').enumerate() { - if i > 0 { - line += 1; - pending = true; - } - if piece.is_empty() { - continue; - } - let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() }; - pending = false; - let a = self.attrs(&diff); - s.push_str(&format!("{}", xml_escape(piece))); - } - } - } - if on_path.is_some() { - s.push_str(""); - } - s.push_str(""); + s.push_str(""); self.line(&s); } - - /// Area type as laid out. Soft line breaks are only known to the layout, so each laid-out line - /// is placed at its baseline (text space), at its left edge, centre or right edge to match the - /// `text-anchor` of the paragraph alignment. - fn area_lines<'t>(&self, t: &'t TextObject) -> Vec> { - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let mut offsets = Vec::with_capacity(t.runs.len()); - let mut off = 0; - for r in &t.runs { - offsets.push(off); - off += r.text.len(); - } - let mut out = vec![]; - for l in &lay.lines { - // Run pieces of this line (the paragraph's `\n` is outside `start..end`). - let mut pieces: Vec<(usize, &str)> = vec![]; - for (ri, (r, &o)) in t.runs.iter().zip(&offsets).enumerate() { - let (a, b) = (l.start.max(o), l.end.min(o + r.text.len())); - if a < b { - pieces.push((ri, &r.text[a - o..b - o])); - } - } - // Spaces where the line wrapped don't belong to the line. - while let Some((_, p)) = pieces.last_mut() { - *p = p.trim_end(); - if !p.is_empty() { - break; - } - pieces.pop(); - } - if pieces.is_empty() { - continue; - } - let x = match t.para.justify { - Justify::Center | Justify::JustifyCenter => (l.x0 + l.x1) / 2.0, - Justify::Right | Justify::JustifyRight => l.x1, - _ => l.x0, - }; - // A hyphenated break: the layout adds the hyphen as a glyph with no source text. - let hyphenated = l.glyph_end > l.glyph_start && lay.glyphs.get(l.glyph_end - 1).is_some_and(|g| g.len == 0); - out.push(AreaLine { x, y: l.baseline, pieces, hyphenated }); - } - out - } } /// What the `` of a run whose properties are `run` sets over its ``'s `base`: the @@ -1282,6 +1402,7 @@ fn initial_value(k: &str) -> Option<&'static str> { "stroke-linejoin" => "miter", "stroke-miterlimit" => "4", "stroke-dashoffset" | "letter-spacing" => "0", + "font-kerning" => "auto", _ => return None, }) } @@ -1290,3 +1411,87 @@ fn initial_value(k: &str) -> Option<&'static str> { fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) } + +/// What the lines of one `` share. +struct TextBase { + /// The `` element's character properties, which each `` differs from. + props: Props, + /// The text's layout bounds and the map from text space to user space (character paints). + space: (Rect, Affine), + /// Lines anchored at their centre or right end (`text-anchor`), positioned once each. + anchored: bool, +} + +/// Characters of one style on one line, written as one ``. +struct Segment { + /// Index into `TextObject::runs`. + run: usize, + /// Where the segment starts (text space; `y` is the baseline). + x: f64, + y: f64, + text: String, + /// The laid-out width (tracking and justification included). + advance: f64, + /// The next segment needs its own position (after a tab, or a word space of a justified line). + brk: bool, +} + +/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, +/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters +/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all +/// caps are upper case. +fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec> { + let plain = t.plain_text(); + let split_words = !fewer && !matches!(t.para.justify, Justify::Left | Justify::Center | Justify::Right); + let mut lines = Vec::with_capacity(lay.lines.len()); + for line in &lay.lines { + let mut segs: Vec = Vec::new(); + let mut cluster = None; + let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; + let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); + // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. + if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { + while let Some((_, rest)) = glyphs.split_last().filter(|(g, _)| source(g).chars().all(|c| c.is_whitespace() && c != '\t')) { + glyphs = rest; + } + } + for g in glyphs { + let Some(run) = t.runs.get(g.run) else { continue }; + if g.len > 0 && cluster == Some(g.byte) { + // Another glyph of the same cluster. + if let Some(last) = segs.last_mut() { + last.advance += g.advance; + } + continue; + } + cluster = Some(g.byte); + let src: String = source(g).chars().filter(|c| *c != '\u{ad}').collect(); + let piece = if run.style.all_caps { src.to_uppercase() } else { src }; + let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); + match segs.last_mut() { + Some(last) if last.run == g.run && !last.brk => { + last.text.push_str(&piece); + last.advance += g.advance; + last.brk = brk; + } + _ => segs.push(Segment { run: g.run, x: g.origin.x, y: g.origin.y, text: piece, advance: g.advance, brk }), + } + } + segs.retain(|s| !s.text.is_empty()); + if !segs.is_empty() { + lines.push(segs); + } + } + lines +} + +/// Where a line of segments starts: its first segment's origin or, `anchor`ed (0.5: centre, 1: +/// right end), that point of its width. `None` for an empty line. +fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { + let (first, last) = (line.first()?, line.last()?); + let x = match anchor { + Some(f) => first.x + f * (last.x + last.advance - first.x), + None => first.x, + }; + Some((x, first.y)) +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 7a2f1b2b..1ecdb352 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; @@ -33,7 +33,16 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { let (kx, ky) = units.k; let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); doc.units = units.unit; - let mut im = Importer { doc, warnings, mask_flags: mask_flags(&xml), links, slots, patterns: HashMap::new() }; + let mut im = Importer { + doc, + warnings, + mask_flags: mask_flags(&xml), + links, + slots, + patterns: HashMap::new(), + midpoints: midpoint_stops(&xml), + labels: labels(&xml), + }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. let mut top = tree.root(); @@ -69,7 +78,8 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { children.extend(im.children(g, base * aff(g.transform()))); let id = im.doc.alloc_id(); let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8; - let mut l = Node::layer(id, g.id(), LayerColor::Preset(preset)); + let name = im.name_of(g.id()).to_string(); + let mut l = Node::layer(id, &name, LayerColor::Preset(preset)); if let Some(ch) = l.children_mut() { *ch = children; } @@ -140,6 +150,43 @@ struct Importer { slots: TextSlots, /// Pattern swatch made for each usvg pattern. patterns: HashMap, + /// Midpoint stops by gradient id (see [`midpoint_stops`]). + midpoints: HashMap>>, + /// Object names by element id (see [`labels`]). + labels: HashMap, +} + +/// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop, +/// the midpoint it stands for. +fn midpoint_stops(xml: &roxmltree::Document) -> HashMap>> { + xml.descendants() + .filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient")) + .filter_map(|g| { + let id = g.attribute("id")?; + let marks: Vec> = g + .children() + .filter(|c| c.tag_name().name() == "stop") + .map(|c| c.attribute("data-vc-midpoint").and_then(|v| v.parse().ok())) + .collect(); + marks.iter().any(Option::is_some).then(|| (id.to_string(), marks)) + }) + .collect() +} + +/// A gradient's stops, with the midpoint stops `marks` names folded back into the midpoints of +/// the stops before them. +fn gradient_stops(g: &usvg::BaseGradient, marks: Option<&[Option]>) -> Vec { + let marks = marks.filter(|m| m.len() == g.stops().len()); + let mut out: Vec = Vec::with_capacity(g.stops().len()); + for (i, s) in g.stops().iter().enumerate() { + if let (Some(mid), Some(prev)) = (marks.and_then(|m| m.get(i).copied().flatten()), out.last_mut()) { + prev.midpoint = mid.clamp(0.0, 1.0); + continue; + } + let c = s.color(); + out.push(GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }); + } + out } /// The options [`crate::export::MASK_FLAGS`] records on exported `` elements, by mask id: @@ -211,6 +258,24 @@ fn link(n: &mut Node, url: &str) { } } +/// The names apps keep beside element ids, by id: `data-name`, `inkscape:label`, `serif:id` or +/// `aria-label` (the first one present). +fn labels(xml: &roxmltree::Document) -> HashMap { + const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape"; + const SERIF: &str = "http://www.serif.com/"; + xml.descendants() + .filter_map(|n| { + let id = n.attribute("id").filter(|id| !id.is_empty())?; + let name = n + .attribute("data-name") + .or_else(|| n.attribute((INKSCAPE, "label"))) + .or_else(|| n.attribute((SERIF, "id"))) + .or_else(|| n.attribute("aria-label"))?; + Some((id.to_string(), name.to_string())) + }) + .collect() +} + fn aff(t: usvg::Transform) -> Affine { Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64]) } @@ -257,15 +322,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, @@ -294,11 +350,21 @@ impl Importer { } } + /// A gradient's stops, midpoints restored. + fn stops(&self, g: &usvg::BaseGradient) -> Vec { + gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice)) + } + + /// The object name for an element id: its label (see [`labels`]), else the id itself. + fn name_of<'a>(&'a self, id: &'a str) -> &'a str { + self.labels.get(id).map_or(id, String::as_str) + } + fn named(&mut self, id: &str, kind: NodeKind) -> Node { let nid = self.doc.alloc_id(); let mut n = Node::new(nid, kind); if !id.is_empty() { - n.name = Some(id.to_string()); + n.name = Some(self.name_of(id).to_string()); } n } @@ -482,15 +548,6 @@ impl Importer { } fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint { - let stops = |g: &usvg::BaseGradient| { - g.stops() - .iter() - .map(|s| { - let c = s.color(); - GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) } - }) - .collect::>() - }; let gp = |kind, stops, geom: GradientGeom| { Paint::Gradient(Box::new(GradientPaint { gradient: Gradient { kind, stops }, @@ -513,7 +570,7 @@ impl Importer { focal: None, }; geom.transform(m * aff(lg.transform()), GradientKind::Linear); - gp(GradientKind::Linear, stops(lg), geom) + gp(GradientKind::Linear, self.stops(lg), geom) } usvg::Paint::RadialGradient(rg) => { if rg.spread_method() != usvg::SpreadMethod::Pad { @@ -527,7 +584,7 @@ impl Importer { // A focal point outside the circle is pulled inside it. geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64))); geom.transform(m * aff(rg.transform()), GradientKind::Radial); - gp(GradientKind::Radial, stops(rg), geom) + gp(GradientKind::Radial, self.stops(rg), geom) } usvg::Paint::Pattern(pt) => self.pattern(pt, m), } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 17be8e70..f3ab14dc 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,88 @@ 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 `