diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 4488b4746..5bea00948 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -21,8 +21,8 @@ use serde_json::{Map, Value, json}; use vectorcraft_brush::Brush; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, - TextStyleDef, + Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, + SymbolRegistration, TextStyleDef, }; use super::super::brushsym::{set_natural_size, stored_natural_size}; @@ -140,6 +140,31 @@ fn node_refs(n: &Node, f: Visit) { }); } +/// The `(name, xml)` of `e` when it is an SVG filter effect. +fn filter_of(e: &Effect) -> Option<(String, String)> { + let text = |k: &str| e.params.get(k).and_then(Value::as_str).map(str::to_string); + if e.id != Appearance::SVG_FILTER { + return None; + } + Some((text("name")?, text("xml")?)) +} + +/// The SVG filter effects of appearance `a` (its own and its fills' and strokes'). +fn appearance_filters(a: &Appearance, out: &mut BTreeSet<(String, String)>) { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + out.extend(a.effects.iter().chain(items).filter_map(filter_of)); +} + +/// The SVG filter effects in the subtree of `n` (opacity-mask art included). +fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) { + n.walk(&mut |m| { + appearance_filters(&m.appearance, out); + if let Some(mask) = &m.extras().mask { + node_filters(&mask.art, out); + } + }); +} + /// A global or spot colour of its own (not a tint of another swatch): what colours link to. fn is_base(w: &Swatch) -> bool { (w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. }) @@ -527,6 +552,9 @@ impl SwatchChoices { #[derive(Clone, Debug, Default)] struct Relink { renames: BTreeMap>, + /// An SVG filter effect's `(name, xml)` on the clipboard → its `(name, xml)` in the document + /// ([`Clipboard::filter_links`]). + filters: BTreeMap<(String, String), (String, String)>, /// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped. styles: BTreeMap, /// Swatch (its name in the document) → the colour linked colours show at 100 %. @@ -566,6 +594,15 @@ impl Relink { }); } fn appearance(&self, a: &mut Appearance) { + if !self.filters.is_empty() { + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items) { + if let Some((name, xml)) = filter_of(e).and_then(|k| self.filters.get(&k)) { + e.params["name"] = json!(name); + e.params["xml"] = json!(xml); + } + } + } for it in &mut a.items { self.paint(it.paint_mut()); if let AppearanceItem::Stroke(l) = it @@ -1013,6 +1050,7 @@ impl Clipboard { /// of definitions that use each other is matched on its own ([`loop_groups`]). fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { let mut out = Imported::default(); + out.relink.filters = self.filter_links(d, same_doc); self.import_images(d, used, same_doc, &mut out); self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art @@ -1170,10 +1208,52 @@ impl Clipboard { } vectorcraft_brush::store(d, &lib); } - out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty()); + out.relinks = !(out.relink.renames.is_empty() + && out.relink.filters.is_empty() + && out.relink.styles.is_empty() + && out.relink.colors.is_empty() + && used.styles.is_empty()); out } + /// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's + /// filter of its name; one that draws another definition than that filter takes the document's + /// filter when pasted back into the document it came from, and elsewhere keeps its own under a + /// free name, so editing or deleting the document's filter leaves it alone. + fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> { + let mut found = BTreeSet::new(); + for n in &self.nodes { + node_filters(n, &mut found); + } + for s in &self.symbols { + node_filters(&s.art, &mut found); + } + for a in self.patterns.iter().flat_map(|p| p.art.iter()) { + node_filters(a, &mut found); + } + for a in self.brushes.iter().flat_map(Brush::art) { + node_filters(a, &mut found); + } + for g in &self.graphic_styles { + appearance_filters(&g.appearance, &mut found); + } + let mut taken: BTreeSet = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect(); + let mut links = BTreeMap::new(); + for (name, xml) in found { + let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue }; + let to = if same_doc { + (name.clone(), held.xml.clone()) + } else { + let new = unique_name(&name, |n| taken.contains(n)); + taken.insert(new.clone()); + let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new).unwrap_or_else(|| xml.clone()); + (new, drawn) + }; + links.insert((name, xml), to); + } + links + } + /// Image blobs: a different blob under a key the document uses gets a new key. fn import_images(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) { for (key, blob) in self.images.iter().filter(|(k, _)| used.has(Res::Image, k)) { diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index fcb4581c2..4edcc6fd1 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -207,6 +207,58 @@ fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() { assert_eq!(drawn(&s, a), json!(SHIFT_MORE)); } +/// The `(name, xml)` of every `svg.filter` effect on the document's objects. +fn filter_effects(s: &Session) -> Vec<(String, String)> { + let mut out = vec![]; + s.doc().unwrap().doc.walk(|n| { + for e in n.appearance.effects.iter().filter(|e| e.id == vectorcraft_render::effects::SVG_FILTER) { + out.push((e.params["name"].as_str().unwrap().to_string(), e.params["xml"].as_str().unwrap().to_string())); + } + }); + out +} + +/// Pasted art whose filter has a name the document gives another filter keeps its own filter +/// under a free name, so editing or deleting the document's filter leaves it alone. Pasted back +/// into the document it came from, it takes the document's filter as it is now. +#[test] +fn pasted_art_keeps_a_filter_another_of_its_name_would_replace() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "Shift 2", "renamed: {pasted:?}"); + assert!(pasted[0].1.contains("id=\"Shift 2\"") && pasted[0].1.contains("dx=\"9\""), "its own filter: {pasted:?}"); + assert_eq!(names(&mut s), ["Shift"]); + run(&mut s, "svgFilter.delete", json!({"name": "Shift"})); + assert_eq!(filter_effects(&s), pasted, "the document's filter leaves it alone"); + // The same filter as the document's keeps its name. + let mut t = session(); + run(&mut t, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT}"# + ); + run(&mut t, "clipboard.importSvg", json!({"svg": svg})); + run(&mut t, "edit.pasteInPlace", json!({})); + assert_eq!(filter_effects(&t), [("Shift".to_string(), SHIFT.to_string())]); + // Copied, then the filter edited: pasted back, the copy draws the edited filter. + let mut u = session(); + let a = rect(&mut u, 10.0); + run(&mut u, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut u, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut u, "select.set", json!({"ids": [a.0]})); + run(&mut u, "edit.copy", json!({})); + run(&mut u, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + run(&mut u, "edit.pasteInPlace", json!({})); + let both = filter_effects(&u); + assert_eq!(both, [("Shift".to_string(), SHIFT_MORE.to_string()), ("Shift".to_string(), SHIFT_MORE.to_string())]); +} + /// Art in a symbol's definition that uses a filter follows its edits too. #[test] fn a_symbols_art_follows_a_filter_edit() { diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..63ce0bb03 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3680,7 +3680,9 @@ step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect ` in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` -empty) until a filter of that name is defined or imported again. Opened from such an effect, Apply SVG Filter shows the +empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's +filters but draws another definition keeps its own filter under a free name (`Soft 2`); pasted back into the document +it was copied from, it takes the document's filter. Opened from such an effect, Apply SVG Filter shows the name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs;