Files
vectorcraft/crates/svg/tests/mask_options.rs
T
Biromedic f4035c98d1 Fix #1141: SVG import keeps a mask whose art is all hidden, so its object stays hidden
Mask art that is all visibility:hidden imports as nothing, and opacity_mask
then returned no mask: the object was drawn whole. An empty mask is what the
file means (black all over); the renderer and the PDF and SVG exports already
draw empty mask art by its clip and invert options.
2026-10-11 16:00:38 +07:00

105 lines
5.5 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).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
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.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.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}");
}
}
#[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}");
}
}
}