Merge pull request #6560 from dotCooCoo/fix/pasted-svg-filter-names

Effects: pasted art keeps an SVG filter whose name the document gives another filter
This commit is contained in:
BFlat
2026-11-11 17:37:26 +07:00
committed by GitHub
3 changed files with 138 additions and 4 deletions
+83 -3
View File
@@ -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<Res, BTreeMap<String, String>>,
/// 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<u32, u32>,
/// 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<String> = 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)) {
+52
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
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#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
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() {
+3 -1
View File
@@ -3739,7 +3739,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;