diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs
index cfaafabe7f..67826614e1 100644
--- a/crates/effects/src/svg_filter.rs
+++ b/crates/effects/src/svg_filter.rs
@@ -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;
}
diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs
index 5ce32e4f0d..07f34437bb 100644
--- a/crates/effects/src/svg_filter/tests.rs
+++ b/crates/effects/src/svg_filter/tests.rs
@@ -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 [
+ ("", 1.5, MAX_WINDOW_PX),
+ ("", 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": "", "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;
diff --git a/docs/mcp.md b/docs/mcp.md
index 4000572535..cce75d189c 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -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