From 6ef785b1d8dcee903db7d20420d77bd46042e66c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 19:00:19 -0800 Subject: [PATCH] SVG Filters: a turned filter measures box units against its box, and export keeps it only where import can In a turned or skewed user space, the source is drawn onto the filter's canvas pixels first and cut to the pixels it draws there, so the image lies along the user space's axes. A region in objectBoundingBox units (the default filterUnits) and primitive lengths with primitiveUnits="objectBoundingBox" are then measured against the object's box in that user space instead of the raster's box turned into it. SVG export and the image fallback keep a filter live only when SVG import can store its user space again: scale and axes together can scale it past 0.001 to 1000 points to a unit while each is valid alone (scale 1000 with axes [2, 0, 0, 2]). Such an object is written as an image. Import and export share the user-space check. --- crates/effects/src/svg_filter.rs | 105 +++++++++++++++++-------- crates/effects/src/svg_filter/tests.rs | 32 ++++++++ crates/engine/src/cmd/rasterfx.rs | 2 +- crates/engine/src/tests/svgfilter.rs | 20 +++++ crates/svg/src/export/paint.rs | 7 +- crates/svg/src/import/fx.rs | 33 +------- docs/mcp.md | 8 +- 7 files changed, 138 insertions(+), 69 deletions(-) diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index cbbd4287d9..cfaafabe7f 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -1274,11 +1274,48 @@ fn axes_param(p: &Value) -> Option { /// `v` as the linear map of an SVG filter effect's `axes` ([`axes_param`]), when it is one. pub fn valid_axes(v: &Value) -> Option { let n: Vec = v.as_array()?.iter().map(Value::as_f64).collect::>()?; - let [a, b, c, d] = <[f64; 4]>::try_from(n).ok()?; + axes_of(<[f64; 4]>::try_from(n).ok()?) +} + +/// `[a, b, c, d]` as the linear map of an SVG filter effect's `axes`, when the effect takes it ([`valid_axes`]). +fn axes_of([a, b, c, d]: [f64; 4]) -> Option { let det = a * d - b * c; ([a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= MAX_AXIS) && det.abs() >= MIN_AXES_DET).then(|| Affine::new([a, b, c, d, 0.0, 0.0])) } +/// A filter's user space as the SVG filter effect stores it ([`user_space`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct UserSpace { + /// The effect's `scale`. + pub scale: f64, + /// The effect's `origin`. + pub origin: [f64; 2], + /// The effect's `axes`; `None` when the axes are the document's. + pub axes: Option<[f64; 4]>, +} + +/// `ts`, a user space mapped to the document, as the SVG filter effect's `scale` (the square root +/// of its area scale), `origin` and, when it turns, skews or scales unevenly, `axes`. `None` when +/// it can't be stored: not finite, `scale` outside 0.001..1000, or `axes` the effect refuses +/// ([`valid_axes`]). +pub fn user_space(ts: Affine) -> Option { + let [a, b, c, d, e, f] = ts.as_coeffs(); + if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { + return None; + } + let scale = ts.determinant().abs().sqrt(); + if !(0.001..=1000.0).contains(&scale) { + return None; + } + let even = b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= 1e-9 * a.max(1.0); + if even { + return Some(UserSpace { scale: a, origin: [e, f], axes: None }); + } + let axes = [a, b, c, d].map(|v| v / scale); + axes_of(axes)?; + Some(UserSpace { scale, origin: [e, f], axes: Some(axes) }) +} + /// The longest a unit vector becomes under the linear part of `a` (its largest singular value). fn stretch(a: Affine) -> f64 { let [a, b, c, d, _, _] = a.as_coeffs(); @@ -1433,6 +1470,12 @@ impl SvgFilterFx { self.scale == 1.0 && self.axes == Affine::IDENTITY } + /// Can SVG import store the filter's user space ([`Self::to_doc`]) again ([`user_space`])? Its + /// `scale` and `axes` together can scale it past what one of them holds alone. + pub fn opens_again(&self) -> bool { + user_space(self.to_doc()).is_some() + } + /// How far beyond `bounds` (document units) the filter can draw: its region's reach past them. pub fn outset(&self, bounds: Rect) -> f64 { let region = self.to_doc().transform_rect_bbox(self.region_in(self.user_box(bounds))); @@ -1460,23 +1503,28 @@ impl SvgFilterFx { let Some(canvas) = self.turned_canvas(w, h, s, space) else { return }; canvas }; - let Some(png) = png_of(data, w, x0, y0, x1, y1).map(Arc::new) else { return }; - // The image's pixels → filter user space. - let place = user.inverse() * space.to_doc * Affine::translate(Vec2::new(x0 as f64, y0 as f64)); - // The object's box in user space: in a turned or skewed one, the box of its pixels turned - // into it, not of their box in the raster. - let b = if self.axes == Affine::IDENTITY { - place.transform_rect_bbox(Rect::new(0.0, 0.0, (x1 - x0) as f64, (y1 - y0) as f64)) + // The source as an image, its size, and the map from its pixels to filter user space. The + // object's box in user space is the image's box there, which resvg measures the region and + // a primitive's lengths in the object's box against. In a turned or skewed user space the + // source is first drawn onto the canvas's pixels, whose rows and columns run along the user + // space's axes, and cut to the pixels it draws there: the image then lies along those axes, + // and its box is the box of the art in the user space, not of the raster's box turned into it. + let source = if self.axes == Affine::IDENTITY { + let place = user.inverse() * space.to_doc * Affine::translate(Vec2::new(x0 as f64, y0 as f64)); + png_of(data, w, x0, y0, x1, y1).map(|png| (png, x1 - x0, y1 - y0, place)) } else { - let Some(b) = turned_box(data, w, user.inverse() * space.to_doc) else { return }; - b + on_canvas(data, w, h, sw, sh, outer * user.inverse() * space.to_doc) + .map(|(png, cx, cy, iw, ih)| (png, iw, ih, outer.inverse() * Affine::translate(Vec2::new(cx as f64, cy as f64)))) }; + let Some((png, iw, ih, place)) = source else { return }; + let png = Arc::new(png); + let b = place.transform_rect_bbox(Rect::new(0.0, 0.0, iw as f64, ih as f64)); let Some(drawn) = self.drawn_xml(b).and_then(|xml| with_id(&xml, DRAWN_ID)) else { return }; let svg = format!( "{drawn}", matrix(outer), - x1 - x0, - y1 - y0, + iw, + ih, matrix(place), base64(&png) ); @@ -1548,25 +1596,20 @@ fn drawn_box(data: &[u8], w: usize, h: usize) -> Option<(usize, usize, usize, us (x1 > x0 && y1 > y0).then_some((x0, y0, x1, y1)) } -/// The box, under the map `a` from pixel coordinates, of the pixels of `data` (`w` wide) that aren't -/// transparent, or of the whole raster when all are. Under a map that keeps lines straight, a row's -/// farthest corners are those of its first and last such pixel, so each row is measured by those two. -fn turned_box(data: &[u8], w: usize, a: Affine) -> Option { - let px = data.as_chunks::<4>().0; - let h = px.len().checked_div(w)?; - let mut out: Option = None; - let mut add = |x: usize, y: usize| { - let r = a.transform_rect_bbox(Rect::new(x as f64, y as f64, x as f64 + 1.0, y as f64 + 1.0)); - out = Some(out.map_or(r, |o| o.union(r))); - }; - for y in 0..h { - let row = px.get(y * w..(y + 1) * w)?; - if let (Some(first), Some(last)) = (row.iter().position(|p| p[3] > 0), row.iter().rposition(|p| p[3] > 0)) { - add(first, y); - add(last, y); - } - } - out.or_else(|| (w > 0 && h > 0).then(|| a.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)))) +/// The pixels of `data` (premultiplied RGBA8, `w` × `h`) drawn onto a `cw` × `ch` canvas through +/// `to_canvas` (the raster's pixels → the canvas's), cut to the box of the canvas pixels that aren't +/// transparent (the whole canvas when all are): a PNG of them, the box's corner on the canvas, and +/// its size. +fn on_canvas(data: &[u8], w: usize, h: usize, cw: u32, ch: u32, to_canvas: Affine) -> Option<(Vec, usize, usize, usize, usize)> { + let size = tiny_skia::IntSize::from_wh(u32::try_from(w).ok()?, u32::try_from(h).ok()?)?; + let raster = tiny_skia::Pixmap::from_vec(data.to_vec(), size)?; + let mut canvas = tiny_skia::Pixmap::new(cw, ch)?; + let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() }; + let [a, b, c, d, e, f] = to_canvas.as_coeffs().map(|v| v as f32); + canvas.draw_pixmap(0, 0, raster.as_ref(), &paint, tiny_skia::Transform::from_row(a, b, c, d, e, f), None); + let (cw, ch) = (cw as usize, ch as usize); + let (x0, y0, x1, y1) = drawn_box(canvas.data(), cw, ch).unwrap_or((0, 0, cw, ch)); + Some((png_of(canvas.data(), cw, x0, y0, x1, y1)?, x0, y0, x1 - x0, y1 - y0)) } /// The pixels of `data` (premultiplied RGBA8, `w` wide) in columns x0..x1 and rows y0..y1, as a PNG. diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index 9a76ea1d18..5ce32e4f0d 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -269,6 +269,38 @@ fn a_turned_region_is_measured_against_the_box_in_its_user_space() { assert!(at(&data, w, 12, 12)[3] < 40, "outside the diamond, inside its box on the page: {:?}", at(&data, w, 12, 12)); } +/// In a turned user space, a region in the object's bounding box (the default `filterUnits`) and a +/// primitive's lengths in it (`primitiveUnits="objectBoundingBox"`) are measured against the +/// object's box there: on a diamond turned an eighth of a turn, a flood over 0..100% of the box +/// covers the diamond alone. +#[test] +fn a_turned_filter_measures_box_units_against_the_box_in_its_user_space() { + let w = 40; + let h = std::f64::consts::FRAC_1_SQRT_2; + let region = r##""##; + let primitive = r##""##; + for xml in [region, primitive] { + let diamond = |x: usize, y: usize| (x as f64 - 19.5).abs() + (y as f64 - 19.5).abs() <= 10.0; + let mut data = image(w, w, |x, y| if diamond(x, y) { [0, 0, 255, 255] } else { [0; 4] }); + filter(json!({ "xml": xml, "axes": [h, h, -h, h] })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0)); + assert_eq!(at(&data, w, 20, 20), [255, 0, 0, 255], "{xml}: inside the diamond"); + assert!(at(&data, w, 12, 12)[3] < 40, "{xml}: outside the diamond, inside its box on the page: {:?}", at(&data, w, 12, 12)); + } +} + +/// SVG import stores a filter's user space again only when `scale` and `axes` together stay within +/// what it holds: each alone can be valid while their product is not. +#[test] +fn a_user_space_opens_again_only_within_what_import_stores() { + let opens = |scale: f64, axes: serde_json::Value| filter(json!({ "xml": SHIFT_X, "scale": scale, "axes": axes })).opens_again(); + assert!(opens(1000.0, json!(null))); + assert!(opens(2.0, json!([0.0, 1.0, -1.0, 0.0]))); + assert!(!opens(1000.0, json!([2.0, 0.0, 0.0, 2.0])), "scale 2000 across"); + assert!(!opens(0.001, json!([0.5, 0.0, 0.0, 0.5])), "scale 0.0005 across"); + let space = user_space(Affine::new([0.0, 2.0, -2.0, 0.0, 5.0, 6.0])).unwrap(); + assert_eq!((space.scale, space.origin, space.axes), (2.0, [5.0, 6.0], Some([0.0, 1.0, -1.0, 0.0]))); +} + /// The region of a turned filter reaches as far as its turned box does. #[test] fn a_turned_region_reaches_along_the_turned_axes() { diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 9fc28e0b75..03ace409b6 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -475,7 +475,7 @@ fn imaged(n: &Node, which: Which) -> bool { && effects::svg_filter::writes_live(&n.appearance.effects) && fx .iter() - .any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if (!s.measured() || fx.len() == 1) && s.writes_as_svg11())); + .any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if (!s.measured() || fx.len() == 1) && s.writes_as_svg11() && s.opens_again())); (pixels > 0 && !live) || (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx))) } diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index a0b22e303d..611c9bfd1f 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -551,6 +551,26 @@ fn svg_export_keeps_a_measured_region_only_on_the_object_alone() { assert!(after.contains(""#; + for (scale, axes, live) in [(2.0, json!([2, 0, 0, 2]), true), (1000.0, json!([2, 0, 0, 2]), false)] { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": hue, "scale": scale, "axes": axes}})); + let out = svg(&mut s); + assert_eq!((out.contains(" String { } /// The SVG filter (Effect › SVG Filters) of an object's `effects` that SVG keeps as a ``: -/// its last visible effect, in any user space the effect gives it ([`Writer::turned_filter_space`]). +/// its last visible effect, in any user space the effect gives it ([`Writer::turned_filter_space`]) +/// that SVG import can store again. fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option { if !vectorcraft_effects::svg_filter::writes_live(effects) { return None; @@ -850,7 +851,9 @@ fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option { + Some(vectorcraft_effects::RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(f))) + if (alone || !f.measured()) && f.writes_as_svg11() && f.opens_again() => + { Some(f) } _ => None, diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index d9cfd753d5..e6fa791f16 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -119,7 +119,7 @@ fn source_of<'a>(filter: &Filter, sources: &'a HashMap) -> Optio /// or scales unevenly keeps those directions in the effect's `axes`, so the filter runs in the /// element's own user space as SVG 1.1 defines it (). pub(super) fn kept(filters: &[Arc], ts: Affine, sources: &HashMap) -> Option> { - let space = user_space(ts)?; + let space = vectorcraft_effects::svg_filter::user_space(ts)?; filters .iter() .map(|filter| { @@ -134,37 +134,6 @@ pub(super) fn kept(filters: &[Arc], ts: Affine, sources: &HashMap, -} - -/// `ts`, a user space mapped to the document, as the SVG filter effect's `scale` (the square root -/// of its area scale), `origin` and, when it turns, skews or scales unevenly, `axes`. `None` when -/// it can't be stored: not finite, `scale` outside 0.001..1000, or `axes` the effect refuses -/// ([`vectorcraft_effects::svg_filter::valid_axes`]). -fn user_space(ts: Affine) -> Option { - let [a, b, c, d, e, f] = ts.as_coeffs(); - if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { - return None; - } - let scale = ts.determinant().abs().sqrt(); - if !(0.001..=1000.0).contains(&scale) { - return None; - } - let even = b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= 1e-9 * a.max(1.0); - if even { - return Some(UserSpace { scale: a, origin: [e, f], axes: None }); - } - let axes = [a, b, c, d].map(|v| v / scale); - vectorcraft_effects::svg_filter::valid_axes(&json!(axes))?; - Some(UserSpace { scale, origin: [e, f], axes: Some(axes) }) -} - /// The object's alpha, blurred (σ), moved and maybe inverted. #[derive(Clone, Copy, Debug, Default, PartialEq)] struct Shape { diff --git a/docs/mcp.md b/docs/mcp.md index c31c8c2d5a..4000572535 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5306,8 +5306,9 @@ 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, and a region measured -against the object's box is measured against its box in that user space. Moving the object moves `origin` and +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 +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 filter is drawn and written rebuilt from its filter elements and their filter attributes alone, without comments, text, descriptions or other attributes. An image in a filter is drawn only when its bytes are a PNG, JPEG, GIF or WebP @@ -5315,7 +5316,8 @@ within the pixel limit. A filter region in user space given in percentages is me and y from its top-left corner), and one in em or ex or left out takes the default region of that box (10% beyond each side); these are VectorCraft's own rules. Scale Strokes & Effects scales `scale`. SVG export writes the filter as a `` element when it is the object's last effect and its only pixel -effect (with a region measured against the object's box, its only raster effect), with the object in a +effect (with a region measured against the object's box, its only raster effect) and SVG import can store its user +space again (`scale` and `axes` together within the import limits below), with the object in a group that places the filter's user space and such a region written in user units. When `scale` isn't 1 or the effect has `axes`, that group's transform is the filter's user space and the object is written in document coordinates in a group inside the filter whose transform is the inverse, so its strokes keep their widths; SVG import reads that back