diff --git a/Cargo.lock b/Cargo.lock index 287a23f0c..5faa6567a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1442,7 +1442,7 @@ dependencies = [ "itertools", "log", "profiling", - "resvg", + "resvg 0.45.1", ] [[package]] @@ -4438,11 +4438,29 @@ dependencies = [ "pico-args", "rgb", "svgtypes 0.15.3", - "tiny-skia", + "tiny-skia 0.11.4", "usvg 0.45.1", "zune-jpeg 0.4.21", ] +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia 0.12.0", + "usvg 0.48.1", + "zune-jpeg 0.5.15", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4727,7 +4745,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "log", "smithay-client-toolkit 0.19.2", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -5593,6 +5611,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" @@ -6235,7 +6268,9 @@ version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", + "resvg 0.48.1", "serde_json", + "svgtypes 0.16.1", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-geom", @@ -6491,7 +6526,7 @@ dependencies = [ "image", "kurbo 0.13.1", "proptest", - "resvg", + "resvg 0.45.1", "serde", "serde_json", "simplecss", diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 81b9aaaf5..09e8154c9 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -296,6 +296,28 @@ impl Appearance { scale(e, s); } } + /// The id of Effect › SVG Filters' effect, whose `origin` parameter is a point of the document. + pub const SVG_FILTER: &str = "svg.filter"; + + /// Does any effect (the object's or a fill's or stroke's) hold an SVG filter? + pub fn has_svg_filter(&self) -> bool { + self.effects.iter().chain(self.items.iter().flat_map(|i| i.effects().iter())).any(|e| e.id == Self::SVG_FILTER) + } + + /// Move the `origin` of every SVG filter effect (where its user space starts in the document) + /// by `a`, as the object it is on moves. + pub fn transform_svg_filters(&mut self, a: kurbo::Affine) { + let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in self.effects.iter_mut().chain(items).filter(|e| e.id == Self::SVG_FILTER) { + let at = + |i: usize| e.params.get("origin").and_then(|o| o.get(i)).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let p = a * kurbo::Point::new(at(0), at(1)); + if p.x.is_finite() && p.y.is_finite() && e.params.is_object() { + e.params["origin"] = serde_json::json!([p.x, p.y]); + } + } + } + /// Gradient paints of the fills and strokes. fn gradients_mut(&mut self) -> impl Iterator { self.items.iter_mut().filter_map(|i| match i { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 78f90dac6..287a6b2aa 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -68,6 +68,7 @@ impl Document { print_setup, assets, svg_scripts, + svg_filters, puppet, new_art, dimensions, @@ -135,6 +136,7 @@ impl Document { && *print_setup == other.print_setup && *assets == other.assets && *svg_scripts == other.svg_scripts + && *svg_filters == other.svg_filters && match (puppet, &other.puppet) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (a, b) => a.is_none() && b.is_none(), @@ -245,6 +247,7 @@ impl Document { print_setup: None, assets: vec![], svg_scripts: vec![], + svg_filters: vec![], puppet: None, new_art: Default::default(), dimensions: false, diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index 4bc7660f9..c40dcf9f2 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -123,6 +123,39 @@ impl Document { std::borrow::Cow::Owned(d) } + /// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions, + /// graph designs, pattern swatches (the one being edited included) and their opacity masks? + pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool { + let roots = self + .layers + .iter() + .chain(self.symbols.iter().map(|s| &s.art)) + .chain(self.graph_designs.iter().map(|g| &g.art)) + .chain(self.patterns.iter().flat_map(|p| p.art.iter())) + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + let mut any = false; + for root in roots { + crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n)); + } + any + } + + /// Change with `f` every node for which `wants` holds, anywhere in the document's art (as + /// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art + /// they share. + pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) { + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + edit_branches_where(root, &wants, &mut f); + } + } + /// Round every path's anchors and handles to `decimals` decimal places: path objects (with /// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine /// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being @@ -194,6 +227,26 @@ impl Document { } } +/// Change with `f` the nodes of `n`'s tree (opacity-mask art included) for which `wants` holds, +/// copying out of the shared art only the branches that hold one. +fn edit_branches_where(n: &mut Arc, wants: &impl Fn(&Node) -> bool, f: &mut impl FnMut(&mut Node)) { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |c| any |= wants(c)); + if !any { + return; + } + let node = Arc::make_mut(n); + if wants(node) { + f(node); + } + if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) { + edit_branches_where(&mut m.art, wants, f); + } + for c in node.children_mut().into_iter().flatten() { + edit_branches_where(c, wants, f); + } +} + /// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every /// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as /// [`Document::round_geometry`] rounds the nodes it holds. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c995158d8..8a866256e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -150,7 +150,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; #[cfg(test)] use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; -pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit}; +pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -310,6 +310,10 @@ pub struct Document { /// given) SVG export links with `", "", + "", + "", + ] { + let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": xml }), visible: true }; + assert!(raster_effects(&[e]).is_empty(), "{xml}"); + } +} + +/// Scale Strokes & Effects scales the filter's lengths with the art: the points a unit measures. +#[test] +fn scaling_the_effect_scales_its_units() { + let mut e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": RED_FLOOD }), visible: true }; + crate::scale_effect(&mut e, 2.0); + assert_eq!(e.params["scale"], json!(2.0)); + crate::scale_effect(&mut e, 0.25); + assert_eq!(e.params["scale"], json!(0.5)); +} + +/// The form SVG output writes, for a filter that fills a 10 × 10 point box. +fn written(xml: &str) -> Option { + filter(json!({ "xml": xml })).written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)) +} + +/// SVG 1.1 output writes an image's reference as `xlink:href`; the filter as it is drawn and +/// listed keeps `href`. +#[test] +fn an_image_is_written_with_an_xlink_reference() { + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + let f = filter(json!({ "xml": format!(r#""#) })); + assert_eq!(f.xml(), format!(r#""#)); + let out = f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).unwrap(); + assert_eq!(out, format!(r#""#)); + assert!(f.writes_as_svg11()); + assert_eq!(check(&out), Ok("i".into()), "the written form reads back"); +} + +/// SVG 1.1 has no `feDropShadow`: it is written as the primitives Filter Effects gives as its +/// equivalent, with its defaults, input, result and subregion, and results no other primitive uses. +#[test] +fn a_drop_shadow_is_written_as_svg_1_1_primitives() { + assert_eq!( + written(r#""#).as_deref(), + Some(concat!( + r#""#, + r#""#, + )) + ); + // Its input, result, subregion, color space and flood; a name the filter uses is not taken. + let given = r#""#; + assert_eq!( + written(given).as_deref(), + Some(concat!( + r#""#, + r#""#, + r##""##, + r#""#, + r#""#, + )) + ); + // An implicit input, or a result no earlier primitive has, is the previous result, named. + for shadow in [r#""#, r#""#] { + let out = written(&format!(r#"{shadow}"#)).unwrap(); + assert!(out.starts_with(r##""#), "{out}"); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); + } + // What SVG 1.1 can't hold leaves the filter to be written as other pixel effects are. + for xml in [ + r#""#, + r#""#, + ] { + let f = filter(json!({ "xml": xml })); + assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{xml}"); + } + // An offset that isn't a number is the shadow's default, which an `feOffset` doesn't share. + let out = written(r#""#).unwrap(); + assert!(out.contains(r#"{}"#, "".repeat(13)); + assert!(!filter(json!({ "xml": many })).writes_as_svg11()); +} + +/// Each input is written as the primitive it reads when drawn: a result name only usvg gives (to +/// a primitive without a `result`, here the shadow), and the inputs usvg draws as `SourceGraphic`. +#[test] +fn inputs_are_written_as_the_primitives_they_read() { + let xml = r#""#; + let out = written(xml).unwrap(); + assert_eq!( + out, + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ) + ); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); +} + +/// Other values SVG 1.1 lacks: SVG 1.1 values that draw alike, or no SVG 1.1 form. +#[test] +fn values_svg_1_1_lacks_are_written_as_resvg_draws_them() { + // `lighter` (Filter Effects) is drawn as the default `over`, and a blur has no `edgeMode`. + assert_eq!( + written(r#""#) + .as_deref(), + Some(r#""#) + ); + assert_eq!( + written(r#""#).as_deref(), + Some(r#""#) + ); + // CSS colors as `#rrggbb`, a flood color's alpha in its opacity, a lighting color's dropped. + assert_eq!( + written(r#""#) + .as_deref(), + Some( + r##""## + ) + ); + // Image scaling as an SVG 1.1 keyword. + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + for (given, kept) in + [("pixelated", "optimizeSpeed"), ("crisp-edges", "optimizeSpeed"), ("smooth", "optimizeQuality"), ("auto", "optimizeQuality")] + { + let out = written(&format!(r#""#)).unwrap(); + assert!(out.contains(&format!(r#"style="image-rendering:{kept}""#)), "{given}: {out}"); + } + // The blend modes SVG 1.1 lacks have no SVG 1.1 form. + for mode in LATER_BLEND_MODES { + let f = filter(json!({ "xml": format!(r#""#) })); + assert!(!f.writes_as_svg11(), "{mode}"); + } +} + +#[test] +fn base64_pads_as_rfc_4648_does() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"foobar"), "Zm9vYmFy"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index ade2994a6..eb61b5126 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -84,6 +84,7 @@ pub(crate) mod slices; mod stroke; mod style; pub mod stylelib; +mod svgfilter; mod swatch; pub mod swatchlib; pub(crate) mod symboledit; @@ -318,6 +319,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(expand::specs()); v.extend(attributes::specs()); v.extend(interactivity::specs()); + v.extend(svgfilter::specs()); v.extend(newart::specs()); v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index df871c84e..c7bee5400 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -18,6 +18,22 @@ use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or}; +/// The name of the `svg.filter` effect drawing `xml`: its `` id. An effect names the +/// document's filter of that name, so `xml` that differs from the document's filter of the same +/// name is refused. +fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result { + let name = effects::svg_filter::check(xml).map_err(|e| bad(cmd, e))?; + if let Some(held) = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name) + && held.xml != xml.trim() + { + return Err(bad( + cmd, + format!("the document has another SVG filter named {name:?}: use svgFilter.apply, or svgFilter.define with `replace` to edit it"), + )); + } + Ok(name) +} + /// The effect commands of the Effect menu and the Appearance panel. pub fn specs() -> Vec { vec![ @@ -245,6 +261,11 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if texture_load_effect(id) { load_texture(&mut params, C)?; } + // An SVG filter needs its element, and is named by its id. + if id == effects::SVG_FILTER { + let xml = params.get("xml").and_then(Value::as_str).ok_or_else(|| bad(C, "give `xml`, one SVG element"))?; + params["name"] = json!(svg_filter_name(s, xml, C)?); + } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); aligned(p, C)?; @@ -536,6 +557,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [] })); } } + // An SVG filter is named by its id: another filter renames the effect. + if edited.iter().any(|e| e == effects::SVG_FILTER) + && let Some(xml) = params.get("xml").and_then(Value::as_str) + { + params["name"] = json!(svg_filter_name(s, xml, C)?); + } for &(id, target, index) in &targets { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index a11e4a4e8..56bfd0f7b 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -177,7 +177,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result Result<()> _ => {} } } + // An SVG filter's XML is one drawable element. + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(xml) = given.get("xml") + { + let xml = xml.as_str().ok_or_else(|| bad(id, "`xml` must be a string"))?; + vectorcraft_render::effects::svg_filter::check(xml).map_err(|e| bad(id, e))?; + } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(scale) = given.get("scale") + && !scale.as_f64().is_some_and(|k| (0.001..=1000.0).contains(&k)) + { + return Err(bad(id, "`scale` must be a number from 0.001 to 1000")); + } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(origin) = given.get("origin") + && !origin.as_array().is_some_and(|o| o.len() == 2 && o.iter().all(|v| v.as_f64().is_some_and(f64::is_finite))) + { + return Err(bad(id, "`origin` must be [x, y] in points")); + } + // Its name is the filter's id. + if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { + return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); + } check_effect_color(id, effect, params)?; check_effect_channel(id, effect, params) } diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 938d2643a..d41a4529b 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -455,8 +455,9 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { enum Which { /// Every raster effect (PDF). All, - /// Effects SVG cannot represent faithfully, with their other raster effects. - Pixel { cmyk: bool }, + /// Effects SVG cannot represent faithfully, with their other raster effects; `filters`: the SVG + /// written has filters (SVG Tiny 1.2 has none). + Pixel { cmyk: bool, filters: bool }, } /// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image? @@ -464,8 +465,14 @@ fn imaged(n: &Node, which: Which) -> bool { let fx = raster_fx(n); match which { Which::All => !fx.is_empty(), - Which::Pixel { cmyk } => { - fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) + Which::Pixel { cmyk, filters } => { + // An SVG filter that is the object's last effect, and its only pixel effect, stays a filter. + let pixels = fx.iter().filter(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))).count(); + let live = filters + && pixels == 1 + && effects::svg_filter::writes_live(&n.appearance.effects) + && fx.iter().any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if s.in_points() && (!s.measured() || fx.len() == 1) && s.writes_as_svg11())); + (pixels > 0 && !live) || (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx))) } } @@ -592,7 +599,13 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { /// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its /// filters draw the other raster effects live), or `None` when there are none. pub fn flatten_pixel_effects(doc: &Document) -> Option { - flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc)) + flatten_pixel_effects_for(doc, true) +} + +/// [`flatten_pixel_effects`] for SVG that has filters (`filters`) or not (SVG Tiny 1.2): without +/// them an SVG filter (Effect › SVG Filters) also makes its object an image. +pub fn flatten_pixel_effects_for(doc: &Document, filters: bool) -> Option { + flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk, filters }, doc)) } fn flatten(doc: &Document, report: &mut FlattenReport) -> Option { diff --git a/crates/engine/src/cmd/svgfilter.rs b/crates/engine/src/cmd/svgfilter.rs new file mode 100644 index 000000000..98b29a6e2 --- /dev/null +++ b/crates/engine/src/cmd/svgfilter.rs @@ -0,0 +1,234 @@ +//! Effect › SVG Filters: the SVG filters a document holds (`svgFilter.list`, `define`, `delete`, +//! `import`) and applying one to objects as a live `svg.filter` effect (`svgFilter.apply`). An +//! applied effect names the document's filter: editing the filter changes what every object that +//! uses it draws, and deleting it leaves their effects in place, drawing nothing, until a filter of +//! that name exists again. Each effect holds the definition it draws with, kept in step here. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Appearance, Document, SvgFilter}; +use vectorcraft_render::effects::{self, svg_filter}; + +use super::fileio::{absolute_path, read_file_head}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, text_param}; + +/// The largest SVG file Import SVG Filter reads. +const MAX_IMPORT_BYTES: usize = 16 << 20; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "svgFilter.list", + "SVG Filters", + [], + None, + "{} → {filters: [{name, xml}…]} the SVG filters the document holds, in the SVG Filters submenu's order", + has_doc, + list + ), + cmd!( + "svgFilter.define", + "New SVG Filter", + [], + None, + "{xml: one SVG element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter has is refused and a document holds at most 64 filters → {name, filters}", + has_doc, + define + ), + cmd!( + "svgFilter.delete", + "Delete SVG Filter", + [], + None, + "{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again → {filters}", + has_doc, + delete + ), + cmd!( + "svgFilter.import", + "Import SVG Filter…", + [], + None, + "{path?|svg?} add the elements of an SVG file (at most 16 MB; not SVGZ; its first 256 filters) or of SVG text to the document's SVG filters, as one undo step; a filter whose name the document already has, and one svgFilter.define would refuse, is left out; objects whose effect names an added filter draw with it → {added: [names…], skipped: count, filters}", + has_doc, + import + ), + cmd!( + "svgFilter.apply", + "Apply SVG Filter", + [], + None, + "{name, ids?|id?, item?} apply the document's SVG filter `name` (svgFilter.list) to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}", + has_doc, + apply + ), + ] +} + +fn filters_json(d: &Document) -> Value { + json!(d.svg_filters.iter().map(|f| json!({ "name": f.name, "xml": f.xml })).collect::>()) +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) +} + +/// `p`'s `name`, trimmed and not empty. +fn name_param(p: &Value, cmd: &str) -> Result { + let n = text_param(p, "name", cmd)?.map(str::trim).filter(|n| !n.is_empty()); + n.map(str::to_string).ok_or_else(|| bad(cmd, "give `name`, an SVG filter's name")) +} + +/// Is `e` an `svg.filter` effect that names filter `name`? +fn names(e: &vectorcraft_doc::Effect, name: &str) -> bool { + e.id == effects::SVG_FILTER && e.params.get("name").and_then(Value::as_str) == Some(name) +} + +/// The `svg.filter` effects of appearance `a` (its own and its fills' and strokes') that name +/// filter `name`. +fn named<'a>(a: &'a Appearance, name: &'a str) -> impl Iterator + 'a { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + a.effects.iter().chain(items).filter(move |e| names(e, name)) +} + +/// Make the effects of appearance `a` that name filter `name` draw with `xml`. +fn update_appearance(a: &mut Appearance, name: &str, xml: &str) { + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items) { + if names(e, name) && e.params.is_object() { + e.params["xml"] = json!(xml); + } + } +} + +/// Does an effect in `d` that names filter `name` draw with something other than `xml` (`""`: +/// nothing)? Symbol definitions, graph designs, patterns, opacity masks and graphic styles count. +fn out_of_step(d: &Document, name: &str, xml: &str) -> bool { + let stale = |a: &Appearance| named(a, name).any(|e| e.params.get("xml").and_then(Value::as_str) != Some(xml)); + d.any_node(|n| stale(&n.appearance)) || d.graphic_styles.iter().any(|s| stale(&s.appearance)) +} + +/// Make every effect in `d` that names filter `name` draw with `xml` (`""` when the document no +/// longer has the filter: the effect keeps its name and draws nothing), symbol definitions, graph +/// designs, patterns, opacity masks and graphic styles included. +fn update_users(d: &mut Document, name: &str, xml: &str) { + // Through `DerefMut` only for an appearance that has such an effect (it is shared copy-on-write). + d.edit_nodes_where(|n| named(&n.appearance, name).next().is_some(), |n| update_appearance(&mut n.appearance, name, xml)); + for s in d.graphic_styles.iter_mut().filter(|s| named(&s.appearance, name).next().is_some()) { + update_appearance(&mut s.appearance, name, xml); + } +} + +fn define(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.define"; + let xml = match p.get("xml") { + Some(Value::String(x)) => x.trim().to_string(), + Some(_) => return Err(bad(C, "`xml` must be a string")), + None => return Err(bad(C, "give `xml`, one SVG element")), + }; + let replace = match p.get("replace") { + None | Some(Value::Null) => None, + Some(Value::String(r)) => Some(r.trim().to_string()), + Some(_) => return Err(bad(C, "`replace` must be a string")), + }; + svg_filter::check(&xml).map_err(|e| bad(C, e))?; + let filters = &s.doc()?.doc.svg_filters; + let at = match &replace { + Some(r) => Some(filters.iter().position(|f| f.name == *r).ok_or_else(|| bad(C, format!("the document has no SVG filter named {r:?}")))?), + None => None, + }; + // An edited filter keeps its name, whatever id its new code gives it. + let xml = match &replace { + Some(r) => svg_filter::with_id(&xml, r).ok_or_else(|| bad(C, "give one element"))?, + None => xml, + }; + let name = svg_filter::check(&xml).map_err(|e| bad(C, e))?; + if filters.iter().enumerate().any(|(i, f)| f.name == name && Some(i) != at) { + return Err(bad(C, format!("the document already has an SVG filter named {name:?}"))); + } + if at.is_none() && filters.len() >= SvgFilter::MAX { + return Err(bad(C, format!("a document holds at most {} SVG filters", SvgFilter::MAX))); + } + let new = SvgFilter { name: name.clone(), xml }; + // Nothing to record when the filter, and every object that uses it, is already as given. + if at.and_then(|i| filters.get(i)) != Some(&new) || out_of_step(&s.doc()?.doc, &new.name, &new.xml) { + let label = if at.is_some() { "Edit SVG Filter" } else { "New SVG Filter" }; + s.edit(label, |d, _| { + update_users(d, &new.name, &new.xml); + match at.and_then(|i| d.svg_filters.get_mut(i)) { + Some(f) => *f = new, + None => d.svg_filters.push(new), + } + Ok(()) + })?; + } + Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.delete"; + let name = name_param(p, C)?; + if !s.doc()?.doc.svg_filters.iter().any(|f| f.name == name) { + return Err(bad(C, format!("the document has no SVG filter named {name:?}"))); + } + s.edit("Delete SVG Filter", |d, _| { + d.svg_filters.retain(|f| f.name != name); + update_users(d, &name, ""); + Ok(()) + })?; + Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) +} + +/// The bytes of `p`'s `path` (an SVG file, read up to [`MAX_IMPORT_BYTES`]) or `svg`. +fn import_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (text_param(p, "path", cmd)?, text_param(p, "svg", cmd)?) { + // One byte past the cap is enough to refuse the file below. + (Some(path), _) => read_file_head(&absolute_path(path), MAX_IMPORT_BYTES + 1)?, + (None, Some(svg)) => svg.as_bytes().to_vec(), + _ => return Err(bad(cmd, "give `path`, an SVG file, or `svg`, SVG text")), + }; + if bytes.len() > MAX_IMPORT_BYTES { + return Err(bad(cmd, format!("the SVG file is larger than {MAX_IMPORT_BYTES} bytes"))); + } + Ok(bytes) +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.import"; + let bytes = import_bytes(p, C)?; + // SVGZ would unpack past the size read above. + if vectorcraft_svg::is_svgz(&bytes) { + return Err(bad(C, "Import SVG Filter reads SVG files, not SVGZ")); + } + let text = vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?; + let held = s.doc()?.doc.svg_filters.clone(); + let room = SvgFilter::MAX.saturating_sub(held.len()); + // Filters past the room left and names the document has are left out unchecked. + let (found, skipped) = vectorcraft_svg::svg_filters(&text, room, |name| held.iter().any(|f| f.name == name)) + .map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?; + let added: Vec = found.into_iter().map(|(name, xml)| SvgFilter { name, xml }).collect(); + let names: Vec = added.iter().map(|f| f.name.clone()).collect(); + if !added.is_empty() { + s.edit("Import SVG Filter", |d, _| { + for f in &added { + update_users(d, &f.name, &f.xml); + } + d.svg_filters.extend(added); + Ok(()) + })?; + } + Ok(json!({ "added": names, "skipped": skipped, "filters": filters_json(&s.doc()?.doc) })) +} + +fn apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.apply"; + let name = name_param(p, C)?; + let f = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name).cloned(); + let f = f.ok_or_else(|| bad(C, format!("the document has no SVG filter named {name:?}")))?; + let mut q = json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }); + for key in ["ids", "id", "item"] { + if let Some(v) = p.get(key) { + q[key] = v.clone(); + } + } + super::effectcmd::apply(s, &q) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 812f08b35..742d7a3ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -166,6 +166,7 @@ mod strokeux; mod stylelib; mod stylepanel; mod styles; +mod svgfilter; mod swatchcmds; mod swatches; mod swatchlib; diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs new file mode 100644 index 000000000..c9f5cef44 --- /dev/null +++ b/crates/engine/src/tests/svgfilter.rs @@ -0,0 +1,476 @@ +//! Effect › SVG Filters (`svgFilter.*`): the filters a document holds, applying one as a live +//! `svg.filter` effect, and what SVG export and the native format keep of them. + +use serde_json::{Value, json}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).unwrap_err().to_string() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()) +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn names(s: &mut Session) -> Vec { + run(s, "svgFilter.list", json!({}))["filters"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap().to_string()).collect() +} + +const BLUR: &str = r#""#; +const SHIFT: &str = r#""#; + +#[test] +fn filters_are_defined_edited_and_deleted_in_one_undo_step_each() { + let mut s = session(); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"], json!([])); + assert_eq!(run(&mut s, "svgFilter.define", json!({"xml": format!(" {BLUR} ")}))["name"], "Soft"); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + assert_eq!(names(&mut s), ["Soft", "Shift"], "in the order they were added"); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], BLUR, "the XML trimmed"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Soft"], "one step"); + run(&mut s, "edit.redo", json!({})); + // A name another filter has is refused; replace edits one in place, and it keeps its name. + assert!(err(&mut s, "svgFilter.define", json!({"xml": BLUR})).contains("already has an SVG filter named \"Soft\"")); + let renamed = r#""#; + run(&mut s, "svgFilter.define", json!({"xml": renamed, "replace": "Soft"})); + assert_eq!(names(&mut s), ["Soft", "Shift"]); + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Nope"})).contains("no SVG filter named \"Nope\"")); + // The same filter again changes nothing and makes no undo step. + let before = steps(&s); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Shift"})); + assert_eq!(steps(&s), before); + assert_eq!(run(&mut s, "svgFilter.delete", json!({"name": "Soft"}))["filters"].as_array().unwrap().len(), 1); + assert!(err(&mut s, "svgFilter.delete", json!({"name": "Soft"})).contains("no SVG filter named")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": " "})).contains("give `name`")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Soft", "Shift"]); +} + +#[test] +fn define_refuses_what_is_not_one_filter() { + let mut s = session(); + for (p, why) in [ + (json!({}), "give `xml`"), + (json!({"xml": 3}), "`xml` must be a string"), + (json!({"xml": ""}), "needs an id"), + (json!({"xml": ""}), "no primitives"), + (json!({"xml": ""}), "give one element"), + (json!({"xml": SHIFT, "replace": 1}), "`replace` must be a string"), + ] { + let e = err(&mut s, "svgFilter.define", p.clone()); + assert!(e.contains(why), "{p}: {e}"); + } + assert_eq!(steps(&s), 0, "nothing recorded"); +} + +#[test] +fn a_document_holds_at_most_64_filters() { + let mut s = session(); + for i in 0..64 { + run(&mut s, "svgFilter.define", json!({"xml": format!("")})); + } + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT})).contains("at most 64 SVG filters")); + run(&mut s, "svgFilter.define", json!({"xml": "", "replace": "f0"})); + assert_eq!(names(&mut s).len(), 64, "a replacement still fits"); +} + +#[test] +fn import_adds_the_files_filters_it_does_not_have() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + "{BLUR}" + ); + let path = std::env::temp_dir().join(format!("vc-svgfilters-{}.svg", std::process::id())); + std::fs::write(&path, &svg).unwrap(); + let r = run(&mut s, "svgFilter.import", json!({"path": path.to_string_lossy()})); + std::fs::remove_file(&path).ok(); + assert_eq!(r["added"], json!(["Soft"])); + assert_eq!(r["skipped"], 2, "a name the document has, and a filter without primitives"); + assert_eq!(names(&mut s), ["Shift", "Soft"]); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], SHIFT, "the document's own filter stays"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Shift"], "one step"); + // Nothing new: no undo step. + let before = steps(&s); + assert_eq!(run(&mut s, "svgFilter.import", json!({"svg": ""}))["added"], json!([])); + assert_eq!(steps(&s), before); + assert!(err(&mut s, "svgFilter.import", json!({"svg": "{}{BLUR}{}", "".repeat(5000), "".repeat(5000)); + assert!(err(&mut s, "svgFilter.import", json!({"svg": deep})).contains("isn't valid SVG")); + assert!(err(&mut s, "svgFilter.import", json!({})).contains("give `path`")); + let svg_text = format!("{BLUR}"); + assert!(err(&mut s, "svgFilter.import", json!({"path": 3, "svg": svg_text})).contains("`path` must be a string")); + assert!(err(&mut s, "svgFilter.import", json!({"svg": ["x"]})).contains("`svg` must be a string")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": 3})).contains("`name` must be a string")); + let svgz = std::env::temp_dir().join(format!("vc-svgfilters-{}.svgz", std::process::id())); + std::fs::write(&svgz, vectorcraft_svg::compress(&format!("{BLUR}"))).unwrap(); + let e = err(&mut s, "svgFilter.import", json!({"path": svgz.to_string_lossy()})); + std::fs::remove_file(&svgz).ok(); + assert!(e.contains("not SVGZ"), "{e}"); + let missing = std::env::temp_dir().join("vc-no-such-svgfilter-file (1).svg"); + assert!(s.execute("svgFilter.import", &json!({"path": missing.to_string_lossy()})).is_err()); +} + +#[test] +fn apply_adds_the_filter_as_a_live_effect_in_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.define", json!({"xml": BLUR})); + let before = steps(&s); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let r = run(&mut s, "svgFilter.apply", json!({"name": "Soft"})); + assert_eq!(r["ids"].as_array().unwrap().len(), 2); + assert_eq!(steps(&s), before + 1, "one step for both objects"); + for id in [a, b] { + let fx = s.doc().unwrap().doc.node(id).unwrap().appearance.effects.clone(); + assert_eq!(fx.len(), 1); + assert_eq!(fx[0].id, "svg.filter"); + assert_eq!((fx[0].params["name"].as_str(), fx[0].params["xml"].as_str()), (Some("Soft"), Some(BLUR))); + } + run(&mut s, "svgFilter.delete", json!({"name": "Soft"})); + assert!(err(&mut s, "svgFilter.apply", json!({"name": "Soft"})).contains("no SVG filter named")); + assert!(err(&mut s, "svgFilter.apply", json!({})).contains("give `name`")); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("svgFilter.apply", &json!({"name": "Shift"})).is_err(), "nothing selected"); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [b.0]})); + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().appearance.effects.len(), 2, "given ids, nothing selected"); +} + +/// The xml the first effect of `id` draws with. +fn drawn(s: &Session, id: NodeId) -> Value { + s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["xml"].clone() +} + +const SHIFT_MORE: &str = r#""#; + +/// Objects name the document's filter: an edit changes what every one of them draws, in one undo +/// step, and a later application takes the edited filter. +#[test] +fn editing_a_filter_changes_every_object_that_uses_it() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + let c = rect(&mut s, 90.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0, b.0]})); + let before = steps(&s); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + assert_eq!(steps(&s), before + 1); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT_MORE), json!(SHIFT_MORE))); + // One undo puts both back. + run(&mut s, "edit.undo", json!({})); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT), json!(SHIFT))); + run(&mut s, "edit.redo", json!({})); + // Applied after the edit: the edited filter. + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [c.0]})); + assert_eq!(drawn(&s, c), json!(SHIFT_MORE)); + // An edit whose code names another id keeps the filter's name. + run(&mut s, "svgFilter.define", json!({"xml": r#""#, "replace": "Shift"})); + assert_eq!(names(&mut s), ["Shift"]); + assert_eq!(drawn(&s, a), json!(r#""#)); +} + +/// Deleting a filter that objects use leaves their effects, which draw nothing until a filter of +/// that name exists again. +#[test] +fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": "Shift"})); + let fx = s.doc().unwrap().doc.node(a).unwrap().appearance.effects.clone(); + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Shift")), "the effect stays, named"); + assert!(vectorcraft_render::effects::raster_effects(&fx).is_empty(), "drawn without the filter"); + // Imported again: the effect draws with it. + run(&mut s, "svgFilter.import", json!({"svg": format!("{SHIFT_MORE}")})); + assert_eq!(drawn(&s, a), json!(SHIFT_MORE)); +} + +/// Art in a symbol's definition that uses a filter follows its edits too. +#[test] +fn a_symbols_art_follows_a_filter_edit() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "symbol.new", json!({"name": "Mark"})); + let in_symbol = |s: &Session| { + let mut xml = vec![]; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| xml.extend(n.appearance.effects.iter().map(|e| e.params["xml"].clone()))); + xml + }; + assert_eq!(in_symbol(&s), [json!(SHIFT)]); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + assert_eq!(in_symbol(&s), [json!(SHIFT_MORE)]); +} + +/// A graphic style that holds a filter follows its edits, so applying it later gives the edited +/// filter. +#[test] +fn a_graphic_style_follows_a_filter_edit() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Shifted", "id": a.0})); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + run(&mut s, "graphicStyle.apply", json!({"name": "Shifted", "ids": [b.0]})); + assert_eq!(drawn(&s, b), json!(SHIFT_MORE)); +} + +/// An edited filter keeps its name, characters XML escapes included. +#[test] +fn an_edit_keeps_a_name_with_escaped_characters() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + assert_eq!(names(&mut s), ["a&b"]); + run(&mut s, "svgFilter.define", json!({"xml": r#""#, "replace": "a&b"})); + assert_eq!(names(&mut s), ["a&b"]); +} + +#[test] +fn effect_apply_takes_a_filter_and_names_it_by_its_id() { + let mut s = session(); + rect(&mut s, 10.0); + let r = run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}})); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["name"], "Shift"); + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter"})).contains("give `xml`")); + for scale in [json!(0), json!(1001), json!("2")] { + let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT, "scale": scale}})); + assert!(e.contains("`scale` must be a number"), "{scale}: {e}"); + } + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": ""}})).contains("give one element")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": ""}})).contains("needs an id")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"scale": 0}})).contains("`scale` must be a number from 0.001")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"name": "Other"}})).contains("give `xml` with `name`")); + // Another filter renames the effect. + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": BLUR}})); + let p = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params; + assert_eq!((p["name"].as_str(), p["xml"].as_str()), (Some("Soft"), Some(BLUR))); +} + +/// An effect names the document's filter, so effect.apply and effect.setParams refuse XML that +/// differs from the document's filter of the same id, and take XML that matches it. +#[test] +fn effect_commands_refuse_another_definition_of_a_document_filter() { + let mut s = session(); + rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": BLUR})); + let other = r#""#; + let before = steps(&s); + let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": other}})); + assert!(e.contains("another SVG filter named \"Soft\""), "{e}"); + assert_eq!(steps(&s), before, "nothing recorded"); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": format!(" {BLUR} ")}})); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}})); + let e = err(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": other}})); + assert!(e.contains("another SVG filter named \"Soft\""), "{e}"); + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": BLUR}})); +} + +/// An effect whose filter `check` refuses (put there by an opened file or a script, not by a +/// command) is neither drawn nor written to SVG. +#[test] +fn svg_export_leaves_out_a_filter_that_fails_the_check() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let bad = r#""#; + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": bad}})).contains("