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