SVG import: a blur that differs by direction is kept as its SVG filter

A feGaussianBlur or feDropShadow whose blur is not the same in every
direction in the document came in as an even Gaussian Blur or Drop
Shadow of the mean standard deviation. That covers two different
standard deviations (stdDeviation="4 1") and an even one in a skewed or
unevenly scaled user space. An SVG Filters effect in such a user space
is exported live under that transform, so on reopening its blur lost
the difference between directions.

Such a filter is now kept as an SVG Filters effect, which draws it as
it is. A blur in a turned, flipped or evenly scaled user space still
becomes the live effect, and a filter that no SVG Filters effect can
keep still comes in as the nearest even effect, as before.
This commit is contained in:
dotCooCoo
2026-11-15 06:41:20 -08:00
parent 3075889f0c
commit 9afdbe2b03
4 changed files with 119 additions and 17 deletions
+57 -12
View File
@@ -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<f64> {
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<Filter>],
ts: Affine,
blends: &HashMap<String, String>,
sources: &HashMap<String, Source>,
) -> Option<(Vec<Effect>, 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<V> {
fn eval(f: &Filter, k: f64, even: bool, blurs: Blurs) -> Option<V> {
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<V> {
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<V> {
}
}
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<V> {
}
/// 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<Filter>], ts: Affine, blends: &HashMap<String, String>) -> Option<Vec<Effect>> {
/// 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<Filter>], ts: Affine, blends: &HashMap<String, String>, blurs: Blurs) -> Option<Vec<Effect>> {
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<Filter>], ts: Affine, blends: &HashMap<Stri
let blur = |sd: f64| num(2.0 * sd * k);
let mode = blends.get(f.id()).map_or("normal", String::as_str);
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
let (id, params) = match eval(f, k)? {
let (id, params) = match eval(f, k, even, blurs)? {
V::Graphic => 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 })),
+4 -4
View File
@@ -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 {
/// (<https://www.w3.org/TR/SVG11/filters.html#FilterEffectsRegion>) 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<Node> {
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() {
+57
View File
@@ -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::<Vec<_>>()
};
let blur = |sd: &str, transform: &str| {
format!(
r##"<defs><filter id="b"><feGaussianBlur stdDeviation="{sd}"/></filter></defs><rect x="50" y="30" width="60" height="40" fill="#08f" transform="{transform}" filter="url(#b)"/>"##
)
};
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##"<defs><filter id="s"><feDropShadow dx="2" dy="2" stdDeviation="{sd}"/></filter></defs><rect width="60" height="40" filter="url(#s)"/>"##
)
};
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#"<filter id="b" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="3"/></filter>"#;
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("<g filter=\"url(#") && !out.contains("<image"), "{out}");
let (back, w) = import_with_report(&out).unwrap();
assert!(w.is_empty(), "{w:?}");
let fx = effects(&back);
assert_eq!(fx.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["svg.filter"], "{out}");
let num = |v: &serde_json::Value| v.as_f64().unwrap();
let axes: Vec<f64> = 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
+1 -1
View File
@@ -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 `<filter>` 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 `<filter>` 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