mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
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:
+57
-12
@@ -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 })),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user