Merge pull request #72 from storytold/pr/m4-svg-symbols-editing

M4: SVG symbols as <symbol>/<use>, hidden layers, metadata; Preserve Editing round trip (M4.39, M4.40)
This commit is contained in:
BFlat 2026-10-05 09:12:13 +07:00 committed by GitHub
commit bba9d2ceb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 846 additions and 61 deletions

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
}

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;

View File

@ -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<Loaded> {
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<Loaded> {
"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
}
}

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;

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"));
}

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",

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)

View File

@ -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("<use ").count(), 3, "{text}");
let r = open(&mut s, "rich.svg", &text);
assert_eq!(r["warnings"], json!([]), "{r}");
let after = comparable(&s.doc().unwrap().doc);
assert_eq!(after, before, "the document came back exactly");
let mut effects = 0;
after.walk(|n| effects += n.appearance.effects.len());
assert_eq!(effects, 1, "the live effect is still live");
}
#[test]
fn an_svg_edited_elsewhere_opens_as_plain_svg_with_a_warning() {
let mut s = rich();
let text = svg(&mut s, json!({"preserveEditing": true}));
// Another app changes a colour (the pattern's red).
let edited = text.replacen("#ff0000", "#00ff00", 1);
assert_ne!(edited, text);
let r = open(&mut s, "edited.svg", &edited);
assert_eq!(r["warnings"][0], load::EDITING_STALE, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.symbols.is_empty() && d.patterns.iter().all(|p| p.name != "Dots"), "plain import: no symbols, no named pattern");
// Unreadable editing data (the hash only covers the markup around it) falls back too.
let damaged = text.replacen("<![CDATA[", "<![CDATA[!!", 1);
let r = open(&mut s, "damaged.svg", &damaged);
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
}
#[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}");
}

View File

@ -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))?;
}
}

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

View File

@ -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!("<title>{}</title>{nl}", xml_escape(&doc.title)));
}
// The editing data carries a hash of the markup around its `<metadata>`.
let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "</svg>", nl])));
w.metadata(&mut out, native);
out.push_str(&w.body);
out.push_str("</svg>");
@ -173,6 +178,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 +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 `<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,10 +465,10 @@ impl Writer<'_> {
}
}
/// `<metadata>`: 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);
/// `<metadata>`: 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, "<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>");
}
if let Some(bytes) = native {
line(2, &format!("<vectorcraft:document xmlns:vectorcraft=\"{EDITING_NS}\">{}</vectorcraft:document>", base64_encode(bytes)));
if let Some((bytes, hash)) = native {
line(
2,
&format!(
"<vectorcraft:document xmlns:vectorcraft=\"{EDITING_NS}\" hash=\"{hash}\"><![CDATA[{}]]></vectorcraft:document>",
base64_encode(bytes)
),
);
}
line(1, "</metadata>");
}
@ -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!("<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 +1213,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) {

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)
}

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.
//!
@ -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 `<metadata>` so VectorCraft
/// reopens the SVG with nothing lost (see [`editing_data`]).
/// reopens the SVG with nothing lost (see [`editing`]).
pub preserve_editing: bool,
/// Write `<metadata>` with the document's Dublin Core title and format.
pub metadata: bool,
/// 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,
}
}
}
@ -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<String> {
/// 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 `<metadata>` 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<Editing> {
if !svg.contains(EDITING_NS) {
return None;
}
@ -225,7 +245,48 @@ pub fn editing_data(svg: &str) -> Option<String> {
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 `<metadata>` 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<String> {
editing(svg).map(|e| e.data)
}
/// The hash [`ExportOptions::preserve_editing`] keeps of an SVG's markup outside its
/// `<metadata>` (`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<String>), 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).

View File

@ -0,0 +1,244 @@
//! Symbols export as one `<symbol>` def and a `<use>` 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("<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);
}
#[test]
fn editing_data_notices_edits_made_elsewhere() {
let d = symbol_doc(2.0, &[Affine::translate((50.0, 50.0))]);
let opts = ExportOptions { preserve_editing: true, metadata: true, ..Default::default() };
let svg = export_full(&d, &opts, Some(b"native bytes")).svg;
assert!(svg.contains("<![CDATA[") && svg.contains(" hash=\""), "{svg}");
let e = editing(&svg).unwrap();
assert!(e.intact && e.data == vectorcraft_svg::base64_encode(b"native bytes"));
// Re-indented, with other line endings: still the same picture.
let reformatted = svg.replace("\n ", "\r\n\t\t");
assert!(editing(&reformatted).unwrap().intact);
let minified = export_full(&d, &ExportOptions { minify: true, ..opts.clone() }, Some(b"native bytes")).svg;
assert!(editing(&minified).unwrap().intact);
// A colour changed elsewhere.
let edited = svg.replacen("#1e5adc", "#ff0000", 1);
assert_ne!(edited, svg);
assert!(!editing(&edited).unwrap().intact);
// Metadata edits don't count: only the markup around the <metadata> element does.
let retitled = svg.replacen("<dc:format>", "<dc:creator>someone</dc:creator><dc:format>", 1);
assert!(editing(&retitled).unwrap().intact);
// Unique ids hash the final output.
let unique = export_full(&d, &ExportOptions { object_ids: vectorcraft_svg::ObjectIds::Unique, ..opts }, Some(b"native bytes")).svg;
assert!(editing(&unique).unwrap().intact);
}

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));

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};

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();

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);
}

View File

@ -130,6 +130,15 @@ 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. A `preserveEditing` SVG carries the native document (CDATA in `<metadata>`) and a hash of the
markup around it: if another app changed the SVG since, `document.open` reads it as plain SVG and says so in
`warnings`.
## Resources
| URI | Content |