mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 20:01:37 +00:00
Effects: Effect › SVG Filters applies the document's SVG filters as live effects
Squashed from #6200 onto main.
This commit is contained in:
Generated
+39
-4
@@ -1442,7 +1442,7 @@ dependencies = [
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
"resvg",
|
||||
"resvg 0.45.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4438,11 +4438,29 @@ dependencies = [
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes 0.15.3",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
"usvg 0.45.1",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"gif 0.14.2",
|
||||
"image-webp",
|
||||
"log",
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes 0.16.1",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg 0.48.1",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.17.2"
|
||||
@@ -4727,7 +4745,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5593,6 +5611,21 @@ dependencies = [
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png 0.18.1",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
@@ -6235,7 +6268,9 @@ version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"resvg 0.48.1",
|
||||
"serde_json",
|
||||
"svgtypes 0.16.1",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
@@ -6491,7 +6526,7 @@ dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"resvg 0.45.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simplecss",
|
||||
|
||||
@@ -296,6 +296,28 @@ impl Appearance {
|
||||
scale(e, s);
|
||||
}
|
||||
}
|
||||
/// The id of Effect › SVG Filters' effect, whose `origin` parameter is a point of the document.
|
||||
pub const SVG_FILTER: &str = "svg.filter";
|
||||
|
||||
/// Does any effect (the object's or a fill's or stroke's) hold an SVG filter?
|
||||
pub fn has_svg_filter(&self) -> bool {
|
||||
self.effects.iter().chain(self.items.iter().flat_map(|i| i.effects().iter())).any(|e| e.id == Self::SVG_FILTER)
|
||||
}
|
||||
|
||||
/// Move the `origin` of every SVG filter effect (where its user space starts in the document)
|
||||
/// by `a`, as the object it is on moves.
|
||||
pub fn transform_svg_filters(&mut self, a: kurbo::Affine) {
|
||||
let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
|
||||
for e in self.effects.iter_mut().chain(items).filter(|e| e.id == Self::SVG_FILTER) {
|
||||
let at =
|
||||
|i: usize| e.params.get("origin").and_then(|o| o.get(i)).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let p = a * kurbo::Point::new(at(0), at(1));
|
||||
if p.x.is_finite() && p.y.is_finite() && e.params.is_object() {
|
||||
e.params["origin"] = serde_json::json!([p.x, p.y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient paints of the fills and strokes.
|
||||
fn gradients_mut(&mut self) -> impl Iterator<Item = &mut vectorcraft_color::GradientPaint> {
|
||||
self.items.iter_mut().filter_map(|i| match i {
|
||||
|
||||
@@ -68,6 +68,7 @@ impl Document {
|
||||
print_setup,
|
||||
assets,
|
||||
svg_scripts,
|
||||
svg_filters,
|
||||
puppet,
|
||||
new_art,
|
||||
dimensions,
|
||||
@@ -135,6 +136,7 @@ impl Document {
|
||||
&& *print_setup == other.print_setup
|
||||
&& *assets == other.assets
|
||||
&& *svg_scripts == other.svg_scripts
|
||||
&& *svg_filters == other.svg_filters
|
||||
&& match (puppet, &other.puppet) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
@@ -245,6 +247,7 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
svg_filters: vec![],
|
||||
puppet: None,
|
||||
new_art: Default::default(),
|
||||
dimensions: false,
|
||||
|
||||
@@ -123,6 +123,39 @@ impl Document {
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions,
|
||||
/// graph designs, pattern swatches (the one being edited included) and their opacity masks?
|
||||
pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool {
|
||||
let roots = self
|
||||
.layers
|
||||
.iter()
|
||||
.chain(self.symbols.iter().map(|s| &s.art))
|
||||
.chain(self.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(self.patterns.iter().flat_map(|p| p.art.iter()))
|
||||
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()));
|
||||
let mut any = false;
|
||||
for root in roots {
|
||||
crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n));
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// Change with `f` every node for which `wants` holds, anywhere in the document's art (as
|
||||
/// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art
|
||||
/// they share.
|
||||
pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) {
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
edit_branches_where(root, &wants, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Round every path's anchors and handles to `decimals` decimal places: path objects (with
|
||||
/// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine
|
||||
/// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being
|
||||
@@ -194,6 +227,26 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Change with `f` the nodes of `n`'s tree (opacity-mask art included) for which `wants` holds,
|
||||
/// copying out of the shared art only the branches that hold one.
|
||||
fn edit_branches_where(n: &mut Arc<Node>, wants: &impl Fn(&Node) -> bool, f: &mut impl FnMut(&mut Node)) {
|
||||
let mut any = false;
|
||||
crate::swatches::walk_with_masks(n, &mut |c| any |= wants(c));
|
||||
if !any {
|
||||
return;
|
||||
}
|
||||
let node = Arc::make_mut(n);
|
||||
if wants(node) {
|
||||
f(node);
|
||||
}
|
||||
if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
edit_branches_where(&mut m.art, wants, f);
|
||||
}
|
||||
for c in node.children_mut().into_iter().flatten() {
|
||||
edit_branches_where(c, wants, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every
|
||||
/// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as
|
||||
/// [`Document::round_geometry`] rounds the nodes it holds.
|
||||
|
||||
@@ -150,7 +150,7 @@ pub use guides::{GridPrefs, Guide, RulerMode};
|
||||
#[cfg(test)]
|
||||
use limits::MAX_NEXT_ID;
|
||||
pub use limits::{DocError, FORMAT_VERSION, MAX_COORD};
|
||||
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit};
|
||||
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit};
|
||||
pub use units::{ColorMode, Unit, parse_number};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -310,6 +310,10 @@ pub struct Document {
|
||||
/// given) SVG export links with `<script>` elements, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_scripts: Vec<String>,
|
||||
/// Effect › SVG Filters: the SVG filters the document holds (at most [`SvgFilter::MAX`]), in
|
||||
/// the order the submenu lists them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_filters: Vec<SvgFilter>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -448,6 +448,9 @@ impl Node {
|
||||
if self.appearance.gradients().next().is_some() {
|
||||
self.appearance.transform_gradients(a);
|
||||
}
|
||||
if self.appearance.has_svg_filter() {
|
||||
self.appearance.transform_svg_filters(a);
|
||||
}
|
||||
if sc.patterns {
|
||||
crate::pattern::transform_pattern_paints(self, a);
|
||||
}
|
||||
|
||||
@@ -126,6 +126,23 @@ impl SavedSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG filter the document holds (Effect › SVG Filters): a `<filter>` element named by its
|
||||
/// `id`, listed in the SVG Filters submenu and in Apply SVG Filter.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SvgFilter {
|
||||
/// The filter element's `id`.
|
||||
pub name: String,
|
||||
/// The `<filter>` element's XML, as it was written.
|
||||
pub xml: String,
|
||||
}
|
||||
|
||||
impl SvgFilter {
|
||||
/// Most SVG filters a document holds.
|
||||
pub const MAX: usize = 64;
|
||||
/// Longest filter element, in bytes.
|
||||
pub const MAX_XML: usize = 64 * 1024;
|
||||
}
|
||||
|
||||
/// A symbol definition.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Symbol {
|
||||
|
||||
@@ -17,6 +17,11 @@ vectorcraft-text = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
image = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
# Effect › SVG Filters draws its filters with resvg (MIT OR Apache-2.0), at the usvg version the
|
||||
# workspace reads SVG with; raster-images decodes the object's pixels it hands the filter as a PNG.
|
||||
resvg = { version = "0.48", default-features = false, features = ["raster-images"] }
|
||||
# The number lists of filter attributes, read as usvg reads them (MIT OR Apache-2.0; already used by usvg).
|
||||
svgtypes = "0.16"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -105,6 +105,8 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"sketch.waterPaper" => always(&["fiberLength"]),
|
||||
TRAP => always(&["thickness", "precision"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
// The points one unit of the filter measures.
|
||||
crate::SVG_FILTER => always(&["scale"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -826,6 +828,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes",
|
||||
json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}),
|
||||
));
|
||||
v.push(r(
|
||||
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)",
|
||||
json!({"scale": 1.0, "origin": [0.0, 0.0]}),
|
||||
));
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, Rasterize, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! Unsharp Mask and Glowing Edges, and the SVG filters of [`svg_filter`]) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
@@ -52,6 +52,7 @@ mod revolve;
|
||||
mod solids;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
pub mod svg_filter;
|
||||
pub mod trap;
|
||||
mod util;
|
||||
mod warp;
|
||||
@@ -88,6 +89,7 @@ pub use solids::{
|
||||
three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options,
|
||||
};
|
||||
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
|
||||
pub use svg_filter::SVG_FILTER;
|
||||
pub use trap::TRAP;
|
||||
pub use vectorcraft_three_d::Mesh;
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
@@ -119,6 +119,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
|
||||
pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE)
|
||||
|| PIXEL_EFFECTS.contains(&id)
|
||||
|| id == crate::SVG_FILTER
|
||||
}
|
||||
|
||||
/// Does `id` change geometry? (Crop Marks and Vector Halftone add art of their own instead,
|
||||
|
||||
@@ -287,6 +287,8 @@ pub enum PixelFx {
|
||||
DeInterlace { even: bool, interpolate: bool },
|
||||
/// Video › NTSC Colors: colours a television signal can't carry made less saturated.
|
||||
NtscColors,
|
||||
/// Effect › SVG Filters: an SVG `<filter>` element run on the pixels.
|
||||
Svg(crate::svg_filter::SvgFilterFx),
|
||||
}
|
||||
|
||||
/// What a raster's colour channels hold, for the filters that treat them apart (Color Halftone
|
||||
@@ -598,6 +600,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
interpolate: text(p, "create", "duplication").eq_ignore_ascii_case("interpolation"),
|
||||
},
|
||||
"video.ntscColors" => PixelFx::NtscColors,
|
||||
crate::SVG_FILTER => PixelFx::Svg(crate::svg_filter::SvgFilterFx::parse(p)?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -666,6 +669,7 @@ impl PixelFx {
|
||||
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } | PixelFx::StainedGlass { cell, .. } => 1.5 * cell,
|
||||
// A square whose centre is on the object is drawn whole.
|
||||
PixelFx::Patchwork { square, .. } => square.max(1.0),
|
||||
PixelFx::Svg(ref f) => f.outset(bounds),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,12 +749,17 @@ impl PixelFx {
|
||||
// The lines above and below.
|
||||
PixelFx::DeInterlace { .. } => Some(video::MAX_LINE),
|
||||
PixelFx::NtscColors => Some(0.0),
|
||||
// A filter's object bounding box is the whole object's.
|
||||
PixelFx::Svg(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
|
||||
/// `space` says. Does nothing when the sizes don't match.
|
||||
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) {
|
||||
if let PixelFx::Svg(f) = self {
|
||||
return f.apply(data, w, h, space);
|
||||
}
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
@@ -829,6 +838,7 @@ impl PixelFx {
|
||||
}
|
||||
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
|
||||
PixelFx::NtscColors => video::ntsc(px),
|
||||
PixelFx::Svg(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,686 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::pixel::Channels;
|
||||
use crate::{PixelFx, RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are `px` points wide (origin top-left), at a raster effects
|
||||
/// resolution whose pixels are `line` points wide.
|
||||
fn space(w: usize, h: usize, px: f64, line: f64) -> PixelSpace {
|
||||
let (wd, hd) = (w as f64 * px, h as f64 * px);
|
||||
PixelSpace { to_doc: Affine::scale(px), px, center: Point::new(wd / 2.0, hd / 2.0), channels: Channels::Rgb, line, extent: 0.5 * wd.hypot(hd) }
|
||||
}
|
||||
|
||||
/// `w` × `h` premultiplied pixels: `f(x, y)` each.
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
|
||||
fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * w + x) * 4;
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
/// The effect `svg.filter` with `params`, as the renderer reads it.
|
||||
fn filter(params: serde_json::Value) -> SvgFilterFx {
|
||||
let e = Effect { id: SVG_FILTER.into(), params, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(PixelFx::Svg(f))) => f.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A blue square of `size` pixels inset by `inset` in a transparent raster `w` wide.
|
||||
fn blue_square(w: usize, inset: usize) -> Vec<u8> {
|
||||
image(w, w, |x, y| if (inset..w - inset).contains(&x) && (inset..w - inset).contains(&y) { [0, 0, 255, 255] } else { [0; 4] })
|
||||
}
|
||||
|
||||
const RED_FLOOD: &str = r##"<filter id="Red"><feFlood flood-color="#ff0000"/><feComposite in2="SourceGraphic" operator="in"/></filter>"##;
|
||||
|
||||
#[test]
|
||||
fn check_names_a_filter_by_its_id() {
|
||||
assert_eq!(check(RED_FLOOD), Ok("Red".into()));
|
||||
assert_eq!(check(&format!(" {RED_FLOOD}\n")), Ok("Red".into()), "spaces around the element");
|
||||
let linked = r#"<filter id=" Shadow 1 "><feImage xlink:href="data:image/png;base64,AAAA"/></filter>"#;
|
||||
assert_eq!(check(linked), Ok("Shadow 1".into()), "the xlink prefix is declared for it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_refuses_what_is_not_one_drawable_filter() {
|
||||
for (xml, why) in [
|
||||
("", "give one <filter> element"),
|
||||
("<filter id=\"a\">", "isn't valid XML"),
|
||||
("<g id=\"a\"><feFlood/></g>", "give one <filter> element"),
|
||||
("<filter id=\"a\"><feFlood/></filter><filter id=\"b\"><feFlood/></filter>", "give one <filter> element"),
|
||||
("text<filter id=\"a\"><feFlood/></filter>", "give one <filter> element"),
|
||||
("<filter><feFlood/></filter>", "needs an id"),
|
||||
("<filter id=\" \"><feFlood/></filter>", "needs an id"),
|
||||
("<filter id=\"a\"></filter>", "no primitives"),
|
||||
("<filter id=\"a\"><feTurbulence numOctaves=\"11\"/></filter>", "numOctaves is at most 10"),
|
||||
("<filter id=\"a\"><feConvolveMatrix order=\"3 12\" kernelMatrix=\"1\"/></filter>", "order is at most 9"),
|
||||
("<filter id=\"a\"><feConvolveMatrix order=\"65536+65536\" kernelMatrix=\"1\"/></filter>", "order is at most 9"),
|
||||
("<filter id=\"a\"><feConvolveMatrix order=\"3 x\" kernelMatrix=\"1\"/></filter>", "order is at most 9"),
|
||||
("<filter id=\"a\"><notAPrimitive/></filter>", "<notAPrimitive> isn't an element of an SVG filter"),
|
||||
("<filter id=\"a\"><feOffset/><script>alert(1)</script></filter>", "<script> isn't an element"),
|
||||
("<filter id=\"a\"><feMerge><feMergeNode/><a/></feMerge></filter>", "<a> isn't an element"),
|
||||
("<filter id=\"a\" onload=\"x()\"><feOffset/></filter>", "no event attributes such as onload"),
|
||||
("<filter id=\"a\"><feOffset ONCLICK=\"x()\"/></filter>", "no event attributes such as ONCLICK"),
|
||||
("<filter id=\"a\"><feImage xlink:href=\"javascript:x()\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"photo.png\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"data:application/gzip;base64,H4sI\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"data:;base64,AAAA\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\" width=\"0\"><feOffset/></filter>", "can't be drawn"),
|
||||
] {
|
||||
let e = check(xml).expect_err(xml);
|
||||
assert!(e.contains(why), "{xml}: {e}");
|
||||
}
|
||||
let many = format!("<filter id=\"a\">{}</filter>", "<feOffset/>".repeat(MAX_PRIMITIVES + 1));
|
||||
assert!(check(&many).is_err_and(|e| e.contains("at most 64 primitives")));
|
||||
let titled = format!("<filter id=\"a\"><title>T</title><desc>D</desc>{}</filter>", "<feOffset/>".repeat(MAX_PRIMITIVES));
|
||||
assert_eq!(check(&titled), Ok("a".into()), "a title and a description aren't primitives");
|
||||
assert!(check("<filter id=\"a\"><title>T</title></filter>").is_err_and(|e| e.contains("no primitives")));
|
||||
let long = format!("<filter id=\"{}\"><feOffset/></filter>", "x".repeat(MAX_NAME + 1));
|
||||
assert!(check(&long).is_err_and(|e| e.contains("at most 255 characters")));
|
||||
let big = format!("<filter id=\"a\"><feOffset/><!--{}--></filter>", " ".repeat(SvgFilter::MAX_XML));
|
||||
assert!(check(&big).is_err_and(|e| e.contains("at most 64 KB")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_id_renames_only_the_filter() {
|
||||
let xml = r#"<filter id="Old" x="0"><feOffset result="Old"/></filter>"#;
|
||||
assert_eq!(with_id(xml, "New").as_deref(), Some(r#"<filter id="New" x="0"><feOffset result="Old"/></filter>"#));
|
||||
assert_eq!(with_id("<filter><feOffset/></filter>", "New"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_in_lists_the_documents_filters_it_takes() {
|
||||
let svg = format!(
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\"><svg xmlns=\"http://www.w3.org/2000/svg\"><defs>{RED_FLOOD}<filter id=\"Empty\"/><filter id=\"Off\"><feOffset dx=\"2\"/></filter></defs></svg>"
|
||||
);
|
||||
let (found, skipped) = filters_in(&svg);
|
||||
assert_eq!(found.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["Red", "Off"]);
|
||||
assert_eq!(found[0].1, RED_FLOOD, "each filter's XML as it is written");
|
||||
assert_eq!(skipped, 1, "the filter without primitives");
|
||||
}
|
||||
|
||||
/// [`filters_of`] on `svg` read as SVG import reads it (with DTDs).
|
||||
fn filters_in(svg: &str) -> (Vec<(String, String)>, usize) {
|
||||
let doc = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
|
||||
filters_of(&doc, svg, usize::MAX, |_| false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_of_stops_at_the_room_left_and_skips_taken_and_styled_filters() {
|
||||
let many = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>",
|
||||
(0..200).map(|i| format!("<filter id=\"f{i}\"><feOffset/></filter>")).collect::<String>()
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&many).unwrap();
|
||||
let (found, skipped) = filters_of(&doc, &many, 3, |name| name == "f1");
|
||||
assert_eq!(found.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["f0", "f2", "f3"]);
|
||||
assert_eq!(skipped, 197);
|
||||
// A filter whose look comes from the document's styles isn't taken.
|
||||
for styled in [
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.r{flood-color:red}</style><filter id=\"a\"><feFlood class=\"r\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"a\"><feFlood flood-color=\"currentColor\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"a\"><feFlood flood-color=\"CURRENTcolor\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><defs color-interpolation-filters=\"sRGB\"><filter id=\"a\"><feGaussianBlur stdDeviation=\"2\"/></filter></defs></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>@media screen { feFlood { flood-color: red } }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>#a feOffset{dx:2}</style><filter id=\"a\"><feOffset/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.s { content: \"{ /*\" } feFlood { flood-color: red }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.outer { fill: blue; & #a { color: red } }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>@scope (#a) { :scope { color: red } }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>svg { color-interpolation-filters: sRGB }</style><filter id=\"a\"><feGaussianBlur stdDeviation=\"2\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>feFlood[data-t=\\;] { flood-color: red }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(styled).unwrap();
|
||||
assert_eq!(filters_of(&doc, styled, usize::MAX, |_| false), (vec![], 1), "{styled}");
|
||||
}
|
||||
// A style sheet for the shapes alone (as CSS export writes), applying the filter to them
|
||||
// included, doesn't reach the filter.
|
||||
for shapes in [
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.cls-1{fill:#f00}</style><filter id=\"a\"><feOffset/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>/* #a */ .shape { filter: url(#a) }</style><filter id=\"a\"><feOffset/></filter></svg>",
|
||||
// An identifier named currentColor takes no color.
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"currentColor\"><feOffset result=\"currentColor\"/></filter></svg>",
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(shapes).unwrap();
|
||||
assert_eq!(filters_of(&doc, shapes, usize::MAX, |_| false).0.len(), 1, "{shapes}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_filters_are_refused_before_they_are_parsed() {
|
||||
let deep = format!("<filter id=\"a\"><feMerge>{}<feMergeNode/>{}</feMerge></filter>", "<g>".repeat(20), "</g>".repeat(20));
|
||||
assert!(check(&deep).is_err_and(|e| e.contains("nest at most 16 deep")));
|
||||
assert_eq!(with_id(&deep, "b"), None);
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": deep }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "drawn without it");
|
||||
// Markup in comments, CDATA and attribute values doesn't count.
|
||||
let quoted =
|
||||
format!("<filter id=\"a\" x=\"{}\"><!--{}--><feOffset/><![CDATA[{}]]></filter>", "<g>".repeat(20), "<g>".repeat(20), "<g>".repeat(20));
|
||||
assert_eq!(check("ed), Ok("a".into()));
|
||||
let gt = format!("<filter id=\"a\" title='{}'>{}<feOffset/></filter>", "x>".repeat(20), "<!-- > -->".repeat(3));
|
||||
assert_eq!(check(>), Ok("a".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_replaces_the_pixels_with_its_result() {
|
||||
let w = 20;
|
||||
let mut data = blue_square(w, 5);
|
||||
filter(json!({ "xml": RED_FLOOD })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert_eq!(at(&data, w, 10, 10), [255, 0, 0, 255], "inside: the flood, cut to the source");
|
||||
assert_eq!(at(&data, w, 1, 1), [0; 4], "outside the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_in_points_times_scale() {
|
||||
let w = 40;
|
||||
let shift = r#"<filter id="Shift" x="-1" y="-1" width="3" height="3"><feOffset dx="4"/></filter>"#;
|
||||
let run = |px: f64, scale: f64| {
|
||||
let n = (w as f64 / px) as usize;
|
||||
let mut data = image(n, n, |x, y| {
|
||||
let (x, y) = (x as f64 * px, y as f64 * px);
|
||||
if (10.0..20.0).contains(&x) && (10.0..20.0).contains(&y) { [0, 0, 255, 255] } else { [0; 4] }
|
||||
});
|
||||
filter(json!({ "xml": shift, "scale": scale })).apply(&mut data, n, n, &space(n, n, px, px));
|
||||
let column = |x: f64| at(&data, n, (x / px) as usize, (15.0 / px) as usize)[3];
|
||||
(column(9.5), column(21.0), column(23.5), column(25.0))
|
||||
};
|
||||
// The square spans 10..20 pt: moved by 4 pt, 14..24.
|
||||
assert_eq!(run(1.0, 1.0), (0, 255, 255, 0));
|
||||
assert_eq!(run(0.5, 1.0), (0, 255, 255, 0), "the same in points at another zoom");
|
||||
// One unit of the filter is half a point: moved by 2 pt, 12..22.
|
||||
assert_eq!(run(1.0, 0.5), (0, 255, 0, 0));
|
||||
}
|
||||
|
||||
/// A user-space region is placed from the effect's origin.
|
||||
#[test]
|
||||
fn user_space_starts_at_the_origin() {
|
||||
let w = 40;
|
||||
// Flood the square, cut to the region x 0..20 of the filter's user space.
|
||||
let half = r##"<filter id="Half" filterUnits="userSpaceOnUse" x="0" y="0" width="20" height="40"><feFlood flood-color="#ff0000"/><feComposite in2="SourceGraphic" operator="in"/></filter>"##;
|
||||
let run = |origin: [f64; 2]| {
|
||||
let mut data = image(w, w, |_, _| [0, 0, 255, 255]);
|
||||
filter(json!({ "xml": half, "origin": origin })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0));
|
||||
(at(&data, w, 5, 20)[3], at(&data, w, 25, 20)[3])
|
||||
};
|
||||
assert_eq!(run([0.0, 0.0]), (255, 0));
|
||||
assert_eq!(run([20.0, 0.0]), (0, 255), "the region 20 pt to the right");
|
||||
let b = Rect::new(0.0, 0.0, 40.0, 40.0);
|
||||
assert!((filter(json!({ "xml": half, "origin": [-10.0, 0.0] })).outset(b) - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_no_finer_than_the_raster_effects_resolution() {
|
||||
let w = 16;
|
||||
// Columns one view pixel wide, alternately opaque and clear.
|
||||
let stripes = image(w, w, |x, _| if x % 2 == 0 { [0, 0, 255, 255] } else { [0; 4] });
|
||||
let keep = r#"<filter id="Keep" x="0" y="0" width="1" height="1"><feOffset/></filter>"#;
|
||||
let mut fine = stripes.clone();
|
||||
filter(json!({ "xml": keep })).apply(&mut fine, w, w, &space(w, w, 0.25, 0.25));
|
||||
assert_eq!(at(&fine, w, 4, 4)[3], 255);
|
||||
assert_eq!(at(&fine, w, 5, 4)[3], 0, "at the view's resolution the stripes stay");
|
||||
let mut coarse = stripes.clone();
|
||||
filter(json!({ "xml": keep })).apply(&mut coarse, w, w, &space(w, w, 0.25, 1.0));
|
||||
let (a, b) = (at(&coarse, w, 4, 4)[3], at(&coarse, w, 5, 4)[3]);
|
||||
assert!(a < 255 && b > 0, "four view pixels to a raster pixel: the stripes blend ({a}, {b})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draws_nothing_new_without_content_or_a_readable_filter() {
|
||||
let w = 8;
|
||||
let mut empty = vec![0; w * w * 4];
|
||||
filter(json!({ "xml": RED_FLOOD })).apply(&mut empty, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert!(empty.iter().all(|v| *v == 0), "a flood cut to a source that draws nothing");
|
||||
// A filter that doesn't read the source draws on art that draws no pixels: the raster is its box.
|
||||
let mut clear = vec![0; w * w * 4];
|
||||
filter(json!({ "xml": "<filter id=\"G\"><feFlood flood-color=\"#00ff00\"/></filter>" })).apply(&mut clear, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert_eq!(at(&clear, w, 4, 4), [0, 255, 0, 255]);
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": "<g/>" }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "not a filter: the object is drawn without it");
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": "<filter id=\"x\" width=\"0\"><feOffset/></filter>" }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "a filter resvg can't draw: the object is drawn without it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outset_is_how_far_the_region_reaches() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
assert!((filter(json!({ "xml": RED_FLOOD })).outset(b) - 10.0).abs() < 1e-9, "the default region: 10% of the width");
|
||||
let wide = r#"<filter id="w" x="-50%" y="0" width="200%" height="100%"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": wide })).outset(b) - 50.0).abs() < 1e-9);
|
||||
let user = r#"<filter id="u" filterUnits="userSpaceOnUse" x="-10" y="0" width="60" height="25"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": user, "scale": 2.0 })).outset(b) - 20.0).abs() < 1e-9, "user units times scale");
|
||||
let inside = r#"<filter id="i" x="0" y="0" width="1" height="1"><feOffset/></filter>"#;
|
||||
assert_eq!(filter(json!({ "xml": inside })).outset(b), 0.0);
|
||||
// User-space lengths in points: -30 pt is -40 user units.
|
||||
let points = r#"<filter id="p" filterUnits="userSpaceOnUse" x="-30pt" y="0" width="100" height="50"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": points })).outset(b) - 40.0).abs() < 1e-9);
|
||||
// In user space, a percentage is a share of the object's box; em, ex and a missing length
|
||||
// take the default region's share of it.
|
||||
let viewport = r#"<filter id="v" filterUnits="userSpaceOnUse" x="-50%" y="0" width="10" height="10"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": viewport })).outset(b) - 50.0).abs() < 1e-9);
|
||||
let far = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1000%" y="0" width="3000%" height="10"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": far })).outset(b) - 1900.0).abs() < 1e-9, "however far it reaches");
|
||||
let font = r#"<filter id="f" filterUnits="userSpaceOnUse" x="-100em" y="0" width="10" height="10"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": font })).outset(b) - 10.0).abs() < 1e-9, "relative to the font");
|
||||
let default = r#"<filter id="d" filterUnits="userSpaceOnUse"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": default })).outset(b) - 10.0).abs() < 1e-9);
|
||||
// In the bounding box, a length with a unit is invalid: the default.
|
||||
let unit = r#"<filter id="u" x="-5mm"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": unit })).outset(b) - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_keeps_the_prefixes_an_ancestor_declares() {
|
||||
let svg = r#"<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:x="urn:other"><svg:defs><svg:filter id="Pre"><svg:feOffset dx="1"/></svg:filter></svg:defs></svg:svg>"#;
|
||||
let (found, skipped) = filters_in(svg);
|
||||
assert_eq!((found.len(), skipped), (1, 0));
|
||||
assert_eq!(
|
||||
found[0].1, r#"<svg:filter xmlns:svg="http://www.w3.org/2000/svg" id="Pre"><svg:feOffset dx="1"/></svg:filter>"#,
|
||||
"only the prefix it uses"
|
||||
);
|
||||
// A declaration on the filter itself, with spaces around its `=`, is kept as it is.
|
||||
let own = r#"<svg xmlns="http://www.w3.org/2000/svg"><defs><s:filter xmlns:s = "http://www.w3.org/2000/svg" id="Own"><s:feOffset/></s:filter></defs></svg>"#;
|
||||
let (found, skipped) = filters_in(own);
|
||||
assert_eq!((found.len(), skipped), (1, 0));
|
||||
assert_eq!(found[0].1, r#"<s:filter xmlns:s = "http://www.w3.org/2000/svg" id="Own"><s:feOffset/></s:filter>"#);
|
||||
// The same declaration again on the filter, and text like one inside an attribute's value.
|
||||
let again = r#"<s:svg xmlns:s="http://www.w3.org/2000/svg"><s:filter xmlns:s="http://www.w3.org/2000/svg" id="A"><s:feOffset/></s:filter><s:filter id="B" color-profile=" xmlns:s = x"><s:feOffset/></s:filter></s:svg>"#;
|
||||
let (found, skipped) = filters_in(again);
|
||||
assert_eq!((found.len(), skipped), (2, 0), "{found:?}");
|
||||
assert!(found[1].1.starts_with(r#"<s:filter xmlns:s="http://www.w3.org/2000/svg" id="B""#), "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_forms_tell_filters_with_the_same_primitives_apart() {
|
||||
let a = primitive_forms(r#"<filter id="a"><feOffset dx="1"/></filter>"#).unwrap();
|
||||
let b = primitive_forms(r#"<filter id="b"><feOffset dx="2"/></filter>"#).unwrap();
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(primitive_forms(r#"<filter id="c"><feOffset dx="1"/></filter>"#), Some(a), "the id doesn't count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn costly_parameters_are_capped() {
|
||||
for (xml, why) in [
|
||||
("<filter id=\"a\"><feMorphology radius=\"101\"/></filter>", "radius is at most 100"),
|
||||
("<filter id=\"a\"><feMorphology radius=\"2 1e9\"/></filter>", "radius is at most 100"),
|
||||
("<filter id=\"a\"><feMorphology radius=\"50+500\"/></filter>", "radius is at most 100"),
|
||||
("<filter id=\"a\"><feGaussianBlur stdDeviation=\"501\"/></filter>", "stdDeviation is at most 500"),
|
||||
("<filter id=\"a\"><feDropShadow stdDeviation=\"1 9e9\"/></filter>", "stdDeviation is at most 500"),
|
||||
(
|
||||
"<filter id=\"a\"><feDiffuseLighting kernelUnitLength=\"101\"><feDistantLight/></feDiffuseLighting></filter>",
|
||||
"kernelUnitLength is at most 100",
|
||||
),
|
||||
] {
|
||||
let e = check(xml).expect_err(xml);
|
||||
assert!(e.contains(why), "{xml}: {e}");
|
||||
}
|
||||
assert_eq!(check("<filter id=\"a\"><feMorphology radius=\"100\"/></filter>"), Ok("a".into()));
|
||||
// usvg also reads attributes in the SVG, XLink and XML namespaces by their local names.
|
||||
for prefixed in [
|
||||
"<filter id=\"a\" xmlns:s=\"http://www.w3.org/2000/svg\"><feMorphology s:radius=\"1000\"/></filter>",
|
||||
"<filter id=\"a\" xmlns:s=\"http://www.w3.org/2000/svg\"><feGaussianBlur s:stdDeviation=\"1e6\"/></filter>",
|
||||
] {
|
||||
assert!(check(prefixed).is_err(), "{prefixed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A filter is drawn and written rebuilt from its filter elements and their filter attributes:
|
||||
/// no comments, text, descriptions, other attributes or prefixes.
|
||||
#[test]
|
||||
fn the_filter_is_drawn_and_written_from_its_filter_parts_alone() {
|
||||
let xml = concat!(
|
||||
"<filter id=\"a\" xmlns:s=\"http://www.w3.org/2000/svg\" data-x=\"1\"><!-- note --><desc><script></desc><title>T</title>",
|
||||
"<feFlood flood-color=\"blue\" style=\"flood-color: red; fill: url(#x); flood-opacity: url(#y)\" foo=\""\"/>",
|
||||
"<feMorphology s:radius=\"2\"/><feImage xlink:href=\"data:image/png;base64,AAAA\"/></filter>",
|
||||
);
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
assert_eq!(
|
||||
f.xml(),
|
||||
"<filter id=\"a\"><feFlood flood-color=\"red\"/><feMorphology radius=\"2\"/><feImage href=\"data:image/png;base64,AAAA\"/></filter>"
|
||||
);
|
||||
// An image keeps how it is scaled, as a style declaration.
|
||||
for image in [
|
||||
"<filter id=\"i\"><feImage image-rendering=\"pixelated\" href=\"data:image/png;base64,AAAA\"/></filter>",
|
||||
"<filter id=\"i\"><feImage style=\"image-rendering: pixelated; fill: red\" href=\"data:image/png;base64,AAAA\"/></filter>",
|
||||
] {
|
||||
assert_eq!(
|
||||
filter(json!({ "xml": image })).xml(),
|
||||
"<filter id=\"i\"><feImage href=\"data:image/png;base64,AAAA\" style=\"image-rendering:pixelated\"/></filter>",
|
||||
"{image}"
|
||||
);
|
||||
}
|
||||
// `currentColor` keeps the color it names.
|
||||
let colored = filter(json!({ "xml": "<filter id=\"c\" color=\"red\"><feFlood flood-color=\"currentColor\" style=\"color: blue\"/></filter>" }));
|
||||
assert_eq!(colored.xml(), "<filter id=\"c\" color=\"red\"><feFlood flood-color=\"currentColor\" color=\"blue\"/></filter>");
|
||||
}
|
||||
|
||||
/// An image of a filter is a PNG, JPEG, GIF or WebP, by its bytes, within the pixel cap by its header.
|
||||
#[test]
|
||||
fn filter_images_are_small_raster_images() {
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::ImageEncoder::write_image(
|
||||
image::codecs::png::PngEncoder::new(&mut out),
|
||||
&vec![0; (w * h * 4) as usize],
|
||||
w,
|
||||
h,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
let raster_image = |data| raster_image_sized(data).map(|(kind, _)| kind);
|
||||
assert!(matches!(raster_image(Arc::new(png(2, 2))), Some(usvg::ImageKind::PNG(_))));
|
||||
assert_eq!(raster_image_sized(Arc::new(png(3, 2))).map(|(_, n)| n), Some(6), "its pixel count");
|
||||
// A header that claims more pixels than the cap: the IHDR's width, with its CRC fixed.
|
||||
let mut big = png(2, 2);
|
||||
big[16..20].copy_from_slice(&5000u32.to_be_bytes());
|
||||
big[20..24].copy_from_slice(&5000u32.to_be_bytes());
|
||||
let crc = crc32(&big[12..29]);
|
||||
big[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
assert!(raster_image(Arc::new(big)).is_none(), "past the pixel cap");
|
||||
assert!(raster_image(Arc::new(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>".to_vec())).is_none(), "SVG");
|
||||
assert!(raster_image(Arc::new(vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0])).is_none(), "compressed SVG");
|
||||
}
|
||||
|
||||
/// A 2 × 2 PNG whose header claims `w` × `h` pixels (its IHDR rewritten, with the CRC fixed).
|
||||
fn png_claiming(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &[0; 16], 2, 2, image::ExtendedColorType::Rgba8).unwrap();
|
||||
out[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
out[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
let crc = crc32(&out[12..29]);
|
||||
out[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A filter's images share the pixel cap: images that are each within it but pass it together
|
||||
/// are refused.
|
||||
#[test]
|
||||
fn a_filters_images_share_the_pixel_cap() {
|
||||
let img = |w: u32, h: u32| format!("<feImage href=\"data:image/png;base64,{}\"/>", base64(&png_claiming(w, h)));
|
||||
let half = img(4096, 2048);
|
||||
let err = check(&format!("<filter id=\"a\">{half}{}</filter>", img(4096, 2049))).unwrap_err();
|
||||
assert!(err.contains("pixels in all"), "{err}");
|
||||
let err = check(&format!("<filter id=\"a\">{half}{half}{}</filter>", img(1, 1))).unwrap_err();
|
||||
assert!(err.contains("pixels in all"), "{err}");
|
||||
assert!(check(&format!("<filter id=\"a\">{half}{half}</filter>")).is_ok(), "two halves fill the cap exactly");
|
||||
}
|
||||
|
||||
/// The image limit is for a filter's own images: the object it runs on is its source graphic
|
||||
/// whatever its size.
|
||||
#[test]
|
||||
fn a_large_object_is_still_the_source_graphic() {
|
||||
let w = 4097;
|
||||
let mut d = blue_square(w, 0);
|
||||
filter(json!({ "xml": RED_FLOOD })).apply(&mut d, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert_eq!(at(&d, w, w / 2, w / 2), [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for b in bytes {
|
||||
c ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// A user-space region in percentages is written in user units, measured against the object's box.
|
||||
#[test]
|
||||
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(),
|
||||
Some(r#"<filter id="r" filterUnits="userSpaceOnUse" x="-10" y="0" width="40" height="10"><feOffset/></filter>"#)
|
||||
);
|
||||
let plain = filter(json!({ "xml": RED_FLOOD }));
|
||||
assert_eq!(plain.written_xml(Rect::new(0.0, 0.0, 1.0, 1.0)).as_deref(), Some(plain.xml()));
|
||||
}
|
||||
|
||||
/// A filter at the size limit whose region is measured against the box still draws: written in
|
||||
/// user units, the region can make the drawn form longer than the limit.
|
||||
#[test]
|
||||
fn a_filter_at_the_size_limit_draws_with_its_measured_region() {
|
||||
let open = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#;
|
||||
let close = r#""/><feOffset/></filter>"#;
|
||||
let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len()));
|
||||
assert_eq!(xml.len(), SvgFilter::MAX_XML);
|
||||
assert_eq!(check(&xml), Ok("r".into()));
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
|
||||
assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len());
|
||||
assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read");
|
||||
}
|
||||
|
||||
/// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach
|
||||
/// filters: class, id, attribute and pseudo-class names that contain "filter" or "fe" don't.
|
||||
#[test]
|
||||
fn style_sheets_reach_filters_only_through_their_type_selectors() {
|
||||
let reaches = |sheet: &str| {
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><style>{sheet}</style></svg>");
|
||||
Styles::of(&roxmltree::Document::parse(&svg).unwrap()).reach_filters
|
||||
};
|
||||
for sheet in
|
||||
[".filter-button { color: red }", "#feature, .fe-icon { fill: blue }", "[data-filter] { fill: red }", "a:hover, rect.filter { stroke: red }"]
|
||||
{
|
||||
assert!(!reaches(sheet), "{sheet}");
|
||||
}
|
||||
for sheet in
|
||||
["filter { x: 0 }", "FeFlood { flood-color: red }", "svg|feBlend { mode: multiply }", "* { color: red }", "g :is(feGaussianBlur) { x: 0 }"]
|
||||
{
|
||||
assert!(reaches(sheet), "{sheet}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An attribute selector styles a filter whose elements carry that attribute, whatever its value
|
||||
/// or namespace prefix; a filter without it is not affected.
|
||||
#[test]
|
||||
fn an_attribute_selector_styles_a_filter_with_that_attribute() {
|
||||
let depends = |sheet: &str, filter: &str| {
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><style>{sheet}</style><defs>{filter}</defs></svg>");
|
||||
let doc = roxmltree::Document::parse(&svg).unwrap();
|
||||
let f = doc.descendants().find(|n| n.tag_name().name() == "filter").unwrap();
|
||||
depends_on_styles(f, &Styles::of(&doc))
|
||||
};
|
||||
let tagged = r#"<filter id="a"><feFlood data-tone="warm"/></filter>"#;
|
||||
let plain = r#"<filter id="a"><feFlood/></filter>"#;
|
||||
for sheet in ["[data-tone] { flood-color: red }", "[data-tone=\"warm\"] { flood-color: red }", "[svg|data-tone ~= warm] { x: 0 }"] {
|
||||
assert!(depends(sheet, tagged), "{sheet}");
|
||||
assert!(!depends(sheet, plain), "{sheet}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_of_reads_at_most_256_filters() {
|
||||
let many = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>",
|
||||
(0..300).map(|i| format!("<filter id=\"f{i}\"><feOffset/></filter>")).collect::<String>()
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&many).unwrap();
|
||||
let (found, skipped) = filters_of(&doc, &many, usize::MAX, |_| false);
|
||||
assert_eq!((found.len(), skipped), (MAX_FILTERS_READ, 300 - MAX_FILTERS_READ));
|
||||
}
|
||||
|
||||
/// However large `scale` makes them, a morphology radius spans at most 32 pixels of the raster the
|
||||
/// filter runs on, and a standard deviation at most 512.
|
||||
#[test]
|
||||
fn costly_lengths_are_clamped_in_pixels() {
|
||||
let sp = space(10, 10, 0.5, 0.5);
|
||||
let morph = filter(json!({ "xml": "<filter id=\"m\"><feMorphology radius=\"100\"/></filter>", "scale": 1000.0 }));
|
||||
let s = morph.resolution(&sp, 10.0);
|
||||
assert!(100.0 * 1000.0 / 0.5 * s <= 32.0 + 1e-6, "{s}");
|
||||
let blur = filter(json!({ "xml": "<filter id=\"b\"><feGaussianBlur stdDeviation=\"500\"/></filter>", "scale": 1000.0 }));
|
||||
let s = blur.resolution(&sp, 10.0);
|
||||
assert!(500.0 * 1000.0 / 0.5 * s <= 512.0 + 1e-6, "{s}");
|
||||
// At a fine raster too (1200 ppi): the cap holds.
|
||||
let fine = space(10, 10, 0.06, 0.06);
|
||||
let s = morph.resolution(&fine, 10.0);
|
||||
assert!(100.0 * 1000.0 / 0.06 * s <= 32.0 + 1e-6, "{s}");
|
||||
let small = filter(json!({ "xml": "<filter id=\"s\"><feMorphology radius=\"1\"/></filter>" }));
|
||||
assert_eq!(small.resolution(&sp, 10.0), 1.0, "a small radius runs at the raster's own pixels");
|
||||
// In the object's bounding box, a radius is a fraction of its box: half a 4096-pixel box.
|
||||
let boxed = filter(json!({ "xml": "<filter id=\"o\" primitiveUnits=\"objectBoundingBox\"><feMorphology radius=\"0.5\"/></filter>" }));
|
||||
let s = boxed.resolution(&sp, 4096.0);
|
||||
assert!(0.5 * 4096.0 * s <= 32.0 + 1e-6, "{s}");
|
||||
}
|
||||
|
||||
/// A filter that `check` refuses is never drawn, whatever path put it in an effect.
|
||||
#[test]
|
||||
fn an_unchecked_filter_is_not_drawn() {
|
||||
for xml in [
|
||||
"<filter id=\"a\"><feOffset/><script>alert(1)</script></filter>",
|
||||
"<filter id=\"a\"><feMorphology radius=\"1e6\"/></filter>",
|
||||
"<filter id=\"a\" onload=\"x()\"><feOffset/></filter>",
|
||||
] {
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": xml }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "{xml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale Strokes & Effects scales the filter's lengths with the art: the points a unit measures.
|
||||
#[test]
|
||||
fn scaling_the_effect_scales_its_units() {
|
||||
let mut e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": RED_FLOOD }), visible: true };
|
||||
crate::scale_effect(&mut e, 2.0);
|
||||
assert_eq!(e.params["scale"], json!(2.0));
|
||||
crate::scale_effect(&mut e, 0.25);
|
||||
assert_eq!(e.params["scale"], json!(0.5));
|
||||
}
|
||||
|
||||
/// The form SVG output writes, for a filter that fills a 10 × 10 point box.
|
||||
fn written(xml: &str) -> Option<String> {
|
||||
filter(json!({ "xml": xml })).written_xml(Rect::new(0.0, 0.0, 10.0, 10.0))
|
||||
}
|
||||
|
||||
/// SVG 1.1 output writes an image's reference as `xlink:href`; the filter as it is drawn and
|
||||
/// listed keeps `href`.
|
||||
#[test]
|
||||
fn an_image_is_written_with_an_xlink_reference() {
|
||||
let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2)));
|
||||
let f = filter(json!({ "xml": format!(r#"<filter id="i"><feImage href="{data}"/></filter>"#) }));
|
||||
assert_eq!(f.xml(), format!(r#"<filter id="i"><feImage href="{data}"/></filter>"#));
|
||||
let out = f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).unwrap();
|
||||
assert_eq!(out, format!(r#"<filter id="i"><feImage xlink:href="{data}"/></filter>"#));
|
||||
assert!(f.writes_as_svg11());
|
||||
assert_eq!(check(&out), Ok("i".into()), "the written form reads back");
|
||||
}
|
||||
|
||||
/// SVG 1.1 has no `feDropShadow`: it is written as the primitives Filter Effects gives as its
|
||||
/// equivalent, with its defaults, input, result and subregion, and results no other primitive uses.
|
||||
#[test]
|
||||
fn a_drop_shadow_is_written_as_svg_1_1_primitives() {
|
||||
assert_eq!(
|
||||
written(r#"<filter id="s"><feDropShadow/></filter>"#).as_deref(),
|
||||
Some(concat!(
|
||||
r#"<filter id="s"><feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="2" dy="2" result="vcShadow0"/><feFlood/>"#,
|
||||
r#"<feComposite in2="vcShadow0" operator="in" result="vcShadow1"/><feMerge><feMergeNode in="vcShadow1"/><feMergeNode in="SourceGraphic"/></feMerge></filter>"#,
|
||||
))
|
||||
);
|
||||
// Its input, result, subregion, color space and flood; a name the filter uses is not taken.
|
||||
let given = r#"<filter id="s"><feOffset dx="1" result="vcShadow0"/><feDropShadow in="vcShadow0" result="r" x="1" width="5" dx="3" dy="-1" stdDeviation="1 2" flood-color="rgba(255,0,0,0.5)" flood-opacity="50%" color-interpolation-filters="sRGB"/></filter>"#;
|
||||
assert_eq!(
|
||||
written(given).as_deref(),
|
||||
Some(concat!(
|
||||
r#"<filter id="s"><feOffset dx="1" result="vcShadow0"/>"#,
|
||||
r#"<feGaussianBlur in="vcShadow0" stdDeviation="1 2" color-interpolation-filters="sRGB"/><feOffset dx="3" dy="-1" result="vcShadow1" color-interpolation-filters="sRGB"/>"#,
|
||||
r##"<feFlood flood-color="#ff0000" flood-opacity="0.25098" color-interpolation-filters="sRGB"/>"##,
|
||||
r#"<feComposite in2="vcShadow1" operator="in" result="vcShadow2" color-interpolation-filters="sRGB"/>"#,
|
||||
r#"<feMerge x="1" width="5" result="r" color-interpolation-filters="sRGB"><feMergeNode in="vcShadow2"/><feMergeNode in="vcShadow0"/></feMerge></filter>"#,
|
||||
))
|
||||
);
|
||||
// An implicit input, or a result no earlier primitive has, is the previous result, named.
|
||||
for shadow in [r#"<feDropShadow dx="1"/>"#, r#"<feDropShadow in="nowhere" dx="1"/>"#] {
|
||||
let out = written(&format!(r#"<filter id="s"><feFlood flood-color="blue"/>{shadow}</filter>"#)).unwrap();
|
||||
assert!(out.starts_with(r##"<filter id="s"><feFlood flood-color="#0000ff" result="vcShadow0"/><feGaussianBlur in="vcShadow0""##), "{out}");
|
||||
assert!(out.ends_with(r#"<feMergeNode in="vcShadow0"/></feMerge></filter>"#), "{out}");
|
||||
assert_eq!(check(&out), Ok("s".into()), "the written form reads back");
|
||||
}
|
||||
// What SVG 1.1 can't hold leaves the filter to be written as other pixel effects are.
|
||||
for xml in [
|
||||
r#"<filter id="s"><feDropShadow flood-color="currentColor"/></filter>"#,
|
||||
r#"<filter id="s" primitiveUnits="objectBoundingBox"><feDropShadow/></filter>"#,
|
||||
] {
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{xml}");
|
||||
}
|
||||
// An offset that isn't a number is the shadow's default, which an `feOffset` doesn't share.
|
||||
let out = written(r#"<filter id="s"><feDropShadow dx="bad" dy="-3"/></filter>"#).unwrap();
|
||||
assert!(out.contains(r#"<feOffset dx="2" dy="-3""#), "{out}");
|
||||
// Expanded shadows past the primitive cap.
|
||||
let many = format!(r#"<filter id="s">{}</filter>"#, "<feDropShadow/>".repeat(13));
|
||||
assert!(!filter(json!({ "xml": many })).writes_as_svg11());
|
||||
}
|
||||
|
||||
/// Each input is written as the primitive it reads when drawn: a result name only usvg gives (to
|
||||
/// a primitive without a `result`, here the shadow), and the inputs usvg draws as `SourceGraphic`.
|
||||
#[test]
|
||||
fn inputs_are_written_as_the_primitives_they_read() {
|
||||
let xml = r#"<filter id="s"><feOffset dx="1"/><feDropShadow in="BackgroundImage"/><feOffset dx="2"/><feMerge><feMergeNode in="result2"/><feMergeNode in="FillPaint"/></feMerge><feComposite in="result1" in2="StrokePaint"/></filter>"#;
|
||||
let out = written(xml).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
concat!(
|
||||
r#"<filter id="s"><feOffset dx="1" result="vcShadow1"/>"#,
|
||||
r#"<feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="2" dy="2" result="vcShadow2"/><feFlood/>"#,
|
||||
r#"<feComposite in2="vcShadow2" operator="in" result="vcShadow3"/><feMerge result="vcShadow0"><feMergeNode in="vcShadow3"/><feMergeNode in="SourceGraphic"/></feMerge>"#,
|
||||
r#"<feOffset dx="2"/><feMerge><feMergeNode in="vcShadow0"/><feMergeNode in="SourceGraphic"/></feMerge>"#,
|
||||
r#"<feComposite in="vcShadow1" in2="SourceGraphic"/></filter>"#,
|
||||
)
|
||||
);
|
||||
assert_eq!(check(&out), Ok("s".into()), "the written form reads back");
|
||||
}
|
||||
|
||||
/// Other values SVG 1.1 lacks: SVG 1.1 values that draw alike, or no SVG 1.1 form.
|
||||
#[test]
|
||||
fn values_svg_1_1_lacks_are_written_as_resvg_draws_them() {
|
||||
// `lighter` (Filter Effects) is drawn as the default `over`, and a blur has no `edgeMode`.
|
||||
assert_eq!(
|
||||
written(r#"<filter id="c"><feComposite in2="SourceAlpha" operator="lighter"/><feGaussianBlur stdDeviation="1" edgeMode="wrap"/></filter>"#)
|
||||
.as_deref(),
|
||||
Some(r#"<filter id="c"><feComposite in2="SourceAlpha"/><feGaussianBlur stdDeviation="1"/></filter>"#)
|
||||
);
|
||||
assert_eq!(
|
||||
written(r#"<filter id="c"><feBlend in2="SourceAlpha" mode="multiply"/></filter>"#).as_deref(),
|
||||
Some(r#"<filter id="c"><feBlend in2="SourceAlpha" mode="multiply"/></filter>"#)
|
||||
);
|
||||
// CSS colors as `#rrggbb`, a flood color's alpha in its opacity, a lighting color's dropped.
|
||||
assert_eq!(
|
||||
written(r#"<filter id="c" color="hsl(120, 100%, 50%)"><feFlood flood-color="transparent"/><feDiffuseLighting lighting-color="rgba(0,0,255,0.5)"><feDistantLight/></feDiffuseLighting></filter>"#)
|
||||
.as_deref(),
|
||||
Some(
|
||||
r##"<filter id="c" color="#00ff00"><feFlood flood-color="#000000" flood-opacity="0"/><feDiffuseLighting lighting-color="#0000ff"><feDistantLight/></feDiffuseLighting></filter>"##
|
||||
)
|
||||
);
|
||||
// Image scaling as an SVG 1.1 keyword.
|
||||
let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2)));
|
||||
for (given, kept) in
|
||||
[("pixelated", "optimizeSpeed"), ("crisp-edges", "optimizeSpeed"), ("smooth", "optimizeQuality"), ("auto", "optimizeQuality")]
|
||||
{
|
||||
let out = written(&format!(r#"<filter id="i"><feImage style="image-rendering:{given}" href="{data}"/></filter>"#)).unwrap();
|
||||
assert!(out.contains(&format!(r#"style="image-rendering:{kept}""#)), "{given}: {out}");
|
||||
}
|
||||
// The blend modes SVG 1.1 lacks have no SVG 1.1 form.
|
||||
for mode in LATER_BLEND_MODES {
|
||||
let f = filter(json!({ "xml": format!(r#"<filter id="b"><feBlend in2="SourceAlpha" mode="{mode}"/></filter>"#) }));
|
||||
assert!(!f.writes_as_svg11(), "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_pads_as_rfc_4648_does() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
@@ -84,6 +84,7 @@ pub(crate) mod slices;
|
||||
mod stroke;
|
||||
mod style;
|
||||
pub mod stylelib;
|
||||
mod svgfilter;
|
||||
mod swatch;
|
||||
pub mod swatchlib;
|
||||
pub(crate) mod symboledit;
|
||||
@@ -318,6 +319,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(interactivity::specs());
|
||||
v.extend(svgfilter::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
|
||||
@@ -18,6 +18,22 @@ use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or};
|
||||
|
||||
/// The name of the `svg.filter` effect drawing `xml`: its `<filter>` id. An effect names the
|
||||
/// document's filter of that name, so `xml` that differs from the document's filter of the same
|
||||
/// name is refused.
|
||||
fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result<String> {
|
||||
let name = effects::svg_filter::check(xml).map_err(|e| bad(cmd, e))?;
|
||||
if let Some(held) = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name)
|
||||
&& held.xml != xml.trim()
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("the document has another SVG filter named {name:?}: use svgFilter.apply, or svgFilter.define with `replace` to edit it"),
|
||||
));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// The effect commands of the Effect menu and the Appearance panel.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -245,6 +261,11 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if texture_load_effect(id) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
// An SVG filter needs its <filter> element, and is named by its id.
|
||||
if id == effects::SVG_FILTER {
|
||||
let xml = params.get("xml").and_then(Value::as_str).ok_or_else(|| bad(C, "give `xml`, one SVG <filter> element"))?;
|
||||
params["name"] = json!(svg_filter_name(s, xml, C)?);
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
aligned(p, C)?;
|
||||
@@ -536,6 +557,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
}
|
||||
// An SVG filter is named by its id: another filter renames the effect.
|
||||
if edited.iter().any(|e| e == effects::SVG_FILTER)
|
||||
&& let Some(xml) = params.get("xml").and_then(Value::as_str)
|
||||
{
|
||||
params["name"] = json!(svg_filter_name(s, xml, C)?);
|
||||
}
|
||||
for &(id, target, index) in &targets {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
|
||||
@@ -177,7 +177,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
|
||||
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
|
||||
// Render effects SVG cannot represent faithfully, including source-model Darkness shadows.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects_for(doc, opts.profile != vectorcraft_svg::Profile::Tiny12);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded { warnings, ..Encoded::default() };
|
||||
for artboard in boards {
|
||||
|
||||
@@ -279,6 +279,29 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// An SVG filter's XML is one drawable <filter> element.
|
||||
if effect == vectorcraft_render::effects::SVG_FILTER
|
||||
&& let Some(xml) = given.get("xml")
|
||||
{
|
||||
let xml = xml.as_str().ok_or_else(|| bad(id, "`xml` must be a string"))?;
|
||||
vectorcraft_render::effects::svg_filter::check(xml).map_err(|e| bad(id, e))?;
|
||||
}
|
||||
if effect == vectorcraft_render::effects::SVG_FILTER
|
||||
&& let Some(scale) = given.get("scale")
|
||||
&& !scale.as_f64().is_some_and(|k| (0.001..=1000.0).contains(&k))
|
||||
{
|
||||
return Err(bad(id, "`scale` must be a number from 0.001 to 1000"));
|
||||
}
|
||||
if effect == vectorcraft_render::effects::SVG_FILTER
|
||||
&& let Some(origin) = given.get("origin")
|
||||
&& !origin.as_array().is_some_and(|o| o.len() == 2 && o.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)))
|
||||
{
|
||||
return Err(bad(id, "`origin` must be [x, y] in points"));
|
||||
}
|
||||
// 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`"));
|
||||
}
|
||||
check_effect_color(id, effect, params)?;
|
||||
check_effect_channel(id, effect, params)
|
||||
}
|
||||
|
||||
@@ -455,8 +455,9 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Effects SVG cannot represent faithfully, with their other raster effects.
|
||||
Pixel { cmyk: bool },
|
||||
/// Effects SVG cannot represent faithfully, with their other raster effects; `filters`: the SVG
|
||||
/// written has filters (SVG Tiny 1.2 has none).
|
||||
Pixel { cmyk: bool, filters: bool },
|
||||
}
|
||||
|
||||
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
|
||||
@@ -464,8 +465,14 @@ fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel { cmyk } => {
|
||||
fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)))
|
||||
Which::Pixel { cmyk, filters } => {
|
||||
// An SVG filter that is the object's last effect, and its only pixel effect, stays a filter.
|
||||
let pixels = fx.iter().filter(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))).count();
|
||||
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()));
|
||||
(pixels > 0 && !live)
|
||||
|| (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx)))
|
||||
}
|
||||
}
|
||||
@@ -592,7 +599,13 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its
|
||||
/// filters draw the other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc))
|
||||
flatten_pixel_effects_for(doc, true)
|
||||
}
|
||||
|
||||
/// [`flatten_pixel_effects`] for SVG that has filters (`filters`) or not (SVG Tiny 1.2): without
|
||||
/// them an SVG filter (Effect › SVG Filters) also makes its object an image.
|
||||
pub fn flatten_pixel_effects_for(doc: &Document, filters: bool) -> Option<Document> {
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk, filters }, doc))
|
||||
}
|
||||
|
||||
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Effect › SVG Filters: the SVG filters a document holds (`svgFilter.list`, `define`, `delete`,
|
||||
//! `import`) and applying one to objects as a live `svg.filter` effect (`svgFilter.apply`). An
|
||||
//! applied effect names the document's filter: editing the filter changes what every object that
|
||||
//! uses it draws, and deleting it leaves their effects in place, drawing nothing, until a filter of
|
||||
//! that name exists again. Each effect holds the definition it draws with, kept in step here.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, SvgFilter};
|
||||
use vectorcraft_render::effects::{self, svg_filter};
|
||||
|
||||
use super::fileio::{absolute_path, read_file_head};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, text_param};
|
||||
|
||||
/// The largest SVG file Import SVG Filter reads.
|
||||
const MAX_IMPORT_BYTES: usize = 16 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "svgFilter.list",
|
||||
"SVG Filters",
|
||||
[],
|
||||
None,
|
||||
"{} → {filters: [{name, xml}…]} the SVG filters the document holds, in the SVG Filters submenu's order",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
"svgFilter.define",
|
||||
"New SVG Filter",
|
||||
[],
|
||||
None,
|
||||
"{xml: one SVG <filter> element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter has is refused and a document holds at most 64 filters → {name, filters}",
|
||||
has_doc,
|
||||
define
|
||||
),
|
||||
cmd!(
|
||||
"svgFilter.delete",
|
||||
"Delete SVG Filter",
|
||||
[],
|
||||
None,
|
||||
"{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again → {filters}",
|
||||
has_doc,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"svgFilter.import",
|
||||
"Import SVG Filter…",
|
||||
[],
|
||||
None,
|
||||
"{path?|svg?} add the <filter> elements of an SVG file (at most 16 MB; not SVGZ; its first 256 filters) or of SVG text to the document's SVG filters, as one undo step; a filter whose name the document already has, and one svgFilter.define would refuse, is left out; objects whose effect names an added filter draw with it → {added: [names…], skipped: count, filters}",
|
||||
has_doc,
|
||||
import
|
||||
),
|
||||
cmd!(
|
||||
"svgFilter.apply",
|
||||
"Apply SVG Filter",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?|id?, item?} apply the document's SVG filter `name` (svgFilter.list) to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn filters_json(d: &Document) -> Value {
|
||||
json!(d.svg_filters.iter().map(|f| json!({ "name": f.name, "xml": f.xml })).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "filters": filters_json(&s.doc()?.doc) }))
|
||||
}
|
||||
|
||||
/// `p`'s `name`, trimmed and not empty.
|
||||
fn name_param(p: &Value, cmd: &str) -> Result<String> {
|
||||
let n = text_param(p, "name", cmd)?.map(str::trim).filter(|n| !n.is_empty());
|
||||
n.map(str::to_string).ok_or_else(|| bad(cmd, "give `name`, an SVG filter's name"))
|
||||
}
|
||||
|
||||
/// Is `e` an `svg.filter` effect that names filter `name`?
|
||||
fn names(e: &vectorcraft_doc::Effect, name: &str) -> bool {
|
||||
e.id == effects::SVG_FILTER && e.params.get("name").and_then(Value::as_str) == Some(name)
|
||||
}
|
||||
|
||||
/// The `svg.filter` effects of appearance `a` (its own and its fills' and strokes') that name
|
||||
/// filter `name`.
|
||||
fn named<'a>(a: &'a Appearance, name: &'a str) -> impl Iterator<Item = &'a vectorcraft_doc::Effect> + 'a {
|
||||
let items = a.items.iter().flat_map(|i| i.effects().iter());
|
||||
a.effects.iter().chain(items).filter(move |e| names(e, name))
|
||||
}
|
||||
|
||||
/// Make the effects of appearance `a` that name filter `name` draw with `xml`.
|
||||
fn update_appearance(a: &mut Appearance, name: &str, xml: &str) {
|
||||
let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
|
||||
for e in a.effects.iter_mut().chain(items) {
|
||||
if names(e, name) && e.params.is_object() {
|
||||
e.params["xml"] = json!(xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does an effect in `d` that names filter `name` draw with something other than `xml` (`""`:
|
||||
/// nothing)? Symbol definitions, graph designs, patterns, opacity masks and graphic styles count.
|
||||
fn out_of_step(d: &Document, name: &str, xml: &str) -> bool {
|
||||
let stale = |a: &Appearance| named(a, name).any(|e| e.params.get("xml").and_then(Value::as_str) != Some(xml));
|
||||
d.any_node(|n| stale(&n.appearance)) || d.graphic_styles.iter().any(|s| stale(&s.appearance))
|
||||
}
|
||||
|
||||
/// Make every effect in `d` that names filter `name` draw with `xml` (`""` when the document no
|
||||
/// longer has the filter: the effect keeps its name and draws nothing), symbol definitions, graph
|
||||
/// designs, patterns, opacity masks and graphic styles included.
|
||||
fn update_users(d: &mut Document, name: &str, xml: &str) {
|
||||
// Through `DerefMut` only for an appearance that has such an effect (it is shared copy-on-write).
|
||||
d.edit_nodes_where(|n| named(&n.appearance, name).next().is_some(), |n| update_appearance(&mut n.appearance, name, xml));
|
||||
for s in d.graphic_styles.iter_mut().filter(|s| named(&s.appearance, name).next().is_some()) {
|
||||
update_appearance(&mut s.appearance, name, xml);
|
||||
}
|
||||
}
|
||||
|
||||
fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgFilter.define";
|
||||
let xml = match p.get("xml") {
|
||||
Some(Value::String(x)) => x.trim().to_string(),
|
||||
Some(_) => return Err(bad(C, "`xml` must be a string")),
|
||||
None => return Err(bad(C, "give `xml`, one SVG <filter> element")),
|
||||
};
|
||||
let replace = match p.get("replace") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(r)) => Some(r.trim().to_string()),
|
||||
Some(_) => return Err(bad(C, "`replace` must be a string")),
|
||||
};
|
||||
svg_filter::check(&xml).map_err(|e| bad(C, e))?;
|
||||
let filters = &s.doc()?.doc.svg_filters;
|
||||
let at = match &replace {
|
||||
Some(r) => Some(filters.iter().position(|f| f.name == *r).ok_or_else(|| bad(C, format!("the document has no SVG filter named {r:?}")))?),
|
||||
None => None,
|
||||
};
|
||||
// An edited filter keeps its name, whatever id its new code gives it.
|
||||
let xml = match &replace {
|
||||
Some(r) => svg_filter::with_id(&xml, r).ok_or_else(|| bad(C, "give one <filter> element"))?,
|
||||
None => xml,
|
||||
};
|
||||
let name = svg_filter::check(&xml).map_err(|e| bad(C, e))?;
|
||||
if filters.iter().enumerate().any(|(i, f)| f.name == name && Some(i) != at) {
|
||||
return Err(bad(C, format!("the document already has an SVG filter named {name:?}")));
|
||||
}
|
||||
if at.is_none() && filters.len() >= SvgFilter::MAX {
|
||||
return Err(bad(C, format!("a document holds at most {} SVG filters", SvgFilter::MAX)));
|
||||
}
|
||||
let new = SvgFilter { name: name.clone(), xml };
|
||||
// Nothing to record when the filter, and every object that uses it, is already as given.
|
||||
if at.and_then(|i| filters.get(i)) != Some(&new) || out_of_step(&s.doc()?.doc, &new.name, &new.xml) {
|
||||
let label = if at.is_some() { "Edit SVG Filter" } else { "New SVG Filter" };
|
||||
s.edit(label, |d, _| {
|
||||
update_users(d, &new.name, &new.xml);
|
||||
match at.and_then(|i| d.svg_filters.get_mut(i)) {
|
||||
Some(f) => *f = new,
|
||||
None => d.svg_filters.push(new),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgFilter.delete";
|
||||
let name = name_param(p, C)?;
|
||||
if !s.doc()?.doc.svg_filters.iter().any(|f| f.name == name) {
|
||||
return Err(bad(C, format!("the document has no SVG filter named {name:?}")));
|
||||
}
|
||||
s.edit("Delete SVG Filter", |d, _| {
|
||||
d.svg_filters.retain(|f| f.name != name);
|
||||
update_users(d, &name, "");
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "filters": filters_json(&s.doc()?.doc) }))
|
||||
}
|
||||
|
||||
/// The bytes of `p`'s `path` (an SVG file, read up to [`MAX_IMPORT_BYTES`]) or `svg`.
|
||||
fn import_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
|
||||
let bytes = match (text_param(p, "path", cmd)?, text_param(p, "svg", cmd)?) {
|
||||
// One byte past the cap is enough to refuse the file below.
|
||||
(Some(path), _) => read_file_head(&absolute_path(path), MAX_IMPORT_BYTES + 1)?,
|
||||
(None, Some(svg)) => svg.as_bytes().to_vec(),
|
||||
_ => return Err(bad(cmd, "give `path`, an SVG file, or `svg`, SVG text")),
|
||||
};
|
||||
if bytes.len() > MAX_IMPORT_BYTES {
|
||||
return Err(bad(cmd, format!("the SVG file is larger than {MAX_IMPORT_BYTES} bytes")));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgFilter.import";
|
||||
let bytes = import_bytes(p, C)?;
|
||||
// SVGZ would unpack past the size read above.
|
||||
if vectorcraft_svg::is_svgz(&bytes) {
|
||||
return Err(bad(C, "Import SVG Filter reads SVG files, not SVGZ"));
|
||||
}
|
||||
let text = vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?;
|
||||
let held = s.doc()?.doc.svg_filters.clone();
|
||||
let room = SvgFilter::MAX.saturating_sub(held.len());
|
||||
// Filters past the room left and names the document has are left out unchecked.
|
||||
let (found, skipped) = vectorcraft_svg::svg_filters(&text, room, |name| held.iter().any(|f| f.name == name))
|
||||
.map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?;
|
||||
let added: Vec<SvgFilter> = found.into_iter().map(|(name, xml)| SvgFilter { name, xml }).collect();
|
||||
let names: Vec<String> = added.iter().map(|f| f.name.clone()).collect();
|
||||
if !added.is_empty() {
|
||||
s.edit("Import SVG Filter", |d, _| {
|
||||
for f in &added {
|
||||
update_users(d, &f.name, &f.xml);
|
||||
}
|
||||
d.svg_filters.extend(added);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "added": names, "skipped": skipped, "filters": filters_json(&s.doc()?.doc) }))
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgFilter.apply";
|
||||
let name = name_param(p, C)?;
|
||||
let f = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name).cloned();
|
||||
let f = f.ok_or_else(|| bad(C, format!("the document has no SVG filter named {name:?}")))?;
|
||||
let mut q = json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } });
|
||||
for key in ["ids", "id", "item"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
q[key] = v.clone();
|
||||
}
|
||||
}
|
||||
super::effectcmd::apply(s, &q)
|
||||
}
|
||||
@@ -166,6 +166,7 @@ mod strokeux;
|
||||
mod stylelib;
|
||||
mod stylepanel;
|
||||
mod styles;
|
||||
mod svgfilter;
|
||||
mod swatchcmds;
|
||||
mod swatches;
|
||||
mod swatchlib;
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Effect › SVG Filters (`svgFilter.*`): the filters a document holds, applying one as a live
|
||||
//! `svg.filter` effect, and what SVG export and the native format keep of them.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
fn err(s: &mut Session, id: &str, p: Value) -> String {
|
||||
s.execute(id, &p).unwrap_err().to_string()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
run(s, "svgFilter.list", json!({}))["filters"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
const BLUR: &str = r#"<filter id="Soft"><feGaussianBlur stdDeviation="2"/></filter>"#;
|
||||
const SHIFT: &str = r#"<filter id="Shift"><feOffset dx="3" dy="3"/></filter>"#;
|
||||
|
||||
#[test]
|
||||
fn filters_are_defined_edited_and_deleted_in_one_undo_step_each() {
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"], json!([]));
|
||||
assert_eq!(run(&mut s, "svgFilter.define", json!({"xml": format!(" {BLUR} ")}))["name"], "Soft");
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
assert_eq!(names(&mut s), ["Soft", "Shift"], "in the order they were added");
|
||||
assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], BLUR, "the XML trimmed");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&mut s), ["Soft"], "one step");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
// A name another filter has is refused; replace edits one in place, and it keeps its name.
|
||||
assert!(err(&mut s, "svgFilter.define", json!({"xml": BLUR})).contains("already has an SVG filter named \"Soft\""));
|
||||
let renamed = r#"<filter id="Softer"><feGaussianBlur stdDeviation="4"/></filter>"#;
|
||||
run(&mut s, "svgFilter.define", json!({"xml": renamed, "replace": "Soft"}));
|
||||
assert_eq!(names(&mut s), ["Soft", "Shift"]);
|
||||
assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Nope"})).contains("no SVG filter named \"Nope\""));
|
||||
// The same filter again changes nothing and makes no undo step.
|
||||
let before = steps(&s);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Shift"}));
|
||||
assert_eq!(steps(&s), before);
|
||||
assert_eq!(run(&mut s, "svgFilter.delete", json!({"name": "Soft"}))["filters"].as_array().unwrap().len(), 1);
|
||||
assert!(err(&mut s, "svgFilter.delete", json!({"name": "Soft"})).contains("no SVG filter named"));
|
||||
assert!(err(&mut s, "svgFilter.delete", json!({"name": " "})).contains("give `name`"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&mut s), ["Soft", "Shift"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_refuses_what_is_not_one_filter() {
|
||||
let mut s = session();
|
||||
for (p, why) in [
|
||||
(json!({}), "give `xml`"),
|
||||
(json!({"xml": 3}), "`xml` must be a string"),
|
||||
(json!({"xml": "<filter><feOffset/></filter>"}), "needs an id"),
|
||||
(json!({"xml": "<filter id=\"a\"/>"}), "no primitives"),
|
||||
(json!({"xml": "<g/>"}), "give one <filter> element"),
|
||||
(json!({"xml": SHIFT, "replace": 1}), "`replace` must be a string"),
|
||||
] {
|
||||
let e = err(&mut s, "svgFilter.define", p.clone());
|
||||
assert!(e.contains(why), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(steps(&s), 0, "nothing recorded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_holds_at_most_64_filters() {
|
||||
let mut s = session();
|
||||
for i in 0..64 {
|
||||
run(&mut s, "svgFilter.define", json!({"xml": format!("<filter id=\"f{i}\"><feOffset/></filter>")}));
|
||||
}
|
||||
assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT})).contains("at most 64 SVG filters"));
|
||||
run(&mut s, "svgFilter.define", json!({"xml": "<filter id=\"g0\"><feOffset dx=\"1\"/></filter>", "replace": "f0"}));
|
||||
assert_eq!(names(&mut s).len(), 64, "a replacement still fits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_the_files_filters_it_does_not_have() {
|
||||
let mut s = session();
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
let svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><defs>{BLUR}<filter id=\"Shift\"><feOffset dx=\"9\"/></filter><filter id=\"Bad\"/></defs><rect width=\"1\" height=\"1\"/></svg>"
|
||||
);
|
||||
let path = std::env::temp_dir().join(format!("vc-svgfilters-{}.svg", std::process::id()));
|
||||
std::fs::write(&path, &svg).unwrap();
|
||||
let r = run(&mut s, "svgFilter.import", json!({"path": path.to_string_lossy()}));
|
||||
std::fs::remove_file(&path).ok();
|
||||
assert_eq!(r["added"], json!(["Soft"]));
|
||||
assert_eq!(r["skipped"], 2, "a name the document has, and a filter without primitives");
|
||||
assert_eq!(names(&mut s), ["Shift", "Soft"]);
|
||||
assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], SHIFT, "the document's own filter stays");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&mut s), ["Shift"], "one step");
|
||||
// Nothing new: no undo step.
|
||||
let before = steps(&s);
|
||||
assert_eq!(run(&mut s, "svgFilter.import", json!({"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\"/>"}))["added"], json!([]));
|
||||
assert_eq!(steps(&s), before);
|
||||
assert!(err(&mut s, "svgFilter.import", json!({"svg": "<svg"})).contains("isn't valid SVG"));
|
||||
// Read with SVG import's limits: elements nested past them are refused before parsing.
|
||||
let deep = format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{}{BLUR}{}</svg>", "<g>".repeat(5000), "</g>".repeat(5000));
|
||||
assert!(err(&mut s, "svgFilter.import", json!({"svg": deep})).contains("isn't valid SVG"));
|
||||
assert!(err(&mut s, "svgFilter.import", json!({})).contains("give `path`"));
|
||||
let svg_text = format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{BLUR}</svg>");
|
||||
assert!(err(&mut s, "svgFilter.import", json!({"path": 3, "svg": svg_text})).contains("`path` must be a string"));
|
||||
assert!(err(&mut s, "svgFilter.import", json!({"svg": ["x"]})).contains("`svg` must be a string"));
|
||||
assert!(err(&mut s, "svgFilter.delete", json!({"name": 3})).contains("`name` must be a string"));
|
||||
let svgz = std::env::temp_dir().join(format!("vc-svgfilters-{}.svgz", std::process::id()));
|
||||
std::fs::write(&svgz, vectorcraft_svg::compress(&format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{BLUR}</svg>"))).unwrap();
|
||||
let e = err(&mut s, "svgFilter.import", json!({"path": svgz.to_string_lossy()}));
|
||||
std::fs::remove_file(&svgz).ok();
|
||||
assert!(e.contains("not SVGZ"), "{e}");
|
||||
let missing = std::env::temp_dir().join("vc-no-such-svgfilter-file (1).svg");
|
||||
assert!(s.execute("svgFilter.import", &json!({"path": missing.to_string_lossy()})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_adds_the_filter_as_a_live_effect_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let b = rect(&mut s, 50.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": BLUR}));
|
||||
let before = steps(&s);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
let r = run(&mut s, "svgFilter.apply", json!({"name": "Soft"}));
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(steps(&s), before + 1, "one step for both objects");
|
||||
for id in [a, b] {
|
||||
let fx = s.doc().unwrap().doc.node(id).unwrap().appearance.effects.clone();
|
||||
assert_eq!(fx.len(), 1);
|
||||
assert_eq!(fx[0].id, "svg.filter");
|
||||
assert_eq!((fx[0].params["name"].as_str(), fx[0].params["xml"].as_str()), (Some("Soft"), Some(BLUR)));
|
||||
}
|
||||
run(&mut s, "svgFilter.delete", json!({"name": "Soft"}));
|
||||
assert!(err(&mut s, "svgFilter.apply", json!({"name": "Soft"})).contains("no SVG filter named"));
|
||||
assert!(err(&mut s, "svgFilter.apply", json!({})).contains("give `name`"));
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
assert!(s.execute("svgFilter.apply", &json!({"name": "Shift"})).is_err(), "nothing selected");
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [b.0]}));
|
||||
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().appearance.effects.len(), 2, "given ids, nothing selected");
|
||||
}
|
||||
|
||||
/// The xml the first effect of `id` draws with.
|
||||
fn drawn(s: &Session, id: NodeId) -> Value {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["xml"].clone()
|
||||
}
|
||||
|
||||
const SHIFT_MORE: &str = r#"<filter id="Shift"><feOffset dx="9" dy="9"/></filter>"#;
|
||||
|
||||
/// Objects name the document's filter: an edit changes what every one of them draws, in one undo
|
||||
/// step, and a later application takes the edited filter.
|
||||
#[test]
|
||||
fn editing_a_filter_changes_every_object_that_uses_it() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let b = rect(&mut s, 50.0);
|
||||
let c = rect(&mut s, 90.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0, b.0]}));
|
||||
let before = steps(&s);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"}));
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT_MORE), json!(SHIFT_MORE)));
|
||||
// One undo puts both back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT), json!(SHIFT)));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
// Applied after the edit: the edited filter.
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [c.0]}));
|
||||
assert_eq!(drawn(&s, c), json!(SHIFT_MORE));
|
||||
// An edit whose code names another id keeps the filter's name.
|
||||
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="Renamed"><feOffset dx="1"/></filter>"#, "replace": "Shift"}));
|
||||
assert_eq!(names(&mut s), ["Shift"]);
|
||||
assert_eq!(drawn(&s, a), json!(r#"<filter id="Shift"><feOffset dx="1"/></filter>"#));
|
||||
}
|
||||
|
||||
/// Deleting a filter that objects use leaves their effects, which draw nothing until a filter of
|
||||
/// that name exists again.
|
||||
#[test]
|
||||
fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]}));
|
||||
run(&mut s, "svgFilter.delete", json!({"name": "Shift"}));
|
||||
let fx = s.doc().unwrap().doc.node(a).unwrap().appearance.effects.clone();
|
||||
assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Shift")), "the effect stays, named");
|
||||
assert!(vectorcraft_render::effects::raster_effects(&fx).is_empty(), "drawn without the filter");
|
||||
// Imported again: the effect draws with it.
|
||||
run(&mut s, "svgFilter.import", json!({"svg": format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{SHIFT_MORE}</svg>")}));
|
||||
assert_eq!(drawn(&s, a), json!(SHIFT_MORE));
|
||||
}
|
||||
|
||||
/// Art in a symbol's definition that uses a filter follows its edits too.
|
||||
#[test]
|
||||
fn a_symbols_art_follows_a_filter_edit() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
run(&mut s, "symbol.new", json!({"name": "Mark"}));
|
||||
let in_symbol = |s: &Session| {
|
||||
let mut xml = vec![];
|
||||
s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| xml.extend(n.appearance.effects.iter().map(|e| e.params["xml"].clone())));
|
||||
xml
|
||||
};
|
||||
assert_eq!(in_symbol(&s), [json!(SHIFT)]);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"}));
|
||||
assert_eq!(in_symbol(&s), [json!(SHIFT_MORE)]);
|
||||
}
|
||||
|
||||
/// A graphic style that holds a filter follows its edits, so applying it later gives the edited
|
||||
/// filter.
|
||||
#[test]
|
||||
fn a_graphic_style_follows_a_filter_edit() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let b = rect(&mut s, 50.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Shifted", "id": a.0}));
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"}));
|
||||
run(&mut s, "graphicStyle.apply", json!({"name": "Shifted", "ids": [b.0]}));
|
||||
assert_eq!(drawn(&s, b), json!(SHIFT_MORE));
|
||||
}
|
||||
|
||||
/// An edited filter keeps its name, characters XML escapes included.
|
||||
#[test]
|
||||
fn an_edit_keeps_a_name_with_escaped_characters() {
|
||||
let mut s = session();
|
||||
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="a&b"><feOffset/></filter>"#}));
|
||||
assert_eq!(names(&mut s), ["a&b"]);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="x"><feOffset dx="2"/></filter>"#, "replace": "a&b"}));
|
||||
assert_eq!(names(&mut s), ["a&b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_apply_takes_a_filter_and_names_it_by_its_id() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0);
|
||||
let r = run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}}));
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["name"], "Shift");
|
||||
assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter"})).contains("give `xml`"));
|
||||
for scale in [json!(0), json!(1001), json!("2")] {
|
||||
let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT, "scale": scale}}));
|
||||
assert!(e.contains("`scale` must be a number"), "{scale}: {e}");
|
||||
}
|
||||
assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": "<g/>"}})).contains("give one <filter> element"));
|
||||
assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": "<filter/>"}})).contains("needs an id"));
|
||||
assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"scale": 0}})).contains("`scale` must be a number from 0.001"));
|
||||
assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"name": "Other"}})).contains("give `xml` with `name`"));
|
||||
// Another filter renames the effect.
|
||||
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": BLUR}}));
|
||||
let p = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params;
|
||||
assert_eq!((p["name"].as_str(), p["xml"].as_str()), (Some("Soft"), Some(BLUR)));
|
||||
}
|
||||
|
||||
/// An effect names the document's filter, so effect.apply and effect.setParams refuse XML that
|
||||
/// differs from the document's filter of the same id, and take XML that matches it.
|
||||
#[test]
|
||||
fn effect_commands_refuse_another_definition_of_a_document_filter() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": BLUR}));
|
||||
let other = r#"<filter id="Soft"><feGaussianBlur stdDeviation="9"/></filter>"#;
|
||||
let before = steps(&s);
|
||||
let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": other}}));
|
||||
assert!(e.contains("another SVG filter named \"Soft\""), "{e}");
|
||||
assert_eq!(steps(&s), before, "nothing recorded");
|
||||
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": format!(" {BLUR} ")}}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}}));
|
||||
let e = err(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": other}}));
|
||||
assert!(e.contains("another SVG filter named \"Soft\""), "{e}");
|
||||
run(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": BLUR}}));
|
||||
}
|
||||
|
||||
/// An effect whose filter `check` refuses (put there by an opened file or a script, not by a
|
||||
/// command) is neither drawn nor written to SVG.
|
||||
#[test]
|
||||
fn svg_export_leaves_out_a_filter_that_fails_the_check() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let bad = r#"<filter id="Bad"><feOffset/><script>alert(1)</script></filter>"#;
|
||||
assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": bad}})).contains("<script>"));
|
||||
let effect = vectorcraft_doc::Effect { id: "svg.filter".into(), params: json!({"name": "Bad", "xml": bad}), visible: true };
|
||||
s.edit("Plant", |d, _| {
|
||||
d.node_mut(a).unwrap().appearance.effects.push(effect);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let out = svg(&mut s);
|
||||
assert!(!out.contains("script") && !out.contains("<filter"), "{out}");
|
||||
}
|
||||
|
||||
/// A user-space region in percentages is measured against what the filter runs on: SVG export keeps
|
||||
/// the filter only when it runs on the object alone, and otherwise writes the object as an image.
|
||||
#[test]
|
||||
fn svg_export_keeps_a_measured_region_only_on_the_object_alone() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let measured = r#"<filter id="M" filterUnits="userSpaceOnUse" x="-50%" y="-50%" width="200%" height="200%"><feOffset dx="3"/></filter>"#;
|
||||
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": measured}}));
|
||||
let alone = svg(&mut s);
|
||||
assert!(alone.contains("<feOffset dx=\"3\"/>") && !alone.contains("<image"), "{alone}");
|
||||
// On a second object, after a blur: an image.
|
||||
let mut t = session();
|
||||
let b = rect(&mut t, 10.0);
|
||||
run(&mut t, "effect.apply", json!({"effect": "blur.gaussian", "ids": [b.0], "params": {"radius": 1}}));
|
||||
run(&mut t, "effect.apply", json!({"effect": "svg.filter", "ids": [b.0], "params": {"xml": measured}}));
|
||||
let after = svg(&mut t);
|
||||
assert!(after.contains("<image") && !after.contains("<feOffset dx=\"3\""), "{after}");
|
||||
}
|
||||
|
||||
/// SVG export writes a filter rebuilt from its filter elements and filter attributes, not the XML
|
||||
/// the effect holds.
|
||||
#[test]
|
||||
fn svg_export_writes_the_filter_rebuilt() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let noted = r#"<filter id="Noted" data-x="1"><!-- <script>x</script> --><desc><b>note</desc><feOffset dx="3" dy="3" foo="bar"/></filter>"#;
|
||||
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": noted}}));
|
||||
let out = svg(&mut s);
|
||||
assert!(out.contains("<feOffset dx=\"3\" dy=\"3\"/></filter>"), "{out}");
|
||||
assert!(!out.contains("script") && !out.contains("note") && !out.contains("data-x") && !out.contains("foo"), "{out}");
|
||||
}
|
||||
|
||||
/// The filter's user space moves with the object: its `origin` follows a move.
|
||||
#[test]
|
||||
fn moving_the_object_moves_the_filters_origin() {
|
||||
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, "origin": [3, 4]}}));
|
||||
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["origin"], json!([8.0, 2.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_survive_a_native_round_trip() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": BLUR}));
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Soft", "ids": [a.0]}));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.svg_filters, doc.svg_filters);
|
||||
assert_eq!(back.node(a).unwrap().appearance.effects, doc.node(a).unwrap().appearance.effects);
|
||||
}
|
||||
|
||||
const RED: &str = r##"<filter id="Red"><feFlood flood-color="#ff0000"/><feComposite in2="SourceGraphic" operator="in"/></filter>"##;
|
||||
|
||||
/// A blue square with no stroke, 100 pt at (50, 50) in a 200 pt document.
|
||||
fn blue_square() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = rect_at(&mut s, 50.0, 100.0);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#0000ff", "ids": [id.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]}));
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn rect_at(s: &mut Session, at: f64, size: f64) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": at, "y": at, "width": size, "height": size}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> vectorcraft_render::Rendered {
|
||||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canvas_and_pdf_draw_the_filter() {
|
||||
let (mut s, id) = blue_square();
|
||||
assert_eq!(&render(&s).pixel(100, 100)[..3], &[0, 0, 255]);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": RED}));
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Red", "ids": [id.0]}));
|
||||
let r = render(&s);
|
||||
assert_eq!(&r.pixel(100, 100)[..3], &[255, 0, 0], "the flood, cut to the square");
|
||||
assert_eq!(r.pixel(20, 20), render(&session_without_art()).pixel(20, 20), "outside the square: nothing");
|
||||
// PDF writes the effected square as an image that reads back as the canvas shows it.
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let out = crate::cmd::rasterfx::export_pdf_with_report(&doc, &Default::default()).unwrap();
|
||||
let back = vectorcraft_pdf::import(&out.bytes).unwrap();
|
||||
let again = vectorcraft_render::Renderer::new().render_region(&back, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
let px = again.pixel(100, 100);
|
||||
assert!(px[0] > 240 && px[1] < 15 && px[2] < 15, "{px:?}");
|
||||
}
|
||||
|
||||
fn session_without_art() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The SVG `document.serialize` writes.
|
||||
fn svg(s: &mut Session) -> String {
|
||||
run(s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_export_writes_the_filter_when_it_is_the_last_effect() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "svgFilter.define", json!({"xml": SHIFT}));
|
||||
run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]}));
|
||||
let out = svg(&mut s);
|
||||
assert!(out.contains("<feOffset dx=\"3\" dy=\"3\"/></filter>"), "{out}");
|
||||
assert!(out.contains("<g filter=\"url(#"), "{out}");
|
||||
assert!(!out.contains("<image"), "the object stays vector art: {out}");
|
||||
// SVG import reads it back as the same filter.
|
||||
let (back, warnings) = vectorcraft_svg::import_with_report(&out).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let mut kept = vec![];
|
||||
back.walk(|n| kept.extend(n.appearance.effects.iter().filter(|e| e.id == "svg.filter").cloned()));
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert!(kept[0].params["xml"].as_str().unwrap().ends_with("<feOffset dx=\"3\" dy=\"3\"/></filter>"), "{kept:?}");
|
||||
// SVG Tiny 1.2 has no filters: the object is written as an image.
|
||||
let tiny = run(&mut s, "document.export", json!({"format": "svg", "profile": "tiny12"}));
|
||||
let tiny = String::from_utf8(vectorcraft_format::base64_decode(tiny["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(tiny.contains("<image") && !tiny.contains("<filter"), "{tiny}");
|
||||
// An artboard away from the origin: the object keeps the document's coordinates, which the
|
||||
// filter's user space measures, in a group that moves it.
|
||||
let user = r#"<filter id="Here" filterUnits="userSpaceOnUse" x="0" y="0" width="60" height="60"><feOffset dx="1"/></filter>"#;
|
||||
let mut t = session();
|
||||
run(&mut t, "artboard.setProps", json!({"index": 0, "x": 5, "y": 5}));
|
||||
let b = rect(&mut t, 10.0);
|
||||
run(&mut t, "effect.apply", json!({"effect": "svg.filter", "ids": [b.0], "params": {"xml": user}}));
|
||||
let moved = svg(&mut t);
|
||||
let at = moved.find("<g transform=\"matrix(1 0 0 1 -5 -5)\">").unwrap_or_else(|| panic!("{moved}"));
|
||||
assert!(moved[at..].contains("10"), "the object in document coordinates: {moved}");
|
||||
assert!(!moved[at..].contains(" 5 5 ") && moved.contains("x=\"0\" y=\"0\" width=\"60\""), "the filter as written: {moved}");
|
||||
// Opened again, the filter's user space starts where the group places it.
|
||||
let (back, warnings) = vectorcraft_svg::import_with_report(&moved).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let mut kept = vec![];
|
||||
back.walk(|n| kept.extend(n.appearance.effects.iter().filter(|e| e.id == "svg.filter").cloned()));
|
||||
assert_eq!(kept.len(), 1, "{moved}");
|
||||
assert_eq!(kept[0].params["origin"], json!([-5.0, -5.0]));
|
||||
assert!(err(&mut t, "effect.setParams", json!({"ids": [b.0], "index": 0, "params": {"origin": [1]}})).contains("`origin` must be [x, y]"));
|
||||
// An effect after it: the object is drawn as an image.
|
||||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "ids": [a.0], "params": {"radius": 1}}));
|
||||
let out = svg(&mut s);
|
||||
assert!(out.contains("<image"), "{out}");
|
||||
assert!(!out.contains("<feOffset dx=\"3\""), "{out}");
|
||||
}
|
||||
|
||||
/// A filter SVG 1.1 can't hold (a blend mode SVG 1.1 lacks) is exported as an image, as the canvas
|
||||
/// draws it; an SVG 1.1 one stays a filter.
|
||||
#[test]
|
||||
fn svg_export_draws_a_filter_svg_1_1_lacks_as_an_image() {
|
||||
for (mode, image) in [("overlay", true), ("multiply", false)] {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let xml = format!(r#"<filter id="B"><feFlood flood-color="red"/><feBlend in2="SourceGraphic" mode="{mode}"/></filter>"#);
|
||||
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": xml}}));
|
||||
let out = svg(&mut s);
|
||||
assert_eq!((out.contains("<image"), out.contains("<feBlend")), (image, !image), "{mode}: {out}");
|
||||
}
|
||||
}
|
||||
@@ -432,6 +432,37 @@ fn structured_junk() {
|
||||
("effect.apply", json!({"effect": "addon.color.halftone", "params": {"frequency": 300, "invert": true, "clip": false, "fromArt": true}})),
|
||||
("effect.apply", json!({"effect": "addon.color.halftone", "params": {"frequency": -1e308, "mode": "cmyk", "color": "#123456", "target": 3}})),
|
||||
("effect.apply", json!({"effect": "addon.color.duotone", "params": {"tones": "x", "skipSpot": "yes"}})),
|
||||
// Effect › SVG Filters: hostile filters are refused or drawn within their bounds.
|
||||
("effect.apply", json!({"effect": "svg.filter", "params": {"xml": "<filter id=\"a\"><feTurbulence numOctaves=\"1e308\"/></filter>"}})),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "svg.filter", "params": {"xml": "<filter id=\"a\"><feGaussianBlur stdDeviation=\"1e308\"/></filter>", "scale": 1e308}}),
|
||||
),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "svg.filter", "params": {"xml": "<filter id=\"a\" filterUnits=\"userSpaceOnUse\" x=\"-1e308\" width=\"1e308\"><feFlood/></filter>"}}),
|
||||
),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "svg.filter", "params": {"xml": "<filter id=\"a\"><feImage xlink:href=\"/etc/passwd\"/></filter>", "scale": -1}}),
|
||||
),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "svg.filter", "params": {"xml": "<!DOCTYPE x [<!ENTITY a \"aaaa\">]><filter id=\"&a;\"><feOffset/></filter>"}}),
|
||||
),
|
||||
("effect.apply", json!({"effect": "svg.filter", "params": {"xml": ["x"], "name": 3}})),
|
||||
("svgFilter.define", json!({"xml": "<filter id=\"a\"><feConvolveMatrix order=\"4294967295\" kernelMatrix=\"1\"/></filter>"})),
|
||||
("svgFilter.define", json!({"xml": format!("<filter id=\"a\">{}</filter>", "<feOffset/>".repeat(100_000)), "replace": "a"})),
|
||||
(
|
||||
"svgFilter.import",
|
||||
json!({"svg": format!(
|
||||
"<!DOCTYPE svg [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b \"{}\"><!ENTITY c \"{}\">]><svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"&c;\"><feOffset/></filter></svg>",
|
||||
"&a;".repeat(10),
|
||||
"&b;".repeat(10)
|
||||
)}),
|
||||
),
|
||||
("svgFilter.apply", json!({"name": "\u{0}", "ids": [u64::MAX]})),
|
||||
("svgFilter.delete", json!({"name": ["a"]})),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "addon.color.duotone", "params": {"tones": [1, null, {"color": [1e308, -1e308, "x"], "curve": [[1e308, -1e308], ["a"]]}]}}),
|
||||
|
||||
@@ -497,13 +497,30 @@ impl Writer<'_> {
|
||||
/// gradients require rendered-effect preparation by the caller to preserve their appearance.
|
||||
/// Photoshop-style
|
||||
/// filters have no SVG equivalent: they are left out with a warning (exports from the app turn
|
||||
/// such objects into images first).
|
||||
/// such objects into images first). An SVG filter (Effect › SVG Filters) that is the last
|
||||
/// effect is written as its own `<filter>` element; elsewhere in the stack it is left out
|
||||
/// like them.
|
||||
pub(super) fn filtered(&mut self, n: &Node) {
|
||||
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.
|
||||
let xf = self.xf;
|
||||
let origin = live_svg_filter(&n.appearance.effects).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();
|
||||
}
|
||||
let opened = self.open_filters(n, &n.appearance.effects);
|
||||
self.node_body(&inner);
|
||||
self.close_filters(opened);
|
||||
if moved {
|
||||
self.xf = xf;
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one `<g filter>` per visible raster effect of `effects` (an object's, or one fill or
|
||||
@@ -525,7 +542,27 @@ impl Writer<'_> {
|
||||
+ 2.0;
|
||||
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
// 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 };
|
||||
for (k, f) in fx.iter().enumerate().rev() {
|
||||
if let RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(sf)) = f
|
||||
&& live.is_some()
|
||||
&& k + 1 == fx.len()
|
||||
{
|
||||
let fid = self.fresh_id("filter");
|
||||
// The filter as it is drawn: a region given against the object's box is measured
|
||||
// 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);
|
||||
self.line(&format!("<g filter=\"url(#{fid})\">"));
|
||||
self.depth += 1;
|
||||
opened += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) {
|
||||
self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out");
|
||||
continue;
|
||||
@@ -644,6 +681,25 @@ pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The SVG filter (Effect › SVG Filters) of an object's `effects` that SVG keeps as a `<filter>`:
|
||||
/// its last visible effect, measured in points.
|
||||
fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option<vectorcraft_effects::svg_filter::SvgFilterFx> {
|
||||
if !vectorcraft_effects::svg_filter::writes_live(effects) {
|
||||
return None;
|
||||
}
|
||||
let mut fx = vectorcraft_effects::raster_effects(effects);
|
||||
// A region measured against the object's box is kept only when the filter runs on the object itself.
|
||||
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() =>
|
||||
{
|
||||
Some(f)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
|
||||
pub(super) fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
|
||||
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
|
||||
|
||||
@@ -101,6 +101,21 @@ fn convolve_matrices_ok(xml: &roxmltree::Document) -> Result<(), SvgError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The SVG filters of SVG text `svg` ([`vectorcraft_effects::svg_filter::filters_of`]), read with
|
||||
/// the limits on depth and entities that [`import`] reads documents with: up to `room`, leaving
|
||||
/// out the names `taken` says.
|
||||
pub(crate) fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) -> Result<(Vec<(String, String)>, usize), SvgError> {
|
||||
let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH);
|
||||
if depth::prescan(svg).too_deep {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let xml = parse(svg)?;
|
||||
if depth::height(&xml, depth::MAX_ELEMENT_DEPTH).is_none() {
|
||||
return Err(too_deep());
|
||||
}
|
||||
Ok(vectorcraft_effects::svg_filter::filters_of(&xml, svg, room, taken))
|
||||
}
|
||||
|
||||
/// The document SVG text `svg` holds, read with `opts`, and the warnings about what it couldn't keep.
|
||||
pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Result<(Document, Vec<String>), SvgError> {
|
||||
// Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by
|
||||
@@ -160,6 +175,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
|
||||
symbol_suffixes: HashMap::new(),
|
||||
pattern_suffixes: HashMap::new(),
|
||||
blends: fx::blends(&xml, &scan.markers),
|
||||
filter_sources: fx::sources(&xml, &scan.markers),
|
||||
files,
|
||||
non_scaling: found.non_scaling,
|
||||
data: found.data,
|
||||
@@ -318,6 +334,8 @@ struct Importer {
|
||||
pattern_suffixes: HashMap<String, usize>,
|
||||
/// Blend modes of the shadows and glows our export wrote, by filter id ([`fx::blends`]).
|
||||
blends: HashMap<String, String>,
|
||||
/// Each `<filter>` element as written, by id ([`fx::sources`]).
|
||||
filter_sources: HashMap<String, fx::Source>,
|
||||
/// The files `<image>` elements link to.
|
||||
files: files::Files,
|
||||
/// Ids of the shapes whose strokes don't scale ([`vector_effect`]).
|
||||
|
||||
@@ -33,6 +33,104 @@ pub(super) fn blends(xml: &roxmltree::Document, markers: &str) -> HashMap<String
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `<filter>` element as written ([`sources`]): its XML, its primitives' element names and its
|
||||
/// place among the filters in document order, and whether it looks different outside the document
|
||||
/// ([`vectorcraft_effects::svg_filter::depends_on_styles`]). Its check and its primitives as usvg
|
||||
/// reads them are worked out once, when first asked for.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Source {
|
||||
xml: String,
|
||||
primitives: Vec<String>,
|
||||
order: usize,
|
||||
styled: bool,
|
||||
name: std::cell::OnceCell<Option<String>>,
|
||||
forms: std::cell::OnceCell<Option<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// The filter's name when [`vectorcraft_effects::svg_filter::check`] takes it.
|
||||
fn name(&self) -> Option<&String> {
|
||||
self.name.get_or_init(|| vectorcraft_effects::svg_filter::check(&self.xml).ok()).as_ref()
|
||||
}
|
||||
|
||||
/// Its primitives as usvg reads them ([`vectorcraft_effects::svg_filter::primitive_forms`]).
|
||||
fn forms(&self) -> Option<&Vec<String>> {
|
||||
self.forms.get_or_init(|| vectorcraft_effects::svg_filter::primitive_forms(&self.xml)).as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Each `<filter>` element with an id, as written, by id, with the document's style sheets read
|
||||
/// once for all of them. A source is checked and read by usvg only when art uses it, once.
|
||||
/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]).
|
||||
pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap<String, Source> {
|
||||
if !markers.contains("<filter") && !markers.contains(":filter") {
|
||||
return HashMap::new();
|
||||
}
|
||||
let text = xml.input_text();
|
||||
let styles = vectorcraft_effects::svg_filter::Styles::of(xml);
|
||||
xml.descendants()
|
||||
.filter(|n| n.has_tag_name((super::SVG_NS, "filter")))
|
||||
.enumerate()
|
||||
.filter_map(|(order, n)| {
|
||||
let id = n.attribute("id")?.to_string();
|
||||
let primitives = n.children().filter(roxmltree::Node::is_element).map(|p| p.tag_name().name().to_string()).collect();
|
||||
let xml = vectorcraft_effects::svg_filter::element_xml(n, text)?;
|
||||
let styled = vectorcraft_effects::svg_filter::depends_on_styles(n, &styles);
|
||||
Some((id, Source { xml, primitives, order, styled, ..Default::default() }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The element name of primitive `k` (`feGaussianBlur`).
|
||||
fn element_name(k: &Kind) -> String {
|
||||
let debug = format!("{k:?}");
|
||||
format!("fe{}", debug.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The source of `filter` in `sources`: by its id, else (usvg gives each further element that uses
|
||||
/// a filter a copy with an id of its own) the only source with the same primitives, compared by
|
||||
/// element name and then as usvg reads them. Sources that differ only in their ids draw alike: the
|
||||
/// first of them in document order is taken. usvg's tree doesn't say which `<filter>` an element
|
||||
/// named, so other sources with the same primitives leave the filter without a source.
|
||||
fn source_of<'a>(filter: &Filter, sources: &'a HashMap<String, Source>) -> Option<&'a Source> {
|
||||
use vectorcraft_effects::svg_filter::with_id;
|
||||
if let Some(s) = sources.get(filter.id()) {
|
||||
return Some(s);
|
||||
}
|
||||
let names: Vec<String> = filter.primitives().iter().map(|p| element_name(p.kind())).collect();
|
||||
let same: Vec<&Source> = sources.values().filter(|s| s.primitives == names).collect();
|
||||
if let [one] = same.as_slice() {
|
||||
return Some(one);
|
||||
}
|
||||
let forms: Vec<String> = filter.primitives().iter().map(|p| format!("{:?}", p.kind())).collect();
|
||||
let read_alike: Vec<&Source> = same.into_iter().filter(|s| s.forms() == Some(&forms)).collect();
|
||||
let first = read_alike.iter().copied().min_by_key(|s| s.order)?;
|
||||
let unnamed = |s: &Source| with_id(&s.xml, "");
|
||||
read_alike.iter().all(|s| unnamed(s) == unnamed(first)).then_some(first)
|
||||
}
|
||||
|
||||
/// `filters` (in the order they apply) as Effect › SVG Filters effects, each holding its filter's
|
||||
/// 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`.
|
||||
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;
|
||||
}
|
||||
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] });
|
||||
Some(Effect { id: vectorcraft_effects::SVG_FILTER.into(), params, visible: true })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The object's alpha, blurred (σ), moved and maybe inverted.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Shape {
|
||||
|
||||
@@ -301,10 +301,11 @@ impl Importer {
|
||||
(None, id) => format!("'{id}'"),
|
||||
};
|
||||
let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label));
|
||||
// Blurs, shadows and glows become live effects (see [`fx`]).
|
||||
// Blurs, shadows and glows become live effects (see [`fx`]); other filters become Effect ›
|
||||
// SVG Filters effects that keep their XML.
|
||||
let effects = match g.filters() {
|
||||
[] => vec![],
|
||||
filters => fx::effects(filters, ts, &self.blends).unwrap_or_else(|| {
|
||||
filters => fx::effects(filters, ts, &self.blends).or_else(|| fx::kept(filters, ts, &self.filter_sources)).unwrap_or_else(|| {
|
||||
// usvg wraps a filtered element in a group of its own: name the element then.
|
||||
let on = match g.children() {
|
||||
[usvg::Node::Group(_)] | [] => label.clone(),
|
||||
@@ -777,9 +778,10 @@ impl Importer {
|
||||
std::mem::take(&mut self.data),
|
||||
std::mem::take(&mut self.events),
|
||||
std::mem::take(&mut self.ellipses),
|
||||
std::mem::take(&mut self.filter_sources),
|
||||
);
|
||||
let art = self.group_node(tree.root(), acc);
|
||||
(self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data, self.events, self.ellipses) = outer;
|
||||
(self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data, self.events, self.ellipses, self.filter_sources) = outer;
|
||||
art
|
||||
}
|
||||
}
|
||||
@@ -811,6 +813,7 @@ mod tests {
|
||||
symbol_suffixes: HashMap::new(),
|
||||
pattern_suffixes: HashMap::new(),
|
||||
blends: HashMap::new(),
|
||||
filter_sources: HashMap::new(),
|
||||
files: Files::default(),
|
||||
non_scaling: HashSet::new(),
|
||||
data: HashMap::new(),
|
||||
|
||||
@@ -548,6 +548,15 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
|
||||
import_with(svg, &ImportOptions::default())
|
||||
}
|
||||
|
||||
/// Effect › SVG Filters › Import SVG Filter: the `<filter>` elements of SVG text `svg` that an SVG
|
||||
/// filter effect takes, as (name, XML) in document order, up to `room` of them and leaving out the
|
||||
/// names `taken` says, and how many it doesn't take. Filters past `room` or named as `taken` says
|
||||
/// are not checked. The text is read with the limits on depth and entities that [`import`] reads
|
||||
/// documents with.
|
||||
pub fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) -> Result<(Vec<(String, String)>, usize), SvgError> {
|
||||
import::svg_filters(svg, room, taken)
|
||||
}
|
||||
|
||||
pub use glyph::glyph_art;
|
||||
pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NODES};
|
||||
|
||||
|
||||
@@ -546,17 +546,25 @@ fn percentages_in_a_symbol_are_of_its_view_box() {
|
||||
assert_similar(&drawing("100%", "100%", "25%", "50%"), &drawing("100", "50", "25", "25"), 24.0, 0.001);
|
||||
}
|
||||
|
||||
/// #1598: an unsupported filter's warning names the filter, its primitives and the object, and
|
||||
/// says it is drawn without it (not "a group": usvg wraps the element in one).
|
||||
/// #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.
|
||||
#[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" filter="url(#f)"/>
|
||||
<rect id="r" x="100" y="20" width="60" height="60" fill="blue" filter="url(#t)"/>"#;
|
||||
<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="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:?}");
|
||||
assert!(w.iter().any(|w| w == "filter 't' (feTurbulence) on 'r' isn't supported; drawn without it"), "{w:?}");
|
||||
assert_eq!(art(&d).iter().filter(|n| n.path_data().is_some()).count(), 2, "both drawn");
|
||||
assert_eq!(w.iter().filter(|w| w.contains("filter")).count(), 2, "{w:?}");
|
||||
let kept: Vec<_> = art(&d).into_iter().flat_map(|n| &n.appearance.effects).collect();
|
||||
assert_eq!(kept.len(), 1, "{kept:?}");
|
||||
assert_eq!((kept[0].id.as_str(), &kept[0].params["name"]), ("svg.filter", &serde_json::json!("f")));
|
||||
assert_eq!(kept[0].params["xml"], r#"<filter id="f"><feColorMatrix type="saturate" values="0"/></filter>"#, "as it is written");
|
||||
assert_eq!(art(&d).iter().filter(|n| n.path_data().is_some()).count(), 3, "all drawn");
|
||||
}
|
||||
|
||||
/// #1601: Inkscape's pages become artboards, at their places (through the viewBox) and with their
|
||||
|
||||
@@ -412,7 +412,8 @@ fn drop_shadow_chains_become_drop_shadows() {
|
||||
}
|
||||
|
||||
/// A proportional RGB filter is not additive Darkness. A fully black shadow remains
|
||||
/// recognizable, while other proportional transforms are reported rather than misrepresented.
|
||||
/// recognizable, while other proportional transforms are kept as SVG filters rather than
|
||||
/// misrepresented.
|
||||
#[test]
|
||||
fn proportional_filters_are_not_misread_as_additive_darkness() {
|
||||
let chain = |space: &str, transfer: &str, slope: f64| {
|
||||
@@ -436,15 +437,108 @@ fn proportional_filters_are_not_misread_as_additive_darkness() {
|
||||
// Scaled on linear values, or blurred on them before an sRGB scale.
|
||||
for (space, transfer) in [(srgb, ""), ("", ""), ("", srgb)] {
|
||||
let (d, w) = open(&chain(space, transfer, 0.5));
|
||||
assert!(w.iter().any(|w| w.contains("filter")), "{transfer}: {w:?}");
|
||||
assert!(effects(&d).is_empty());
|
||||
assert!(w.is_empty(), "{transfer}: {w:?}");
|
||||
let ids: Vec<String> = effects(&d).iter().map(|e| e.id.clone()).collect();
|
||||
assert_eq!(ids, ["svg.filter"], "{transfer}: kept as it is written, not as a shadow");
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters used by several objects keep their own XML on each, also when two filters have the same
|
||||
/// primitives with other values.
|
||||
#[test]
|
||||
fn other_filters_are_reported() {
|
||||
fn reused_filters_keep_their_own_xml() {
|
||||
let body = r##"<defs><filter id="a"><feTurbulence baseFrequency="0.05"/></filter><filter id="b"><feTurbulence baseFrequency="0.2"/></filter></defs>
|
||||
<rect width="20" height="20" filter="url(#a)"/><rect x="30" width="20" height="20" filter="url(#b)"/>
|
||||
<rect y="30" width="20" height="20" filter="url(#a)"/><rect x="30" y="30" width="20" height="20" filter="url(#b)"/>"##;
|
||||
let (d, w) = open(body);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let names: Vec<String> =
|
||||
art(&d).into_iter().flat_map(|n| &n.appearance.effects).map(|e| e.params["name"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(names, ["a", "b", "a", "b"]);
|
||||
// Two filters that differ only in their ids draw alike: a further use of either takes the first.
|
||||
let same = r##"<defs><filter id="a"><feTurbulence baseFrequency="0.05"/></filter><filter id="b"><feTurbulence baseFrequency="0.05"/></filter></defs>
|
||||
<rect width="20" height="20" filter="url(#a)"/><rect x="30" width="20" height="20" filter="url(#b)"/>
|
||||
<rect y="30" width="20" height="20" filter="url(#a)"/><rect x="30" y="30" width="20" height="20" filter="url(#b)"/>"##;
|
||||
let (d, w) = open(same);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let xml: Vec<String> = art(&d).into_iter().flat_map(|n| &n.appearance.effects).map(|e| e.params["xml"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(xml.len(), 4);
|
||||
assert!(xml.iter().all(|x| x.contains("baseFrequency=\"0.05\"")), "{xml:?}");
|
||||
// A `filter` element of another namespace with the same id isn't the source.
|
||||
let foreign = r##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter><o:filter xmlns:o="urn:other" id="t"><o:x/></o:filter></defs>
|
||||
<rect width="20" height="20" filter="url(#t)"/>"##;
|
||||
let (d, w) = open(foreign);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let fx = effects(&d);
|
||||
assert!(fx[0].params["xml"].as_str().unwrap().contains("feTurbulence"), "{fx:?}");
|
||||
}
|
||||
|
||||
/// An SVG image's filters are its own: one with the same id as a filter of the outer document is
|
||||
/// not imported with the outer document's XML. The image's own source isn't read, so its filter is
|
||||
/// left out with a warning.
|
||||
#[test]
|
||||
fn an_svg_images_filter_does_not_take_the_outer_filter_of_its_id() {
|
||||
let inner = r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><filter id="f"><feTurbulence baseFrequency="0.2"/></filter><rect width="10" height="10" filter="url(#f)"/></svg>"##;
|
||||
let data = format!("data:image/svg+xml;base64,{}", vectorcraft_svg::base64_encode(inner.as_bytes()));
|
||||
let (d, w) = open(&format!(
|
||||
r#"<defs><filter id="f"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="20" height="20" filter="url(#f)"/><image href="{data}" x="100" width="40" height="20"/>"#
|
||||
));
|
||||
let xml: Vec<String> = art(&d)
|
||||
.into_iter()
|
||||
.flat_map(|n| &n.appearance.effects)
|
||||
.filter(|e| e.id == "svg.filter")
|
||||
.map(|e| e.params["xml"].as_str().unwrap_or_default().to_string())
|
||||
.collect();
|
||||
assert_eq!(xml.len(), 1, "only the outer rect: {xml:?}");
|
||||
assert!(xml[0].contains("baseFrequency=\"0.05\""), "{xml:?}");
|
||||
assert!(w.iter().any(|w| w.contains("filter")), "{w:?}");
|
||||
}
|
||||
|
||||
/// A filter the document's style sheet colors would draw differently on its own: it is reported
|
||||
/// and left out.
|
||||
#[test]
|
||||
fn filters_styled_by_the_document_are_reported() {
|
||||
let (d, w) = open(
|
||||
r##"<style>.red{flood-color:#f00}</style><defs><filter id="t"><feFlood class="red"/><feComposite in2="SourceGraphic" operator="in"/></filter></defs><rect width="60" height="40" filter="url(#t)"/>"##,
|
||||
);
|
||||
assert!(w.iter().any(|w| w.contains("filter")), "{w:?}");
|
||||
assert!(effects(&d).is_empty());
|
||||
}
|
||||
|
||||
/// Among many filters, art that uses one twice keeps it both times (the second use is a copy
|
||||
/// under another id).
|
||||
#[test]
|
||||
fn a_filter_used_twice_among_many_is_kept_twice() {
|
||||
let used = r##"<filter id="g"><feOffset dx="1"/><feFlood flood-color="#f00"/><feComposite in2="SourceGraphic" operator="in"/></filter>"##;
|
||||
let defs: String = (0..300)
|
||||
.map(|i| format!(r##"<filter id="f{i}"><feFlood flood-color="#00f"/><feComposite in2="SourceGraphic" operator="in"/></filter>"##))
|
||||
.collect();
|
||||
let (d, w) = open(&format!(
|
||||
r#"<defs>{defs}{used}</defs><rect width="60" height="40" filter="url(#g)"/><rect x="100" width="60" height="40" filter="url(#g)"/>"#
|
||||
));
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let named = |n: &&Node| n.appearance.effects.iter().filter(|e| e.id == "svg.filter").map(|e| e.params["name"].clone()).collect::<Vec<_>>();
|
||||
let filters: Vec<Vec<serde_json::Value>> = art(&d).iter().map(named).collect();
|
||||
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.
|
||||
#[test]
|
||||
fn other_filters_are_kept_or_reported() {
|
||||
let (d, w) =
|
||||
open(r##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="60" height="40" filter="url(#t)"/>"##);
|
||||
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")));
|
||||
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)"/>"##,
|
||||
);
|
||||
assert!(w.iter().any(|w| w.contains("filter")), "{w:?}");
|
||||
assert!(effects(&d).is_empty());
|
||||
// Past the 0.001 to 1000 points a unit of the effect measures: reported and left out too.
|
||||
let (d, w) = open(
|
||||
r##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="0.04" height="0.04" transform="scale(2000)" filter="url(#t)"/>"##,
|
||||
);
|
||||
assert!(w.iter().any(|w| w.contains("filter")), "{w:?}");
|
||||
assert!(effects(&d).is_empty());
|
||||
}
|
||||
@@ -530,6 +624,58 @@ fn an_exported_outer_glow_round_faint_art_draws_as_the_canvas_does() {
|
||||
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 40.0, 0.02);
|
||||
}
|
||||
|
||||
/// A document with one red rectangle whose only effect is the SVG filter `xml`.
|
||||
fn filtered_rect(xml: &str) -> Document {
|
||||
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),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "svg.filter".into(), params: json!({ "xml": xml }), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let data = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
let xml = format!(r#"<filter id="i"><feImage href="{data}"/><feComposite in2="SourceGraphic" operator="in"/></filter>"#);
|
||||
let d = filtered_rect(&xml);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains(&format!(r#"<feImage xlink:href="{data}"/>"#)), "{svg}");
|
||||
assert!(!svg.contains(" href="), "{svg}");
|
||||
let (back, w) = import_with_report(&svg).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
match vectorcraft_effects::raster_effects(&effects(&back)).as_slice() {
|
||||
[RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(f))] => {
|
||||
assert!(f.xml().contains(&format!(r#"<feImage href="{data}"/>"#)), "the filter as it is drawn: {}", f.xml())
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG 1.1 has no `feDropShadow`: export writes the SVG 1.1 primitives it stands for, which draw
|
||||
/// as the canvas draws the shadow and import back as a filter that draws the same.
|
||||
#[test]
|
||||
fn an_exported_svg_drop_shadow_is_svg_1_1_and_draws_as_the_canvas_does() {
|
||||
let xml = r##"<filter id="s" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="120"><feDropShadow dx="12" dy="9" stdDeviation="3" flood-color="#0040ff" flood-opacity="0.7"/></filter>"##;
|
||||
let d = filtered_rect(xml);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(!svg.contains("feDropShadow") && svg.contains("<feGaussianBlur") && svg.contains("<feMerge"), "{svg}");
|
||||
let canvas = render_artboard(&d);
|
||||
// The shadow, beside the art.
|
||||
let [r, g, b] = canvas.over_white(105, 75);
|
||||
assert!(b > 150 && r < 200 && g < 200, "{:?}", [r, g, b]);
|
||||
assert_similar(&canvas, &resvg_render(&svg, 200, 120), 40.0, 0.02);
|
||||
let (back, w) = import_with_report(&svg).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(effects(&back).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["svg.filter"]);
|
||||
assert_similar(&canvas, &render_artboard(&back), 40.0, 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_and_effects_export_import_export_is_stable() {
|
||||
// A doc with a symbol instance carrying a shadow: export → import → export is stable.
|
||||
|
||||
@@ -365,6 +365,7 @@ impl VectorcraftApp {
|
||||
}
|
||||
crate::keymap::note_os(ctx);
|
||||
shortcut_editor::sync(&self.ui);
|
||||
crate::dialogs::svg_filter::sync(self);
|
||||
crate::panels::actions::sync_function_keys(&self.ui);
|
||||
prefs_dialog::apply_runtime(self, ctx);
|
||||
// The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the
|
||||
|
||||
@@ -114,6 +114,7 @@ pub(crate) mod simplify_slider;
|
||||
pub mod slices;
|
||||
pub(crate) mod smooth_slider;
|
||||
pub mod spot_colors;
|
||||
pub mod svg_filter;
|
||||
pub(crate) mod svg_options;
|
||||
pub mod swatch_conflict;
|
||||
pub mod swatch_options;
|
||||
@@ -350,6 +351,7 @@ registry! {
|
||||
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
|
||||
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
|
||||
JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC,
|
||||
SvgFilter: [svg_filter::KIND] => svg_filter::SPEC,
|
||||
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
|
||||
Place: [place::KIND] => place::SPEC,
|
||||
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC,
|
||||
|
||||
@@ -64,6 +64,10 @@ pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec {
|
||||
/// or add `effect`, first asking when that list already has it.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let id = p.get("effect").and_then(Value::as_str).unwrap_or_default();
|
||||
// An SVG filter is chosen and edited in Apply SVG Filter.
|
||||
if id == vectorcraft_effects::SVG_FILTER {
|
||||
return super::svg_filter::open(app, p);
|
||||
}
|
||||
let info = vectorcraft_effects::effect_info(id).ok_or_else(|| format!("unknown effect `{id}`"))?;
|
||||
// The list the effect goes to: `item` as given, else the active item's (the object's without
|
||||
// one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual.
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Apply SVG Filter (Effect › SVG Filters › Apply SVG Filter…): the document's SVG filters in a
|
||||
//! list. OK applies the selected one to the selection as a live effect (`svgFilter.apply`), or,
|
||||
//! opened from the Appearance panel for an applied SVG filter, puts it in that effect's place
|
||||
//! (`effect.setParams`). New and Edit open the filter's code under the list; Save adds the filter
|
||||
//! or replaces the one being edited (`svgFilter.define`). Delete removes the selected filter
|
||||
//! (`svgFilter.delete`) and Import… adds the filters of an SVG file (`svgFilter.import`). Those
|
||||
//! act at once. Effect › SVG Filters lists the same filters below its first items.
|
||||
//!
|
||||
//! Fields: `selected` (a filter's name), `code` (the code being edited), `editing` (`""`, `"new"`
|
||||
//! or the name of the filter being edited), and with an applied effect `__index`, `__item` and
|
||||
//! `missing` (the name of a deleted filter the effect names).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::doc::SvgFilter;
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::picks::PickRequest;
|
||||
use crate::state::Dialog;
|
||||
use crate::{FilePick, VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Apply SVG Filter.
|
||||
pub const KIND: &str = "svgFilter";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Apply SVG Filter…").trim_end_matches('…').to_string(),
|
||||
body,
|
||||
confirm,
|
||||
ok_enabled: Some(|app, d| d.str("editing").is_empty() && filters(app).iter().any(|f| f.name == d.str("selected"))),
|
||||
min_width: 440.0,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The active document's SVG filters.
|
||||
fn filters(app: &VectorcraftApp) -> Vec<SvgFilter> {
|
||||
app.session.active().map(|st| st.doc.svg_filters.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `ui.svgFilterDialog {index?, item?}`: open Apply SVG Filter for the active document; with
|
||||
/// `index`, for the applied SVG filter at `index` of `item` (null: the object's effects): the
|
||||
/// document's filter of its name selected; when the document has none of its name, the name shown
|
||||
/// above the list if the filter was deleted, or else its code open as a new filter.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if app.session.active().is_none() {
|
||||
return Err("no document open".into());
|
||||
}
|
||||
let mut fields = json!({ "selected": "", "code": "", "editing": "" });
|
||||
if let Some(k) = p.get("index").and_then(Value::as_u64) {
|
||||
let mut q = json!({});
|
||||
if let Some(item) = p.get("item") {
|
||||
q["item"] = item.clone();
|
||||
}
|
||||
let applied = app.session.appearance_effect_target(&q).map(|(_, fx)| fx.to_vec()).unwrap_or_default();
|
||||
let e = applied.get(k as usize).filter(|e| e.id == vectorcraft_effects::SVG_FILTER).ok_or_else(|| format!("no SVG filter at index {k}"))?;
|
||||
let name = e.params.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let xml = e.params.get("xml").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let held = filters(app).into_iter().find(|f| f.name == name);
|
||||
match held {
|
||||
// The document's filter it names: selected.
|
||||
Some(_) => fields["selected"] = json!(name),
|
||||
// Deleted from the document: shown above the list, not selectable; OK waits for a choice.
|
||||
None if xml.is_empty() => fields["missing"] = json!(name),
|
||||
// A filter the document doesn't hold (from an opened file): its code, to add it.
|
||||
None => {
|
||||
fields["code"] = json!(xml);
|
||||
fields["editing"] = json!("new");
|
||||
}
|
||||
}
|
||||
fields["__index"] = json!(k);
|
||||
// As given: `null` is the object's own effects, and none is the active item's.
|
||||
if let Some(item) = p.get("item") {
|
||||
fields["__item"] = item.clone();
|
||||
}
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// OK: the selected filter to the selection, or in the applied effect's place.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let name = d.str("selected");
|
||||
let f = filters(app).into_iter().find(|f| f.name == name).ok_or("select an SVG filter")?;
|
||||
let r = match d.fields.get("__index").and_then(Value::as_u64) {
|
||||
Some(k) => {
|
||||
let mut p = json!({ "index": k, "params": { "name": f.name, "xml": f.xml } });
|
||||
if let Some(item) = d.fields.get("__item") {
|
||||
p["item"] = item.clone();
|
||||
}
|
||||
app.run("effect.setParams", p)?
|
||||
}
|
||||
None => app.run("svgFilter.apply", json!({ "name": f.name }))?,
|
||||
};
|
||||
app.ui.dialog = None;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The active document's filter names, mirrored for the (static) menu tree ([`sync`]).
|
||||
fn names_store() -> &'static std::sync::RwLock<Vec<&'static str>> {
|
||||
static NAMES: std::sync::OnceLock<std::sync::RwLock<Vec<&'static str>>> = std::sync::OnceLock::new();
|
||||
NAMES.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Mirror the active document's SVG filter names for the menu tree (cheap when unchanged).
|
||||
pub fn sync(app: &VectorcraftApp) {
|
||||
let names: Vec<String> = app.session.active().map(|st| st.doc.svg_filters.iter().map(|f| f.name.clone()).collect()).unwrap_or_default();
|
||||
let same = names_store().read().map(|v| v.len() == names.len() && v.iter().zip(&names).all(|(a, b)| *a == b)).unwrap_or(true);
|
||||
if !same {
|
||||
if let Ok(mut v) = names_store().write() {
|
||||
*v = names.iter().map(|n| crate::shortcut_editor::intern(n)).collect();
|
||||
}
|
||||
crate::shortcut_editor::GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect › SVG Filters: Apply SVG Filter…, Import SVG Filter… and then the active document's
|
||||
/// filters, each applying itself to the selection.
|
||||
pub fn menu_items() -> Vec<crate::menus::Item> {
|
||||
items_for(names_store().read().map(|v| v.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// [`menu_items`] for filters named `names`.
|
||||
fn items_for(names: Vec<&'static str>) -> Vec<crate::menus::Item> {
|
||||
use crate::menus::Item;
|
||||
let mut v =
|
||||
vec![Item::Cmd("Apply SVG Filter…", "ui.svgFilterDialog", Value::Null), Item::Cmd("Import SVG Filter…", "ui.importSvgFilter", Value::Null)];
|
||||
if !names.is_empty() {
|
||||
v.push(Item::Sep);
|
||||
v.extend(names.into_iter().map(|n| Item::Cmd(n, "svgFilter.apply", json!({ "name": n }))));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `ui.importSvgFilter {}`: add the filters of an SVG file the user picks to the active document.
|
||||
pub fn import_file(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let path = crate::picks::open(app, &pick()).ok_or("cancelled")?;
|
||||
let r = app.run("svgFilter.import", json!({ "path": path }))?;
|
||||
let added = r["added"].as_array().map_or(0, Vec::len);
|
||||
app.status(crate::i18n::msg_n(added as u64, "Imported {n} SVG filter", "Imported {n} SVG filters", &[]));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The extensions of the SVG files Import… reads.
|
||||
const SVG_EXTS: &[&str] = &["svg"];
|
||||
|
||||
/// The open dialog for an SVG file.
|
||||
pub(crate) fn pick() -> FilePick {
|
||||
FilePick { filters: vec![FilePick::filter("SVG", SVG_EXTS)], ..Default::default() }
|
||||
}
|
||||
|
||||
/// Import…: the filters of the SVG file picked in the open dialog, added to the document, the first
|
||||
/// one added selected.
|
||||
fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||||
let Some(path) = crate::picks::open(app, &pick()) else { return Ok(()) };
|
||||
let r = app.run("svgFilter.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["added"].get(0).and_then(Value::as_str) {
|
||||
d.fields.insert("selected".into(), json!(first));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a button asked for, done once the dialog is drawn.
|
||||
enum Action {
|
||||
Select(String),
|
||||
New,
|
||||
/// Open this filter's code (a double-clicked row, or the selected one).
|
||||
Edit(String),
|
||||
Delete,
|
||||
Import,
|
||||
Save,
|
||||
Discard,
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let list = filters(app);
|
||||
let selected = d.str("selected");
|
||||
let editing = d.str("editing");
|
||||
let mut act = None;
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("svg-filters").min_scrolled_height(160.0).max_height(160.0).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.set_min_height(160.0);
|
||||
// Filter names are data: shown as they are. A deleted filter the effect still names
|
||||
// comes first, dimmed and not selectable.
|
||||
let missing = d.str("missing");
|
||||
if !missing.is_empty() {
|
||||
ui.add_enabled_ui(false, |ui| ui.selectable_label(false, missing.as_str()));
|
||||
}
|
||||
for f in &list {
|
||||
let r = ui.selectable_label(f.name == selected, f.name.as_str());
|
||||
if r.double_clicked() && editing.is_empty() {
|
||||
act = Some(Action::Edit(f.name.clone()));
|
||||
} else if r.clicked() {
|
||||
act = Some(Action::Select(f.name.clone()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let has = list.iter().any(|f| f.name == selected);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let idle = editing.is_empty();
|
||||
if ui.add_enabled_ui(idle && list.len() < SvgFilter::MAX, |ui| widgets::flat_button(ui, tl!("New"), 64.0)).inner.clicked() {
|
||||
act = Some(Action::New);
|
||||
}
|
||||
if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Edit"), 64.0)).inner.clicked() {
|
||||
act = Some(Action::Edit(selected.clone()));
|
||||
}
|
||||
if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() {
|
||||
act = Some(Action::Delete);
|
||||
}
|
||||
if crate::picks::can(app, &PickRequest::Open(pick()))
|
||||
&& ui.add_enabled_ui(idle, |ui| widgets::flat_button(ui, tl!("Import…"), 64.0)).inner.clicked()
|
||||
{
|
||||
act = Some(Action::Import);
|
||||
}
|
||||
});
|
||||
if !editing.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
let mut code = d.str("code");
|
||||
// The filter's code is data: edited as it is written.
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut code).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY).desired_rows(10));
|
||||
if r.changed() {
|
||||
d.fields.insert("code".into(), json!(code));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if ui.add_enabled_ui(!code.trim().is_empty(), |ui| widgets::flat_button(ui, tl!("Save"), 64.0)).inner.clicked() {
|
||||
act = Some(Action::Save);
|
||||
}
|
||||
if widgets::flat_button(ui, tl!("Cancel"), 64.0).clicked() {
|
||||
act = Some(Action::Discard);
|
||||
}
|
||||
});
|
||||
}
|
||||
let r = match act {
|
||||
None => Ok(()),
|
||||
Some(Action::Select(name)) => {
|
||||
d.fields.insert("selected".into(), json!(name));
|
||||
Ok(())
|
||||
}
|
||||
Some(Action::New) => {
|
||||
d.fields.insert("editing".into(), json!("new"));
|
||||
d.fields.insert("code".into(), json!(""));
|
||||
Ok(())
|
||||
}
|
||||
Some(Action::Edit(name)) => {
|
||||
if let Some(f) = list.iter().find(|f| f.name == name) {
|
||||
d.fields.insert("selected".into(), json!(f.name));
|
||||
d.fields.insert("editing".into(), json!(f.name));
|
||||
d.fields.insert("code".into(), json!(f.xml));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some(Action::Delete) => app.run("svgFilter.delete", json!({ "name": selected })).map(|_| {
|
||||
d.fields.insert("selected".into(), json!(""));
|
||||
}),
|
||||
// The open dialog, shown off the UI thread, fills the dialog as it is then.
|
||||
Some(Action::Import) => crate::picks::in_dialog(app, d, import),
|
||||
Some(Action::Save) => {
|
||||
let mut p = json!({ "xml": d.str("code") });
|
||||
if editing != "new" {
|
||||
p["replace"] = json!(editing);
|
||||
}
|
||||
app.run("svgFilter.define", p).map(|r| {
|
||||
d.fields.insert("selected".into(), r["name"].clone());
|
||||
d.fields.insert("editing".into(), json!(""));
|
||||
d.fields.insert("code".into(), json!(""));
|
||||
})
|
||||
}
|
||||
Some(Action::Discard) => {
|
||||
d.fields.insert("editing".into(), json!(""));
|
||||
d.fields.insert("code".into(), json!(""));
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
const SHIFT: &str = r#"<filter id="Shift"><feOffset dx="3"/></filter>"#;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog's body: the texts drawn.
|
||||
fn frame(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
body(app, ui, d);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_the_documents_filters_and_ok_applies_the_selected_one() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert!(open(&mut app, &json!({})).is_err(), "no document");
|
||||
let mut app = self::app();
|
||||
app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap();
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
crate::menus::invoke(&mut app, "ui.svgFilterDialog", json!({}));
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert!(frame(&mut app, &mut d).iter().any(|t| t == "Shift"), "the filter's name, as it is");
|
||||
assert!(!(SPEC.ok_enabled.unwrap())(&app, &d), "nothing selected");
|
||||
d.fields.insert("selected".into(), json!("Shift"));
|
||||
assert!((SPEC.ok_enabled.unwrap())(&app, &d));
|
||||
confirm(&mut app, &d).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let fx = &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects;
|
||||
assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Shift")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_for_an_applied_filter_ok_replaces_it() {
|
||||
let mut app = app();
|
||||
app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap();
|
||||
app.session.execute("svgFilter.define", &json!({"xml": "<filter id=\"Other\"><feOffset dy=\"2\"/></filter>"})).unwrap();
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("svgFilter.apply", &json!({"name": "Shift"})).unwrap();
|
||||
// With a fill row active, the object's own effect is still the one edited.
|
||||
app.session.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
|
||||
// The Appearance panel opens the effect's dialog: this one.
|
||||
app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap();
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("selected").as_str()), (KIND, "Shift"));
|
||||
d.fields.insert("selected".into(), json!("Other"));
|
||||
confirm(&mut app, &d).unwrap();
|
||||
let fx = &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects;
|
||||
assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Other")), "in its place, not added");
|
||||
// Edited since it was applied: the effect follows the edit, and the filter stays selected.
|
||||
app.session.execute("svgFilter.define", &json!({"xml": "<filter id=\"Other\"><feOffset dy=\"9\"/></filter>", "replace": "Other"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!((d.str("selected").as_str(), d.str("editing").as_str()), ("Other", ""));
|
||||
// Deleted: its name shows above the list, nothing is selected, and OK waits for a choice.
|
||||
app.session.execute("svgFilter.delete", &json!({"name": "Other"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!((d.str("missing").as_str(), d.str("selected").as_str(), d.str("editing").as_str()), ("Other", "", ""));
|
||||
assert!(!(SPEC.ok_enabled.unwrap())(&app, &d));
|
||||
// An applied filter the document never held (from an opened file) opens as code to save.
|
||||
let e = vectorcraft_doc::Effect {
|
||||
id: "svg.filter".into(),
|
||||
params: json!({"name": "Kept", "xml": "<filter id=\"Kept\"><feOffset dx=\"1\"/></filter>"}),
|
||||
visible: true,
|
||||
};
|
||||
app.session
|
||||
.edit("Plant", |d, _| {
|
||||
d.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects = vec![e];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(d.str("editing"), "new");
|
||||
assert!(d.str("code").contains("id=\"Kept\""));
|
||||
}
|
||||
|
||||
/// Edit, started from one object's effect in the Appearance panel, changes every object that uses
|
||||
/// the filter; a deleted filter's name is drawn above the list.
|
||||
#[test]
|
||||
fn an_edit_from_one_objects_effect_changes_every_user() {
|
||||
let mut app = app();
|
||||
app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap();
|
||||
let rect = |app: &mut VectorcraftApp, x: f64| {
|
||||
app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0));
|
||||
app.session.execute("svgFilter.apply", &json!({"name": "Shift", "ids": [a, b]})).unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap();
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
// Edit opens the selected filter's code; Save replaces it, as its button does.
|
||||
d.fields.insert("editing".into(), json!(d.str("selected")));
|
||||
let edited = r#"<filter id="Shift"><feOffset dx="8"/></filter>"#;
|
||||
app.run("svgFilter.define", json!({"xml": edited, "replace": d.str("editing")})).unwrap();
|
||||
let xml = |app: &VectorcraftApp, id: u64| {
|
||||
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["xml"].clone()
|
||||
};
|
||||
assert_eq!((xml(&app, a), xml(&app, b)), (json!(edited), json!(edited)));
|
||||
app.session.execute("svgFilter.delete", &json!({"name": "Shift"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap();
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
assert!(frame(&mut app, &mut d).iter().any(|t| t == "Shift"), "the deleted filter's name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_submenu_lists_the_documents_filters_below_its_commands() {
|
||||
use crate::menus::Item;
|
||||
let ids =
|
||||
|items: &[Item]| items.iter().map(|i| if let Item::Cmd(l, c, p) = i { format!("{l} {c} {p}") } else { "—".into() }).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&items_for(vec![])), ["Apply SVG Filter… ui.svgFilterDialog null", "Import SVG Filter… ui.importSvgFilter null"]);
|
||||
assert_eq!(
|
||||
ids(&items_for(vec!["Shift", "A (1)"]))[2..],
|
||||
["—", "Shift svgFilter.apply {\"name\":\"Shift\"}", "A (1) svgFilter.apply {\"name\":\"A (1)\"}"]
|
||||
);
|
||||
let mut app = app();
|
||||
assert!(!crate::menus::enabled(&app, "svgFilter.apply"), "nothing selected");
|
||||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
assert!(crate::menus::enabled(&app, "svgFilter.apply"));
|
||||
assert_eq!(crate::menus::display_label(&app, "svgFilter.apply", "Shift"), "Shift", "a filter's name as it is");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_saves_the_code_and_selects_the_filter() {
|
||||
let mut app = app();
|
||||
open(&mut app, &json!({})).unwrap();
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
d.fields.insert("editing".into(), json!("new"));
|
||||
d.fields.insert("code".into(), json!(SHIFT));
|
||||
assert!(!(SPEC.ok_enabled.unwrap())(&app, &d), "not while the code is open");
|
||||
// Save, as its button does.
|
||||
let r = app.run("svgFilter.define", json!({"xml": d.str("code")})).unwrap();
|
||||
assert_eq!(r["name"], "Shift");
|
||||
assert_eq!(app.session.active().unwrap().doc.svg_filters.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_the_picked_files_filters() {
|
||||
let mut app = app();
|
||||
let path = std::env::temp_dir().join(format!("vc-ui-svgfilters-{}.svg", std::process::id()));
|
||||
std::fs::write(&path, format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><defs>{SHIFT}</defs></svg>")).unwrap();
|
||||
let picked = path.to_string_lossy().into_owned();
|
||||
app.services.pick_open = Some(Box::new(move |_: &FilePick| Some(picked.clone())));
|
||||
let mut d = Dialog::new(KIND, json!({ "selected": "", "code": "", "editing": "" }));
|
||||
import(&mut app, &mut d).unwrap();
|
||||
std::fs::remove_file(&path).ok();
|
||||
assert_eq!(d.str("selected"), "Shift");
|
||||
assert_eq!(app.session.active().unwrap().doc.svg_filters[0].name, "Shift");
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option<String>
|
||||
|
||||
/// Does the menu item `id` show a name that is user, file or system data rather than an interface
|
||||
/// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a
|
||||
/// plug-in? Such names are shown as they are, never translated (a workspace the user calls
|
||||
/// plug-in, an SVG filter? Such names are shown as they are, never translated (a workspace the user calls
|
||||
/// "Layers" stays "Layers").
|
||||
pub(super) fn shows_a_name(id: &str, label: &str) -> bool {
|
||||
const SLOTS: [&str; 8] = [
|
||||
@@ -138,7 +138,7 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool {
|
||||
];
|
||||
SLOTS.iter().any(|s| id.starts_with(s))
|
||||
|| saved_selection_slot(id).is_some()
|
||||
|| matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom")
|
||||
|| matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom" | "svgFilter.apply")
|
||||
|| (id == "window.workspace" && !crate::workspaces::is_builtin(label))
|
||||
|| (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label))
|
||||
}
|
||||
|
||||
@@ -805,6 +805,8 @@ fn run_ui_part10(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
|
||||
"ui.dialogReset" => crate::dialogs::reset(app),
|
||||
"attributes.openUrl" => crate::panels::attributes::open_url(app, p),
|
||||
"ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p),
|
||||
"ui.svgFilterDialog" => crate::dialogs::svg_filter::open(app, p),
|
||||
"ui.importSvgFilter" => crate::dialogs::svg_filter::import_file(app),
|
||||
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
|
||||
"ui.menuDialog" => match s("command").as_deref() {
|
||||
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
|
||||
|
||||
@@ -175,7 +175,7 @@ pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool {
|
||||
/// targeted layer, group or object, else the selection). Without `ids` (or with an empty list) and
|
||||
/// without a subject they are off, as the panel's buttons are: in menus, in the command palette and
|
||||
/// on their keys.
|
||||
pub(super) const SUBJECT_COMMANDS: [&str; 2] = ["appearance.addFill", "appearance.addStroke"];
|
||||
pub(super) const SUBJECT_COMMANDS: [&str; 3] = ["appearance.addFill", "appearance.addStroke", "svgFilter.apply"];
|
||||
|
||||
/// Is there an Appearance panel subject: a targeted layer, group or object, or a selected one?
|
||||
pub(super) fn has_subject(app: &VectorcraftApp) -> bool {
|
||||
@@ -246,7 +246,12 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
let min = crate::panels::pathfinder::minimum_operands(op);
|
||||
crate::panels::pathfinder::operands(app) >= min
|
||||
}),
|
||||
"file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(),
|
||||
"file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" | "ui.svgFilterDialog" => {
|
||||
app.session.active().is_some()
|
||||
}
|
||||
"ui.importSvgFilter" => {
|
||||
app.session.active().is_some() && crate::picks::can(app, &crate::picks::PickRequest::Open(crate::dialogs::svg_filter::pick()))
|
||||
}
|
||||
"ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(),
|
||||
"ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
|
||||
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
|
||||
|
||||
@@ -172,11 +172,15 @@ pub(super) fn effect_menu() -> Vec<Item> {
|
||||
out.push(item(e));
|
||||
continue;
|
||||
}
|
||||
// Apply SVG Filter…, Import SVG Filter… and the document's filters.
|
||||
if sub_name == "SVG Filters" {
|
||||
out.push(sub(sub_name, crate::dialogs::svg_filter::menu_items()));
|
||||
continue;
|
||||
}
|
||||
let items = items_at(&["Effect", sub_name], true);
|
||||
if items.is_empty() {
|
||||
let placeholder = match sub_name {
|
||||
"3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")],
|
||||
"SVG Filters" => vec![todo("Apply SVG Filter…")],
|
||||
_ => continue,
|
||||
};
|
||||
out.push(sub(sub_name, placeholder));
|
||||
|
||||
@@ -1082,6 +1082,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected",
|
||||
),
|
||||
(
|
||||
"ui.svgFilterDialog",
|
||||
"Apply SVG Filter…",
|
||||
"",
|
||||
"{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file",
|
||||
),
|
||||
(
|
||||
"ui.importSvgFilter",
|
||||
"Import SVG Filter…",
|
||||
"",
|
||||
"{} pick an SVG file and add its filters to the active document's SVG filters (svgFilter.import) → svgFilter.import's result",
|
||||
),
|
||||
(
|
||||
"ui.spotColors",
|
||||
"Spot Colors…",
|
||||
|
||||
@@ -453,8 +453,9 @@ fn listed(overrides: &Overrides, e: &Entry) -> bool {
|
||||
/// Commands whose params are all optional that do nothing, or fail, when a key press runs them
|
||||
/// with none, and that no item of the menu bar runs: Transparency and Opacity Mask Options need
|
||||
/// a value to set, Align and Distribute a direction, Delete Swatch and Move Swatch a swatch name,
|
||||
/// and the library, data set and text style loaders a file, which their panel menu items ask for.
|
||||
const NEEDS_A_PARAM: [&str; 19] = [
|
||||
/// and the library, data set, text style and SVG filter loaders a file, which their menu items ask
|
||||
/// for.
|
||||
const NEEDS_A_PARAM: [&str; 20] = [
|
||||
"transparency.set",
|
||||
"transparency.setOpacityMask",
|
||||
"stroke.set",
|
||||
@@ -474,6 +475,7 @@ const NEEDS_A_PARAM: [&str; 19] = [
|
||||
"charStyle.load",
|
||||
"paraStyle.load",
|
||||
"textStyle.loadAll",
|
||||
"svgFilter.import",
|
||||
];
|
||||
|
||||
/// The (label, params) of each menu bar item, by command id.
|
||||
|
||||
@@ -11,7 +11,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
|
||||
|
||||
| Group | Illustrator | VectorCraft | % | Remaining (h) |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Illustrator effects (vector and stylize) | 50 | 49 | 98% | 4–8 |
|
||||
| Illustrator effects (vector and stylize) | 50 | 50 | 100% | — |
|
||||
| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) |
|
||||
| 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 |
|
||||
| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — |
|
||||
@@ -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 | 0/1 | the whole submenu (SVG filters on import round-trip already) |
|
||||
| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. 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 |
|
||||
| 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; 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
|
||||
|
||||
+56
@@ -3176,6 +3176,62 @@ it. The native format keeps the events and the files.
|
||||
{"name":"run_command","arguments":{"command":"svgInteractivity.addFile","params":{"file":"menu.js"}}}
|
||||
```
|
||||
|
||||
## SVG filters
|
||||
|
||||
Effect › SVG Filters lists the document's SVG filters below Apply SVG Filter… and Import SVG Filter…; choosing one
|
||||
applies it to the selection. `svgFilter.list {}` returns them as `filters: [{name, xml}]`. Each is one `<filter>`
|
||||
element whose `id` is its name, with 1 to 64 primitives and at most 64 KB of XML (`feTurbulence` octaves up to 10,
|
||||
`feConvolveMatrix` kernels up to 9 cells a side, `feMorphology` radii and `kernelUnitLength` up to 100 and
|
||||
`stdDeviation` up to 500 user units, elements nested at most 16 deep). It holds only filter primitives,
|
||||
what they hold, and `desc` and `title`: no scripts, no event attributes, and images only as PNG, JPEG, GIF or WebP
|
||||
`data:` URLs of at most 4096 × 4096 pixels. A document
|
||||
holds at most 64. `svgFilter.define {xml, replace?}` adds
|
||||
a filter after the others, or with `replace` replaces the filter of that name, which keeps its name; a name another
|
||||
filter has is refused, and the same filter again records no undo step. `svgFilter.delete {name}` removes one, and `svgFilter.import
|
||||
{path?|svg?}` adds the `<filter>` elements of an SVG file (at most 16 MB, not SVGZ, the first 256 filters) or of SVG text, leaving out the names the
|
||||
document already has and the filters `svgFilter.define` would refuse (`{added, skipped}`). Each change is one undo
|
||||
step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect `svg.filter`, as `effect.apply` does,
|
||||
in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes
|
||||
what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited
|
||||
filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml`
|
||||
empty) until a filter of that name is defined or imported again. Opened from such an effect, Apply SVG Filter shows the
|
||||
name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter
|
||||
directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply
|
||||
SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs;
|
||||
`ui.importSvgFilter {}` picks an SVG file and imports its filters.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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`;
|
||||
the inputs `BackgroundImage`, `BackgroundAlpha`, `FillPaint` and `StrokePaint` as `SourceGraphic`, and an input that
|
||||
names no earlier result as the previous primitive's result;
|
||||
and an `feComposite` operator SVG 1.1 lacks, or `edgeMode` on a primitive other than `feConvolveMatrix`, left out, as
|
||||
the canvas draws them. A filter with an `feBlend` mode SVG 1.1 lacks (`overlay` and the others), an `feDropShadow`
|
||||
whose `flood-color` is `currentColor` or with `primitiveUnits="objectBoundingBox"`, or more than 64 primitives once
|
||||
its shadows are expanded, has no SVG 1.1 form. 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.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"svgFilter.define","params":{"xml":"<filter id=\"Soft\"><feGaussianBlur stdDeviation=\"2\"/></filter>"}}}
|
||||
{"name":"run_command","arguments":{"command":"svgFilter.apply","params":{"name":"Soft","ids":[12]}}}
|
||||
```
|
||||
|
||||
## Removing anchor points
|
||||
|
||||
`path.removeAnchors {}` (Object › Path › Remove Anchor Points) removes the direct-selected anchors (`select.anchors
|
||||
|
||||
@@ -1453,6 +1453,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795).
|
||||
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests/pdfraster.rs`).
|
||||
- **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests/rastersettings.rs`, `crates/render/src/tests/rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html).
|
||||
- **SVG filters:** Effect › SVG Filters lists the document's own SVG filters below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). VectorCraft ships no default filters. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `<filter>` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html).
|
||||
|
||||
- **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user