diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 91180981db..08e2dfeb9a 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -315,9 +315,51 @@ impl V { } } -/// σ of a blur with standard deviations `x`, `y` (their mean: the effects blur evenly). -fn sd(x: f32, y: f32) -> f64 { - (x as f64 + y as f64) / 2.0 +/// σ of a blur with standard deviations `x`, `y` in a user space the document scales by the same +/// amount in every direction when `even` (turned, flipped or scaled alike along both axes). The +/// effects blur evenly: `None` when the blur differs by direction in the document, unless `blurs` +/// is [`Blurs::Nearest`], which takes the mean. +fn sd(x: f32, y: f32, even: bool, blurs: Blurs) -> Option { + let uneven = (x > 0.0 || y > 0.0) && (x != y || !even); + (blurs == Blurs::Nearest || !uneven).then_some((x as f64 + y as f64) / 2.0) +} + +/// How [`effects`] reads a blur that differs by direction in the document, which the effects +/// draw the same in every direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Blurs { + /// Not as an effect: the filter is left for an SVG Filters effect ([`kept`]), which draws it. + Exact, + /// As the even blur of the mean standard deviation. + Nearest, +} + +/// Whether the linear part of `ts` scales every direction by the same amount (a turn, a flip and +/// an even scale), so an even blur stays even in the document. +fn even_space(ts: Affine) -> bool { + let [a, b, c, d, _, _] = ts.as_coeffs(); + let tol = 1e-9 * ts.determinant().abs().sqrt().max(1.0); + let near = |p: f64, q: f64| (p - q).abs() <= tol; + (near(a, d) && near(b, -c)) || (near(a, -d) && near(b, c)) +} + +/// `filters` (in the order they apply) imported in user space mapped to the document by `ts`: as +/// the live effects they draw as ([`effects`]), else as SVG Filters effects that keep their XML +/// ([`kept`]), else as the live effects nearest them (a blur that differs by direction drawn as +/// an even one). With whether they are live effects. `None` when none of these takes them. +pub(super) fn imported( + filters: &[Arc], + ts: Affine, + blends: &HashMap, + sources: &HashMap, +) -> Option<(Vec, bool)> { + if let Some(fx) = effects(filters, ts, blends, Blurs::Exact) { + return Some((fx, true)); + } + if let Some(fx) = kept(filters, ts, sources) { + return Some((fx, false)); + } + effects(filters, ts, blends, Blurs::Nearest).map(|fx| (fx, true)) } fn color(c: usvg::Color) -> Color { @@ -364,9 +406,10 @@ fn transferred(v: V, [r, g, b, a]: [&TransferFunction; 4], srgb: bool) -> Option } } -/// Evaluate one filter's primitives, `k` the scale from its user space to the document. +/// Evaluate one filter's primitives, `k` the scale from its user space to the document, `even` +/// when that scale is the same in every direction ([`even_space`]), blurs read as `blurs` says. /// `None` when one does something the effects cannot represent. -fn eval(f: &Filter, k: f64) -> Option { +fn eval(f: &Filter, k: f64, even: bool, blurs: Blurs) -> Option { let mut results: HashMap<&str, V> = HashMap::new(); let mut last = V::Graphic; for p in f.primitives() { @@ -380,7 +423,7 @@ fn eval(f: &Filter, k: f64) -> Option { let v = match p.kind() { Kind::GaussianBlur(b) => { let linear = p.color_interpolation() == ColorInterpolation::LinearRGB; - input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get()), linear)? + input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get(), even, blurs)?, linear)? } Kind::Offset(o) => input(o.input())?.offset(o.dx() as f64, o.dy() as f64)?, Kind::Flood(fl) => V::Flood(color(fl.color()), fl.opacity().get()), @@ -450,8 +493,8 @@ fn eval(f: &Filter, k: f64) -> Option { } } Kind::DropShadow(d) if matches!(input(d.input())?, V::Graphic) => { - let shape = - Shape { sd: sd(d.std_dev_x().get(), d.std_dev_y().get()), dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false }; + let sd = sd(d.std_dev_x().get(), d.std_dev_y().get(), even, blurs)?; + let shape = Shape { sd, dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false }; V::Stack(vec![V::Paint { shape, color: color(d.color()), opacity: d.opacity().get(), clipped: false }, V::Graphic]) } _ => return None, @@ -463,10 +506,12 @@ fn eval(f: &Filter, k: f64) -> Option { } /// The live effects `filters` (in the order they apply) stand for, in user space mapped to the -/// document by `ts`; `blends`: the modes our export recorded ([`blends`]). `None` when one of -/// them isn't an effect the canvas draws. -pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap) -> Option> { +/// document by `ts`; `blends`: the modes our export recorded ([`blends`]); `blurs`: how a blur +/// that differs by direction reads ([`Blurs`]). `None` when one of them isn't an effect the +/// canvas draws. +fn effects(filters: &[Arc], ts: Affine, blends: &HashMap, blurs: Blurs) -> Option> { let k = ts.determinant().abs().sqrt(); + let even = even_space(ts); let [a, b, c, d, _, _] = ts.as_coeffs(); let along = |dx: f64, dy: f64| Vec2::new(a * dx + c * dy, b * dx + d * dy); let num = |v: f64| (v * 1000.0).round() / 1000.0; @@ -475,7 +520,7 @@ pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap continue, // SVG blurs in linear light unless the filter asks for sRGB (`color-interpolation-filters`). V::Blurred(sd, true) => ("blur.gaussian", json!({ "radius": blur(sd), "linear": true })), diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 2eede1f7be..5c62eb10b3 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -303,11 +303,11 @@ impl Importer { (None, id) => format!("'{id}'"), }; let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); - // Blurs, shadows and glows become live effects (see [`fx`]); other filters become Effect › - // SVG Filters effects that keep their XML. + // Blurs, shadows and glows become live effects (see [`fx`]); other filters, and blurs that + // differ by direction in the document, become Effect › SVG Filters effects that keep their XML. let effects = match g.filters() { [] => vec![], - filters => fx::effects(filters, ts, &self.blends).or_else(|| fx::kept(filters, ts, &self.filter_sources)).unwrap_or_else(|| { + filters => fx::imported(filters, ts, &self.blends, &self.filter_sources).map(|(fx, _)| fx).unwrap_or_else(|| { // usvg wraps a filtered element in a group of its own: name the element then. let on = match g.children() { [usvg::Node::Group(_)] | [] => label.clone(), @@ -383,7 +383,7 @@ impl Importer { /// () cuts a filter's result at /// its region. A filter that didn't import (drawn without it) clips nothing. fn filter_regions(&mut self, g: &usvg::Group, ts: Affine, mut n: Node) -> Option { - if g.filters().is_empty() || fx::effects(g.filters(), ts, &self.blends).is_none() { + if g.filters().is_empty() || !fx::imported(g.filters(), ts, &self.blends, &self.filter_sources).is_some_and(|(_, live)| live) { return Some(n); } for f in g.filters() { diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index b39842e4fa..5e1757ae8b 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -284,6 +284,63 @@ fn a_gaussian_blur_becomes_the_effect_with_its_radius() { assert_eq!(effects(&d)[0].params["radius"], json!(12.0)); } +/// A blur that differs by direction in the document (standard deviations that differ, or an even +/// one in a user space scaled unevenly or skewed) is kept as an SVG filter, which draws it as it +/// is, not as an even Gaussian Blur. In a turned, flipped or evenly scaled user space an even blur +/// stays the effect. +#[test] +fn a_blur_that_differs_by_direction_is_kept_as_its_filter() { + let ids = |body: &str| { + let (d, w) = open(body); + assert!(w.is_empty(), "{w:?}"); + effects(&d).iter().map(|e| e.id.clone()).collect::>() + }; + let blur = |sd: &str, transform: &str| { + format!( + r##""## + ) + }; + assert_eq!(ids(&blur("4 1", "translate(0)")), ["svg.filter"]); + assert_eq!(ids(&blur("3", "scale(1 4)")), ["svg.filter"]); + assert_eq!(ids(&blur("3", "skewX(30)")), ["svg.filter"]); + assert_eq!(ids(&blur("3", "rotate(30) scale(2)")), ["blur.gaussian"]); + assert_eq!(ids(&blur("3", "scale(-2 2)")), ["blur.gaussian"]); + let shadow = |sd: &str| { + format!( + r##""## + ) + }; + assert_eq!(ids(&shadow("3 1")), ["svg.filter"]); + assert_eq!(ids(&shadow("3")), ["stylize.dropShadow"]); +} + +/// An SVG filter's blur in a user space scaled unevenly is written live and opens again as the +/// same effect, which blurs more along the stretched axis, not as an even Gaussian Blur. +#[test] +fn an_exported_blur_in_a_stretched_user_space_opens_as_the_same_effect() { + let xml = r#""#; + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(60.0, 40.0, 140.0, 80.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::None, 0.0), + ); + let params = json!({ "xml": xml, "scale": 2.0, "origin": [0.0, 0.0], "axes": [0.5, 0.0, 0.0, 2.0] }); + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export(&d, &ExportOptions::default()); + assert!(out.contains(">(), ["svg.filter"], "{out}"); + let num = |v: &serde_json::Value| v.as_f64().unwrap(); + let axes: Vec = fx[0].params["axes"].as_array().unwrap().iter().map(num).collect(); + assert!(axes.iter().zip([0.5, 0.0, 0.0, 2.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{axes:?}"); + assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.02); +} + /// `color-interpolation-filters` (SVG 1.1 §15.7.6, initially linearRGB: /// https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationFiltersProperty) picks whether the /// blur mixes colours in linear light; sRGB, on the primitive or the filter, keeps them as they diff --git a/docs/effects-parity.md b/docs/effects-parity.md index f108bb328d..915ba26033 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -27,7 +27,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | -| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned, skewed or unevenly scaled object in an imported SVG is kept and runs in that object's user space, and SVG export writes it back under the same transform | +| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned, skewed or unevenly scaled object in an imported SVG is kept and runs in that object's user space, and SVG export writes it back under the same transform. A blur or drop shadow that blurs more in one direction than another in the document (two different standard deviations, or one in a skewed or unevenly scaled user space) is kept as this effect rather than opened as an even Gaussian Blur or Drop Shadow | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers