//! 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.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 { 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("")).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#" "#; 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#""#, r#""#, ] { let svg = format!( r#" {art} "# ); 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##" "## ); 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:?}"); } }