From 789a151ff92982740a1766acb6ea51646811ce92 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 22:39:50 +0200 Subject: [PATCH] SVG import: an alpha mask counts by its art's alpha, not its luminance (fixes #2210) --- crates/doc/src/node.rs | 34 +++++++++++++++++++++++++++++++- crates/pdf/src/import_mask.rs | 33 +------------------------------ crates/svg/src/import.rs | 18 +++++++++++------ crates/svg/tests/mask_options.rs | 25 +++++++++++++++++++++++ 4 files changed, 71 insertions(+), 39 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 0e3d74cb8..bce0ad42b 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; -use vectorcraft_color::BlendMode; +use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_geom::corners::cut_corners; use vectorcraft_geom::shapes::{self, CornerKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; @@ -876,6 +876,38 @@ impl OpacityMask { } } +/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity +/// mask's, whose luminance over the black outside the art is then the art's alpha. +pub fn whiten(n: &mut Node) { + let white = |p: &mut Paint| match p { + Paint::Solid { color, swatch, tint } => { + (*color, *swatch, *tint) = (Color::WHITE, None, 1.0); + } + Paint::Gradient(g) => { + for s in &mut g.gradient.stops { + (s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0); + } + } + Paint::None | Paint::Pattern { .. } => {} + }; + for item in &mut n.appearance.items { + white(item.paint_mut()); + } + match &mut n.kind { + NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE), + NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| { + white(&mut r.style.fill); + white(&mut r.style.stroke); + }), + _ => {} + } + if let Some(ch) = n.children_mut() { + for c in ch { + whiten(Arc::make_mut(c)); + } + } +} + impl Node { pub fn new(id: NodeId, kind: NodeKind) -> Self { Self { diff --git a/crates/pdf/src/import_mask.rs b/crates/pdf/src/import_mask.rs index 9bdea94df..bfbe28139 100644 --- a/crates/pdf/src/import_mask.rs +++ b/crates/pdf/src/import_mask.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use kurbo::{Rect, Shape}; use vectorcraft_color::blend::MASK_LUM; use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::node::whiten; use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask}; /// What a soft mask does to what it masks. @@ -52,38 +53,6 @@ pub(crate) fn contains(outer: Rect, inner: Rect) -> bool { outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1 } -/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as a -/// luminosity mask's. -fn whiten(n: &mut Node) { - let white = |p: &mut Paint| match p { - Paint::Solid { color, swatch, tint } => { - (*color, *swatch, *tint) = (Color::WHITE, None, 1.0); - } - Paint::Gradient(g) => { - for s in &mut g.gradient.stops { - (s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0); - } - } - Paint::None | Paint::Pattern { .. } => {} - }; - for item in &mut n.appearance.items { - white(item.paint_mut()); - } - match &mut n.kind { - NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE), - NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| { - white(&mut r.style.fill); - white(&mut r.style.stroke); - }), - _ => {} - } - if let Some(ch) = n.children_mut() { - for c in ch { - whiten(Arc::make_mut(c)); - } - } -} - /// How a soft mask drawing `art` acts: `alpha` for an alpha mask, `backdrop` the luminance outside /// the art, `inverted` when its transfer function inverts it. `page`: the area masks cover. pub(crate) fn mask_spec( diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index dc65646e8..5ba39ddac 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -1091,13 +1091,11 @@ impl Importer { Some(n) } - /// `` → opacity mask (luminance; alpha masks are approximated by their luminance). A - /// mask our export wrote gets its options back ([`mask_flags`]) and its art without what - /// stands for them: the inverting filter group and the backdrop rectangle. + /// `` → opacity mask. An alpha mask's art comes in white with its opacity kept, so its + /// luminance is its alpha (images in it keep their colours, with a warning). A mask our export wrote gets its + /// options back ([`mask_flags`]) and its art without what stands for them: the inverting + /// filter group and the backdrop rectangle. fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option> { - if m.kind() == usvg::MaskType::Alpha { - self.warn(format!("alpha mask on {label} imported as a luminance opacity mask")); - } if m.mask().is_some() { self.warn(format!("nested mask on {label} ignored")); } @@ -1115,6 +1113,14 @@ impl Importer { nodes = rest; } let mut children: Vec> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect(); + if m.kind() == usvg::MaskType::Alpha { + children.iter_mut().for_each(|c| vectorcraft_doc::node::whiten(Arc::make_mut(c))); + let mut image = false; + children.iter().for_each(|c| c.walk(&mut |n| image |= matches!(n.kind, NodeKind::Image(_)))); + if image { + self.warn(format!("image in the alpha mask on {label} counts by its luminance")); + } + } // Mask art that is all hidden draws nothing: that is an empty mask, not no mask (black all // over when clipped, white when not, inverted when the mask is). let art = match children.len() { diff --git a/crates/svg/tests/mask_options.rs b/crates/svg/tests/mask_options.rs index 2ead42f19..4c7e71f2d 100644 --- a/crates/svg/tests/mask_options.rs +++ b/crates/svg/tests/mask_options.rs @@ -102,3 +102,28 @@ fn a_mask_whose_art_is_all_hidden_hides_its_object() { } } } + +#[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:?}"); + } +}