mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 17:44:02 +00:00
Effects: pasted art keeps an SVG filter whose name the document gives another filter
Pasted art whose svg.filter effect named one of the document's SVG filters
but drew another definition kept that name, so editing or deleting the
document's filter rewrote the pasted art's filter. Paste now renames such
a filter to a free name ("Soft 2") when the art comes from another
document or from SVG, and takes the document's filter when the art is
pasted back into the document it was copied from. Symbols, patterns,
brushes and graphic styles on the clipboard follow the same rule.
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user