M4.40: Preserve Editing SVGs reopen exactly, or as plain SVG once edited elsewhere

The editing data now sits in CDATA inside <metadata> with a hash of the
markup around that element (whitespace between tags and line endings
don't count). document.open restores the native document when the hash
matches (data written without a hash is trusted as before); when another
app changed the SVG, or the data can't be read, it imports the SVG and
says why in its warnings. vectorcraft_svg::editing reports the data and
whether it is intact; editing_data stays. Live effects, symbols and
patterns come back exactly; the import fuzz test now mutates saved SVGs
with editing data and hidden layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 09:03:52 +07:00
co-authored by Claude Opus 5.5
parent 370c6e2fc0
commit 74b5a904b7
7 changed files with 221 additions and 20 deletions
+18 -3
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
}
}
+69 -2
View File
@@ -1,7 +1,9 @@
//! SVG files that reopen as they were: Save keeps hidden layers and linked images stay linked.
//! 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::NodeKind;
use vectorcraft_doc::{Document, NodeKind};
use super::tests_svg::{image_session, svg};
use super::*;
@@ -14,10 +16,75 @@ 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();
+25
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))?;
}
}
+14 -6
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)>;
@@ -112,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>");
@@ -464,9 +466,9 @@ impl Writer<'_> {
}
/// `<metadata>`: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the
/// native document ([`ExportOptions::preserve_editing`]).
fn metadata(&self, out: &mut String, native: Option<&[u8]>) {
let native = native.filter(|_| self.opts.preserve_editing);
/// 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;
}
@@ -486,8 +488,14 @@ impl Writer<'_> {
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>");
}
+64 -6
View File
@@ -137,7 +137,7 @@ 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,
@@ -225,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;
}
@@ -235,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)?
@@ -285,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).
+28 -2
View File
@@ -1,5 +1,6 @@
//! Symbols export as one `<symbol>` def and a `<use>` per instance (instances the def can't stand
//! for get their own art), and hidden layers are kept hidden when asked and come back hidden.
//! 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)]
@@ -9,7 +10,7 @@ use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PatternDef, Symbol};
use vectorcraft_geom::{Affine, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export, import};
use vectorcraft_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.
@@ -216,3 +217,28 @@ fn hidden_groups_others_use_stay_hidden() {
// 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);
}
+3 -1
View File
@@ -135,7 +135,9 @@ own art: one stained by its fill; one scaled while the symbol has strokes (their
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.
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