diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 2a5ce5fd9d..8e0d63e9d0 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -859,7 +859,7 @@ pub fn effect_catalog() -> Vec { crate::SVG_FILTER, "SVG Filter", &["Effect", "SVG Filters"], - "{name: string (the filter's id), xml: string (one SVG element with an id and 1..64 primitives, at most 64 KB), scale: number 0.001..1000 (1; points per unit of the filter's user space), origin: [x, y] pt ([0, 0]; where the filter's user space starts in the document)} runs the SVG filter on the object's pixels, its lengths in points times scale, at the Document Raster Effects resolution or finer; svgFilter.apply applies one of the document's filters (svgFilter.list)", + "{name: string (the filter's id), xml: string (one SVG element with an id and 1..64 primitives, at most 64 KB), scale: number 0.001..1000 (1; points per unit of the filter's user space), origin: [x, y] pt ([0, 0]; where the filter's user space starts in the document), axes?: [a, b, c, d] (absent or [1, 0, 0, 1]: the document's axes, and not null; the document directions of one unit along the filter's x axis [a, b] and y axis [c, d] before scale, each -1000..1000, with a·d - b·c at least 0.001 from 0; a turned, skewed or unevenly scaled user space, as SVG import gives a filtered element under a rotate, skew or uneven scale; the filter's offsets, blurs and region run along these axes; moving, turning or skewing the object moves origin and leaves axes)} runs the SVG filter on the object's pixels, its lengths in points times scale, at the Document Raster Effects resolution or finer; svgFilter.apply applies one of the document's filters (svgFilter.list)", json!({"scale": 1.0, "origin": [0.0, 0.0]}), )); v diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 0c4bff0f1d..67826614e1 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -1,7 +1,8 @@ //! Effect › SVG Filters (`svg.filter`): an SVG `` element applied as a live raster effect. //! The filter runs on the object's pixels as SVG 1.1 describes it //! (), drawn by resvg. Its lengths are in points times -//! the effect's `scale`, its user space starts at the effect's `origin`, its object bounding box is the box of the drawn pixels, and it runs at the +//! the effect's `scale`, its user space starts at the effect's `origin`, its axes point as the effect's `axes` say (a turned or +//! skewed user space), its object bounding box is the box of the drawn pixels, and it runs at the //! Document Raster Effects resolution when the view shows the object at a finer one. use std::sync::Arc; @@ -1224,13 +1225,16 @@ fn region_length(v: Option<&str>, default: f64, user: bool) -> Length { } /// An SVG filter effect: the filter's XML, how many points one of its units is, where its user -/// space's origin lies in the document, and its region. +/// space's origin lies in the document, which way its axes point, and its region. #[derive(Clone, Debug, PartialEq)] pub struct SvgFilterFx { /// The filter as it is drawn and written ([`rebuilt`]). xml: Arc, scale: f64, origin: Vec2, + /// The directions of the user space's axes in the document, before `scale` ([`axes_param`]): + /// the identity unless the user space is turned, skewed or scaled unevenly. + axes: Affine, /// Its region (filter effects region): x, y, width, height. region: [Length; 4], /// Is the region in user space with a length measured against the object's box? It is then @@ -1250,11 +1254,92 @@ fn origin_param(p: &Value) -> Vec2 { Vec2::new(at(0), at(1)) } +/// The largest magnitude of an entry of an SVG filter effect's `axes`. +pub const MAX_AXIS: f64 = 1000.0; + +/// The smallest magnitude of the determinant of an SVG filter effect's `axes`. +pub const MIN_AXES_DET: f64 = 0.001; + +/// The `axes` of an SVG filter effect's parameters `p`: `[a, b, c, d]`, the document directions +/// of one unit along the filter's x axis (`[a, b]`) and its y axis (`[c, d]`), before `scale`. +/// The identity when absent; `None` when it isn't four numbers from -[`MAX_AXIS`] to [`MAX_AXIS`] +/// whose determinant is at least [`MIN_AXES_DET`] across ([`valid_axes`]). +fn axes_param(p: &Value) -> Option { + match p.get("axes") { + None | Some(Value::Null) => Some(Affine::IDENTITY), + Some(v) => valid_axes(v), + } +} + +/// `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::>()?; + 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(); + let sum = a * a + b * b + c * c + d * d; + let det = a * d - b * c; + (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; + impl SvgFilterFx { - /// The effect's parameters `p` (defaults merged in): `xml`, the `` element, `scale` - /// and `origin`. `None` when `xml` isn't a filter [`check`] takes, however the effect came to - /// hold it (an opened file, a script): the object is then drawn, printed and exported without it. + /// The effect's parameters `p` (defaults merged in): `xml`, the `` element, `scale`, + /// `origin` and `axes`. `None` when `xml` isn't a filter [`check`] takes, or `axes` isn't + /// valid ([`axes_param`]), however the effect came to hold it (an opened file, a script): the + /// object is then drawn, printed and exported without it. pub(crate) fn parse(p: &Value) -> Option { + let axes = axes_param(p)?; let xml = checked(text(p, "xml", ""))?; let (wrapped, _) = wrap(&xml); let doc = roxmltree::Document::parse(&wrapped).ok()?; @@ -1273,7 +1358,17 @@ impl SvgFilterFx { let (window, blur) = (largest(&["radius", "kernelUnitLength"]), largest(&["stdDeviation"])); let box_units = f.attribute("primitiveUnits").is_some_and(|u| u.trim() == "objectBoundingBox"); let scale = num(p, "scale", 1.0).clamp(1e-3, 1e3); - Some(Self { xml, scale, origin: origin_param(p), region, measured, window, blur, box_units }) + Some(Self { xml, scale, origin: origin_param(p), axes, region, measured, window, blur, box_units }) + } + + /// The filter's user space → the document: moved to `origin`, scaled by `scale`, along `axes`. + pub fn to_doc(&self) -> Affine { + Affine::translate(self.origin) * Affine::scale(self.scale) * self.axes + } + + /// The box of `bounds` (document units) in the filter's user space. + fn user_box(&self, bounds: Rect) -> Rect { + self.to_doc().inverse().transform_rect_bbox(bounds) } /// The filter's region in its user space for an object whose box there is `b`: lengths in user @@ -1308,11 +1403,11 @@ impl SvgFilterFx { with_attributes(xml, &[("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())]) } - /// The filter as SVG output writes it (one unit a point, [`Self::in_points`]) for an object - /// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1 - /// can't hold it ([`Self::writes_as_svg11`]). - pub fn written_xml(&self, bounds: Rect) -> Option { - let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?; + /// The filter as SVG output writes it, in its user space ([`Self::to_doc`]), for an object + /// whose box in that user space is `user_box`: in its SVG 1.1 form ([`svg11`]), `None` when SVG + /// 1.1 can't hold it ([`Self::writes_as_svg11`]). + pub fn written_xml(&self, user_box: Rect) -> Option { + let out = self.with_region(&self.svg11_form()?, user_box)?; (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } @@ -1345,10 +1440,11 @@ 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 }; - // A length spans length × scale / px raster pixels, or a fraction of the box. + 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)] { - let px = if self.box_units { length * box_px.max(0.0) } else { length * self.scale / space.px }; + let px = if self.box_units { length * box_px.max(0.0) } else { length * reach / space.px }; if px * s > most { s = most / px; } @@ -1374,15 +1470,21 @@ impl SvgFilterFx { self.measured } - /// Does one unit of the filter's user space measure one point (its `scale` is 1)? - pub fn in_points(&self) -> bool { - self.scale == 1.0 + /// Is the filter's user space the document's, only moved: one unit a point, its axes the + /// document's (`scale` 1, no `axes`)? + pub fn moves_only(&self) -> bool { + 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 b = (bounds - self.origin).scale_from_origin(1.0 / self.scale); - let region = self.region_in(b).scale_from_origin(self.scale) + self.origin; + let region = self.to_doc().transform_rect_bbox(self.region_in(self.user_box(bounds))); let reach = [bounds.x0 - region.x0, bounds.y0 - region.y0, region.x1 - bounds.x1, region.y1 - bounds.y1].into_iter().fold(0.0, f64::max); if reach.is_finite() { reach.min(MAX_OUTSET) } else { 0.0 } } @@ -1397,20 +1499,40 @@ 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 (sw, sh) = ((w as f64 * s).ceil().max(1.0) as u32, (h as f64 * s).ceil().max(1.0) as u32); - let Some(png) = png_of(data, w, x0, y0, x1, y1).map(Arc::new) else { return }; - let k = self.scale; - // Filter user space → the small canvas, and the image's pixels → filter user space. - let outer = Affine::scale(s) * space.to_doc.inverse() * Affine::translate(self.origin) * Affine::scale(k); - let place = Affine::scale(1.0 / k) * Affine::translate(-self.origin) * space.to_doc * Affine::translate(Vec2::new(x0 as f64, y0 as f64)); - let b = place.transform_rect_bbox(Rect::new(0.0, 0.0, (x1 - x0) as f64, (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 { + // 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 + // 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 { + 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) ); @@ -1420,19 +1542,65 @@ impl SvgFilterFx { } let Some(mut small) = tiny_skia::Pixmap::new(sw, sh) else { return }; resvg::render(&tree, tiny_skia::Transform::identity(), &mut small.as_mut()); - let out = if s < 1.0 { + let out = if back == Affine::IDENTITY { + small + } else { let Some(mut full) = tiny_skia::Pixmap::new(w as u32, h as u32) else { return }; let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() }; - let up = (1.0 / s) as f32; - full.draw_pixmap(0, 0, small.as_ref(), &paint, tiny_skia::Transform::from_scale(up, up), None); + let [a, b, c, d, e, f] = back.as_coeffs().map(|v| v as f32); + full.draw_pixmap(0, 0, small.as_ref(), &paint, tiny_skia::Transform::from_row(a, b, c, d, e, f), None); full - } else { - small }; if out.data().len() == data.len() { data.copy_from_slice(out.data()); } } + + /// 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` + /// ([`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 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(); + let (mut qx, mut qy) = (s * a.hypot(b), s * c.hypot(d)); + let pixels = ub.width() * qx * ub.height() * qy; + if pixels > most { + 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; + } + let (cw, ch) = ((ub.width() * qx).ceil().max(1.0), (ub.height() * qy).ceil().max(1.0)); + // Rounding up adds at most a row and a column. + if cw * ch > most + cw + ch + 1.0 { + return None; + } + let outer = Affine::scale_non_uniform(qx, qy) * Affine::translate(Vec2::new(-ub.x0, -ub.y0)); + Some((cw as u32, ch as u32, outer, to_px * outer.inverse())) + } } /// The farthest a filter's region reaches beyond the object, in points. @@ -1450,6 +1618,22 @@ 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 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. fn png_of(data: &[u8], w: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> Option> { use image::ImageEncoder; diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index f93cb53124..4ab6328359 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -222,6 +222,154 @@ fn user_space_starts_at_the_origin() { assert!((filter(json!({ "xml": half, "origin": [-10.0, 0.0] })).outset(b) - 10.0).abs() < 1e-9); } +/// The opacity of `data` (`w` wide) after the filter `xml` with `params` runs on a blue square +/// over 10..20 pt of a 40 pt raster one point a pixel, at each of `points`. +fn turned_run(xml: &str, params: serde_json::Value, points: &[(usize, usize)]) -> Vec { + let w = 40; + let mut data = image(w, w, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [0, 0, 255, 255] } else { [0; 4] }); + let mut p = params; + p["xml"] = json!(xml); + filter(p).apply(&mut data, w, w, &space(w, w, 1.0, 1.0)); + points.iter().map(|&(x, y)| at(&data, w, x, y)[3]).collect() +} + +const SHIFT_X: &str = r#""#; + +/// With `axes`, the filter runs in its turned or skewed user space: an offset along its x axis +/// moves the art along that axis in the document (SVG 1.1 measures a primitive's lengths in the +/// user space of the element the filter is on). +#[test] +fn a_turned_user_space_moves_the_art_along_its_own_axes() { + // The square spans 10..20 pt; moved 4 pt along x it covers 14..24 across. + let points = [(22, 15), (15, 22), (12, 11)]; + assert_eq!(turned_run(SHIFT_X, json!({}), &points), [255, 0, 0], "the document's axes: to the right"); + // Turned a quarter turn: the filter's x axis points down the page. + let down = turned_run(SHIFT_X, json!({ "axes": [0.0, 1.0, -1.0, 0.0] }), &points); + assert_eq!(down, [0, 255, 0], "moved down, not to the right"); + // Skewed: one unit along y is one across and one down, so dy 4 moves the art 4 pt each way. + let shift_y = r#""#; + let skewed = turned_run(shift_y, json!({ "axes": [1.0, 0.0, 1.0, 1.0] }), &[(22, 22), (11, 22), (11, 11)]); + assert!(skewed[0] > 200 && skewed[1] < 60 && skewed[2] < 60, "moved across and down: {skewed:?}"); +} + +#[test] +fn axes_the_effect_cannot_measure_are_not_drawn() { + for axes in [json!([0, 0, 0, 0]), json!([1, 2]), json!([1, 0, 0, "1"]), json!([2000, 0, 0, 1]), json!([0.01, 0, 0, 0.01]), json!("turned")] { + let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": SHIFT_X, "axes": axes }), visible: true }; + assert!(raster_effects(&[e]).is_empty(), "{axes}: drawn without it"); + assert!(valid_axes(&axes).is_none(), "{axes}"); + } + assert_eq!(valid_axes(&json!([0, 1, -1, 0])), Some(Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]))); +} + +/// A region measured against the object's box is measured against its box in the turned user +/// space: a diamond turned an eighth of a turn is a square there, and a flood over 0..100% of it +/// covers the diamond alone. +#[test] +fn a_turned_region_is_measured_against_the_box_in_its_user_space() { + let w = 40; + 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] }); + let flood = r##""##; + let h = std::f64::consts::FRAC_1_SQRT_2; + filter(json!({ "xml": flood, "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], "inside the diamond"); + 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() { + let b = Rect::new(0.0, 0.0, 50.0, 50.0); + let user = r#""#; + assert!((filter(json!({ "xml": user })).outset(b) - 45.0).abs() < 1e-9, "45 pt below the box"); + // A quarter turn: user y runs to the left of the page, so -5..95 of it lies 95 pt left of the box. + assert!((filter(json!({ "xml": user, "axes": [0.0, 1.0, -1.0, 0.0] })).outset(b) - 95.0).abs() < 1e-9); +} + +/// A filter skewed as far as `axes` allows runs on a canvas no larger than four times the raster's +/// pixels, however far the skew spreads the raster's box in its user space. +#[test] +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 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. + let mut data = image(w, h, |x, y| if (20..40).contains(&x) && (10..30).contains(&y) { [0, 0, 255, 255] } else { [0; 4] }); + 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, 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, 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; @@ -448,7 +596,8 @@ fn a_region_measured_against_the_box_is_written_out() { let far = r#""#; let f = filter(json!({ "xml": far, "origin": [10.0, 0.0] })); assert_eq!( - f.written_xml(Rect::new(10.0, 0.0, 30.0, 10.0)).as_deref(), + // The object over 10..30 pt of the page: 0..20 of the user space that starts at the origin. + f.written_xml(Rect::new(0.0, 0.0, 20.0, 10.0)).as_deref(), Some(r#""#) ); let plain = filter(json!({ "xml": RED_FLOOD })); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index fa9e9b2996..fdf7dd4b19 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -369,6 +369,12 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> { return Err(bad(id, "`origin` must be [x, y] in points")); } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(axes) = given.get("axes") + && vectorcraft_render::effects::svg_filter::valid_axes(axes).is_none() + { + return Err(bad(id, "`axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0")); + } // Its name is the filter's id. if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index c9efc9d0bb..5f412e080d 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -486,7 +486,9 @@ fn imaged(n: &Node, which: Which) -> bool { let live = filters && pixels == 1 && effects::svg_filter::writes_live(&n.appearance.effects) - && fx.iter().any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if s.in_points() && (!s.measured() || fx.len() == 1) && s.writes_as_svg11())); + && fx + .iter() + .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/params.rs b/crates/engine/src/tests/params.rs index 52cb314de0..708b602d1f 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -704,10 +704,11 @@ fn effect_params_are_the_effects_own() { fn an_effect_parameter_without_a_default_is_held_to_its_type() { use crate::cmd::params::undefaulted_type; use vectorcraft_render::effects::effect_catalog; - // Read by checks of their own: a stored texture or a texture to load, an SVG filter, a Warp's + // Read by checks of their own: a stored texture or a texture to load, an SVG filter and its axes, a Warp's // map and the style of Crop Marks. let own_check = |effect: &str, key: &str| match key { "textureImage" | "name" | "xml" => true, + "axes" => effect == "svg.filter", "version" => effect.starts_with("warp."), "style" => effect == "cropMarks", _ => false, diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index e42d7f9736..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(" { let mut inner = n.clone(); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); // An SVG filter's user-space coordinates are the document's from the effect's origin: the - // object is written in them, in a group that moves it where the rest of the art goes. + // object is written in them, in a group that moves it where the rest of the art goes. A + // user space that is scaled or turned ([`turned_filter_space`]) is the group's own: the + // object is written as it is in a group inside the filter that maps it back + // ([`Self::open_filters`]), so its strokes keep their widths. let xf = self.xf; - let origin = live_svg_filter(&n.appearance.effects).map(|f| Affine::translate(f.origin())); + let live = live_svg_filter(&n.appearance.effects); + let turned = self.turned_filter_space(live.as_ref()); + let origin = live.filter(|_| turned.is_none()).map(|f| Affine::translate(f.origin())); let moved = origin.is_some_and(|o| xf * o != Affine::IDENTITY); if let Some(o) = origin.filter(|_| moved) { self.line(&format!("", self.matrix(xf * o))); self.depth += 1; self.xf = o.inverse(); } + if let Some(space) = turned { + self.line(&format!("", exact_matrix(space))); + self.depth += 1; + } let opened = self.open_filters(n, &n.appearance.effects); self.node_body(&inner); self.close_filters(opened); - if moved { + if moved || turned.is_some() { self.xf = xf; self.depth -= 1; self.line(""); } } + /// The group transform that holds a live SVG filter `live` ([`live_svg_filter`]) whose user + /// space is scaled or turned (not only moved, [`vectorcraft_effects::svg_filter::SvgFilterFx::moves_only`]): the filter's user + /// space in the output's. `None` for other filters and in SVG Tiny, which writes no filters. + fn turned_filter_space(&self, live: Option<&vectorcraft_effects::svg_filter::SvgFilterFx>) -> Option { + live.filter(|f| !f.moves_only() && !self.tiny()).map(|f| self.xf * f.to_doc()) + } + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or /// stroke's) on `n`, outermost last effect; returns how many to close. pub(super) fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { @@ -644,6 +660,7 @@ impl Writer<'_> { let mut opened = 0; // An SVG filter that is the object's last effect is written as it is ([`Self::filtered`]). let live = if std::ptr::eq(effects, n.appearance.effects.as_slice()) { live_svg_filter(effects) } else { None }; + let turned = self.turned_filter_space(live.as_ref()); for (k, f) in fx.iter().enumerate().rev() { if let RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(sf)) = f && live.is_some() @@ -654,12 +671,40 @@ impl Writer<'_> { // against the object alone, which is all it runs on ([`live_svg_filter`]). let mut input = n.clone(); input.appearance.effects.clear(); - let input_bounds = input.visual_bounds_with(&self.doc.symbol_boxes()).unwrap_or_default(); - if let Some(def) = sf.written_xml(input_bounds).and_then(|xml| vectorcraft_effects::svg_filter::with_id(&xml, &fid)) { - self.def(1, &def); + // Its box in the filter's user space: in a turned or skewed one, the box of the + // object's geometry turned into it, grown by what its strokes and other paint add + // around it in the document: a square that far to each side, as it measures in + // user space along each axis (a miter corner reaches that far along both). + let symbols = self.doc.symbol_boxes(); + let visual = input.visual_bounds_with(&symbols).unwrap_or_default(); + let user_box = if sf.moves_only() { + visual - sf.origin() + } else { + let geometric = input.geometric_bounds_with(&symbols).unwrap_or(visual); + let margin = [geometric.x0 - visual.x0, geometric.y0 - visual.y0, visual.x1 - geometric.x1, visual.y1 - geometric.y1] + .into_iter() + .fold(0.0, f64::max); + let to_user = sf.to_doc().inverse(); + input.transform(to_user, false); + let [a, b, c, d, _, _] = to_user.as_coeffs(); + let turned = input.geometric_bounds_with(&symbols).unwrap_or_default(); + turned.inflate(margin * (a.abs() + c.abs()), margin * (b.abs() + d.abs())) + }; + let def = sf.written_xml(user_box).and_then(|xml| vectorcraft_effects::svg_filter::with_id(&xml, &fid)); + if let Some(def) = &def { + self.def(1, def); self.line(&format!("")); self.depth += 1; opened += 1; + } + // Inside the group of a turned user space ([`Self::filtered`]), the object and its + // other effects are written as they are, in a group that maps them back to it. + if let Some(space) = turned { + self.line(&format!("", exact_matrix(space.inverse()))); + self.depth += 1; + opened += 1; + } + if def.is_some() { continue; } } @@ -786,8 +831,18 @@ pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { } } +/// `a` as an SVG `matrix(…)` with every digit its numbers need to read back the same, whatever +/// the export's decimals: the transforms around a turned SVG filter ([`Writer::turned_filter_space`]) +/// and their inverses still undo each other when one holds very small numbers. +fn exact_matrix(a: Affine) -> String { + // (Without negative zeros.) + let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v + 0.0); + format!("matrix({a} {b} {c} {d} {e} {f})") +} + /// The SVG filter (Effect › SVG Filters) of an object's `effects` that SVG keeps as a ``: -/// its last visible effect, measured in points. +/// 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; @@ -797,7 +852,7 @@ fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option + if (alone || !f.measured()) && f.writes_as_svg11() && f.opens_again() => { Some(f) } diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 4a549552d4..91180981db 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -142,19 +142,22 @@ fn source_of<'a>(filter: &Filter, sources: &'a HashMap) -> Optio /// XML from `sources` ([`sources`]), in user space mapped to the document by `ts`. `None` when one /// isn't there, looks different outside the document, or isn't a filter the effect draws /// ([`vectorcraft_effects::svg_filter::check`]), or -/// when `ts` turns or skews the user space or scales it past what the effect measures (0.001 to -/// 1000 points to a unit). Where `ts` moves the user space is the effect's `origin`. +/// when `ts` scales the user space past what the effect measures (0.001 to 1000 points to a unit, +/// as the square root of its area scale) or stretches its axes past what the effect's `axes` hold. +/// Where `ts` moves the user space is the effect's `origin`; a user space that `ts` turns, skews +/// 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 [a, b, c, d, e, f] = ts.as_coeffs(); - if b != 0.0 || c != 0.0 || !(0.001..=1000.0).contains(&a) || (a - d).abs() > 1e-9 * a.abs().max(1.0) || !e.is_finite() || !f.is_finite() { - return None; - } + let space = vectorcraft_effects::svg_filter::user_space(ts)?; filters .iter() .map(|filter| { // A filter styled by the document is left out rather than drawn differently. let source = source_of(filter, sources).filter(|s| !s.styled)?; - let params = json!({ "name": source.name()?, "xml": source.xml, "scale": a, "origin": [e, f] }); + let mut params = json!({ "name": source.name()?, "xml": source.xml, "scale": space.scale, "origin": space.origin }); + if let Some(axes) = space.axes { + params["axes"] = json!(axes); + } Some(Effect { id: vectorcraft_effects::SVG_FILTER.into(), params, visible: true }) }) .collect() diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 3a598f5ca1..239ce7e07a 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -763,14 +763,15 @@ fn percentages_in_a_symbol_are_of_its_view_box() { } /// #1598: the warning for a filter that neither the live effects nor an SVG filter effect can hold -/// (here, on turned objects) names the filter, its primitives and the object, and says it is drawn -/// without it (not "a group": usvg wraps the element in one). Such a filter on an object that -/// isn't turned comes in as an SVG filter effect holding the filter's XML. +/// (here, on objects skewed past what the effect measures) names the filter, its primitives and the +/// object, and says it is drawn without it (not "a group": usvg wraps the element in one). Such a +/// filter on an object that isn't skewed that far comes in as an SVG filter effect holding the +/// filter's XML. #[test] fn unsupported_filters_are_named_in_the_warning() { let body = r#" - - + + "#; let (d, w) = import_with_report(&svg(body)).unwrap(); assert!(w.iter().any(|w| w == "filter 'f' (feColorMatrix) on an object isn't supported; drawn without it"), "{w:?}"); diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 1d10c5d9fe..b39842e4fa 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -522,7 +522,8 @@ fn a_filter_used_twice_among_many_is_kept_twice() { assert_eq!(filters, vec![vec![json!("g")], vec![json!("g")]], "{:?}", art(&d)); } -/// Other filters come in as SVG filters; one on a turned object is reported and left out. +/// Other filters come in as SVG filters, on a turned or skewed object too, with the object's user +/// space in `scale`, `origin` and `axes`; one past what the effect measures is reported and left out. #[test] fn other_filters_are_kept_or_reported() { let (d, w) = @@ -530,8 +531,32 @@ fn other_filters_are_kept_or_reported() { assert!(w.is_empty(), "{w:?}"); let fx = effects(&d); assert_eq!((fx.len(), fx[0].id.as_str(), &fx[0].params["name"]), (1, "svg.filter", &json!("t"))); + assert!(fx[0].params.get("axes").is_none(), "the document's axes: {:?}", fx[0].params); let (d, w) = open( - r##""##, + r##""##, + ); + assert!(w.is_empty(), "{w:?}"); + let fx = effects(&d); + assert_eq!(fx.len(), 1); + let p = &fx[0].params; + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let num = |v: &serde_json::Value| v.as_f64().unwrap(); + assert!((num(&p["scale"]) - 2.0).abs() < 1e-6 && p["origin"] == json!([10.0, 20.0]), "{p}"); + let axes: Vec = p["axes"].as_array().unwrap().iter().map(num).collect(); + assert!(axes.iter().zip([c, s, -s, c]).all(|(a, b)| (a - b).abs() < 1e-6), "{axes:?}"); + // Skewed, and scaled unevenly: the axes keep both. + let (d, w) = open( + r##""##, + ); + assert!(w.is_empty(), "{w:?}"); + let all: Vec> = + art(&d).iter().flat_map(|n| &n.appearance.effects).map(|e| e.params["axes"].as_array().unwrap().iter().map(num).collect()).collect(); + assert_eq!(all.len(), 2); + assert!(all.iter().any(|a| a.iter().zip([1.0, 0.0, 1.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-6)), "{all:?}"); + assert!(all.iter().any(|a| a.iter().zip([0.5, 0.0, 0.0, 2.0]).all(|(a, b)| (a - b).abs() < 1e-6)), "{all:?}"); + // Skewed so far that an axis would stretch past 1000 times: reported and left out. + let (d, w) = open( + r##""##, ); assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); assert!(effects(&d).is_empty()); @@ -543,6 +568,40 @@ fn other_filters_are_kept_or_reported() { assert!(effects(&d).is_empty()); } +/// A filter on a turned object runs in the object's user space (SVG 1.1 measures its lengths +/// there): opened, it draws as the same filter does on the same art drawn unturned with its offset +/// turned by hand. +#[test] +fn a_filter_on_a_turned_object_draws_in_its_user_space() { + let filter = |dx: f64, dy: f64| { + format!( + r#""# + ) + }; + let turned = format!( + r##"{}"##, + filter(24.0, 0.0) + ); + let (d, w) = open(&turned); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(effects(&d).iter().map(|e| e.id.as_str()).collect::>(), ["svg.filter"]); + // The same square, its corners turned by hand, with the offset 24 along the turned x axis. + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let corner = |x: f64, y: f64| format!("{},{}", 80.0 + c * (x - 80.0) - s * (y - 50.0), 50.0 + s * (x - 80.0) + c * (y - 50.0)); + let reference = format!( + r##"{}"##, + filter(24.0 * c, 24.0 * s), + corner(60.0, 30.0), + corner(100.0, 30.0), + corner(100.0, 70.0), + corner(60.0, 70.0) + ); + let canvas = render_artboard(&d); + // The moved square lies beyond where it was: its center, turned and moved, is near (100.8, 62). + assert!(canvas.over_white(101, 62) != [255, 255, 255], "drawn where the offset puts it"); + assert_similar(&canvas, &resvg_render(&svg(&reference), 200, 120), 40.0, 0.02); +} + #[test] fn a_filter_and_a_clip_on_one_object_filter_first() { let body = r##" @@ -638,6 +697,69 @@ fn filtered_rect(xml: &str) -> Document { d } +/// An SVG filter in a scaled, turned or skewed user space is written live: the filter's user space +/// is a group's transform, and the art is written in document coordinates in a group inside the +/// filter that maps it back, so its stroke keeps its width. Opened again, it is the same effect +/// and draws as the canvas drew it. +#[test] +fn an_exported_turned_filter_opens_as_the_same_effect() { + let xml = r#""#; + for (scale, axes) in [(2.0, json!([0.8, 0.6, -0.6, 0.8])), (1.0, json!([1.0, 0.0, 0.5, 1.0])), (0.5, serde_json::Value::Null)] { + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::solid(Color::BLACK), 4.0), + ); + let mut params = json!({ "xml": xml, "scale": scale, "origin": [30.0, 10.0] }); + if !axes.is_null() { + params["axes"] = axes.clone(); + } + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export(&d, &ExportOptions::default()); + assert!(out.contains(" = fx[0].params["origin"].as_array().unwrap().iter().map(num).collect(); + assert!((origin[0] - 30.0).abs() < 1e-6 && (origin[1] - 10.0).abs() < 1e-6, "{origin:?}"); + match fx[0].params.get("axes") { + Some(got) => { + let got: Vec = got.as_array().unwrap().iter().map(num).collect(); + let want: Vec = axes.as_array().unwrap().iter().map(num).collect(); + assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{got:?}"); + } + None => assert!(axes.is_null(), "{:?}", fx[0].params), + } + assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.02); + } + // A user space stretched 10,000 times along x: the inverse transform holds 0.0001, which the + // export's three decimals would round to 0, and the art still opens at its place. + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::None, 0.0), + ); + let xml = r#""#; + let params = json!({ "xml": xml, "scale": 10.0, "axes": [1000.0, 0.0, 0.0, 1.0] }); + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export(&d, &ExportOptions::default()); + let (back, w) = import_with_report(&out).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let bounds = |d: &Document| art(d).iter().find_map(|n| n.path_data().and_then(|p| p.bounds())).unwrap(); + let (a, b) = (bounds(&d), bounds(&back)); + assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3 && (a.y1 - b.y1).abs() < 1e-3, "{a:?} {b:?}: {out}"); +} + /// SVG 1.1 export writes an SVG filter's image reference as `xlink:href`, and it imports back. #[test] fn an_exported_filter_image_has_an_xlink_reference() { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 05e9004bcb..f21726ba95 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6670,6 +6670,7 @@ digits Malayalam Malaiàlam @msg {what} points outside memory {what} apunta fora de la memòria @msg `angle` must be a number `angle` ha de ser un nombre @msg `aspect` must be from 0.01 to 100 `aspect` ha d’estar entre 0.01 i 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` ha de ser [a, b, c, d], cadascun entre -1000 i 1000, amb a·d - b·c com a mínim a 0.001 de 0 @msg `cap` must be "butt", "round" or "square" `cap` ha de ser "butt", "round" o "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` ha de ser "nonzero" o "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` ha de ser una adreça https:// de {MAX_HELP_URL} bytes com a màxim diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index cb37d0e83f..c6fe81c344 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7977,6 +7977,7 @@ breakText Glyphs Glyfy @msg `names` must be an array of style names `names` musí být pole názvů stylů @msg `new` is a design name, or true `new` je název návrhu, nebo true @msg `origin` must be [x, y] in points `origin` musí být [x, y] v bodech +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` musí být [a, b, c, d], každé od -1000 do 1000, a a·d - b·c se musí od 0 lišit alespoň o 0.001 @msg `path` must be a folder `path` musí být složka @msg `path` must be a path object `path` musí být objekt cesty @msg `picks` must be an array (got {v}) `picks` musí být pole (zadáno {v}) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 442ea36859..01e5bef54d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -427,6 +427,7 @@ @msg {what} points outside memory {what} peger uden for hukommelsen @msg `angle` must be a number `angle` skal være et tal @msg `aspect` must be from 0.01 to 100 `aspect` skal være fra 0,01 til 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` skal være [a, b, c, d], hver fra -1000 til 1000, med a·d - b·c mindst 0,001 fra 0 @msg `cap` must be "butt", "round" or "square" `cap` skal være "butt", "round" eller "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` skal være "nonzero" eller "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` skal være en https://-adresse på højst {MAX_HELP_URL} byte diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 22b6c437db..3340807dd2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -7292,6 +7292,7 @@ breakText Glyphs Glyphen @msg `name` must be a name that is not empty `name` muss ein nicht leerer Name sein @msg `name` must not be empty `name` darf nicht leer sein @msg `origin` must be [x, y] in points `origin` muss [x, y] in Punkt sein +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` muss [a, b, c, d] sein, jeweils von -1000 bis 1000, mit a·d - b·c mindestens 0,001 von 0 entfernt @msg `picks` must be an array (got {v}) `picks` muss ein Array sein (erhalten: {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` muss 1 bis {MAX_PICKS} Auswahlen enthalten (erhalten: {_1}) @msg `radius` must be 0 or more `radius` muss 0 oder größer sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7afffee7d6..c02fd8097e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6508,6 +6508,7 @@ axis Y Axis Άξονας Y @msg {what} points outside memory το {what} δείχνει εκτός μνήμης @msg `angle` must be a number το `angle` πρέπει να είναι αριθμός @msg `aspect` must be from 0.01 to 100 το `aspect` πρέπει να είναι από 0.01 έως 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 το `axes` πρέπει να είναι [a, b, c, d], καθένα από -1000 έως 1000, με το a·d - b·c να απέχει τουλάχιστον 0.001 από το 0 @msg `cap` must be "butt", "round" or "square" το `cap` πρέπει να είναι "butt", "round" ή "square" @msg `fillRule` must be "nonzero" or "evenodd" το `fillRule` πρέπει να είναι "nonzero" ή "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes το `helpUrl` πρέπει να είναι διεύθυνση https:// έως {MAX_HELP_URL} byte diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index adaa7a8dc6..99b3fa5cbb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6497,6 +6497,7 @@ axis Y Axis Eje Y @msg {what} points outside memory {what} apunta fuera de la memoria @msg `angle` must be a number `angle` debe ser un número @msg `aspect` must be from 0.01 to 100 `aspect` debe estar entre 0.01 y 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` debe ser [a, b, c, d], cada uno entre -1000 y 1000, con a·d - b·c a al menos 0.001 de 0 @msg `cap` must be "butt", "round" or "square" `cap` debe ser "butt", "round" o "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` debe ser "nonzero" o "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` debe ser una dirección https:// de {MAX_HELP_URL} bytes como máximo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3aac57b1ee..aa38f8586a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6586,6 +6586,7 @@ axis Y Axis Y-akseli @msg {what} points outside memory {what} osoittaa muistin ulkopuolelle @msg `angle` must be a number `angle` on oltava luku @msg `aspect` must be from 0.01 to 100 `aspect` on oltava 0.01 ja 100 välillä +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` on oltava [a, b, c, d], kukin -1000 ja 1000 välillä, ja a·d - b·c:n on oltava vähintään 0.001:n päässä 0:sta @msg `cap` must be "butt", "round" or "square" `cap` on oltava "butt", "round" tai "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` on oltava "nonzero" tai "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` on oltava enintään {MAX_HELP_URL} tavun https://-osoite diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8456251424..1b236f290b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7844,6 +7844,7 @@ breakText Glyphs Glyphes @msg `names` must be an array of style names `names` doit être une liste de noms de styles @msg `new` is a design name, or true `new` est un nom de motif, ou true @msg `origin` must be [x, y] in points `origin` doit être [x, y] en points +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` doit être [a, b, c, d], chacun compris entre -1000 et 1000, avec a·d - b·c à au moins 0.001 de 0 @msg `path` must be a folder `path` doit être un dossier @msg `path` must be a path object `path` doit être un objet tracé @msg `picks` must be an array (got {v}) `picks` doit être une liste (reçu : {v}) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8a74c4fe75..80669121d6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5711,6 +5711,7 @@ antiAlias Sharp शार्प @msg needs approval: it asks for {0}, which the user grants when installing it on its own स्वीकृति आवश्यक: यह {0} माँगता है, जिसे उपयोगकर्ता इसे अलग से स्थापित करते समय देता है @msg `angle` must be a number `angle` एक संख्या होना चाहिए @msg `aspect` must be from 0.01 to 100 `aspect` 0.01 से 100 तक होना चाहिए +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` [a, b, c, d] होना चाहिए, हर मान -1000 से 1000 तक, और a·d - b·c का 0 से अंतर कम से कम 0.001 होना चाहिए @msg `cap` must be "butt", "round" or "square" `cap` "butt", "round" या "square" होना चाहिए @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` "nonzero" या "evenodd" होना चाहिए @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` अधिकतम {MAX_HELP_URL} बाइट का https:// पता होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ec1774747e..3194629ece 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7847,6 +7847,7 @@ breakText Glyphs Glifák @msg `names` must be an array of style names `names`: stílusnevek tömbje kell @msg `new` is a design name, or true `new`: diagramminta neve vagy true @msg `origin` must be [x, y] in points `origin`: [x, y] kell pontban megadva +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes`: [a, b, c, d] kell, mindegyik -1000 és 1000 között, és a·d - b·c legalább 0.001-gyel térjen el 0-tól @msg `path` must be a folder `path`: mappa kell @msg `path` must be a path object `path`: útvonalobjektum kell @msg `picks` must be an array (got {v}) `picks`: tömb kell (kapott érték: {v}) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7b09eeb9eb..c23d3defbe 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -7332,6 +7332,7 @@ breakText Glyphs Glifi @msg `name` must be a name that is not empty `name` deve essere un nome non vuoto @msg `name` must not be empty `name` non deve essere vuoto @msg `origin` must be [x, y] in points `origin` deve essere [x, y] in punti +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` deve essere [a, b, c, d], ciascuno compreso tra -1000 e 1000, con a·d - b·c distante almeno 0.001 da 0 @msg `picks` must be an array (got {v}) `picks` deve essere un array (ricevuto {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` deve contenere da 1 a {MAX_PICKS} scelte (ricevute {_1}) @msg `points` must hold from 2 to 100000 points `points` deve contenere da 2 a 100000 punti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1afd8a863d..9a474f87c6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6532,6 +6532,7 @@ axis Y Axis Y 軸 @msg `name` must be a name that is not empty `name` には空でない名前を指定してください @msg `name` must not be empty `name` を空にすることはできません @msg `origin` must be [x, y] in points `origin` にはポイント単位の [x, y] を指定してください +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` には [a, b, c, d] を指定してください (各値は -1000 から 1000、a·d - b·c は 0 から 0.001 以上離れた値) @msg `picks` must be an array (got {v}) `picks` には配列を指定してください (指定値: {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` には 1~{MAX_PICKS} 個の指定が必要です (指定数: {_1}) @msg `points` must hold from 2 to 100000 points `points` には 2~100000 個の点が必要です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 28c75307c7..b12bc1ef9e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6507,6 +6507,7 @@ axis Y Axis Y ღერძი @msg {what} points outside memory {what} მეხსიერების გარეთ მიუთითებს @msg `angle` must be a number `angle` უნდა იყოს რიცხვი @msg `aspect` must be from 0.01 to 100 `aspect` უნდა იყოს 0.01-დან 100-მდე +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` უნდა იყოს [a, b, c, d], თითოეული -1000-დან 1000-მდე, ხოლო a·d - b·c უნდა განსხვავდებოდეს 0-ისგან მინიმუმ 0.001-ით @msg `cap` must be "butt", "round" or "square" `cap` უნდა იყოს "butt", "round" ან "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` უნდა იყოს "nonzero" ან "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` უნდა იყოს https:// მისამართი, არაუმეტეს {MAX_HELP_URL} ბაიტისა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 566c1858d5..e75630810a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7572,6 +7572,7 @@ breakText Glyphs 글리프 @msg `names` must be an array of style names `names`은(는) 스타일 이름의 배열이어야 합니다 @msg `new` is a design name, or true `new`은(는) 디자인 이름 또는 true입니다 @msg `origin` must be [x, y] in points `origin`은(는) 포인트 단위의 [x, y]여야 합니다 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes`은(는) [a, b, c, d]여야 하며, 각 값은 -1000에서 1000 사이이고 a·d - b·c는 0에서 0.001 이상 떨어져야 합니다 @msg `path` must be a folder `path`은(는) 폴더여야 합니다 @msg `path` must be a path object `path`은(는) 패스 개체여야 합니다 @msg `picks` must be an array (got {v}) `picks`은(는) 배열이어야 합니다({v} 입력됨) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 6ea7ac9646..dafe410d98 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5900,6 +5900,7 @@ imageTrace Outline Contour @msg `names` must be an array of style names `names` moet een array van stijlnamen zijn @msg `new` is a design name, or true `new` is de naam van een ontwerp, of true @msg `origin` must be [x, y] in points `origin` moet [x, y] in punten zijn +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` moet [a, b, c, d] zijn, elk van -1000 tot 1000, met a·d - b·c minstens 0,001 van 0 af @msg `path` must be a folder `path` moet een map zijn @msg `path` must be a path object `path` moet een padobject zijn @msg `picks` must be an array (got {v}) `picks` moet een array zijn (kreeg {v}) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 53b3269f18..2ef0d348ac 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7567,6 +7567,7 @@ breakText Glyphs Glify @msg `name` must be a name that is not empty `name` musi być niepustą nazwą @msg `name` must not be empty `name` nie może być puste @msg `origin` must be [x, y] in points `origin` musi mieć postać [x, y] w punktach +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` musi mieć postać [a, b, c, d], każda wartość od -1000 do 1000, a a·d - b·c musi różnić się od 0 o co najmniej 0,001 @msg `picks` must be an array (got {v}) `picks` musi być tablicą (podano {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` musi zawierać od 1 do {MAX_PICKS} wskazań (podano {_1}) @msg `points` must hold from 2 to 100000 points `points` musi zawierać od 2 do 100000 punktów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c1ac7fcc1a..3139681c9d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -8039,6 +8039,7 @@ swatch Yellow Green Verde-amarelado @msg `names` must be an array of style names `names` deve ser uma matriz de nomes de estilos @msg `new` is a design name, or true `new` é o nome de um projeto, ou true @msg `origin` must be [x, y] in points `origin` deve ser [x, y] em pontos +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` deve ser [a, b, c, d], cada um de -1000 a 1000, com a·d - b·c a pelo menos 0.001 de 0 @msg `path` must be a folder `path` deve ser uma pasta @msg `path` must be a path object `path` deve ser um objeto de caminho @msg `picks` must be an array (got {v}) `picks` deve ser uma matriz (recebido {v}) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index aeb5caed12..28374f0ffd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6165,6 +6165,7 @@ axis Y Axis Ось Y @msg {what} points outside memory {what} указывает за пределы памяти @msg `angle` must be a number `angle` должно быть числом @msg `aspect` must be from 0.01 to 100 `aspect` должно быть от 0.01 до 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` должно быть [a, b, c, d], каждое от -1000 до 1000, а a·d - b·c должно отличаться от 0 не менее чем на 0.001 @msg `cap` must be "butt", "round" or "square" `cap` должно быть "butt", "round" или "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` должно быть "nonzero" или "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` должно быть адресом https:// длиной не более {MAX_HELP_URL} байт diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d00172e6a0..c6bbc53f1b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6533,6 +6533,7 @@ axis Y Axis Y-axel @msg needs approval: it asks for {0}, which the user grants when installing it on its own kräver godkännande: det begär {0}, som användaren beviljar när pluginet installeras för sig @msg `angle` must be a number `angle` måste vara ett tal @msg `aspect` must be from 0.01 to 100 `aspect` måste vara från 0.01 till 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` måste vara [a, b, c, d], vart och ett från -1000 till 1000, med a·d - b·c minst 0.001 från 0 @msg `cap` must be "butt", "round" or "square" `cap` måste vara "butt", "round" eller "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` måste vara "nonzero" eller "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` måste vara en https://-adress på högst {MAX_HELP_URL} byte diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9a811ad35a..4692cec184 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6915,6 +6915,7 @@ axis Y Axis Y ekseni @msg `name` must be a name that is not empty `name` boş olmayan bir ad olmalıdır @msg `name` must not be empty `name` boş olmamalıdır @msg `origin` must be [x, y] in points `origin` punto cinsinden [x, y] olmalıdır +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` [a, b, c, d] olmalıdır; her biri -1000 ile 1000 arasında, a·d - b·c ise 0'dan en az 0.001 uzakta olmalıdır @msg `picks` must be an array (got {v}) `picks` bir dizi olmalıdır (verilen: {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` 1 ile {MAX_PICKS} arasında seçim içermelidir (verilen: {_1}) @msg `points` must hold from 2 to 100000 points `points` 2 ile 100000 arasında nokta içermelidir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 45f0f0fd26..0c0da05da7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7581,6 +7581,7 @@ breakText Glyphs Гліфи @msg `names` must be an array of style names `names` має бути масивом імен стилів @msg `new` is a design name, or true `new` — це назва дизайну або true @msg `origin` must be [x, y] in points `origin` має бути [x, y] у пунктах +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` має бути [a, b, c, d], кожне від -1000 до 1000, а a·d - b·c має відрізнятися від 0 щонайменше на 0.001 @msg `path` must be a folder `path` має бути папкою @msg `path` must be a path object `path` має бути об’єктом-контуром @msg `picks` must be an array (got {v}) `picks` має бути масивом (отримано {v}) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 98a5629d11..2a112ba5b4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6163,6 +6163,7 @@ axis Y Axis Trục Y @msg {what} points outside memory {what} trỏ ra ngoài bộ nhớ @msg `angle` must be a number `angle` phải là một số @msg `aspect` must be from 0.01 to 100 `aspect` phải từ 0.01 đến 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` phải là [a, b, c, d], mỗi số từ -1000 đến 1000, và a·d - b·c phải cách 0 ít nhất 0.001 @msg `cap` must be "butt", "round" or "square" `cap` phải là "butt", "round" hoặc "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` phải là "nonzero" hoặc "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` phải là một địa chỉ https:// dài tối đa {MAX_HELP_URL} byte diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f5fd59ee8b..bf6859801d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7548,6 +7548,7 @@ breakText Glyphs 字形 @msg `name` must be a name that is not empty `name` 必须是非空的名称 @msg `name` must not be empty `name` 不能为空 @msg `origin` must be [x, y] in points `origin` 必须是以点为单位的 [x, y] +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` 必须是 [a, b, c, d],每个值在 -1000 到 1000 之间,且 a·d - b·c 与 0 的差至少为 0.001 @msg `picks` must be an array (got {v}) `picks` 必须是数组 (实际为 {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` 必须包含 1 到 {MAX_PICKS} 个拾取项 (实际为 {_1}) @msg `points` must hold from 2 to 100000 points `points` 必须包含 2 到 100000 个点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 29599476be..9f3344a340 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7476,6 +7476,7 @@ breakText Glyphs 字符 @msg `name` must be a name that is not empty `name` 必須是不為空的名稱 @msg `name` must not be empty `name` 不能為空 @msg `origin` must be [x, y] in points `origin` 必須是以點為單位的 [x, y] +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` 必須是 [a, b, c, d],每個值介於 -1000 到 1000 之間,且 a·d - b·c 與 0 的差至少為 0.001 @msg `picks` must be an array (got {v}) `picks` 必須是陣列 (收到 {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` 必須包含 1 到 {MAX_PICKS} 個選擇 (收到 {_1}) @msg `points` must hold from 2 to 100000 points `points` 必須包含 2 到 100000 個點 diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 14f93dcfa5..290148b89d 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -27,7 +27,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | -| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | +| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned, skewed or unevenly scaled object in an imported SVG is kept and runs in that object's user space, and SVG export writes it back under the same transform | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers diff --git a/docs/mcp.md b/docs/mcp.md index 25b499bf07..e837b65bef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5566,17 +5566,30 @@ SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edit The canvas, raster export, printing and PDF draw the filter with the object as its source graphic, its user space starting at `origin` (`[x, y]` in points, `[0, 0]` by default) with its units `scale` points (1 by default), its -object bounding box the drawn object's, and at the Document Raster Effects resolution when the view is finer. The +axes along `axes` (`[a, b, c, d]`: one unit along the filter's x axis points `[a, b]` in the document and one along +its y axis `[c, d]`, before `scale`; absent or `[1, 0, 0, 1]`, the document's axes, and `effect.setParams` sets them +back with `[1, 0, 0, 1]`, since the commands refuse `null`; each number -1000 to 1000 and `a·d - b·c` at least +0.001 from 0, or the command refuses it), its object bounding box the drawn object's, and at the Document Raster Effects resolution when the view is finer. The 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. An effect whose XML these rules refuse (from a file or a script) is not drawn or exported. The +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, 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, turning, scaling or skewing the object moves `origin` with it and +leaves `axes` as they are, and `scale` too unless Scale Strokes & Effects scales it. 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 within the pixel limit. A filter region in user space given in percentages is measured against the object's box (x 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, its only pixel -effect and `scale` is 1 (with a region measured against the object's box, its only raster effect), with the object in a -group that places the filter's user space and such a region written in user units. The filter is written in SVG 1.1 +`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) 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 +as the same `scale`, `origin` and `axes`. The filter is written in SVG 1.1 terms: `feImage`'s reference as `xlink:href`; each `feDropShadow` as the blur, offset, flood, composite and merge that [Filter Effects](https://www.w3.org/TR/filter-effects-1/#feDropShadowElement) gives as its equivalent; colors as `#rrggbb` with a flood color's alpha in `flood-opacity`; `image-rendering` as `optimizeSpeed` or `optimizeQuality`; @@ -5588,9 +5601,12 @@ whose `flood-color` is `currentColor` or with `primitiveUnits="objectBoundingBox its shadows are expanded, has no SVG 1.1 form. Neither does a filter whose written form, with its region and id, is one SVG import would not read, such as one longer than 64 KB. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object becomes an image. SVG import keeps a filter that the live effects (Drop Shadow, the -glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale` and -`origin` from the object's user space, unless that user space is turned, skewed or scaled past 0.001 to 1000 points -to a unit, or the filter is styled by the document's style sheets: such a filter is left out with a warning. The native format keeps the document's filters and the effects. +glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale`, +`origin` and, when the object's user space is turned, skewed or scaled unevenly (a `rotate`, `skewX`, `skewY`, uneven +`scale` or `matrix` transform), `axes` from that user space, so the filter runs in the element's user space as +[SVG 1.1](https://www.w3.org/TR/SVG11/filters.html#FilterEffectsRegion) defines it. A filter is left out with a +warning when that user space is scaled past 0.001 to 1000 points to a unit (the square root of its area scale), its +`axes` would pass the limits above, or the filter is styled by the document's style sheets. The native format keeps the document's filters and the effects. ```json {"name":"run_command","arguments":{"command":"svgFilter.define","params":{"xml":""}}}