SVG Filters: a turned filter keeps its costly lengths within the caps on its own canvas

A filter in a turned or skewed user space runs on a canvas whose axes are the
user space's. Its morphology radii, kernel units and blur deviations are now
measured in that canvas's pixels, and a length in the object's box against
the art's box on the canvas, which a skew can spread far wider than the art's
box in the raster. When one would span more than 32 or 512 canvas pixels,
the canvas runs on coarser pixels.
This commit is contained in:
dotCooCoo
2026-11-14 20:43:20 -08:00
parent 6ef785b1d8
commit 12b1408fd5
3 changed files with 65 additions and 15 deletions
+33 -11
View File
@@ -1324,6 +1324,12 @@ fn stretch(a: Affine) -> f64 {
(0.5 * (sum + (sum * sum - 4.0 * det * det).max(0.0).sqrt())).sqrt()
}
/// The share of a raster pixel, `space.px` points wide, that a pixel of the Document Raster Effects
/// resolution is (at most 1).
fn raster_resolution(space: &PixelSpace) -> f64 {
if space.line.is_finite() && space.line > 0.0 && space.px.is_finite() && space.px > 0.0 { (space.px / space.line).min(1.0) } else { 1.0 }
}
/// How many times the raster's pixels a turned filter's canvas may hold ([`SvgFilterFx::turned_canvas`]).
const TURNED_ROOM: f64 = 4.0;
@@ -1434,7 +1440,7 @@ impl SvgFilterFx {
if !(space.px.is_finite() && space.px > 0.0) {
return 1.0;
}
let mut s: f64 = if space.line.is_finite() && space.line > 0.0 { (space.px / space.line).min(1.0) } else { 1.0 };
let mut s = raster_resolution(space);
// A length spans at most length × scale × the axes' stretch / px raster pixels, or a fraction of the box.
let reach = self.scale * stretch(self.axes);
for (length, most) in [(self.window, MAX_WINDOW_PX), (self.blur, MAX_BLUR_PX)] {
@@ -1493,14 +1499,16 @@ impl SvgFilterFx {
}
// Art that draws no pixels still has a box: the whole raster stands for it.
let (x0, y0, x1, y1) = drawn_box(data, w, h).unwrap_or((0, 0, w, h));
let s = self.resolution(space, (x1 - x0).max(y1 - y0) as f64);
let user = self.to_doc();
// Filter user space → the small canvas, and the small canvas → the raster's pixels.
let (sw, sh, outer, back) = if self.axes == Affine::IDENTITY {
let s = self.resolution(space, (x1 - x0).max(y1 - y0) as f64);
let (sw, sh) = ((w as f64 * s).ceil().max(1.0) as u32, (h as f64 * s).ceil().max(1.0) as u32);
(sw, sh, Affine::scale(s) * space.to_doc.inverse() * user, Affine::scale(1.0 / s))
} else {
let Some(canvas) = self.turned_canvas(w, h, s, space) else { return };
// The canvas measures the costly lengths in its own pixels.
let drawn = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64);
let Some(canvas) = self.turned_canvas(w, h, raster_resolution(space), drawn, space) else { return };
canvas
};
// The source as an image, its size, and the map from its pixels to filter user space. The
@@ -1550,15 +1558,19 @@ impl SvgFilterFx {
/// The canvas a filter whose user space is turned, skewed or scaled unevenly runs on, for a
/// `w` × `h` raster lying in the document as `space` says, at resolution `s`
/// ([`Self::resolution`]): its axes are the user space's, so the filter's offsets, blurs and
/// other lengths run along them as SVG 1.1 measures them, and it covers what the raster shows.
/// Along each of its axes its pixels are about as large as the raster's at `s`, and coarser when it would hold more
/// than [`TURNED_ROOM`] times as many pixels as the raster does at `s`. Returns its size, the
/// map from user space to it, and the map from it to the raster's pixels; `None` when the
/// spaces can't be measured.
fn turned_canvas(&self, w: usize, h: usize, s: f64, space: &PixelSpace) -> Option<(u32, u32, Affine, Affine)> {
/// ([`raster_resolution`]), whose art draws within `drawn` (raster pixels): its axes are the user
/// space's, so the filter's offsets, blurs and other lengths run along them as SVG 1.1 measures
/// them, and it covers what the raster shows. Along each of its axes its pixels are about as
/// large as the raster's at `s`, and coarser when it would hold more than [`TURNED_ROOM`] times
/// as many pixels as the raster does at `s`, and when a morphology radius or kernel unit would
/// span more than 32 of its pixels or a blur's standard deviation more than 512 (a length in the
/// object's box measured against the art's box on the canvas). Returns its size, the map from
/// user space to it, and the map from it to the raster's pixels; `None` when the spaces can't
/// be measured.
fn turned_canvas(&self, w: usize, h: usize, s: f64, drawn: Rect, space: &PixelSpace) -> Option<(u32, u32, Affine, Affine)> {
let to_px = space.to_doc.inverse() * self.to_doc();
let ub = to_px.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let from_px = to_px.inverse();
let ub = from_px.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let most = ((w as f64 * s).ceil().max(1.0) * (h as f64 * s).ceil().max(1.0) * TURNED_ROOM).max(1.0);
// Canvas pixels a unit along each axis of the user space: as many as the raster's at `s`.
let [a, b, c, d, _, _] = to_px.as_coeffs();
@@ -1568,6 +1580,16 @@ impl SvgFilterFx {
let k = (most / pixels).sqrt();
(qx, qy) = (qx * k, qy * k);
}
// A length along an axis spans length × q canvas pixels, or length × the art's box on the
// canvas along that axis: both shrink with q.
let art = from_px.transform_rect_bbox(drawn);
for (length, cap) in [(self.window, MAX_WINDOW_PX), (self.blur, MAX_BLUR_PX)] {
let px = if self.box_units { length * (art.width() * qx).max(art.height() * qy) } else { length * qx.max(qy) };
if px > cap {
let k = cap / px;
(qx, qy) = (qx * k, qy * k);
}
}
if !(qx.is_finite() && qx > 0.0 && qy.is_finite() && qy > 0.0 && ub.is_finite()) {
return None;
}
+30 -3
View File
@@ -317,7 +317,8 @@ fn a_turned_region_reaches_along_the_turned_axes() {
fn a_skewed_filter_runs_within_its_pixel_budget() {
let (w, h) = (64, 48);
let f = filter(json!({ "xml": SHIFT_X, "axes": [1.0, 0.0, 999.0, 1.0] }));
let (cw, ch, ..) = f.turned_canvas(w, h, 1.0, &space(w, h, 1.0, 1.0)).unwrap();
let all = Rect::new(0.0, 0.0, w as f64, h as f64);
let (cw, ch, ..) = f.turned_canvas(w, h, 1.0, all, &space(w, h, 1.0, 1.0)).unwrap();
let (cw, ch) = (f64::from(cw), f64::from(ch));
assert!(cw * ch <= TURNED_ROOM * (w * h) as f64 + cw + ch + 1.0, "{cw} × {ch}");
// It still draws.
@@ -325,16 +326,42 @@ fn a_skewed_filter_runs_within_its_pixel_budget() {
f.apply(&mut data, w, h, &space(w, h, 1.0, 1.0));
// A plain quarter turn keeps the raster's own pixel size.
let turned = filter(json!({ "xml": SHIFT_X, "axes": [0.0, 1.0, -1.0, 0.0] }));
let (cw, ch, outer, _) = turned.turned_canvas(w, h, 1.0, &space(w, h, 1.0, 1.0)).unwrap();
let (cw, ch, outer, _) = turned.turned_canvas(w, h, 1.0, all, &space(w, h, 1.0, 1.0)).unwrap();
assert_eq!((cw, ch), (48, 64));
assert!((outer.determinant() - 1.0).abs() < 1e-9);
// Stretched 100 times across and squeezed 100 times down: each canvas axis keeps the raster's
// detail along it, not one column of it.
let stretched = filter(json!({ "xml": SHIFT_X, "axes": [100.0, 0.0, 0.0, 0.01] }));
let (cw, ch, ..) = stretched.turned_canvas(100, 100, 1.0, &space(100, 100, 1.0, 1.0)).unwrap();
let (cw, ch, ..) = stretched.turned_canvas(100, 100, 1.0, Rect::new(0.0, 0.0, 100.0, 100.0), &space(100, 100, 1.0, 1.0)).unwrap();
assert_eq!((cw, ch), (100, 100));
}
/// On a far skewed canvas, a radius or a standard deviation in the object's box spans no more
/// canvas pixels than the caps allow: it is measured against the art's box on the canvas, which the
/// skew spreads far wider than the art's box in the raster.
#[test]
fn a_skewed_filter_keeps_box_lengths_within_the_caps_on_its_canvas() {
let (w, h) = (64, 48);
let sp = space(w, h, 1.0, 1.0);
let drawn = Rect::new(20.0, 10.0, 40.0, 30.0);
for (xml, length, cap) in [
("<filter id=\"m\" primitiveUnits=\"objectBoundingBox\"><feMorphology radius=\"1.5\"/></filter>", 1.5, MAX_WINDOW_PX),
("<filter id=\"b\" primitiveUnits=\"objectBoundingBox\"><feGaussianBlur stdDeviation=\"20\"/></filter>", 20.0, MAX_BLUR_PX),
] {
let f = filter(json!({ "xml": xml, "axes": [1.0, 0.0, 999.0, 1.0] }));
let (_, _, outer, _) = f.turned_canvas(w, h, 1.0, drawn, &sp).unwrap();
// The art's box on the canvas: the drawn pixels taken into user space, then onto the canvas.
let art = (outer * f.to_doc().inverse() * sp.to_doc).transform_rect_bbox(drawn);
let px = length * art.width().max(art.height());
assert!(px <= cap + 1e-6, "{xml}: {px} canvas pixels");
}
// A user-space radius spans no more than 32 canvas pixels along either axis.
let f = filter(json!({ "xml": "<filter id=\"u\"><feMorphology radius=\"100\"/></filter>", "scale": 1000.0, "axes": [1.0, 0.0, 999.0, 1.0] }));
let (_, _, outer, _) = f.turned_canvas(w, h, 1.0, drawn, &sp).unwrap();
let [qx, _, _, qy, _, _] = outer.as_coeffs();
assert!(100.0 * qx.max(qy) <= MAX_WINDOW_PX + 1e-6, "{qx} {qy}");
}
#[test]
fn runs_no_finer_than_the_raster_effects_resolution() {
let w = 16;
+2 -1
View File
@@ -5306,7 +5306,8 @@ its y axis `[c, d]`, before `scale`; absent, the document's axes; each number -1
filter runs on coarser pixels when a morphology radius or kernel unit would span more than 32 of them, or a standard
deviation more than 512. With `axes`, the filter runs on pixels lined up with its own axes, so offsets, blurs and the
region run along them, and the result is drawn onto the object's pixels through `axes`; those pixels are about as fine
along each axis as the object's and coarser when there would be more than 4 times as many. The object's box is then
along each axis as the object's and coarser when there would be more than 4 times as many, or when a radius, kernel
unit or standard deviation would span more than 32 or 512 of them. The object's box is then
its box in that user space: a region measured against it, and a region or primitive lengths in `objectBoundingBox`
units, are measured against that box. Moving the object moves `origin` and
leaves `scale` and `axes` as they are. An effect whose XML or `axes` these rules refuse (from a file or a script) is not drawn or exported. The