Files
vectorcraft/crates/svg/tests/it/mask_options.rs
T
dotCooCoo bd0515ccc8 SVG export: mask and filter regions cover the art they apply to
Opacity masks, inverted and unclipped masks, and knockout groups were
written with mask and filter regions of 200,000 x 200,000 units around
the origin. CairoSVG draws each mask into an image of its region's size
and stops with "out of memory", so a file with any of them did not
open; Firefox drew inverted masks wrong and WebKit drew knockout groups
and inverted masks wrong.

Each region now covers what the masked or knocked-out art paints: its
visual box grown by its raster effects, brush art and members' reach,
the mask art's box, and in a scaled symbol instance its strokes and
effects at their own size. A mask whose art reaches past its object
still comes back whole on import.
2026-11-09 22:16:58 -08:00

149 lines
8.1 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.
//! Opacity-mask options survive SVG (M3.87): exported masks list the options that differ from the
//! defaults in `data-vectorcraft-mask`, and import restores them and the original mask art (without
//! the backdrop rectangle and inverting filter that draw the options in SVG).
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_svg::{ExportOptions, export, import_with_report};
use vectorcraft_testkit::raster::{Image, render_artboard};
/// A red square masked by white art over its left half.
fn masked(clip: bool, invert: bool) -> Document {
let mut d = Document::new(200.0, 200.0);
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0);
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), red);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 90.0)), white);
let mut m = OpacityMask::new(art, clip);
m.invert = invert;
n.extras_mut().mask = Some(Box::new(m));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn masks(d: &Document) -> Vec<OpacityMask> {
let mut v = vec![];
d.walk(|n| v.extend(n.extras().mask.as_deref().cloned()));
v
}
#[test]
fn an_inverted_unclipped_mask_round_trips() {
for (clip, invert, attr) in
[(false, true, Some("noclip invert")), (true, true, Some("invert")), (false, false, Some("noclip")), (true, false, None)]
{
let s = export(&masked(clip, invert), &ExportOptions::default());
match attr {
Some(a) => assert!(s.contains(&format!("data-vectorcraft-mask=\"{a}\"")), "{s}"),
None => assert!(!s.contains("data-vectorcraft-mask"), "{s}"),
}
let (back, warnings) = import_with_report(&s).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") };
assert_eq!((m.clip, m.invert), (clip, invert), "{s}");
// The original art: one white rectangle, no backdrop and no filter group around it.
assert!(m.art.path_data().is_some(), "{:?}", m.art.kind);
let b = m.art.geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.x1 - 50.0).abs() < 1e-6 && (b.y0 - 10.0).abs() < 1e-6 && (b.y1 - 90.0).abs() < 1e-6, "{b:?}");
assert_eq!(m.art.appearance.fill_paint(), Paint::solid(Color::WHITE));
// Exporting the import again writes the same mask.
let again = export(&back, &ExportOptions::default());
let mask_def = |s: &str| s.lines().skip_while(|l| !l.contains("<mask id=")).take_while(|l| !l.contains("</mask>")).count();
assert_eq!(mask_def(&again), mask_def(&s), "{again}\n{s}");
}
}
/// Mask art that reaches past the masked object comes back whole: the mask's region covers the art
/// as well as the object, so import does not clip the art to the region.
#[test]
fn mask_art_larger_than_its_object_round_trips_unclipped() {
let mut d = Document::new(200.0, 200.0);
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0);
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), red);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 20.0, 180.0, 170.0)), white);
n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let s = export(&d, &ExportOptions::default());
let (back, warnings) = import_with_report(&s).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") };
assert!(m.art.path_data().is_some(), "the art alone, not clipped by the region: {:?}\n{s}", m.art.kind);
let b = m.art.geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 170.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn foreign_masks_keep_their_art() {
// Without the attribute a white backdrop is part of the mask art (another app's file).
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"><rect width="100" height="100" fill="white"/><rect width="50" height="100" fill="black"/></mask>
<g mask="url(#m)"><rect width="100" height="100" fill="red"/></g></svg>"#;
let (d, _) = import_with_report(svg).unwrap();
let [m] = &masks(&d)[..] else { panic!("one mask") };
assert!(m.clip && !m.invert);
assert_eq!(m.art.children().map(|c| c.len()), Some(2));
}
/// `svg` drawn by resvg, `w`×`h` on white.
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() }
}
#[test]
fn a_mask_whose_art_is_all_hidden_hides_its_object() {
// Hidden mask art draws nothing: the mask is black all over and the red square doesn't show,
// as resvg draws the file. The square keeps its (empty) mask, so it can still be edited.
for art in [
r#"<rect width="100" height="100" fill="white" visibility="hidden"/>"#,
r#"<g visibility="hidden"><rect width="100" height="100" fill="white"/></g>"#,
] {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">{art}</mask>
<g mask="url(#m)"><rect width="100" height="100" fill="red"/></g></svg>"#
);
let (d, _) = import_with_report(&svg).unwrap();
let [m] = &masks(&d)[..] else { panic!("the square keeps its mask: {svg}") };
assert!(m.clip && !m.invert);
assert!(m.art.geometric_bounds().is_none(), "no visible art: {:?}", m.art.kind);
let (canvas, reference) = (render_artboard(&d), resvg_render(&svg, 100, 100));
for (x, y) in [(10, 10), (50, 50), (90, 90)] {
assert_eq!(reference.over_white(x, y), [255, 255, 255], "resvg ({x},{y}): {svg}");
assert_eq!(canvas.over_white(x, y), [255, 255, 255], "canvas ({x},{y}): {svg}");
}
}
}
#[test]
fn an_alpha_mask_uses_its_art_alpha_not_its_luminance() {
// `mask-type: alpha` (CSS Masking §7.2): an opaque dark red shows the square fully, black at
// half opacity shows it half, whatever their luminance. As a property and as an attribute.
for mask_type in [r#"style="mask-type:alpha""#, r#"mask-type="alpha""#] {
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100" {mask_type}>
<rect width="50" height="100" fill="#b43333"/><rect x="50" width="50" height="100" fill="black" fill-opacity="0.5"/></mask>
<rect width="100" height="100" fill="#1a73e8" mask="url(#m)"/></svg>"##
);
let (d, warnings) = import_with_report(&svg).unwrap();
let [m] = &masks(&d)[..] else { panic!("one mask: {svg}") };
assert!(m.clip && !m.invert);
let (canvas, reference) = (render_artboard(&d), resvg_render(&svg, 100, 100));
for (x, y, want) in [(25, 50, [26, 115, 232]), (75, 50, [141, 185, 244])] {
let (got, r) = (canvas.over_white(x, y), reference.over_white(x, y));
let near = |a: [u8; 3]| a.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 3);
assert!(near(r), "resvg ({x},{y}) {r:?}: {svg}");
assert!(near(got), "canvas ({x},{y}) {got:?}, want {want:?}: {svg}");
}
assert!(warnings.is_empty(), "{warnings:?}");
}
}