Merge pull request #2283 from marcoarnulfo/fix/svg-mask-type-alpha

SVG import: an alpha mask counts by its art's alpha, not its luminance
This commit is contained in:
BFlat
2026-10-16 06:35:54 +07:00
committed by GitHub
4 changed files with 71 additions and 39 deletions
+33 -1
View File
@@ -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 {
+1 -32
View File
@@ -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(
+12 -6
View File
@@ -1091,13 +1091,11 @@ impl Importer {
Some(n)
}
/// `<mask>` → 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.
/// `<mask>` → 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<Box<vectorcraft_doc::OpacityMask>> {
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<Arc<Node>> = 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() {
+25
View File
@@ -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##"<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:?}");
}
}