Files
vectorcraft/crates/svg/tests/it/blend_export.rs
T
dotCooCoo 48bbcb115d SVG export: masked groups span their region only for effects that are written
A fill or stroke that the export leaves out (hidden, unpainted or zero
width) no longer adds the hidden rectangle for an effect on it. An
instance of a dynamic symbol is checked in its overridden art, so an
override that paints a fill or stroke with an effect still adds it.
2026-11-14 03:43:24 -08:00

561 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the
//! reference formulas when resvg draws the file, and masks take luminance the canvas's way.
use usvg::roxmltree;
use vectorcraft_color::blend::blend_rgb;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, OpacityMask, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{Image, render_artboard};
fn rect(d: &mut Document, r: Rect, rgb: [u8; 3]) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb8(rgb[0], rgb[1], rgb[2])), Paint::None, 0.0))
}
fn add(d: &mut Document, n: Node) {
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
/// The exported 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() }
}
fn within(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
#[test]
fn every_blend_mode_follows_the_reference_formulas() {
// (backdrop, source, whether a non-separable result leaves the gamut). resvg's rasteriser
// departs from the reference formulas when Hue, Saturation, Color or Luminosity have to clip
// their result back into the gamut, so those cases are checked on the canvas only
// (vectorcraft-render's blend tests).
let pairs: [([u8; 3], [u8; 3], bool); 4] = [
([51, 128, 230], [153, 64, 128], false),
([230, 26, 102], [77, 204, 179], true),
([200, 180, 40], [20, 60, 250], true),
([10, 90, 160], [128, 128, 128], false),
];
for mode in BlendMode::ALL {
for (b, s, clips) in pairs {
if clips && !mode.is_separable() {
continue;
}
let mut d = Document::new(20.0, 20.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), b);
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), s);
top.blend = mode;
add(&mut d, below);
add(&mut d, top);
let svg = export(&d, &ExportOptions::default());
let px = resvg_render(&svg, 20, 20).over_white(10, 10);
let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0);
let want = blend_rgb(mode, f(b), f(s)).map(|v| (v * 255.0).round() as u8);
assert!(within(px, want, 1), "{mode:?}, {b:?} under {s:?}: {px:?}, want {want:?}");
}
}
}
/// A red square under a mask of green art (luminance 0.7152 with Rec. 709 weights, 0.59 with the
/// blend-mode weights) that covers its left half.
fn masked(clip: bool, invert: bool) -> Document {
let mut d = Document::new(40.0, 20.0);
let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), [0, 255, 0]);
let mut red = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [255, 0, 0]);
let mut m = OpacityMask::new(art, clip);
m.invert = invert;
red.extras_mut().mask = Some(Box::new(m));
add(&mut d, red);
d
}
#[test]
fn clip_and_invert_masks_match_the_canvas() {
for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] {
let d = masked(clip, invert);
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("color-interpolation=\"sRGB\""), "{svg}");
let canvas = render_artboard(&d);
let written = resvg_render(&svg, 40, 20);
for x in [10, 30] {
let (c, w) = (canvas.over_white(x, 10), written.over_white(x, 10));
assert!(within(c, w, 2), "clip {clip}, invert {invert}, x {x}: canvas {c:?}, svg {w:?}");
}
// Over the green art the red shows at the art's luminance (inverted: its complement).
let lum: f32 = if invert { 1.0 - 0.7152 } else { 0.7152 };
let g = (255.0 * (1.0 - lum)).round() as u8;
assert!(within(canvas.over_white(10, 10), [255, g, g], 2), "{:?}", canvas.over_white(10, 10));
}
}
/// A knockout group of opaque art draws its elements plainly: each covers what it knocks out, and
/// masks of their shapes would leave seams along the antialiased edges. Translucent art still knocks
/// out through masks.
#[test]
fn a_knockout_group_of_opaque_art_needs_no_masks() {
let knockout = |opacity: f32| {
let mut d = Document::new(20.0, 20.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), [200, 0, 0]);
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]);
top.opacity = opacity;
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
export(&d, &ExportOptions::default())
};
let opaque = knockout(1.0);
assert!(!opaque.contains("<mask"), "{opaque}");
assert!(within(resvg_render(&opaque, 20, 20).over_white(10, 10), [0, 0, 200], 2));
let translucent = knockout(0.5);
assert!(translucent.contains("<mask"), "{translucent}");
// The top half-shows white, not the red it knocks out.
assert!(
within(resvg_render(&translucent, 20, 20).over_white(10, 10), [128, 128, 228], 3),
"{:?}",
resvg_render(&translucent, 20, 20).over_white(10, 10)
);
}
/// A brushed stroke is no opaque art: its pieces carry their own opacity (a bristle brush's vary),
/// so the knockout group keeps its masks.
#[test]
fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() {
let mut d = Document::new(40.0, 40.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [200, 0, 0]);
let mut stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 200)), 8.0);
stroke.brush = Some("Bristle Round".into());
let line = shapes::line(vectorcraft_geom::Point::new(5.0, 20.0), vectorcraft_geom::Point::new(35.0, 20.0));
let top = Node::path(d.alloc_id(), line, Appearance { items: vec![vectorcraft_doc::AppearanceItem::Stroke(stroke)], ..Default::default() });
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("<mask"), "{svg}");
}
/// The `x`, `y`, `width` and `height` of each `<mask>` and `<filter>` element in `svg`.
fn regions(svg: &str) -> Vec<[f64; 4]> {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
xml.descendants()
.filter(|n| matches!(n.tag_name().name(), "mask" | "filter"))
.map(|n| ["x", "y", "width", "height"].map(|a| n.attribute(a).and_then(|v| v.parse().ok()).unwrap_or(f64::NAN)))
.collect()
}
/// Mask and filter regions cover the art they apply to and no more: readers draw a mask into an
/// image of its region's size, and one drawn at 200,000 units square runs out of memory.
#[test]
fn mask_and_filter_regions_cover_their_art() {
let knockout = {
let mut d = Document::new(40.0, 20.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [200, 0, 0]);
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]);
top.opacity = 0.5;
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
d
};
for (what, d) in [("clip", masked(true, false)), ("no clip, inverted", masked(false, true)), ("knockout", knockout)] {
let svg = export(&d, &ExportOptions::default());
let found = regions(&svg);
assert!(!found.is_empty(), "{what}: {svg}");
for [x, y, w, h] in found {
// The art is 40 × 20, with 2 to spare on each side.
assert!(x >= -2.5 && y >= -2.5 && w <= 45.0 && h <= 25.0 && w >= 40.0 && h >= 20.0, "{what}: {x} {y} {w} {h} in {svg}");
}
}
}
/// A mask's region reaches as far as the masked art paints: a drop shadow of the object, or of an
/// object in the masked group, shows past the object as on the canvas.
#[test]
fn a_mask_region_keeps_the_shadows_of_the_masked_art() {
let shadow = vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}),
visible: true,
};
for in_group in [false, true] {
let mut d = Document::new(60.0, 60.0);
let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]);
red.appearance.effects.push(shadow.clone());
let mut masked = if in_group { Node::group(d.alloc_id(), vec![red.into()]) } else { red };
// An unclipped mask lets the art show outside the mask art: all of the shadow shows.
let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, masked);
let svg = export(&d, &ExportOptions::default());
let (canvas, written) = (render_artboard(&d), resvg_render(&svg, 60, 60));
for (x, y) in [(15, 15), (40, 40), (35, 28)] {
let (c, w) = (canvas.over_white(x, y), written.over_white(x, y));
assert!(within(c, w, 3), "in a group {in_group}, at {x},{y}: canvas {c:?}, svg {w:?}\n{svg}");
}
assert!(canvas.over_white(40, 40)[0] < 100, "the shadow shows on the canvas: {:?}", canvas.over_white(40, 40));
}
}
fn drop_shadow() -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}),
visible: true,
}
}
/// Whether every `<g mask>` in `svg` starts with a hidden, unpainted rectangle over its mask's region,
/// and whether any does.
fn spanning_rects(svg: &str) -> (bool, bool) {
let spans = masked_group_spans(svg);
(spans.iter().all(|s| *s), spans.iter().any(|s| *s))
}
/// For each `<g mask>` in `svg`, in document order, whether it starts with a hidden, unpainted
/// rectangle over its mask's region.
fn masked_group_spans(svg: &str) -> Vec<bool> {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
let spans: Vec<bool> = xml
.descendants()
.filter(|n| n.has_tag_name("g") && n.has_attribute("mask"))
.map(|group| {
let id = group.attribute("mask").unwrap().trim_start_matches("url(#").trim_end_matches(')');
let mask = xml.descendants().find(|n| n.has_tag_name("mask") && n.attribute("id") == Some(id)).unwrap();
let first = group.children().find(|n| n.is_element()).unwrap();
first.has_tag_name("rect")
&& first.attribute("fill") == Some("none")
&& first.attribute("visibility") == Some("hidden")
&& ["x", "y", "width", "height"].iter().all(|a| first.attribute(*a).is_some() && first.attribute(*a) == mask.attribute(*a))
})
.collect();
assert!(!spans.is_empty(), "no masked group in {svg}");
spans
}
/// The number of paths `svg` imports as.
fn imported_paths(svg: &str) -> usize {
let (back, warnings) = vectorcraft_svg::import_with_report(svg).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let mut paths = 0;
back.walk(|n| paths += usize::from(n.path_data().is_some()));
paths
}
/// A masked group whose content holds a raster effect (a drop shadow, blur, glow or feather, which
/// paint past the shapes) starts with a hidden, unpainted rectangle over the mask's region, so that
/// a reader that draws a masked group only inside the box of its shapes still draws it. Art without
/// one gets none, brushed strokes (written as shapes) included, nor does an object whose own shadow
/// is drawn outside the mask. Import leaves the rectangle out.
#[test]
fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() {
// How the red square sits under the mask.
#[derive(Debug, Clone, Copy)]
enum Case {
Alone,
InGroup,
InNestedGroup,
FillEffect,
HiddenFill,
UnpaintedStroke,
HiddenInGroup,
Brushed,
InSymbol,
OverriddenSymbol,
InBlend,
}
use Case::*;
for (case, shadowed, want) in [
(InGroup, true, true),
(InGroup, false, false),
(Alone, true, false),
(Alone, false, false),
(InNestedGroup, true, true),
(FillEffect, true, true),
(FillEffect, false, false),
// A fill or stroke that isn't written doesn't count.
(HiddenFill, true, false),
(UnpaintedStroke, true, false),
(HiddenInGroup, true, false),
(Brushed, false, false),
(Brushed, true, true),
(InSymbol, true, true),
(InSymbol, false, false),
// An unpainted fill in a dynamic symbol's art that the instance paints counts.
(OverriddenSymbol, true, true),
(OverriddenSymbol, false, false),
(InBlend, true, true),
(InBlend, false, false),
] {
let mut d = Document::new(100.0, 60.0);
let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]);
if matches!(case, Brushed) {
let mut stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 200)), 8.0);
stroke.brush = Some("Bristle Round".into());
red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(stroke));
}
if matches!(case, UnpaintedStroke) {
red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::None, 8.0)));
}
if shadowed {
match case {
FillEffect | HiddenFill | OverriddenSymbol => red.appearance.items[0].effects_mut().push(drop_shadow()),
UnpaintedStroke => red.appearance.items.last_mut().unwrap().effects_mut().push(drop_shadow()),
_ => red.appearance.effects.push(drop_shadow()),
}
}
if matches!(case, HiddenFill)
&& let vectorcraft_doc::AppearanceItem::Fill(f) = &mut red.appearance.items[0]
{
f.visible = false;
}
if matches!(case, OverriddenSymbol) {
red.appearance.set_fill(Paint::None);
}
let group = |d: &mut Document, n: Node| Node::group(d.alloc_id(), vec![n.into()]);
let mut masked = match case {
Alone | FillEffect => red,
HiddenFill | UnpaintedStroke => group(&mut d, red),
InGroup | Brushed => group(&mut d, red),
InNestedGroup => {
let inner = group(&mut d, red);
group(&mut d, inner)
}
HiddenInGroup => {
red.visible = false;
group(&mut d, red)
}
InSymbol => {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Square".into(), art: std::sync::Arc::new(red) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY });
group(&mut d, instance)
}
OverriddenSymbol => {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Square".into(), art: std::sync::Arc::new(red) });
let mut instance =
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let fill = Some(Paint::solid(Color::rgb8(255, 0, 0)));
instance.extras_mut().symbol_overrides.push(vectorcraft_doc::symbol_overrides::SymbolOverride { fill, ..Default::default() });
group(&mut d, instance)
}
InBlend => {
let mut blue = rect(&mut d, Rect::new(60.0, 10.0, 80.0, 30.0), [0, 0, 255]);
blue.appearance.effects = red.appearance.effects.clone();
let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![red.into(), blue.into()], spec: Default::default() });
group(&mut d, blend)
}
};
let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, masked);
let svg = export(&d, &ExportOptions::default());
assert_eq!(spanning_rects(&svg).0, want, "{case:?}, shadowed {shadowed}:\n{svg}");
if matches!(case, Alone | InGroup | InNestedGroup | FillEffect) {
// The red square alone: no rectangle of the region's size comes back.
assert_eq!(imported_paths(&svg), 1, "{case:?}, shadowed {shadowed}:\n{svg}");
}
}
// Type can't be measured, so its mask's region covers any artwork: a group of type gets the
// rectangle only when it holds a raster effect, on the type or on a path beside it.
for (shadowed, path_shadowed) in [(false, false), (true, false), (false, true)] {
let mut d = Document::new(80.0, 40.0);
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style))));
if shadowed {
t.appearance.effects.push(drop_shadow());
}
let mut red = rect(&mut d, Rect::new(50.0, 5.0, 60.0, 15.0), [255, 0, 0]);
if path_shadowed {
red.appearance.effects.push(drop_shadow());
}
let mut group = Node::group(d.alloc_id(), vec![t.into(), red.into()]);
let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]);
group.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, group);
let svg = export(&d, &ExportOptions::default());
let want = shadowed || path_shadowed;
assert_eq!(svg.contains("visibility=\"hidden\""), want, "type {shadowed}, path {path_shadowed}:\n{svg}");
}
}
/// In a knockout group, each element is drawn inside one mask of the elements above it. When the
/// element inside a mask's group has a drop shadow, that group starts with the same hidden,
/// unpainted rectangle over the region, the other groups don't, and import leaves it out.
#[test]
fn a_knockout_group_spans_its_masks_when_an_element_paints_past_its_shapes() {
for shadowed in [false, true] {
let mut d = Document::new(60.0, 60.0);
let mut below = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 25.0), [200, 0, 0]);
if shadowed {
below.appearance.effects.push(drop_shadow());
}
let mut middle = rect(&mut d, Rect::new(15.0, 15.0, 35.0, 35.0), [0, 200, 0]);
middle.opacity = 0.5;
let mut top = rect(&mut d, Rect::new(20.0, 5.0, 30.0, 40.0), [0, 0, 200]);
top.opacity = 0.5;
let mut group = Node::group(d.alloc_id(), vec![below.into(), middle.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
let svg = export(&d, &ExportOptions::default());
// The bottom and middle elements are masked, in paint order; the top one isn't.
assert_eq!(masked_group_spans(&svg), [shadowed, false], "shadowed {shadowed}:\n{svg}");
assert_eq!(imported_paths(&svg), 3, "shadowed {shadowed}:\n{svg}");
}
}
/// Type is measured by its layout or its path, and a reader sets live type in its own font, so a
/// mask or knockout over type keeps the region that covers any artwork.
#[test]
fn mask_and_filter_regions_over_type_cover_any_artwork() {
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let text = |d: &mut Document| Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style.clone()))));
let masked = {
let mut d = Document::new(80.0, 40.0);
let mut t = text(&mut d);
let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]);
t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, t);
d
};
let knockout = {
let mut d = Document::new(80.0, 40.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 80.0, 40.0), [200, 0, 0]);
let mut top = text(&mut d);
top.opacity = 0.5;
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
d
};
for (what, d) in [("masked type", masked), ("type in a knockout group", knockout)] {
let svg = export(&d, &ExportOptions::default());
let found = regions(&svg);
assert!(!found.is_empty(), "{what}: {svg}");
for [x, y, w, h] in found {
assert!(x <= -100000.0 && y <= -100000.0 && w >= 200000.0 && h >= 200000.0, "{what}: {x} {y} {w} {h} in {svg}");
}
}
}
/// A live object under a mask is measured as it is written, expanded: the mask's region covers
/// the drop shadows of a blend's steps.
#[test]
fn a_mask_region_covers_a_blend_as_written() {
let shadow = vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}),
visible: true,
};
let mut d = Document::new(100.0, 50.0);
let mut a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), [255, 0, 0]);
let mut b = rect(&mut d, Rect::new(70.0, 10.0, 80.0, 20.0), [0, 0, 255]);
a.appearance.effects.push(shadow.clone());
b.appearance.effects.push(shadow);
let mut blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![a.into(), b.into()], spec: Default::default() });
let art = rect(&mut d, Rect::new(0.0, 0.0, 5.0, 5.0), [255, 255, 255]);
blend.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
add(&mut d, blend);
let svg = export(&d, &ExportOptions::default());
// The steps' drop shadows are filters with regions of their own; the mask's covers them all:
// the steps run from 10 to 80 across, and their shadows 15 further.
let xml = roxmltree::Document::parse_with_options(&svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
let mask = xml.descendants().find(|n| n.has_tag_name("mask")).unwrap();
let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(f64::NAN));
assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}");
}
/// A knockout group of `n` red, green and blue squares at 50% opacity that all overlap, over an
/// opaque backdrop. `knockout_shape`: their opacity defines their knockout shape.
fn knockout_squares(n: usize, knockout_shape: bool) -> Document {
let mut d = Document::new(200.0, 200.0);
let backdrop = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), [230, 200, 50]);
add(&mut d, backdrop);
let colors = [[255, 0, 0], [0, 200, 0], [0, 0, 255]];
let squares = (0..n)
.map(|i| {
let x = (i % 20) as f64 * 2.0;
let mut s = rect(&mut d, Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), colors[i % 3]);
(s.opacity, s.knockout_shape) = (0.5, knockout_shape);
std::sync::Arc::new(s)
})
.collect();
let mut group = Node::group(d.alloc_id(), squares);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
d
}
/// The deepest an element of `svg` nests, counting the root element as 1.
fn element_depth(svg: &str) -> usize {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
xml.descendants().filter(|n| n.is_element()).map(|n| n.ancestors().filter(|a| a.is_element()).count()).max().unwrap_or(0)
}
/// The elements of a knockout group are siblings, each in one mask of the overlapping elements
/// above it, however many there are: 200 translucent elements stay well within the nesting the
/// web build reads (34 levels), and the file reads back with nothing merged. Where the elements'
/// shapes are the area they paint, the file draws as the canvas does.
#[test]
fn a_knockout_group_of_many_translucent_elements_stays_shallow() {
for knockout_shape in [false, true] {
let d = knockout_squares(200, knockout_shape);
let (svg, warnings) = vectorcraft_svg::export_with_report(&d, &ExportOptions::default());
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(svg.matches("<mask").count(), 199);
// Each shape is written once.
assert_eq!(svg.matches("<use ").count(), 200 * 199 / 2);
let depth = element_depth(&svg);
assert!(depth < 12, "{depth}");
let (back, notes) = vectorcraft_svg::import_with_report(&svg).unwrap();
assert!(!notes.contains(&vectorcraft_doc::depth::merged_note()), "{notes:?}");
assert!(back.layers[0].children().is_some_and(|c| !c.is_empty()));
if !knockout_shape {
let (file, canvas) = (resvg_render(&svg, 200, 200), render_artboard(&d));
for (x, y) in [(30, 100), (100, 100), (150, 100), (10, 10)] {
assert!(
within(file.over_white(x, y), canvas.over_white(x, y), 3),
"{x},{y}: {:?} {:?}",
file.over_white(x, y),
canvas.over_white(x, y)
);
}
}
}
}
/// When the masks of a knockout group would use more than 65,536 shapes, the group is written as
/// a plain group, with a warning.
#[test]
fn a_knockout_group_too_large_to_mask_is_a_plain_group_with_a_warning() {
// 400 elements that all overlap: 79,800 shapes in their masks.
let d = knockout_squares(400, false);
let (svg, warnings) = vectorcraft_svg::export_with_report(&d, &ExportOptions::default());
assert!(warnings.iter().any(|w| w.contains("too many overlapping elements")), "{warnings:?}");
assert!(!svg.contains("<mask"));
assert!(element_depth(&svg) < 12);
}
/// Elements that don't overlap need no masks of each other.
#[test]
fn knockout_masks_hold_only_the_elements_that_overlap() {
let mut d = Document::new(200.0, 100.0);
let squares = (0..3)
.map(|i| {
let x = f64::from(i) * 60.0;
let mut s = rect(&mut d, Rect::new(x, 0.0, x + 40.0, 40.0), [0, 0, 255]);
s.opacity = 0.5;
std::sync::Arc::new(s)
})
.collect();
let mut group = Node::group(d.alloc_id(), squares);
group.knockout = vectorcraft_doc::Knockout::On;
add(&mut d, group);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<mask"), "{svg}");
}