mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 17:06:13 +00:00
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.
561 lines
27 KiB
Rust
561 lines
27 KiB
Rust
//! 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}");
|
||
}
|