From 370c6e2fc07fb621bb8f98c1a5fcee522841a966 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:38:11 +0700 Subject: [PATCH 1/2] M4.39: SVG symbols as /, hidden layers on Save, Dublin Core metadata Symbol instances export as one def per symbol and a per instance. An instance the def can't stand for exactly (stained by its fill, or a transform under which the canvas paints differently: scaling with strokes, effects or live objects, turning with effects or brushes, any move with pattern paints or unlinked masks) is written as its own art, now stained like the canvas and with its patterns and unlinked masks left on the page. Symbols nested in themselves stop at a fixed depth. New SVG option hiddenLayers keeps hidden layers as display:none groups; document.save (and Save As / Save a Copy) turns it on unless told otherwise, exports leave hidden layers out. Import shows display:none layer groups to usvg and marks them hidden, so they reopen hidden (groups a refers to stay templates). File Info metadata goes through one Dublin Core list. The symbol stain helper moves to vectorcraft-brush so the renderer and the SVG writer share it. Co-Authored-By: Claude Opus 5.5 --- crates/brush/src/colorize.rs | 16 ++ crates/brush/src/lib.rs | 2 +- crates/engine/src/cmd/fileio/mod.rs | 6 +- crates/engine/src/cmd/fileio/save.rs | 6 +- crates/engine/src/cmd/fileio/svg.rs | 6 + crates/engine/src/cmd/fileio/tests_svg.rs | 4 +- crates/engine/src/cmd/fileio/tests_svgedit.rs | 62 +++++ crates/render/src/brush_fx.rs | 13 +- crates/svg/src/export.rs | 210 ++++++++++++++++- crates/svg/src/import.rs | 78 ++++++- crates/svg/src/lib.rs | 12 +- crates/svg/tests/symbols_layers.rs | 218 ++++++++++++++++++ crates/ui-egui/src/dialogs/svg_options.rs | 4 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_svg.rs | 4 +- crates/ui-egui/src/tests_svgsave.rs | 28 +++ docs/mcp.md | 7 + 17 files changed, 631 insertions(+), 47 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_svgedit.rs create mode 100644 crates/svg/tests/symbols_layers.rs create mode 100644 crates/ui-egui/src/tests_svgsave.rs diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index e563fbc2..686e4006 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -99,3 +99,19 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { } } } + +/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's +/// opacity (how far the art mixes towards it). `None` for an unstained instance. +pub fn stain(inst: &Node) -> Option<(Color, f32)> { + let f = inst.appearance.fill().filter(|f| f.visible)?; + Some((f.paint.color()?, f.opacity)) +} + +/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]). +pub fn instance_art(art: &Node, inst: &Node) -> Node { + let mut a = art.clone(); + if let Some((c, amount)) = stain(inst) { + tint_node(&mut a, &c, amount); + } + a +} diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 08d86f15..8d999b50 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -23,7 +23,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{BezPath, Rect}; -pub use colorize::{colorize, tint_node}; +pub use colorize::{colorize, instance_art, stain, tint_node}; pub use defaults::defaults; pub use serde_json; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index aff8c913..4bf58750 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Save Document", [], None, - "{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)", + "{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, and keeps hidden layers (display:none) unless hiddenLayers is false. A never-saved document without path → {dataBase64} (stays modified)", has_doc, save::save ), @@ -65,7 +65,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{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", + "{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, hiddenLayers} (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 ), @@ -416,3 +416,5 @@ mod tests; mod tests_pdf; #[cfg(test)] mod tests_svg; +#[cfg(test)] +mod tests_svgedit; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 69c52bcb..80737f85 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -22,7 +22,7 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res /// 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. +/// the document was last saved with, and keeps hidden layers (hidden) unless they say otherwise. pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, Value)> { const C: &str = "document.save"; if f.id == "vectorcraft" { @@ -30,7 +30,9 @@ pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, V } 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 }))?; + let mut svg = opts.as_object().cloned().unwrap_or_default(); + svg.entry("hiddenLayers").or_insert(Value::Bool(true)); + let enc = encode_all(&st.doc, f.id, &json!({ "svg": svg }))?; if enc.files.len() != 1 { return Err(bad(C, "Save writes one artboard: name one, or export several with document.export")); } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 56be86ae..aa81b98a 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -61,6 +61,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "type: one positioned per line instead of one per style run, tab and justified word (smaller; viewers space the line themselves)", }, + FormatOption { + name: "hiddenLayers", + ty: "boolean", + default: "false", + description: "keep hidden layers, not displayed (display:none); document.save sets it unless given", + }, FormatOption { name: "svg", ty: "object", diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index f0037b7f..890d8e91 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -12,7 +12,7 @@ fn session(artboards: usize) -> Session { s } -fn svg(s: &mut Session, p: Value) -> String { +pub(super) fn svg(s: &mut Session, p: Value) -> String { let mut p = p; p["format"] = json!("svg"); s.execute("document.serialize", &p).unwrap_or_else(|e| panic!("{p}: {e}"))["text"].as_str().unwrap().to_string() @@ -25,7 +25,7 @@ fn tmp_dir(tag: &str) -> std::path::PathBuf { } /// A session whose document holds one embedded 2×2 PNG. -fn image_session() -> Session { +pub(super) fn image_session() -> Session { let mut png = Vec::new(); image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 200, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) diff --git a/crates/engine/src/cmd/fileio/tests_svgedit.rs b/crates/engine/src/cmd/fileio/tests_svgedit.rs new file mode 100644 index 00000000..07f8dfca --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_svgedit.rs @@ -0,0 +1,62 @@ +//! SVG files that reopen as they were: Save keeps hidden layers and linked images stay linked. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; + +use super::tests_svg::{image_session, svg}; +use super::*; + +fn new_id(v: &Value) -> u64 { + v["id"].as_u64().or_else(|| v["ids"][0].as_u64()).unwrap_or_else(|| panic!("no id in {v}")) +} + +fn rect(s: &mut Session, x: f64, y: f64) -> u64 { + new_id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 20, "height": 20})).unwrap()) +} + +fn open(s: &mut Session, name: &str, text: &str) -> Value { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap() +} + +#[test] +fn save_keeps_hidden_layers_and_export_leaves_them_out() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + rect(&mut s, 10.0, 10.0); + let notes = new_id(&s.execute("layer.new", &json!({"name": "Notes"})).unwrap()); + rect(&mut s, 50.0, 50.0); + s.execute("layer.setProps", &json!({"id": notes, "visible": false})).unwrap(); + + let exported = svg(&mut s, json!({})); + assert!(!exported.contains("Notes"), "{exported}"); + let saved = s.execute("document.save", &json!({"format": "svg"})).unwrap(); + let saved = String::from_utf8(vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(saved.contains("id=\"Notes\"") && saved.contains("display=\"none\""), "{saved}"); + // Asked not to, Save leaves them out too. + let r = s.execute("document.save", &json!({"format": "svg", "svg": {"hiddenLayers": false}})).unwrap(); + assert!(!String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap().contains("Notes")); + + open(&mut s, "saved.svg", &saved); + let d = &s.doc().unwrap().doc; + let layers: Vec<(Option<&str>, bool, usize)> = + d.layers.iter().map(|l| (l.name.as_deref(), l.visible, l.children().map_or(0, Vec::len))).collect(); + assert_eq!(layers, [(Some("Layer 1"), true, 1), (Some("Notes"), false, 1)]); +} + +#[test] +fn link_mode_writes_no_data_uri() { + let mut s = image_session(); + // An image placed linked: it has both its bytes and its file. + s.edit("Link", |d, _| { + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(NodeKind::Image(im)) = d.node_mut(id).map(|n| &mut n.kind) { + im.link = Some("art/dot.png".into()); + } + Ok(()) + }) + .unwrap(); + let linked = svg(&mut s, json!({"images": "link"})); + assert!(linked.contains("xlink:href=\"art/dot.png\"") && !linked.contains("data:"), "{linked}"); + let embedded = svg(&mut s, json!({"images": "embed"})); + assert!(embedded.contains("href=\"data:image/png;base64,") && !embedded.contains("art/dot.png"), "{embedded}"); +} diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 901ace4a..0d02f13e 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -61,18 +61,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option { Some(b.inflate(pad, pad)) } -/// A symbol's art for instance `inst`, stained by the instance's fill (Symbol Stainer: the fill -/// colour mixed in by the fill layer's opacity). -pub fn instance_art(art: &Node, inst: &Node) -> Node { - let mut a = art.clone(); - if let Some(f) = inst.appearance.fill() - && f.visible - && let Some(c) = f.paint.color() - { - vectorcraft_brush::tint_node(&mut a, &c, f.opacity); - } - a -} +pub use vectorcraft_brush::instance_art; impl Renderer { /// Paint stroke `st` of `n` (outline `bp`) with its brush. Returns false when the brush is diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index a67b3e3f..4b359382 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -56,6 +56,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: warnings: vec![], id_prefix, linked: Vec::new(), + instance_xf: Affine::IDENTITY, + symbols: HashMap::new(), + symbol_nest: 0, }; w.assign_name_ids(); // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. @@ -173,6 +176,34 @@ struct Writer<'a> { id_prefix: &'a str, /// Image files the SVG links to ([`ImageMode::Link`]). linked: Vec, + /// The transform of the symbol instances written out as their own art around the object + /// being written (document space; identity outside them and in symbol defs). + instance_xf: Affine, + /// What each symbol's instances can share, by symbol name (see [`Writer::symbol_use`]). + symbols: HashMap, + /// Symbols being written inside one another (instances nested deeper are left out: a symbol + /// can't contain itself). + symbol_nest: u32, +} + +/// A symbol's `` def and the instance transforms that can `` it. +struct SymbolDef { + /// The def's id, once written. + id: Option, + reuse: Reuse, +} + +/// Which instance transforms paint a symbol's art exactly as the def under that transform (the +/// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked +/// masks stay put). +#[derive(Clone, Copy)] +struct Reuse { + /// Translations (no pattern paints or unlinked masks). + moves: bool, + /// Rotations and reflections (nor effects or brushes). + turns: bool, + /// Any other transform (nor strokes or live objects). + scales: bool, } /// How one stroke is written. @@ -182,6 +213,101 @@ enum StrokePlan { Written(Written), } +/// How deep symbols may sit in one another's art (deeper instances are left out). +const MAX_SYMBOL_NEST: u32 = 8; + +impl Reuse { + const NONE: Self = Self { moves: false, turns: false, scales: false }; + + /// What a def of symbol art `art` stands for. + fn of(doc: &Document, art: &Node) -> Self { + let mut r = Self { moves: true, turns: true, scales: true }; + r.scan(doc, art, 0); + r + } + + /// Narrow down to what `n` (inside symbols nested `depth` deep) allows. + fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); + let unlinked_mask = n.mask.as_ref().is_some_and(|m| !m.linked); + if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { + *self = Self::NONE; + return; + } + let effects = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); + if effects(&n.appearance.effects) || n.appearance.items.iter().any(|i| effects(i.effects())) { + (self.turns, self.scales) = (false, false); + } + for i in &n.appearance.items { + if let AppearanceItem::Stroke(s) = i + && s.visible + && !s.paint.is_none() + && s.width > 0.0 + { + // Stroke weights don't scale; brushes can orient their art to the page. + self.scales = false; + self.turns &= s.brush.is_none(); + } + } + match &n.kind { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + (self.turns, self.scales) = (false, false) + } + NodeKind::SymbolInstance { symbol, .. } => { + if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { + self.scan(doc, &s.art, depth + 1); + } + } + _ => {} + } + if let Some(m) = &n.mask { + self.scan(doc, &m.art, depth); + } + for c in n.children().into_iter().flatten() { + self.scan(doc, c, depth); + } + } + + /// Can the def stand for an instance with transform `xf`? + fn allows(self, xf: Affine) -> bool { + const EPS: f64 = 1e-9; + let [a, b, c, d, _, _] = xf.as_coeffs(); + if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { + return self.moves; + } + let rigid = (a * a + b * b - 1.0).abs() < EPS && (c * c + d * d - 1.0).abs() < EPS && (a * c + b * d).abs() < EPS; + if rigid { self.turns } else { self.scales } + } +} + +/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas +/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement +/// and the art of unlinked opacity masks. +fn pin_to_page(n: &mut Node, m: Affine) { + vectorcraft_doc::pattern::transform_pattern_paints(n, m); + if let Some(mask) = n.mask.as_deref_mut() { + let linked = mask.linked; + let art = std::sync::Arc::make_mut(&mut mask.art); + if !linked { + art.transform(m, false); + } + pin_to_page(art, m); + } + for c in n.children_mut().into_iter().flatten() { + pin_to_page(std::sync::Arc::make_mut(c), m); + } +} + +/// The Dublin Core terms File Info writes to ``. +fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { + let mut terms = vec![("format", "image/svg+xml".to_string())]; + if !doc.title.is_empty() { + terms.push(("title", doc.title.clone())); + } + terms +} + /// A region covering any artwork (mask and filter extents). const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; /// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the @@ -337,8 +463,8 @@ impl Writer<'_> { } } - /// ``: the Dublin Core title and format ([`ExportOptions::metadata`]) and the native - /// document ([`ExportOptions::preserve_editing`]). + /// ``: the Dublin Core terms of File Info ([`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() { @@ -354,9 +480,8 @@ impl Writer<'_> { 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))); + for (term, value) in dublin_core(self.doc) { + line(4, &format!("{}", xml_escape(&value))); } line(3, ""); line(2, ""); @@ -838,12 +963,15 @@ impl Writer<'_> { std::mem::replace(&mut self.body, body) } - /// Props of a group: a knockout group is isolated. + /// Props of a group (or layer): a knockout group is isolated, a hidden layer not displayed. fn group_props(&self, n: &Node) -> Props { let mut p = Self::node_props(n); if !n.isolate && n.knocks_out(self.knockout) { p.push(("isolation", "isolate".into())); } + if !n.visible { + p.push(("display", "none".into())); + } p } @@ -923,7 +1051,9 @@ impl Writer<'_> { } fn node_body(&mut self, n: &Node) { - if !n.visible { + // Hidden objects are left out; hidden layers too, unless the options keep them (hidden). + let kept = self.opts.hidden_layers && matches!(n.kind, NodeKind::Layer { template: false, .. }); + if !(n.visible || kept) { return; } if has_raster(&n.appearance.effects) { @@ -1032,20 +1162,40 @@ impl Writer<'_> { )); } NodeKind::SymbolInstance { symbol, xf } => { - let Some(sym) = self.doc.symbols.iter().find(|s| s.name == *symbol) else { return }; - let saved = self.xf; - self.xf = saved * *xf; + let doc = self.doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; + if self.symbol_nest > MAX_SYMBOL_NEST { + return; + } let id = self.id_attr(n); let a = self.attrs(&Self::node_props(n)); + if let Some(def) = self.symbol_use(sym, n, *xf) { + let m = self.xf * *xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + self.line(&format!("")); + return; + } + // An instance of its own: the (stained) art moved by the instance, its pattern + // paints and unlinked masks left in place as on the canvas. + let mut art = vectorcraft_brush::instance_art(&sym.art, n); + let moved = self.instance_xf * *xf; + if moved.determinant().abs() > 1e-12 { + pin_to_page(&mut art, moved.inverse()); + } + let saved = (self.xf, self.instance_xf); + self.xf = saved.0 * *xf; + self.instance_xf = moved; self.line(&format!("")); self.depth += 1; // The symbol's art is the instance's own picture, outside any knockout around it. let knockout = std::mem::take(&mut self.knockout); - self.node(&sym.art.clone()); + self.symbol_nest += 1; + self.node(&art); + self.symbol_nest -= 1; self.knockout = knockout; self.depth -= 1; self.line(""); - self.xf = saved; + (self.xf, self.instance_xf) = saved; } // Live blends/envelopes/meshes export their evaluated (expanded) form. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { @@ -1055,6 +1205,42 @@ impl Writer<'_> { } } + /// The id of the `` def instance `inst` (transform `xf`) can ``, written on first + /// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved + /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). + fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { + if vectorcraft_brush::stain(inst).is_some() { + return None; + } + if !self.symbols.contains_key(&sym.name) { + let reuse = Reuse::of(self.doc, &sym.art); + self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); + } + let def = self.symbols.get(&sym.name)?; + if !def.reuse.allows(xf) { + return None; + } + if let Some(id) = &def.id { + return Some(id.clone()); + } + // The art in symbol space, as its own picture: no knockout around it, and no object ids + // (every instance shows it). + let id = self.unique_id(&sanitize_id(&sym.name)); + let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); + (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); + self.symbol_nest += 1; + let art = self.detached(3, |w| w.node(&sym.art)); + self.symbol_nest -= 1; + (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; + self.def(1, &format!("")); + self.defs.push_str(&art); + self.def(1, ""); + if let Some(def) = self.symbols.get_mut(&sym.name) { + def.id = Some(id.clone()); + } + Some(id) + } + /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` /// per effect, the first effect innermost (the reference app's stacking order). fn filtered(&mut self, n: &Node) { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 1ecdb352..9b3aa16e 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -1,6 +1,7 @@ //! usvg tree → document conversion. -use std::collections::HashMap; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; @@ -20,7 +21,8 @@ mod css; mod text; pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { - let (svg, links) = link_ids(svg); + let (linked, links) = link_ids(svg); + let (svg, hidden) = hidden_groups(&linked); let svg = svg.as_ref(); let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) .map_err(|e| SvgError::Parse(e.to_string()))?; @@ -42,6 +44,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { patterns: HashMap::new(), midpoints: midpoint_stops(&xml), labels: labels(&xml), + hidden, }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. @@ -83,6 +86,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { if let Some(ch) = l.children_mut() { *ch = children; } + l.visible = !im.hidden.contains(g.id()); im.doc.layers.push(Arc::new(l)); } } else { @@ -154,6 +158,8 @@ struct Importer { midpoints: HashMap>>, /// Object names by element id (see [`labels`]). labels: HashMap, + /// Ids of the hidden groups shown for usvg ([`hidden_groups`]). + hidden: HashSet, } /// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop, @@ -228,27 +234,72 @@ fn link_ids(svg: &str) -> (std::borrow::Cow<'_, str>, HashMap) { let id = match a.attribute("id") { Some(id) => id.to_string(), None => { - let start = a.range().start; - let end = svg[start + 1..].find(|c: char| c.is_whitespace() || c == '/' || c == '>').map_or(start + 2, |i| start + 1 + i); let id = format!("{LINK_ID}{}", inserts.len()); - inserts.push((end, id.clone())); + inserts.push((tag_name_end(svg, a), format!(" id=\"{id}\""))); id } }; links.insert(id, url.to_string()); } + (insert(svg, inserts), links) +} + +/// Where the start tag of element `n` ends its name (where an attribute can go). +fn tag_name_end(svg: &str, n: XNode) -> usize { + let start = n.range().start; + svg.get(start + 1..).and_then(|s| s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map_or(start + 2, |i| start + 1 + i) +} + +/// `svg` with each `(offset, text)` of `inserts` (in offset order) inserted; offsets that are out +/// of order or not on a character boundary are skipped. +fn insert(svg: &str, inserts: Vec<(usize, String)>) -> Cow<'_, str> { if inserts.is_empty() { - return (svg.into(), links); + return svg.into(); } - let mut out = String::with_capacity(svg.len() + inserts.len() * 32); + let mut out = String::with_capacity(svg.len() + inserts.iter().map(|(_, t)| t.len()).sum::()); let mut last = 0; - for (at, id) in inserts { - out.push_str(&svg[last..at]); - out.push_str(&format!(" id=\"{id}\"")); + for (at, text) in inserts { + let Some(part) = svg.get(last..at) else { continue }; + out.push_str(part); + out.push_str(&text); last = at; } - out.push_str(&svg[last..]); - (out.into(), links) + out.push_str(svg.get(last..).unwrap_or("")); + out.into() +} + +/// usvg drops what isn't displayed, but hidden layers (as Save writes them: `display: none` on a +/// top-level ``, or on such a group inside groups) come back hidden. Those groups are shown +/// for usvg; returns the SVG so changed and their ids. A group something links to (a `` +/// template) stays as it is. +fn hidden_groups(svg: &str) -> (Cow<'_, str>, HashSet) { + let mut ids = HashSet::new(); + if !svg.contains("display") { + return (svg.into(), ids); + } + let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { + return (svg.into(), ids); + }; + let css = css::Styles::new(&xml); + let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect(); + let group = |n: &XNode| n.is_element() && n.tag_name().name() == "g" && n.attribute("id").is_none_or(|id| !linked.contains(id)); + let mut inserts = vec![]; + let mut groups: Vec = xml.root_element().children().filter(group).collect(); + while let Some(g) = groups.pop() { + groups.extend(g.children().filter(group)); + let Some(id) = g.attribute("id").filter(|id| !id.is_empty()) else { continue }; + if css.own(g, "display").as_deref() != Some("none") { + continue; + } + ids.insert(id.to_string()); + // A `style` declaration wins over the attribute and style sheet rules. + inserts.push(match g.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { + Some(a) => (a.range_value().end, ";display:inline".to_string()), + None => (tag_name_end(svg, g), " style=\"display:inline\"".to_string()), + }); + } + inserts.sort_by_key(|(at, _)| *at); + (insert(svg, inserts), ids) } /// Make `n` link to `url`, unless it links somewhere already (an inner link wins). @@ -375,6 +426,9 @@ impl Importer { if let Some(url) = url { link(&mut n, &url); } + if self.hidden.contains(g.id()) { + n.visible = false; + } Some(n) } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index f3ab14dc..9732c2ae 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -3,7 +3,8 @@ //! * [`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). +//! embedded as `data:` URIs or linked; text as ``/`` or outlines; symbols as one +//! `` each with a `` per instance; hidden layers left out, or kept not displayed). //! * [`import`] / [`import_with_report`] parse SVG with `usvg` and convert its normalized tree into //! document nodes with all transforms baked into the geometry. //! @@ -22,6 +23,9 @@ //! `` per appearance item, in paint order. //! * Live geometry effects are written as their result and raster effects (shadows, glows, blurs) //! as SVG filters. Freeform gradients export as linear gradients. +//! * **Symbol instances** the shared `` would paint differently from the canvas (stained, +//! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding +//! pattern paints or unlinked masks, which stay on the page) are written as their own art. //! //! ## Import approximations //! @@ -36,6 +40,9 @@ //! clip only. //! * `` becomes a pattern swatch (its content clipped to the tile) painted with the //! pattern's placement. +//! * A `` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back +//! as a hidden layer or group, unless a `` refers to it. Other undisplayed elements are left +//! out. `` instances (and ``s) become plain art. //! * Text lines after the first start at the first line's x; text in a clip path is ignored; //! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways. //! @@ -137,6 +144,8 @@ pub struct ExportOptions { /// One positioned `` per line of type instead of one per style run, tab stop and /// justified word (smaller; viewers then space the line with their own font metrics). pub fewer_tspans: bool, + /// Keep hidden layers, written hidden (`display:none`), as Save does; exports leave them out. + pub hidden_layers: bool, } impl Default for ExportOptions { @@ -153,6 +162,7 @@ impl Default for ExportOptions { preserve_editing: false, metadata: false, fewer_tspans: false, + hidden_layers: false, } } } diff --git a/crates/svg/tests/symbols_layers.rs b/crates/svg/tests/symbols_layers.rs new file mode 100644 index 00000000..fe949b28 --- /dev/null +++ b/crates/svg/tests/symbols_layers.rs @@ -0,0 +1,218 @@ +//! Symbols export as one `` def and a `` per instance (instances the def can't stand +//! for get their own art), and hidden layers are kept hidden when asked and come back hidden. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::pattern::pattern_paint; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PatternDef, Symbol}; +use vectorcraft_geom::{Affine, Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export, import}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +/// The exported SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str, w: u32, h: u32) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: w, height: h, rgba: pm.data().to_vec() } +} + +/// A 200×120 document with symbol "My Mark" (a blue square with a thick black stroke, `stroke` +/// wide) and an instance per transform. +fn symbol_doc(stroke: f64, xfs: &[Affine]) -> Document { + let mut d = Document::new(200.0, 120.0); + let ap = Appearance::basic(Paint::solid(Color::rgb8(30, 90, 220)), Paint::solid(Color::BLACK), stroke); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), ap); + d.symbols.push(Symbol { name: "My Mark".into(), art: Arc::new(art) }); + for xf in xfs { + instance(&mut d, "My Mark", *xf); + } + d +} + +fn instance(d: &mut Document, symbol: &str, xf: Affine) -> Node { + let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }); + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n.clone()).unwrap(); + n +} + +#[test] +fn three_instances_give_one_symbol_and_three_uses() { + let d = symbol_doc(4.0, &[Affine::translate((30.0, 30.0)), Affine::translate((100.0, 40.0)), Affine::translate((160.0, 90.0))]); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.matches(" Document { + let mut e = d.clone(); + let l = Arc::make_mut(&mut e.layers[0]); + for c in l.children_mut().unwrap().iter_mut() { + if let NodeKind::SymbolInstance { symbol, xf } = &c.kind { + let sym = d.symbols.iter().find(|s| s.name == *symbol).unwrap(); + let mut art = vectorcraft_brush::instance_art(&sym.art, c); + art.transform(*xf, false); + *c = Arc::new(art); + } + } + e +} + +#[test] +fn masks_in_symbols_move_with_the_art_when_linked() { + for linked in [true, false] { + let mut d = Document::new(200.0, 120.0); + let fill = |c| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), fill(Color::rgb8(200, 0, 0))); + let shape = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 60.0)), fill(Color::WHITE)); + let mut mask = vectorcraft_doc::OpacityMask::new(shape, true); + mask.linked = linked; + art.mask = Some(Box::new(mask)); + d.symbols.push(Symbol { name: "Half".into(), art: Arc::new(art) }); + instance(&mut d, "Half", Affine::translate((10.0, 10.0))); + instance(&mut d, "Half", Affine::translate((15.0, 50.0))); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.contains(" Document { + let mut d = Document::new(100.0, 100.0); + let fill = |c| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), fill(Color::rgb8(255, 0, 0))); + let l = d.layers[0].id; + d.insert(Some(l), 0, a).unwrap(); + let mut hidden = Node::layer(d.alloc_id(), "Notes", vectorcraft_doc::LayerColor::Preset(1)); + let b = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)), fill(Color::rgb8(0, 0, 255))); + hidden.children_mut().unwrap().push(Arc::new(b)); + hidden.visible = false; + d.layers.push(Arc::new(hidden)); + d +} + +#[test] +fn hidden_layers_are_kept_hidden_when_asked() { + let d = layered(); + let plain = export(&d, &ExportOptions::default()); + assert!(!plain.contains("Notes") && plain.matches("