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/load.rs b/crates/engine/src/cmd/fileio/load.rs index c629771c..13d4cb4b 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -67,6 +67,11 @@ fn image_format(f: image::ImageFormat) -> Option<&'static Format> { f.extensions_str().iter().find_map(|e| format(e)).filter(|f| f.read && f.raster) } +/// Why an SVG carrying editing data opened as plain SVG: it was changed after it was saved. +pub const EDITING_STALE: &str = "this SVG was changed in another app after it was saved with its editing data: it opened as plain SVG"; +/// Why an SVG carrying editing data opened as plain SVG: the data can't be read. +pub const EDITING_DAMAGED: &str = "this SVG's editing data can't be read: it opened as plain SVG"; + /// Read a file of any readable format (`name`: its file name or path, for the extension and title). pub fn load(name: &str, bytes: &[u8]) -> Result { let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; @@ -76,12 +81,22 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { "vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?, "svg" | "svgz" => { 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)?, + // An SVG saved with Preserve Editing carries the native document: open that, unless + // the SVG was edited since (or the data is damaged). + let editing = vectorcraft_svg::editing(&text); + let native = editing + .as_ref() + .filter(|e| e.intact) + .and_then(|e| vectorcraft_format::base64_decode(&e.data)) + .and_then(|b| vectorcraft_format::load(&b).ok()); + match native { + Some(d) => d, None => { let (d, w) = vectorcraft_svg::import_with_report(&text).map_err(err)?; warnings = w; + if let Some(e) = editing { + warnings.insert(0, (if e.intact { EDITING_DAMAGED } else { EDITING_STALE }).to_string()); + } d } } 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..5e2e54cc --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_svgedit.rs @@ -0,0 +1,129 @@ +//! SVG files that reopen as they were: Preserve Editing restores the document exactly (unless the +//! SVG was edited elsewhere), Save keeps hidden layers, symbols share one def, and linked images +//! stay linked. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, 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 select(s: &mut Session, ids: &[u64]) { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); +} + +/// A roughened square, three instances of a symbol and a square filled with a pattern. +fn rich() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let rough = rect(&mut s, 10.0, 10.0); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [rough]})).unwrap(); + let mark = rect(&mut s, 60.0, 10.0); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + for (x, y) in [(150.0, 50.0), (200.0, 120.0)] { + s.execute("symbol.place", &json!({"name": "Mark", "x": x, "y": y})).unwrap(); + } + let dot = rect(&mut s, 0.0, 150.0); + s.execute("paint.setFill", &json!({"ids": [dot], "color": "#ff0000"})).unwrap(); + select(&mut s, &[dot]); + s.execute("object.pattern.make", &json!({"name": "Dots", "width": 30, "height": 30})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + let filled = rect(&mut s, 100.0, 150.0); + s.execute("paint.setFill", &json!({"ids": [filled], "swatch": "Dots"})).unwrap(); + s +} + +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() +} + +/// `d` without what opening a file sets anew. +fn comparable(d: &Document) -> Document { + let mut d = d.clone(); + d.title.clear(); + d +} + +#[test] +fn preserve_editing_restores_effects_symbols_and_patterns_exactly() { + let mut s = rich(); + let before = comparable(&s.doc().unwrap().doc); + assert!(before.symbols.iter().any(|x| x.name == "Mark") && before.pattern("Dots").is_some()); + let text = svg(&mut s, json!({"preserveEditing": true, "styling": "css"})); + assert_eq!(text.matches(", 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/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index dd1287a2..20f2aa12 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -448,3 +448,28 @@ proptest! { prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); } } + +// ---------- SVG saved with hidden layers and editing data ---------- + +/// The rich document with a hidden layer, saved as SVG with its editing data. +fn saved_svg() -> String { + let mut d = rich_doc(); + if let Some(l) = d.layers.first_mut() { + std::sync::Arc::make_mut(l).visible = false; + } + let opts = vectorcraft_svg::ExportOptions { hidden_layers: true, preserve_editing: true, ..Default::default() }; + vectorcraft_svg::export_full(&d, &opts, Some(&vectorcraft_format::save(&d, false))).svg +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn svg_mutated_save_never_panics( + cut in 0usize..40_000, + edits in prop::collection::vec((0usize..40_000, prop::sample::select(vec!['<', '>', '"', '/', '-', '9', 'e', '.', ' ', '#', '%', '&', ';', 'x', ']', 'A'])), 0..10), + ) { + let svg = mutate_text(&saved_svg(), cut, &edits); + survive("mutated saved svg", || vectorcraft_engine::cmd::fileio::load("saved.svg", svg.as_bytes()).ok().map(|l| l.doc))?; + } +} 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..0aaf41ff 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{CharStyle, Justify}; use vectorcraft_effects::stroke::{self, Written, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; -use crate::{EDITING_NS, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output, Styling, base64_encode, fmt_num, fnv1a, xml_escape}; +use crate::{EDITING_NS, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output, Styling, base64_encode, body_hash, fmt_num, fnv1a, xml_escape}; type Props = Vec<(&'static str, String)>; @@ -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. @@ -109,6 +112,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("{}{nl}", xml_escape(&doc.title))); } + // The editing data carries a hash of the markup around its ``. + let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "", nl]))); w.metadata(&mut out, native); out.push_str(&w.body); out.push_str(""); @@ -173,6 +178,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 +215,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,10 +465,10 @@ impl Writer<'_> { } } - /// ``: 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); + /// ``: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the + /// native document ([`ExportOptions::preserve_editing`]) with the [`body_hash`] of the markup + /// around this element. + fn metadata(&self, out: &mut String, native: Option<(&[u8], String)>) { if !self.opts.metadata && native.is_none() { return; } @@ -354,15 +482,20 @@ 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, ""); } - if let Some(bytes) = native { - line(2, &format!("{}", base64_encode(bytes))); + if let Some((bytes, hash)) = native { + line( + 2, + &format!( + "", + base64_encode(bytes) + ), + ); } line(1, ""); } @@ -838,12 +971,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 +1059,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 +1170,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 +1213,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..a79e209a 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. //! @@ -130,13 +137,15 @@ pub struct ExportOptions { /// Fonts → Convert to Outlines: text becomes paths (portable, no font needed to view it). pub outline_text: bool, /// Embed the native document (passed to [`export_full`]) in `` so VectorCraft - /// reopens the SVG with nothing lost (see [`editing_data`]). + /// reopens the SVG with nothing lost (see [`editing`]). pub preserve_editing: bool, /// Write `` with the document's Dublin Core title and format. pub metadata: bool, /// 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, } } } @@ -215,9 +225,19 @@ pub fn export_full(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) /// The XML namespace of the editing data [`ExportOptions::preserve_editing`] embeds. pub const EDITING_NS: &str = "urn:vectorcraft:editing"; -/// The native document an SVG carries (base64, as written with -/// [`ExportOptions::preserve_editing`]), if any. -pub fn editing_data(svg: &str) -> Option { +/// The native document an SVG carries ([`ExportOptions::preserve_editing`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Editing { + /// The native document, base64. + pub data: String, + /// The SVG around it is still the one written with it: the hash it keeps of the markup + /// outside its `` matches (whitespace between tags and line endings aside). False + /// once another app edited the SVG; true for editing data written without a hash. + pub intact: bool, +} + +/// The native document an SVG carries, if any, and whether the SVG was edited since. +pub fn editing(svg: &str) -> Option { if !svg.contains(EDITING_NS) { return None; } @@ -225,7 +245,48 @@ pub fn editing_data(svg: &str) -> Option { let xml = usvg::roxmltree::Document::parse_with_options(svg, opts).ok()?; let el = xml.descendants().find(|n| n.tag_name().namespace() == Some(EDITING_NS) && n.tag_name().name() == "document")?; let data: String = el.children().filter_map(|c| c.text()).flat_map(|t| t.chars().filter(|c| !c.is_whitespace())).collect(); - Some(data).filter(|d| !d.is_empty()) + if data.is_empty() { + return None; + } + // The hash covers everything but the `` element holding the data. + let intact = match (el.attribute("hash"), el.parent_element()) { + (None, _) => true, + (Some(h), Some(meta)) => { + let r = meta.range(); + let (head, tail) = (svg.get(..r.start).unwrap_or(""), svg.get(r.end..).unwrap_or("")); + body_hash(&[head, tail]) == h + } + (Some(_), None) => false, + }; + Some(Editing { data, intact }) +} + +/// The native document an SVG carries (base64), if any, edited since or not (see [`editing`]). +pub fn editing_data(svg: &str) -> Option { + editing(svg).map(|e| e.data) +} + +/// The hash [`ExportOptions::preserve_editing`] keeps of an SVG's markup outside its +/// `` (`parts`, in order): FNV-1a of the text with whitespace between tags dropped and +/// other whitespace runs read as one space, so re-indenting or other line endings don't count as +/// edits. +pub(crate) fn body_hash(parts: &[&str]) -> String { + let mut h = FNV_SEED; + // The last byte hashed and whether whitespace came after it. + let (mut last, mut gap) = (None, false); + for b in parts.iter().flat_map(|p| p.bytes()) { + if b.is_ascii_whitespace() { + gap = true; + continue; + } + if gap && last.is_some_and(|l| l != b'>') && b != b'<' { + h = fnv1a_from(h, b" "); + } + gap = false; + last = Some(b); + h = fnv1a_from(h, &[b]); + } + format!("{h:016x}") } /// Does `bytes` start like gzip data (an SVGZ file)? @@ -275,7 +336,14 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec), SvgError /// FNV-1a: a stable content hash (image keys, unique id prefixes). pub(crate) fn fnv1a(bytes: &[u8]) -> u64 { - bytes.iter().fold(0xcbf29ce484222325, |h, b| (h ^ *b as u64).wrapping_mul(0x100000001b3)) + fnv1a_from(FNV_SEED, bytes) +} + +const FNV_SEED: u64 = 0xcbf29ce484222325; + +/// FNV-1a of `bytes` continued from hash `h`. +fn fnv1a_from(h: u64, bytes: &[u8]) -> u64 { + bytes.iter().fold(h, |h, b| (h ^ *b as u64).wrapping_mul(0x100000001b3)) } /// Standard base64 (RFC 4648, with padding). diff --git a/crates/svg/tests/symbols_layers.rs b/crates/svg/tests/symbols_layers.rs new file mode 100644 index 00000000..17f2d216 --- /dev/null +++ b/crates/svg/tests/symbols_layers.rs @@ -0,0 +1,244 @@ +//! Symbols export as one `` def and a `` per instance (instances the def can't stand +//! for get their own art), hidden layers are kept hidden when asked and come back hidden, and the +//! editing data notices edits made elsewhere. +// 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, editing, export, export_full, 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("