Merge pull request #7853 from dotCooCoo/fix/svg-blur-by-direction

SVG import: a blur that differs by direction is kept as its SVG filter
This commit is contained in:
BFlat
2026-11-16 01:31:37 +07:00
committed by GitHub
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