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:
commit
bba9d2ceb8
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,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}");
|
||||
}
|
||||
|
|
@ -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))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue