diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index fb376a9ba..66c9b5010 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -32,6 +32,8 @@ pub(crate) use output::sanitize_id; use output::{entity_value, latin1_refs, valid_xml_value, xml_attr_escape, xml_text}; use text::FontUse; +const UNIQUE_PLACEHOLDER: &str = "u_vc_unique_-"; + pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output { // Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result. let baked = vectorcraft_effects::bake_document(doc); @@ -39,11 +41,30 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]> if opts.object_ids != ObjectIds::Unique { return write(doc, opts, native, ""); } - // Unique ids: every id and class name gets a prefix hashed from the output written without - // one, so the same document always gets the same ids and different ones never share any. - let plain = write(doc, opts, native, ""); - let prefix = format!("u{:010x}-", fnv1a(plain.svg.as_bytes()) & 0xff_ffff_ffff); - write(doc, opts, native, &prefix) + // Unique ids: write the body once with a placeholder prefix ([`UNIQUE_PLACEHOLDER`]), + // hash the result, and replace the placeholder with the computed prefix in a single pass. + let mut out = write(doc, opts, native, UNIQUE_PLACEHOLDER); + let prefix = format!("u{:010x}-", fnv1a(out.svg.as_bytes()) & 0xff_ffff_ffff); + out.svg = out.svg.replace(UNIQUE_PLACEHOLDER, &prefix); + if opts.preserve_editing && native.is_some() { + update_editing_hash(&mut out.svg); + } + out +} + +/// After replacing the unique placeholder prefix, recompute the editing body hash so that +/// `editing(&svg).unwrap().intact` remains valid when `preserve_editing` is enabled. +fn update_editing_hash(svg: &mut String) { + let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let Some(xml) = usvg::roxmltree::Document::parse_with_options(svg, opts).ok() else { return }; + let Some(el) = xml.descendants().find(|n| n.tag_name().namespace() == Some(EDITING_NS) && n.tag_name().name() == "document") else { return }; + let (Some(old_h), Some(meta)) = (el.attribute("hash"), el.parent_element()) else { return }; + let r = meta.range(); + let (head, tail) = (svg.get(..r.start).unwrap_or(""), svg.get(r.end..).unwrap_or("")); + let new_h = body_hash(&[head, tail]); + if new_h != old_h { + *svg = svg.replacen(&format!("hash=\"{old_h}\""), &format!("hash=\"{new_h}\""), 1); + } } /// `doc` as SVG with `opts`, the native file `native` embedded for Preserve Editing, its generated diff --git a/crates/svg/tests/it/ids.rs b/crates/svg/tests/it/ids.rs index 07dc55617..0781397fa 100644 --- a/crates/svg/tests/it/ids.rs +++ b/crates/svg/tests/it/ids.rs @@ -136,3 +136,26 @@ fn label_attributes_name_objects() { assert_eq!(d.layers[0].name.as_deref(), Some("Background")); assert_eq!(names(&d), ["Background", "Sky box", "Sun disc", "Badge", "plain"]); } + +#[test] +fn unique_ids_differ_when_only_gradient_stops_differ() { + let make_doc = |stop_color: Color| { + let mut d = Document::new(200.0, 200.0); + d.title = "Identical Structure".into(); + let l = d.layers[0].id; + let stops = vec![GradientStop::new(0.0, stop_color), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), Appearance::basic(g, Paint::None, 0.0)); + d.insert(Some(l), usize::MAX, n).unwrap(); + d + }; + + let opts = ExportOptions { object_ids: ObjectIds::Unique, ..Default::default() }; + let svg1 = export(&make_doc(Color::rgb(1.0, 0.0, 0.0)), &opts); + let svg2 = export(&make_doc(Color::rgb(0.0, 1.0, 0.0)), &opts); + + let ids1 = ids(&svg1); + let ids2 = ids(&svg2); + assert!(!ids1.is_empty() && !ids2.is_empty()); + assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}"); +}