mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:20:56 -06:00
SVG export wrote an SVG filter as a live <filter> when its SVG 1.1 form was at most 64 KB, without counting the region and id it writes into it. A filter close to the limit came back over it, SVG import dropped it, and the reopened object had no filter. Export now leaves room for the id and the measured region, checks the SVG 1.1 form the way import does, and counts each character past U+00FF as the character reference Latin-1 output writes. A filter that does not fit is exported as the other pixel effects are. The SVG 1.1 form and its check are kept for the filters seen last, as checked filters already are, so export does not check one filter again for each object.
764 lines
42 KiB
Rust
764 lines
42 KiB
Rust
//! SVG import keeps what the canvas edits live: `<pattern>` becomes a pattern swatch, `<symbol>`
|
||
//! with `<use>` a symbol and its instances, blurs and drop-shadow chains live effects; and
|
||
//! export → import keeps them.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeKind};
|
||
use vectorcraft_effects::RasterFx;
|
||
use vectorcraft_geom::{Point, Rect, shapes};
|
||
use vectorcraft_svg::{ExportOptions, export, import, import_with_report};
|
||
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
|
||
|
||
fn svg(body: &str) -> String {
|
||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120">{body}</svg>"#)
|
||
}
|
||
|
||
fn open(body: &str) -> (Document, Vec<String>) {
|
||
import_with_report(&svg(body)).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn decoded_id_selectors_do_not_recolor_bare_ellipses() {
|
||
for selector in ["circle[id]", "ellipse[id]", "circle[id]", "ellipse[id]", "circle[ id ]", r"ellipse[\69 d]"] {
|
||
let source = svg(&format!(
|
||
"<style>{selector} {{ fill: red }}</style><circle cx=\"40\" cy=\"50\" r=\"20\"/><ellipse cx=\"100\" cy=\"50\" rx=\"20\" ry=\"10\"/><circle id=\"c\" cx=\"40\" cy=\"100\" r=\"8\"/><ellipse id=\"e\" cx=\"100\" cy=\"100\" rx=\"12\" ry=\"8\"/>"
|
||
));
|
||
let doc = import(&source).unwrap();
|
||
let actual = render_artboard(&doc);
|
||
let expected = resvg_render(&source, 200, 120);
|
||
assert_eq!((actual.width, actual.height), (expected.width, expected.height));
|
||
for (x, y) in [(40, 50), (100, 50), (40, 100), (100, 100)] {
|
||
let at = 4 * (y * actual.width + x) as usize;
|
||
assert_eq!(&actual.rgba[at..at + 4], &expected.rgba[at..at + 4], "{selector}: ({x}, {y})");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn metadata_ids_preserve_attribute_selector_colors_and_metadata() {
|
||
for attrs in [r#"data-foo="kept""#, r#"xmlns:meta="https://example.org/metadata" meta:key="kept""#, r#"onclick="kept""#] {
|
||
let source = svg(&format!(
|
||
r#"<style>circle[id], ellipse[id] {{ fill:red }}</style><circle {attrs} cx="40" cy="50" r="20"/><ellipse {attrs} cx="100" cy="50" rx="20" ry="10"/>"#
|
||
));
|
||
let doc = import(&source).unwrap();
|
||
let actual = render_artboard(&doc);
|
||
let expected = resvg_render(&source, 200, 120);
|
||
for (x, y) in [(40, 50), (100, 50)] {
|
||
let at = 4 * (y * actual.width + x) as usize;
|
||
assert_eq!(&actual.rgba[at..at + 4], &expected.rgba[at..at + 4], "{attrs}: ({x}, {y})");
|
||
}
|
||
let has_metadata = |doc: &Document| {
|
||
art(doc)
|
||
.into_iter()
|
||
.filter(|n| {
|
||
n.extras().attrs.as_deref().is_some_and(|a| {
|
||
a.data.iter().any(|(k, v)| k == "foo" && v == "kept")
|
||
|| a.foreign.iter().any(|a| a.ns == "https://example.org/metadata" && a.name == "key" && a.value == "kept")
|
||
|| a.events.iter().any(|(k, v)| k == "onclick" && v == "kept")
|
||
})
|
||
})
|
||
.count()
|
||
};
|
||
assert_eq!(has_metadata(&doc), 2, "{attrs}");
|
||
let back = import(&export(&doc, &ExportOptions::default())).unwrap();
|
||
assert_eq!(has_metadata(&back), 2, "round trip: {attrs}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn metadata_id_styles_keep_the_original_cascade_and_structure() {
|
||
for body in [
|
||
r#"<style>circle[id] {fill:red} .dot {fill:blue} circle {fill:green}</style><circle class="dot" data-foo="kept" cx="40" cy="50" r="20"/>"#,
|
||
r#"<style>circle[id] {fill:red} circle {fill:blue}</style><style>circle {fill:green}</style><circle data-foo="kept" cx="40" cy="50" r="20"/>"#,
|
||
r#"<style>circle[id] {fill:red} circle {fill:blue!important}</style><circle style="fill:green" data-foo="kept" cx="40" cy="50" r="20"/>"#,
|
||
r#"<style>circle[id] {fill:red} circle {fill:blue!important}</style><circle style="fill:green!important" data-foo="kept" cx="40" cy="50" r="20"/>"#,
|
||
r#"<style>circle[id] {fill:red} g {fill:blue} circle + ellipse {fill:green}</style><g><circle data-foo="kept" cx="40" cy="50" r="20"/><ellipse cx="100" cy="50" rx="20" ry="10"/></g>"#,
|
||
r#"<style>circle[id] {fill:red} circle:first-child {fill:blue}</style><g><circle data-foo="kept" cx="40" cy="50" r="20"/><circle cx="100" cy="50" r="20"/></g>"#,
|
||
r#"<style>circle[id] {fill:red} #existing {fill:blue}</style><circle id="existing" data-foo="kept" cx="40" cy="50" r="20"/>"#,
|
||
] {
|
||
let source = svg(body);
|
||
let actual = render_artboard(&import(&source).unwrap());
|
||
let expected = resvg_render(&source, 200, 120);
|
||
for (x, y) in [(40, 50), (100, 50)] {
|
||
let at = 4 * (y * actual.width + x) as usize;
|
||
assert_eq!(&actual.rgba[at..at + 4], &expected.rgba[at..at + 4], "{body}: ({x}, {y})");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// All non-layer nodes in paint order.
|
||
fn art(d: &Document) -> Vec<&Node> {
|
||
let mut v = Vec::new();
|
||
d.walk(|n| {
|
||
if !n.is_layer() {
|
||
v.push(n)
|
||
}
|
||
});
|
||
v
|
||
}
|
||
|
||
/// The SVG rendered by resvg on white at 1 px/pt.
|
||
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
|
||
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
|
||
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
|
||
pm.fill(resvg::tiny_skia::Color::WHITE);
|
||
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
|
||
Image { width: w, height: h, rgba: pm.data().to_vec() }
|
||
}
|
||
|
||
/// The effects of the only object.
|
||
fn effects(d: &Document) -> Vec<Effect> {
|
||
// Not the clip group a filter's region may put it in (SVG 1.1 §15.5), nor its clipping path.
|
||
let a: Vec<&Node> =
|
||
art(d).into_iter().filter(|n| !matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Path { clipping: true, .. })).collect();
|
||
assert_eq!(a.len(), 1, "{a:?}");
|
||
a[0].appearance.effects.clone()
|
||
}
|
||
|
||
#[test]
|
||
fn a_checker_pattern_becomes_a_pattern_swatch() {
|
||
let (d, w) = open(
|
||
r##"<defs><pattern id="Checker" width="20" height="20" patternUnits="userSpaceOnUse">
|
||
<rect width="10" height="10" fill="#000"/><rect x="10" y="10" width="10" height="10" fill="#000"/>
|
||
</pattern></defs>
|
||
<rect width="200" height="120" fill="url(#Checker)"/>"##,
|
||
);
|
||
assert!(w.is_empty(), "{w:?}");
|
||
assert_eq!(d.patterns.len(), 1);
|
||
let p = &d.patterns[0];
|
||
assert_eq!((p.name.as_str(), p.tile), ("Checker", Rect::new(0.0, 0.0, 20.0, 20.0)));
|
||
assert!(matches!(art(&d)[0].appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Checker"));
|
||
assert_similar(
|
||
&render_artboard(&d),
|
||
&resvg_render(
|
||
&svg(
|
||
r##"<defs><pattern id="Checker" width="20" height="20" patternUnits="userSpaceOnUse"><rect width="10" height="10" fill="#000"/><rect x="10" y="10" width="10" height="10" fill="#000"/></pattern></defs><rect width="200" height="120" fill="url(#Checker)"/>"##,
|
||
),
|
||
200,
|
||
120,
|
||
),
|
||
24.0,
|
||
0.01,
|
||
);
|
||
}
|
||
|
||
const SYMBOL: &str = r##"<defs><symbol id="Star" data-name="Gold Star"><path d="M0 0H20V20H0Z" fill="#e0a000"/><circle cx="10" cy="10" r="4" fill="#fff"/></symbol></defs>"##;
|
||
|
||
#[test]
|
||
fn two_uses_give_one_symbol_and_two_instances() {
|
||
let body = format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/><use xlink:href="#Star" transform="translate(100 50) rotate(30)"/>"##);
|
||
let (d, w) = open(&body);
|
||
assert!(w.is_empty(), "{w:?}");
|
||
assert_eq!(d.symbols.len(), 1);
|
||
assert_eq!(d.symbols[0].name, "Gold Star");
|
||
assert!(matches!(&d.symbols[0].art.kind, NodeKind::Group { children, .. } if children.len() == 2));
|
||
let instances: Vec<_> = art(&d)
|
||
.into_iter()
|
||
.filter_map(|n| match &n.kind {
|
||
NodeKind::SymbolInstance { symbol, xf } => Some((symbol.clone(), *xf)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(instances.len(), 2, "{:?}", art(&d));
|
||
assert!(instances.iter().all(|(s, _)| s == "Gold Star"));
|
||
assert!((instances[0].1 * Point::ORIGIN).distance(Point::new(20.0, 30.0)) < 1e-9);
|
||
assert!((instances[1].1 * Point::ORIGIN).distance(Point::new(100.0, 50.0)) < 1e-9);
|
||
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.003);
|
||
}
|
||
|
||
#[test]
|
||
fn an_instance_is_measured_by_its_symbols_art() {
|
||
let (d, _) = open(&format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/>"##));
|
||
let id = art(&d)[0].id;
|
||
// The art, (0, 0) to (20, 20), drawn from (20, 30): not the 20 × 20 square around the origin,
|
||
// which the same transform places from (10, 20) to (30, 40).
|
||
assert_eq!(d.bounds_of(&[id], false), Some(Rect::new(20.0, 30.0, 40.0, 50.0)));
|
||
assert_eq!(d.bounds_of(&[id], true), Some(Rect::new(20.0, 30.0, 40.0, 50.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn uses_the_symbol_cant_stand_for_stay_art() {
|
||
// Inherited paint: the second use shows the symbol in another colour. Scaled: the canvas keeps
|
||
// stroke weights as they are.
|
||
let body = r##"<defs><symbol id="Dot"><circle cx="5" cy="5" r="4" stroke="#000" stroke-width="2"/></symbol></defs>
|
||
<use href="#Dot" fill="red"/><use href="#Dot" x="40" fill="blue"/><use href="#Dot" x="80" fill="red"/>
|
||
<use href="#Dot" fill="red" transform="translate(120 0) scale(3)"/>"##;
|
||
let (d, _) = open(body);
|
||
assert_eq!(d.symbols.len(), 1);
|
||
let a = art(&d);
|
||
let instances = a.iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count();
|
||
assert_eq!(instances, 2, "the red ones not scaled: {a:?}");
|
||
assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 24.0, 0.003);
|
||
}
|
||
|
||
#[test]
|
||
fn symbols_round_trip_through_export() {
|
||
let (d, _) = open(&format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/><use href="#Star" x="120" y="60"/>"##));
|
||
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
||
assert_eq!(back.symbols.len(), 1);
|
||
assert_eq!(back.symbols[0].name, "Gold Star");
|
||
assert_eq!(art(&back).iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(), 2);
|
||
assert_similar(&render_artboard(&d), &render_artboard(&back), 8.0, 0.001);
|
||
}
|
||
|
||
/// `name="…"` of the first element `tag` in `svg`, as a number.
|
||
fn number_attr(svg: &str, tag: &str, name: &str) -> f64 {
|
||
let at = svg.find(&format!("<{tag} ")).unwrap_or_else(|| panic!("no <{tag}> in {svg}"));
|
||
let rest = &svg[at..];
|
||
let from = rest.find(&format!(" {name}=\"")).unwrap() + name.len() + 3;
|
||
rest[from..from + rest[from..].find('"').unwrap()].parse().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn an_instances_shadow_filter_and_the_art_export_cover_its_symbols_art() {
|
||
let (mut d, _) = open(&format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/>"##));
|
||
let id = art(&d)[0].id;
|
||
let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({"x": 0, "y": 0, "blur": 0}), visible: true };
|
||
d.node_mut(id).unwrap().appearance.effects.push(shadow);
|
||
// The filter region holds the art, (20, 30) to (40, 50).
|
||
let svg = export(&d, &ExportOptions::default());
|
||
let (x, y) = (number_attr(&svg, "filter", "x"), number_attr(&svg, "filter", "y"));
|
||
let (w, h) = (number_attr(&svg, "filter", "width"), number_attr(&svg, "filter", "height"));
|
||
assert!(x <= 20.0 && y <= 30.0 && x + w >= 40.0 && y + h >= 50.0, "({x}, {y}) {w} × {h}");
|
||
// Exported without an artboard, the file is the art's size and the instance draws the art from
|
||
// the file's top left corner.
|
||
d.node_mut(id).unwrap().appearance.effects.clear();
|
||
let svg = export(&d, &ExportOptions { artboard: None, ..ExportOptions::default() });
|
||
assert_eq!((number_attr(&svg, "svg", "width"), number_attr(&svg, "svg", "height")), (20.0, 20.0), "{svg}");
|
||
let back = import(&svg).unwrap();
|
||
let NodeKind::SymbolInstance { xf, .. } = &art(&back)[0].kind else { panic!("an instance: {svg}") };
|
||
assert!((*xf * Point::ORIGIN).distance(Point::ORIGIN) < 1e-9, "{svg}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_figma_blurred_shape_filter_becomes_gaussian_blur() {
|
||
let (d, w) = open(
|
||
r##"<defs><filter id="b"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur" stdDeviation="3"/></filter></defs><rect x="50" y="30" width="60" height="40" fill="#08f" filter="url(#b)"/>"##,
|
||
);
|
||
assert!(w.is_empty(), "{w:?}");
|
||
let fx = effects(&d);
|
||
assert_eq!(fx.len(), 1);
|
||
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0, "linear": true})));
|
||
}
|
||
|
||
#[test]
|
||
fn figma_blurred_shapes_inside_mask_import_with_effects_and_no_warnings() {
|
||
let body = r##"<mask id="m"><circle cx="50" cy="50" r="40" fill="#fff"/></mask>
|
||
<g mask="url(#m)">
|
||
<g filter="url(#b1)"><circle cx="30" cy="30" r="20" fill="#ff0"/></g>
|
||
<g filter="url(#b2)"><circle cx="70" cy="70" r="20" fill="#00f"/></g>
|
||
</g>
|
||
<defs>
|
||
<filter id="b1"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur" stdDeviation="2"/></filter>
|
||
<filter id="b2"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur" stdDeviation="4"/></filter>
|
||
</defs>"##;
|
||
let (d, w) = open(body);
|
||
assert!(w.is_empty(), "{w:?}");
|
||
let mut blurs = vec![];
|
||
d.walk(|n| {
|
||
for e in &n.appearance.effects {
|
||
if e.id == "blur.gaussian" {
|
||
blurs.push(e.params["radius"].as_f64().unwrap());
|
||
}
|
||
}
|
||
});
|
||
assert_eq!(blurs, vec![4.0, 8.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_gaussian_blur_becomes_the_effect_with_its_radius() {
|
||
let (d, w) = open(
|
||
r##"<defs><filter id="b"><feGaussianBlur stdDeviation="3"/></filter></defs><rect x="50" y="30" width="60" height="40" fill="#08f" filter="url(#b)"/>"##,
|
||
);
|
||
assert!(w.is_empty(), "{w:?}");
|
||
let fx = effects(&d);
|
||
assert_eq!(fx.len(), 1);
|
||
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0, "linear": true})));
|
||
// In a scaled group, the radius scales too.
|
||
let (d, _) = open(
|
||
r##"<defs><filter id="b"><feGaussianBlur stdDeviation="3"/></filter></defs><g transform="scale(2)"><rect width="30" height="20" filter="url(#b)"/></g>"##,
|
||
);
|
||
assert_eq!(effects(&d)[0].params["radius"], json!(12.0));
|
||
}
|
||
|
||
/// `color-interpolation-filters` (SVG 1.1 §15.7.6, initially linearRGB:
|
||
/// https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationFiltersProperty) picks whether the
|
||
/// blur mixes colours in linear light; sRGB, on the primitive or the filter, keeps them as they
|
||
/// are (#5877).
|
||
#[test]
|
||
fn a_gaussian_blur_keeps_its_colour_space() {
|
||
let blur = |filter: &str, primitive: &str| {
|
||
let (d, w) = open(&format!(
|
||
r##"<defs><filter id="b"{filter}><feGaussianBlur stdDeviation="10"{primitive}/></filter></defs><rect width="100" height="200" filter="url(#b)"/>"##
|
||
));
|
||
assert!(w.is_empty(), "{w:?}");
|
||
effects(&d)[0].params.clone()
|
||
};
|
||
let linear = json!({"radius": 20.0, "linear": true});
|
||
let srgb = json!({"radius": 20.0});
|
||
assert_eq!(blur("", r#" color-interpolation-filters="linearRGB""#), linear);
|
||
assert_eq!(blur("", ""), linear, "linearRGB is the initial value");
|
||
assert_eq!(blur("", r#" color-interpolation-filters="sRGB""#), srgb);
|
||
assert_eq!(blur(r#" color-interpolation-filters="sRGB""#, ""), srgb, "inherited from the filter");
|
||
// Export writes the space back on the blur, so it survives a round trip.
|
||
let (d, _) =
|
||
open(r##"<defs><filter id="b"><feGaussianBlur stdDeviation="10"/></filter></defs><rect width="100" height="200" filter="url(#b)"/>"##);
|
||
let svg = export(&d, &ExportOptions::default());
|
||
assert!(svg.contains(r#"color-interpolation-filters="linearRGB"/>"#), "{svg}");
|
||
assert_eq!(effects(&import(&svg).unwrap())[0].params, linear);
|
||
// Drawn, black beside white meets as resvg (which blurs in either space) draws it.
|
||
for space in ["linearRGB", "sRGB"] {
|
||
let source = format!(
|
||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><filter id="f" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="10" color-interpolation-filters="{space}"/></filter></defs><g filter="url(#f)"><rect width="100" height="200"/><rect x="100" width="100" height="200" fill="white"/></g></svg>"##
|
||
);
|
||
let (ours, theirs) = (render_artboard(&import(&source).unwrap()), resvg_render(&source, 200, 200));
|
||
for x in [95, 99, 100, 105] {
|
||
let at = 4 * (100 * 200 + x) as usize;
|
||
assert!(ours.rgba[at].abs_diff(theirs.rgba[at]) <= 6, "{space} at {x}: {} vs {}", ours.rgba[at], theirs.rgba[at]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The colour of pixel (`x`, `y`) of `svg` imported and drawn on white.
|
||
fn drawn_at(svg: &str, x: u32, y: u32) -> [u8; 4] {
|
||
let img = render_artboard(&import(svg).unwrap());
|
||
let at = 4 * (y * img.width + x) as usize;
|
||
[img.rgba[at], img.rgba[at + 1], img.rgba[at + 2], img.rgba[at + 3]]
|
||
}
|
||
|
||
/// SVG 1.1 §15.5 (https://www.w3.org/TR/SVG11/filters.html#FilterEffectsRegion): a filter's
|
||
/// result is cut at its region, so a blurred square shows only inside it (#5876). A region that
|
||
/// holds the whole blur adds no clip.
|
||
#[test]
|
||
fn a_blur_is_cut_at_its_filter_region() {
|
||
let doc = |width: u32| {
|
||
format!(
|
||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><filter id="f" filterUnits="userSpaceOnUse" x="0" y="0" width="{width}" height="200"><feGaussianBlur stdDeviation="5"/></filter></defs><rect width="200" height="200" fill="red" filter="url(#f)"/></svg>"##
|
||
)
|
||
};
|
||
let red = |p: [u8; 4]| p[0] > 240 && p[1] < 30 && p[2] < 30;
|
||
assert!(red(drawn_at(&doc(50), 25, 100)));
|
||
assert_eq!(drawn_at(&doc(50), 100, 100), [255, 255, 255, 255], "outside the region");
|
||
assert!(red(drawn_at(&doc(200), 100, 100)));
|
||
// With room for the blur all round, the object is as before: no clip group.
|
||
let roomy = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><filter id="f" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="200"><feGaussianBlur stdDeviation="2"/></filter></defs><rect x="50" y="50" width="100" height="100" fill="red" filter="url(#f)"/></svg>"##;
|
||
let d = import(roomy).unwrap();
|
||
assert_eq!(art(&d).len(), 1);
|
||
// Our own export sizes the region to the blur: it comes back unclipped.
|
||
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
||
assert_eq!(art(&back).len(), 1);
|
||
}
|
||
|
||
/// SVG 1.1 §14.4 (https://www.w3.org/TR/SVG11/masking.html#MaskElement): a mask's art counts
|
||
/// only inside the mask's region, in user space or the object's bounding box (#5875).
|
||
#[test]
|
||
fn a_mask_counts_only_inside_its_region() {
|
||
let doc = |region: &str| {
|
||
format!(
|
||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><mask id="m" {region}><rect width="200" height="200" fill="white"/></mask></defs><rect width="200" height="200" fill="red" mask="url(#m)"/></svg>"##
|
||
)
|
||
};
|
||
let red = [255, 0, 0, 255];
|
||
for region in
|
||
[r#"maskUnits="userSpaceOnUse" x="0" y="0" width="50" height="200""#, r#"maskUnits="objectBoundingBox" x="0" y="0" width="0.25" height="1""#]
|
||
{
|
||
assert_eq!(drawn_at(&doc(region), 25, 100), red, "{region}");
|
||
assert_eq!(drawn_at(&doc(region), 100, 100), [255, 255, 255, 255], "{region}");
|
||
}
|
||
let wide = doc(r#"maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200""#);
|
||
assert_eq!(drawn_at(&wide, 100, 100), red);
|
||
// The default region (-10% to 120% of the object) holds the art: the mask art is as before.
|
||
let d = import(&doc("")).unwrap();
|
||
let mask = art(&d)[0].extras().mask.clone().unwrap();
|
||
assert!(matches!(mask.art.kind, NodeKind::Path { .. }), "{:?}", mask.art.kind);
|
||
}
|
||
|
||
/// The one drop shadow of the only object: (dx, dy, blur, colour, opacity %).
|
||
fn shadow(d: &Document) -> (f64, f64, f64, Color, f32) {
|
||
let fx = vectorcraft_effects::raster_effects(&effects(d));
|
||
match fx.as_slice() {
|
||
[RasterFx::DropShadow { dx, dy, blur, color, opacity, mode, darkness }] => {
|
||
assert_eq!(*mode, BlendMode::Normal, "filters composite normally");
|
||
assert_eq!(*darkness, None);
|
||
(*dx, *dy, *blur, *color, (opacity * 100.0).round())
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drop_shadow_chains_become_drop_shadows() {
|
||
let rect = r##"<rect x="50" y="30" width="60" height="40" fill="#08f" filter="url(#s)"/>"##;
|
||
let chains = [
|
||
// feDropShadow.
|
||
r##"<feDropShadow dx="4" dy="5" stdDeviation="2" flood-color="#ff0000" flood-opacity="0.5"/>"##,
|
||
// Offset, blur, flood, composite, merge.
|
||
r##"<feOffset in="SourceAlpha" dx="4" dy="5"/><feGaussianBlur stdDeviation="2" result="b"/><feFlood flood-color="#ff0000" flood-opacity="0.5"/><feComposite in2="b" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>"##,
|
||
// Flood in the source, blur, offset, the source over it.
|
||
r##"<feFlood flood-color="#ff0000" flood-opacity="0.5" result="f"/><feComposite in="f" in2="SourceGraphic" operator="in"/><feGaussianBlur stdDeviation="2"/><feOffset dx="4" dy="5" result="o"/><feComposite in="SourceGraphic" in2="o" operator="over"/>"##,
|
||
// A colour matrix paints the blurred, moved silhouette.
|
||
r##"<feGaussianBlur in="SourceAlpha" stdDeviation="2"/><feOffset dx="4" dy="5"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0" result="c"/><feMerge><feMergeNode in="c"/><feMergeNode in="SourceGraphic"/></feMerge>"##,
|
||
];
|
||
for chain in chains {
|
||
let (d, w) = open(&format!(r#"<defs><filter id="s">{chain}</filter></defs>{rect}"#));
|
||
assert!(w.is_empty(), "{chain}: {w:?}");
|
||
let (dx, dy, blur, color, opacity) = shadow(&d);
|
||
assert_eq!((dx, dy, blur, opacity), (4.0, 5.0, 4.0, 50.0), "{chain}");
|
||
assert_eq!(color.to_hex(), "#ff0000", "{chain}");
|
||
}
|
||
}
|
||
|
||
/// A proportional RGB filter is not additive Darkness. A fully black shadow remains
|
||
/// 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| {
|
||
format!(
|
||
r##"<defs><filter id="s"{space}><feGaussianBlur in="SourceGraphic" stdDeviation="3" color-interpolation-filters="linearRGB" result="unused"/><feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="4" dy="5"/><feComponentTransfer result="p"{transfer}><feFuncR type="linear" slope="{slope}"/><feFuncG type="linear" slope="{slope}"/><feFuncB type="linear" slope="{slope}"/><feFuncA type="linear" slope="0.6"/></feComponentTransfer><feMerge><feMergeNode in="p"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="50" y="30" width="60" height="40" fill="#f00" filter="url(#s)"/>"##
|
||
)
|
||
};
|
||
let srgb = r#" color-interpolation-filters="sRGB""#;
|
||
// The scale 0 makes black whatever values it works on.
|
||
for (space, slope, darkness) in [(srgb, 0.0, 100.0), ("", 0.0, 100.0)] {
|
||
let (d, w) = open(&chain(space, "", slope));
|
||
assert!(w.is_empty(), "{w:?}");
|
||
let fx = effects(&d);
|
||
let p = &fx[0].params;
|
||
assert_eq!(
|
||
(fx[0].id.as_str(), &p["shade"], &p["darkness"], &p["opacity"]),
|
||
("stylize.dropShadow", &json!("darkness"), &json!(darkness), &json!(60.0))
|
||
);
|
||
assert_eq!((&p["x"], &p["y"], &p["blur"]), (&json!(4.0), &json!(5.0), &json!(4.0)));
|
||
}
|
||
// 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.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 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());
|
||
}
|
||
|
||
#[test]
|
||
fn a_filter_and_a_clip_on_one_object_filter_first() {
|
||
let body = r##"<defs><filter id="b"><feGaussianBlur stdDeviation="4"/></filter><clipPath id="c"><rect x="60" y="40" width="40" height="40"/></clipPath></defs>
|
||
<rect x="50" y="30" width="60" height="60" fill="#08f" filter="url(#b)" clip-path="url(#c)"/>"##;
|
||
let (d, w) = open(body);
|
||
assert!(w.is_empty(), "{w:?}");
|
||
let a = art(&d);
|
||
assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }) && a[0].appearance.effects.is_empty());
|
||
assert!(a.iter().any(|n| n.appearance.effects.iter().any(|e| e.id == "blur.gaussian")));
|
||
assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 40.0, 0.02);
|
||
}
|
||
|
||
#[test]
|
||
fn exported_effects_import_as_the_same_effects() {
|
||
let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true };
|
||
let cases = [
|
||
vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "color": "#203040"}))],
|
||
vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "shade": "darkness", "darkness": 40.0}))],
|
||
vec![fx("stylize.outerGlow", json!({"blur": 8.0, "color": "#ffcc00", "opacity": 80.0}))],
|
||
vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#ffffff", "source": "edge", "mode": "normal"}))],
|
||
vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#00ff00", "source": "center", "mode": "multiply"}))],
|
||
vec![fx("stylize.feather", json!({"radius": 4.0}))],
|
||
// Stacked: the first innermost.
|
||
vec![fx("blur.gaussian", json!({"radius": 3.0})), fx("stylize.dropShadow", json!({"x": 2.0, "y": 2.0, "blur": 1.0}))],
|
||
];
|
||
for effects in cases {
|
||
let mut d = Document::new(200.0, 120.0);
|
||
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 30.0, 110.0, 90.0)), Appearance::default_art());
|
||
n.appearance.effects = effects.clone();
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let (back, w) = import_with_report(&export(&d, &ExportOptions::default())).unwrap();
|
||
assert!(w.is_empty(), "{w:?}");
|
||
let got = vectorcraft_effects::raster_effects(&art(&back)[0].appearance.effects);
|
||
assert_eq!(got, vectorcraft_effects::raster_effects(&effects), "{effects:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() {
|
||
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.8, 0.4, 0.2)), Paint::None, 0.0),
|
||
);
|
||
let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 25.0});
|
||
n.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }];
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let svg = export(&d, &ExportOptions::default());
|
||
let (canvas, exported) = (render_artboard(&d), resvg_render(&svg, 200, 120));
|
||
// An intermediate color distinguishes subtraction from proportional darkening.
|
||
for img in [&canvas, &exported] {
|
||
let [r, g, b] = img.over_white(115, 90);
|
||
assert!(r.abs_diff(140) <= 2 && g.abs_diff(38) <= 2 && b <= 2, "{:?}", [r, g, b]);
|
||
}
|
||
assert_similar(&canvas, &exported, 40.0, 0.02);
|
||
}
|
||
|
||
#[test]
|
||
fn an_exported_outer_glow_round_faint_art_draws_as_the_canvas_does() {
|
||
// GUARDS: the export's glow blurs a solid silhouette like the renderer, not the art's faint alpha.
|
||
let mut d = Document::new(200.0, 120.0);
|
||
let mut n = Node::path(
|
||
d.alloc_id(),
|
||
shapes::rectangle(Rect::new(70.0, 40.0, 130.0, 80.0)),
|
||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||
);
|
||
n.appearance.fill_mut().unwrap().opacity = 0.2;
|
||
n.appearance.effects = vec![Effect {
|
||
id: "stylize.outerGlow".into(),
|
||
params: json!({"blur": 12.0, "color": "#ff0000", "opacity": 100.0, "mode": "normal"}),
|
||
visible: true,
|
||
}];
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let svg = export(&d, &ExportOptions::default());
|
||
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 export writes a filter only in a form SVG import reads back. A filter at the size limit,
|
||
/// whose id or region written out would make it longer, is left out of the SVG with the other pixel
|
||
/// effects (exports that turn pixel effects into images draw it as an image), and the SVG opens
|
||
/// without a filter that import can't read.
|
||
#[test]
|
||
fn an_exported_filter_is_one_import_reads_back() {
|
||
let max = vectorcraft_doc::SvgFilter::MAX_XML;
|
||
let close = r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#;
|
||
let short_id = r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##;
|
||
let measured =
|
||
r##"<filter id="r" filterUnits="userSpaceOnUse" x="-10%" y="-10%" width="120%" height="120%"><feFlood flood-color="#ff0000" result=""##;
|
||
for open in [short_id, measured] {
|
||
let xml = format!("{open}{}{close}", "a".repeat(max - open.len() - close.len()));
|
||
let d = filtered_rect(&xml);
|
||
assert_eq!(effects(&d).len(), 1);
|
||
let svg = export(&d, &ExportOptions::default());
|
||
assert!(!svg.contains("<filter"), "{open}");
|
||
let (_, w) = import_with_report(&svg).unwrap();
|
||
assert!(w.is_empty(), "{open}: {w:?}");
|
||
}
|
||
// A shorter one is written and read back.
|
||
let xml = format!("{short_id}{}{close}", "a".repeat(max - 64 - short_id.len() - close.len()));
|
||
let svg = export(&filtered_rect(&xml), &ExportOptions::default());
|
||
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"]);
|
||
}
|
||
|
||
/// With the Latin-1 encoding, export writes each character past U+00FF as a character reference,
|
||
/// which import counts as text. A filter is measured with those references in either encoding: one
|
||
/// that passes the limit with them is left out of both, and one that fits with them is written live
|
||
/// in Latin-1 and read back.
|
||
#[test]
|
||
fn an_exported_filter_counts_the_references_latin1_writes() {
|
||
let open = r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##;
|
||
let close = r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#;
|
||
let max = vectorcraft_doc::SvgFilter::MAX_XML;
|
||
let latin1 = ExportOptions { encoding: vectorcraft_svg::Encoding::Latin1, ..ExportOptions::default() };
|
||
// `国` takes three bytes as UTF-8 and eight as `国`.
|
||
let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max / 2 - open.len() - close.len()) / 3));
|
||
let d = filtered_rect(&xml);
|
||
assert_eq!(effects(&d).len(), 1);
|
||
assert!(!export(&d, &ExportOptions::default()).contains("<filter"), "its references pass the limit");
|
||
let svg = export(&d, &latin1);
|
||
assert!(!svg.contains("<filter"), "its references pass the limit");
|
||
let (_, w) = import_with_report(&svg).unwrap();
|
||
assert!(w.is_empty(), "{w:?}");
|
||
// One that fits with its references.
|
||
let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max - 64 - open.len() - close.len()) / 8));
|
||
let svg = export(&filtered_rect(&xml), &latin1);
|
||
assert!(svg.contains("国"), "the references are written");
|
||
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"]);
|
||
}
|
||
|
||
/// 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.
|
||
let mut d = Document::new(200.0, 120.0);
|
||
let dot = Node::path(
|
||
d.alloc_id(),
|
||
shapes::ellipse(Rect::new(0.0, 0.0, 20.0, 20.0)),
|
||
Appearance::basic(Paint::solid(Color::rgb8(200, 30, 30)), Paint::None, 0.0),
|
||
);
|
||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) });
|
||
let l = d.layers[0].id;
|
||
for x in [10.0, 60.0] {
|
||
let id = d.alloc_id();
|
||
d.insert(
|
||
Some(l),
|
||
usize::MAX,
|
||
Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: vectorcraft_geom::Affine::translate((x, 40.0)) }),
|
||
)
|
||
.unwrap();
|
||
}
|
||
let mut blurred = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 30.0, 180.0, 90.0)), Appearance::default_art());
|
||
blurred.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"x": 3.0, "y": 3.0, "blur": 2.0}), visible: true });
|
||
d.insert(Some(l), usize::MAX, blurred).unwrap();
|
||
let opts = ExportOptions { object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() };
|
||
let first = export(&d, &opts);
|
||
let second = export(&import(&first).unwrap(), &opts);
|
||
let strip = |s: &str| s.lines().filter(|l| !l.contains("<title>")).collect::<Vec<_>>().join("\n");
|
||
assert_eq!(strip(&first), strip(&second));
|
||
}
|