From 12b1408fd51e7dd2c96284bb00cf8b36efc2f545 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 20:39:36 -0800 Subject: [PATCH] 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. --- crates/effects/src/svg_filter.rs | 44 +++++++++++++++++++------- crates/effects/src/svg_filter/tests.rs | 33 +++++++++++++++++-- docs/mcp.md | 3 +- 3 files changed, 65 insertions(+), 15 deletions(-) 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