mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge pull request #7731 from dotCooCoo/feat/svg-filter-user-space-matrix
SVG Filters: a filter on a turned or skewed object is kept on import and drawn
This commit is contained in:
@@ -859,7 +859,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
crate::SVG_FILTER,
|
||||
"SVG Filter",
|
||||
&["Effect", "SVG Filters"],
|
||||
"{name: string (the filter's id), xml: string (one SVG <filter> 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 <filter> 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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Effect › SVG Filters (`svg.filter`): an SVG `<filter>` element applied as a live raster effect.
|
||||
//! The filter runs on the object's pixels as SVG 1.1 describes it
|
||||
//! (<https://www.w3.org/TR/SVG11/filters.html>), 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<str>,
|
||||
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<Affine> {
|
||||
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<Affine> {
|
||||
let n: Vec<f64> = v.as_array()?.iter().map(Value::as_f64).collect::<Option<_>>()?;
|
||||
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<Affine> {
|
||||
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<UserSpace> {
|
||||
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 `<filter>` 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 `<filter>` 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<Self> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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!(
|
||||
"<svg xmlns=\"{SVG_NS}\" xmlns:xlink=\"{XLINK_NS}\" width=\"{sw}\" height=\"{sh}\" viewBox=\"0 0 {sw} {sh}\"><defs>{drawn}</defs><g transform=\"{}\"><g filter=\"url(#{DRAWN_ID})\"><image width=\"{}\" height=\"{}\" preserveAspectRatio=\"none\" transform=\"{}\" xlink:href=\"data:image/png;base64,{}\"/></g></g></svg>",
|
||||
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<u8>, 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<Vec<u8>> {
|
||||
use image::ImageEncoder;
|
||||
|
||||
@@ -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<u8> {
|
||||
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#"<filter id="Shift" x="-1" y="-1" width="3" height="3"><feOffset dx="4"/></filter>"#;
|
||||
|
||||
/// 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#"<filter id="Shift" x="-1" y="-1" width="3" height="3"><feOffset dy="4"/></filter>"#;
|
||||
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##"<filter id="F" filterUnits="userSpaceOnUse" x="0%" y="0%" width="100%" height="100%"><feFlood flood-color="#ff0000"/></filter>"##;
|
||||
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##"<filter id="F" x="0" y="0" width="1" height="1"><feFlood flood-color="#ff0000"/></filter>"##;
|
||||
let primitive = r##"<filter id="F" filterUnits="userSpaceOnUse" x="-1000" y="-1000" width="2000" height="2000" primitiveUnits="objectBoundingBox"><feFlood flood-color="#ff0000" x="0" y="0" width="1" height="1"/></filter>"##;
|
||||
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#"<filter id="u" filterUnits="userSpaceOnUse" x="-30" y="-5" width="100" height="100"><feOffset/></filter>"#;
|
||||
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 [
|
||||
("<filter id=\"m\" primitiveUnits=\"objectBoundingBox\"><feMorphology radius=\"1.5\"/></filter>", 1.5, MAX_WINDOW_PX),
|
||||
("<filter id=\"b\" primitiveUnits=\"objectBoundingBox\"><feGaussianBlur stdDeviation=\"20\"/></filter>", 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": "<filter id=\"u\"><feMorphology radius=\"100\"/></filter>", "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#"<filter id="r" filterUnits="userSpaceOnUse" x="-50%" y="0" width="200%" height="10"><feOffset/></filter>"#;
|
||||
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#"<filter id="r" filterUnits="userSpaceOnUse" x="-10" y="0" width="40" height="10"><feOffset/></filter>"#)
|
||||
);
|
||||
let plain = filter(json!({ "xml": RED_FLOOD }));
|
||||
|
||||
@@ -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`"));
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -551,6 +551,26 @@ fn svg_export_keeps_a_measured_region_only_on_the_object_alone() {
|
||||
assert!(after.contains("<image") && !after.contains("<feOffset dx=\"3\""), "{after}");
|
||||
}
|
||||
|
||||
/// SVG export keeps a filter live only in a user space SVG import stores again: `scale` 1000 with
|
||||
/// `axes` that double it scales the user space 2000 times, past what import holds, so the object is
|
||||
/// written as an image instead of a filter that would be left out when the file is opened.
|
||||
#[test]
|
||||
fn svg_export_writes_an_image_for_a_user_space_import_cannot_store() {
|
||||
let hue = r#"<filter id="Hue"><feColorMatrix type="hueRotate" values="90"/></filter>"#;
|
||||
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("<feColorMatrix"), out.contains("<image")), (live, !live), "scale {scale}: {out}");
|
||||
let (back, warnings) = vectorcraft_svg::import_with_report(&out).unwrap();
|
||||
assert!(warnings.is_empty(), "scale {scale}: {warnings:?}");
|
||||
let mut kept = 0;
|
||||
back.walk(|n| kept += n.appearance.effects.iter().filter(|e| e.id == "svg.filter").count());
|
||||
assert_eq!(kept, usize::from(live), "scale {scale}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG export writes a filter rebuilt from its filter elements and filter attributes, not the XML
|
||||
/// the effect holds.
|
||||
#[test]
|
||||
@@ -575,6 +595,23 @@ fn moving_the_object_moves_the_filters_origin() {
|
||||
assert_eq!(p["origin"], json!([8.0, 2.0]));
|
||||
}
|
||||
|
||||
/// `axes` turns or skews the filter's user space; a move moves only its origin. Axes the effect
|
||||
/// can't measure are refused.
|
||||
#[test]
|
||||
fn axes_turn_the_filters_user_space_and_bad_axes_are_refused() {
|
||||
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": SHIFT, "axes": [0, 1, -1, 0]}}));
|
||||
run(&mut s, "object.move", json!({"ids": [a.0], "dx": 5, "dy": -2}));
|
||||
let p = &s.doc().unwrap().doc.node(a).unwrap().appearance.effects[0].params;
|
||||
assert_eq!((&p["axes"], &p["origin"]), (&json!([0, 1, -1, 0]), &json!([5.0, -2.0])));
|
||||
for axes in [json!([0, 0, 0, 0]), json!([1, 0, 0]), json!([1, 0, 0, "1"]), json!([1001, 0, 0, 1]), json!(null), json!("x")] {
|
||||
let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": SHIFT, "axes": axes}}));
|
||||
assert!(e.contains("`axes` must be [a, b, c, d]"), "{axes}: {e}");
|
||||
}
|
||||
assert!(err(&mut s, "effect.setParams", json!({"ids": [a.0], "index": 0, "params": {"axes": [0.01, 0, 0, 0.01]}})).contains("`axes` must be"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_survive_a_native_round_trip() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -604,25 +604,41 @@ impl Writer<'_> {
|
||||
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!("<g transform=\"{}\">", self.matrix(xf * o)));
|
||||
self.depth += 1;
|
||||
self.xf = o.inverse();
|
||||
}
|
||||
if let Some(space) = turned {
|
||||
self.line(&format!("<g transform=\"{}\">", 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("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Affine> {
|
||||
live.filter(|f| !f.moves_only() && !self.tiny()).map(|f| self.xf * f.to_doc())
|
||||
}
|
||||
|
||||
/// Open one `<g filter>` 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!("<g filter=\"url(#{fid})\">"));
|
||||
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!("<g transform=\"{}\">", 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 `<filter>`:
|
||||
/// 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<vectorcraft_effects::svg_filter::SvgFilterFx> {
|
||||
if !vectorcraft_effects::svg_filter::writes_live(effects) {
|
||||
return None;
|
||||
@@ -797,7 +852,7 @@ fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option<vectorcraft_ef
|
||||
let alone = fx.len() == 1;
|
||||
match fx.pop() {
|
||||
Some(vectorcraft_effects::RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(f)))
|
||||
if f.in_points() && (alone || !f.measured()) && f.writes_as_svg11() =>
|
||||
if (alone || !f.measured()) && f.writes_as_svg11() && f.opens_again() =>
|
||||
{
|
||||
Some(f)
|
||||
}
|
||||
|
||||
@@ -142,19 +142,22 @@ fn source_of<'a>(filter: &Filter, sources: &'a HashMap<String, Source>) -> 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 (<https://www.w3.org/TR/SVG11/filters.html#FilterEffectsRegion>).
|
||||
pub(super) fn kept(filters: &[Arc<Filter>], ts: Affine, sources: &HashMap<String, Source>) -> Option<Vec<Effect>> {
|
||||
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()
|
||||
|
||||
@@ -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#"<filter id="f"><feColorMatrix type="saturate" values="0"/></filter><filter id="t"><feTurbulence baseFrequency="0.05"/></filter>
|
||||
<rect x="20" y="20" width="60" height="60" fill="red" transform="rotate(10 50 50)" filter="url(#f)"/>
|
||||
<rect id="r" x="100" y="20" width="60" height="60" fill="blue" transform="rotate(10 130 50)" filter="url(#t)"/>
|
||||
<rect x="0.02" y="0.02" width="0.06" height="0.06" fill="red" transform="skewX(89.95)" filter="url(#f)"/>
|
||||
<rect id="r" x="0.1" y="0.02" width="0.06" height="0.06" fill="blue" transform="skewX(89.95)" filter="url(#t)"/>
|
||||
<rect x="20" y="90" width="20" height="20" fill="red" filter="url(#f)"/>"#;
|
||||
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:?}");
|
||||
|
||||
@@ -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##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="60" height="40" transform="rotate(30)" filter="url(#t)"/>"##,
|
||||
r##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="60" height="40" transform="translate(10 20) scale(2) rotate(30)" filter="url(#t)"/>"##,
|
||||
);
|
||||
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<f64> = 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##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="60" height="40" transform="skewX(45)" filter="url(#t)"/><rect width="60" height="40" transform="scale(1 4)" filter="url(#t)"/>"##,
|
||||
);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let all: Vec<Vec<f64>> =
|
||||
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##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="0.01" height="0.01" transform="skewX(89.95)" filter="url(#t)"/>"##,
|
||||
);
|
||||
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#"<filter id="m" x="-1" y="-1" width="3" height="3"><feOffset dx="{dx}" dy="{dy}"/><feColorMatrix type="hueRotate" values="90"/></filter>"#
|
||||
)
|
||||
};
|
||||
let turned = format!(
|
||||
r##"<defs>{}</defs><rect x="60" y="30" width="40" height="40" fill="#08f" transform="rotate(30 80 50)" filter="url(#m)"/>"##,
|
||||
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::<Vec<_>>(), ["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##"<defs>{}</defs><polygon points="{} {} {} {}" fill="#08f" filter="url(#m)"/>"##,
|
||||
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##"<defs><filter id="b"><feGaussianBlur stdDeviation="4"/></filter><clipPath id="c"><rect x="60" y="40" width="40" height="40"/></clipPath></defs>
|
||||
@@ -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#"<filter id="m" x="-1" y="-1" width="3" height="3"><feOffset dx="10"/><feColorMatrix type="hueRotate" values="90"/></filter>"#;
|
||||
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("<g filter=\"url(#") && !out.contains("<image"), "{out}");
|
||||
assert!(out.contains("stroke-width=\"4\""), "the stroke keeps its width: {out}");
|
||||
let (back, w) = import_with_report(&out).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let fx = effects(&back);
|
||||
assert_eq!(fx.len(), 1, "{out}");
|
||||
let num = |v: &serde_json::Value| v.as_f64().unwrap();
|
||||
assert!((num(&fx[0].params["scale"]) - scale).abs() < 1e-6, "{:?}", fx[0].params);
|
||||
let origin: Vec<f64> = 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<f64> = got.as_array().unwrap().iter().map(num).collect();
|
||||
let want: Vec<f64> = 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#"<filter id="k"><feColorMatrix type="hueRotate" values="90"/></filter>"#;
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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})
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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})
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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:// पता होना चाहिए
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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})
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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 個の点が必要です
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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} ბაიტისა
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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} 입력됨)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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})
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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})
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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} байт
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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})
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 205 and column 5.
|
@@ -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 个点
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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 個點
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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 `<filter>` 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 `<filter>` 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
|
||||
|
||||
+24
-8
@@ -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 `<filter>` 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 `<filter>` 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":"<filter id=\"Soft\"><feGaussianBlur stdDeviation=\"2\"/></filter>"}}}
|
||||
|
||||
Reference in New Issue
Block a user