M4.39: SVG symbols as <symbol>/<use>, hidden layers on Save, Dublin Core metadata

Symbol instances export as one <symbol> def per symbol and a <use> 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 <use> 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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 09:03:52 +07:00
1 parent 4cccbc2c6e
commit 370c6e2fc0
17 files changed
+631 -47

No files matched your search

+16
View File
@@ -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
}
+1 -1
View File
@@ -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;
+4 -2
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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;
+4 -2
View File
@@ -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"));
}
+6
View File
@@ -61,6 +61,12 @@ pub const OPTIONS: &[FormatOption] = &[
default: "false",
description: "type: one positioned <tspan> 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",
+2 -2
View File
@@ -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)
@@ -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}");
}
+1 -12
View File
@@ -61,18 +61,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
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
+198 -12
View File
@@ -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<LinkedImage>,
/// 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<String, SymbolDef>,
/// Symbols being written inside one another (instances nested deeper are left out: a symbol
/// can't contain itself).
symbol_nest: u32,
}
/// A symbol's `<symbol>` def and the instance transforms that can `<use>` it.
struct SymbolDef {
/// The def's id, once written.
id: Option<String>,
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 `<metadata>`.
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 `<mask>`: user-space units, and luminance taken from the sRGB values (as the
@@ -337,8 +463,8 @@ impl Writer<'_> {
}
}
/// `<metadata>`: the Dublin Core title and format ([`ExportOptions::metadata`]) and the native
/// document ([`ExportOptions::preserve_editing`]).
/// `<metadata>`: 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, "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
line(3, "<rdf:Description rdf:about=\"\">");
line(4, "<dc:format>image/svg+xml</dc:format>");
if !self.doc.title.is_empty() {
line(4, &format!("<dc:title>{}</dc:title>", xml_escape(&self.doc.title)));
for (term, value) in dublin_core(self.doc) {
line(4, &format!("<dc:{term}>{}</dc:{term}>", xml_escape(&value)));
}
line(3, "</rdf:Description>");
line(2, "</rdf:RDF>");
@@ -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!("<use{id} xlink:href=\"#{def}\"{tr}{a}/>"));
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!("<g{id}{a}>"));
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("</g>");
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 `<symbol>` def instance `inst` (transform `xf`) can `<use>`, 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<String> {
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!("<symbol id=\"{id}\" overflow=\"visible\">"));
self.defs.push_str(&art);
self.def(1, "</symbol>");
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 `<g filter>`
/// per effect, the first effect innermost (the reference app's stacking order).
fn filtered(&mut self, n: &Node) {
+66 -12
View File
@@ -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<String>), 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<String>), 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<String>), 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<String, Vec<Option<f32>>>,
/// Object names by element id (see [`labels`]).
labels: HashMap<String, String>,
/// Ids of the hidden groups shown for usvg ([`hidden_groups`]).
hidden: HashSet<String>,
}
/// 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<String, String>) {
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::<usize>());
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 `<g id>`, 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 `<use>`
/// template) stays as it is.
fn hidden_groups(svg: &str) -> (Cow<'_, str>, HashSet<String>) {
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<XNode> = 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)
}
+11 -1
View File
@@ -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 `<defs>` with `userSpaceOnUse`; clip groups as `<clipPath>`; images
//! embedded as `data:` URIs or linked; text as `<text>`/`<tspan>` or outlines).
//! embedded as `data:` URIs or linked; text as `<text>`/`<tspan>` or outlines; symbols as one
//! `<symbol>` each with a `<use>` 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 @@
//! `<path>` 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 `<symbol>` 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.
//! * `<pattern>` becomes a pattern swatch (its content clipped to the tile) painted with the
//! pattern's placement.
//! * A `<g id>` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back
//! as a hidden layer or group, unless a `<use>` refers to it. Other undisplayed elements are left
//! out. `<use>` instances (and `<symbol>`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 `<tspan>` 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,
}
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Symbols export as one `<symbol>` def and a `<use>` 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("<symbol ").count(), 1, "{svg}");
assert_eq!(svg.matches("<use ").count(), 3, "{svg}");
assert!(svg.contains("<symbol id=\"My_Mark\"") && svg.contains("xlink:href=\"#My_Mark\""), "{svg}");
// The art is written once.
assert_eq!(svg.matches("<path").count(), 1, "{svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002);
// Read back: the three squares are where the instances are.
let back = import(&svg).unwrap();
let mut boxes = vec![];
back.walk(|n| {
if let Some(p) = n.path_data() {
boxes.push(p.bounds().unwrap());
}
});
assert_eq!(boxes.len(), 3, "{boxes:?}");
assert!(boxes.iter().any(|b| (b.x0 - 90.0).abs() < 1e-3 && (b.y0 - 30.0).abs() < 1e-3), "{boxes:?}");
}
#[test]
fn instances_the_def_cant_stand_for_get_their_own_art() {
// Rotated: shared (a stroke turns with its path). Scaled: stroke weights don't scale on the
// canvas, so that instance is its own art.
let rot = Affine::translate((100.0, 60.0)) * Affine::rotate(0.5);
let scaled = Affine::translate((160.0, 60.0)) * Affine::scale(1.5);
let mut d = symbol_doc(6.0, &[Affine::translate((40.0, 60.0)), rot, scaled]);
// Stained: its fill tints the art.
let mut stained = instance(&mut d, "My Mark", Affine::translate((40.0, 20.0)));
stained.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0);
stained.appearance.items.retain(|i| i.is_fill());
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut stained.appearance.items[0] {
f.opacity = 0.5;
}
let id = stained.id;
*d.node_mut(id).unwrap() = stained;
let svg = export(&d, &ExportOptions::default());
assert_eq!((svg.matches("<symbol ").count(), svg.matches("<use ").count()), (1, 2), "{svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.003);
// Without strokes, scaling keeps the def exact.
let d = symbol_doc(0.0, &[Affine::translate((40.0, 60.0)), scaled]);
let svg = export(&d, &ExportOptions::default());
assert_eq!(svg.matches("<use ").count(), 2, "{svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002);
}
#[test]
fn pattern_paints_in_symbols_stay_on_the_page() {
let mut d = Document::new(200.0, 120.0);
let red = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::rgb8(220, 0, 0)), Paint::None, 0.0),
);
let mut def = PatternDef::new("Checks", vec![Arc::new(red)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(def);
let art =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
d.symbols.push(Symbol { name: "Tiles".into(), art: Arc::new(art) });
instance(&mut d, "Tiles", Affine::translate((10.0, 10.0)));
instance(&mut d, "Tiles", Affine::translate((105.0, 37.0)));
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<use "), "{svg}");
// Both instances paint the one page-fixed tiling.
assert_eq!(svg.matches("<pattern ").count(), 1, "{svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.003);
}
/// `d` with every top-level instance replaced by its art moved as the instance moves it.
fn expanded(d: &Document) -> 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("<use "), linked, "{svg}");
// As the instances' art moved by the model's own transform.
let want = export(&expanded(&d), &ExportOptions::default());
assert_similar(&resvg_render(&want, 200, 120), &resvg_render(&svg, 200, 120), 8.0, 0.001);
}
}
#[test]
fn symbols_inside_themselves_end() {
let mut d = symbol_doc(1.0, &[Affine::translate((50.0, 50.0))]);
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Loop".into(), xf: Affine::translate((5.0, 0.0)) });
let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(inner)], clip: false });
g.name = Some("g".into());
d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(g) });
instance(&mut d, "Loop", Affine::translate((20.0, 20.0)));
let svg = export(&d, &ExportOptions::default());
assert!(import(&svg).is_ok(), "{svg}");
}
/// Two layers, the second hidden, each with a square.
fn layered() -> 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("<path").count() == 1, "{plain}");
// Page isolation wraps the layers in one more group.
let mut isolated = d.clone();
isolated.page_isolate = true;
let styles = [vectorcraft_svg::Styling::PresentationAttributes, vectorcraft_svg::Styling::InlineStyle, vectorcraft_svg::Styling::InternalCss];
for (d, styling) in styles.iter().map(|s| (&d, *s)).chain([(&isolated, vectorcraft_svg::Styling::InternalCss)]) {
let kept = export(d, &ExportOptions { hidden_layers: true, styling, ..Default::default() });
assert!(kept.contains("id=\"Notes\"") && kept.contains("display") && kept.matches("<path").count() == 2, "{kept}");
// Viewers don't show it.
assert_eq!(resvg_render(&kept, 100, 100).pixel(70, 70), [255, 255, 255, 255], "{kept}");
// It reopens as a hidden layer with its art.
let back = import(&kept).unwrap();
assert_eq!(back.layers.len(), 2, "{kept}");
let notes = &back.layers[1];
assert_eq!((notes.name.as_deref(), notes.visible), (Some("Notes"), false));
assert_eq!(notes.children().unwrap().len(), 1);
assert!(back.layers[0].visible);
}
}
#[test]
fn hidden_groups_others_use_stay_hidden() {
// A display:none group a <use> refers to is a template, not a hidden layer.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">
<g id="tpl" style="display:none"><rect width="10" height="10" fill="red"/></g>
<use xlink:href="#tpl" x="50" y="50"/>
</svg>"##;
let d = import(svg).unwrap();
let (mut paths, mut hidden) = (0, 0);
d.walk(|n| {
paths += usize::from(n.path_data().is_some());
hidden += usize::from(!n.visible);
});
// Read as before: the template isn't shown (nor shown through the `<use>`).
assert_eq!((paths, hidden), (0, 0), "{:?}", d.layers);
}
+3 -1
View File
@@ -59,9 +59,11 @@ const OBJECT_IDS: [(&str, &str); 3] = [("layerNames", "Layer Names"), ("minimal"
const UI_KEYS: [&str; 5] = ["mode", "path", "showCode", "allArtboards", "range"];
/// The options a first SVG Options dialog starts from: the engine's defaults with Internal CSS
/// and Responsive on (as the reference app's Export As starts).
/// and Responsive on (as the reference app's Export As starts). Hidden layers are left to the
/// command (Save keeps them, Export leaves them out).
fn first_use() -> Map<String, Value> {
let mut m = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).ok().and_then(|v| v.as_object().cloned()).unwrap_or_default();
m.remove("hiddenLayers");
m.insert("styling".into(), json!("css"));
m.insert("responsive".into(), json!(true));
m.insert("useArtboards".into(), json!(true));
+2
View File
@@ -48,6 +48,8 @@ mod tests_recolor;
#[cfg(test)]
mod tests_svg;
#[cfg(test)]
mod tests_svgsave;
#[cfg(test)]
mod tests_transparencygrid;
use std::sync::mpsc::{Receiver, Sender};
+2 -2
View File
@@ -8,10 +8,10 @@ use vectorcraft_engine::Session;
use crate::{Services, VectorcraftApp, io};
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
pub(super) type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with a rectangle whose save dialog answers `picked`; the writer records its files.
fn app(picked: &str) -> (VectorcraftApp, Written) {
pub(super) fn app(picked: &str) -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let picked = picked.to_string();
+28
View File
@@ -0,0 +1,28 @@
//! SVG Options and hidden layers: Save As SVG keeps them (hidden), Export As leaves them out, and
//! a save with Preserve Editing Capabilities reopens as the document.
use serde_json::{Value, json};
use crate::tests_svg::app;
#[test]
fn save_as_svg_keeps_hidden_layers_and_export_as_leaves_them_out() {
let (mut app, written) = app("/tmp/layers.svg");
let notes = app.run("layer.new", json!({"name": "Notes"})).unwrap()["id"].clone();
app.run("shape.ellipse", json!({"x": 50, "y": 40, "width": 20, "height": 20})).unwrap();
app.run("layer.setProps", json!({"id": notes, "visible": false})).unwrap();
app.run("file.export.svg", Value::Null).unwrap();
crate::dialogs::confirm(&mut app).unwrap();
app.run("file.saveAs", Value::Null).unwrap();
let d = app.ui.dialog.as_mut().expect("SVG Options");
d.fields.insert("preserveEditing".into(), json!(true));
crate::dialogs::confirm(&mut app).unwrap();
let text = |i: usize| String::from_utf8(written.borrow()[i].1.clone()).unwrap();
assert!(!text(0).contains("Notes"), "exported: {}", text(0));
assert!(text(1).contains("id=\"Notes\"") && text(1).contains("display:none"), "saved: {}", text(1));
// Reopened, the saved SVG is the document again.
let before = app.session.doc().unwrap().doc.layers.clone();
let saved = text(1);
app.session.execute("document.open", &json!({"name": "layers.svg", "dataBase64": vectorcraft_format::base64_encode(saved.as_bytes())})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, before);
}
+7
View File
@@ -130,6 +130,13 @@ reopens as the full document), `metadata` and `fewerTspans` (one `<tspan>` per l
lists every option with its default. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open,
place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`).
Symbols export as one `<symbol>` with a `<use>` per instance. An instance the def can't stand for is written as its
own art: one stained by its fill; one scaled while the symbol has strokes (their weight doesn't scale), or rotated or
scaled while it has effects, brushes or live objects; and every instance of a symbol with pattern paints or unlinked
opacity masks (those stay on the page). `hiddenLayers: true` keeps hidden layers as groups that aren't displayed
(`display:none`); `document.save` keeps them unless told otherwise, exports leave them out, and they reopen as
hidden layers.
## Resources
| URI | Content |