From a1a5c79b0332f99720c14c9424660689d85345bf Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:45:24 +0700 Subject: [PATCH] M3.73: blend modes follow the reference formulas on screen, in SVG and in PDF color::blend gains blend_rgb (the 16 blend functions, with the 0.3/0.59/0.11 luminance of the non-separable modes), composite (source-over with blending) and the shared opacity-mask luminance weights. Exported SVG masks take luminance from sRGB values (color-interpolation="sRGB"), as the canvas does. Tests: a per-mode table on the canvas (object, fill and group blend modes, within 1/255; Exclusion 2/255 in the rasteriser's 8-bit pipeline), the same table through SVG export rendered by resvg, clip and invert masks against the canvas, and every mode's PDF name read back. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/blend.rs | 177 +++++++++++++++++++++++++++++++ crates/pdf/src/lib.rs | 2 + crates/pdf/src/tests_blend.rs | 44 ++++++++ crates/render/src/lib.rs | 5 +- crates/render/src/tests_blend.rs | 109 +++++++++++++++++++ crates/svg/src/export.rs | 9 +- crates/svg/tests/blend_export.rs | 95 +++++++++++++++++ 7 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 crates/pdf/src/tests_blend.rs create mode 100644 crates/render/src/tests_blend.rs create mode 100644 crates/svg/tests/blend_export.rs diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index a5f61decc..372669ca6 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -75,6 +75,11 @@ impl BlendMode { _ => 5, } } + /// Whether the mode works on each colour channel separately (all but Hue, Saturation, Color + /// and Luminosity). + pub fn is_separable(self) -> bool { + self.group() != 5 + } /// Parse a label or identifier, case/space-insensitively. pub fn parse(s: &str) -> Option { let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-'], ""); @@ -83,10 +88,182 @@ impl BlendMode { } } +/// Luminance weights of the non-separable modes (Hue, Saturation, Color, Luminosity), as the PDF +/// and W3C compositing specifications define them. +pub const LUM: [f32; 3] = [0.3, 0.59, 0.11]; + +/// Luminance weights of opacity masks (Rec. 709, applied to the sRGB values as SVG luminance masks +/// with `color-interpolation="sRGB"` apply them). +pub const MASK_LUM: [f32; 3] = [0.2126, 0.7152, 0.0722]; + +/// The blend function B(backdrop, source) of `mode` on straight (not premultiplied) RGB in 0..=1: +/// the colour that replaces the backdrop where an opaque source covers an opaque backdrop. Every +/// output (screen, SVG, PDF and transparency flattening) follows these formulas. +pub fn blend_rgb(mode: BlendMode, backdrop: [f32; 3], source: [f32; 3]) -> [f32; 3] { + use BlendMode as B; + let separable = |f: fn(f32, f32) -> f32| [0, 1, 2].map(|i| f(backdrop[i].clamp(0.0, 1.0), source[i].clamp(0.0, 1.0))); + match mode { + B::Normal => separable(|_, s| s), + B::Multiply => separable(|b, s| b * s), + B::Screen => separable(screen), + B::Overlay => separable(|b, s| hard_light(s, b)), + B::Darken => separable(f32::min), + B::Lighten => separable(f32::max), + B::ColorDodge => separable(|b, s| { + if b <= 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } + }), + B::ColorBurn => separable(|b, s| { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } + }), + B::HardLight => separable(hard_light), + B::SoftLight => separable(|b, s| { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } + }), + B::Difference => separable(|b, s| (b - s).abs()), + B::Exclusion => separable(|b, s| b + s - 2.0 * b * s), + B::Hue => set_lum(set_sat(source, sat(backdrop)), lum(backdrop)), + B::Saturation => set_lum(set_sat(backdrop, sat(source)), lum(backdrop)), + B::Color => set_lum(source, lum(backdrop)), + B::Luminosity => set_lum(backdrop, lum(source)), + } +} + +/// Source-over compositing of `source` onto `backdrop` with `mode` (straight RGBA in 0..=1, the +/// result straight too): the source colour is mixed with B(backdrop, source) by the backdrop's +/// alpha, then laid over the backdrop by its own alpha. +pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4]) -> [f32; 4] { + let (ab, as_) = (backdrop[3].clamp(0.0, 1.0), source[3].clamp(0.0, 1.0)); + let ao = as_ + ab * (1.0 - as_); + if ao <= 0.0 { + return [0.0; 4]; + } + let mixed = blend_rgb(mode, [backdrop[0], backdrop[1], backdrop[2]], [source[0], source[1], source[2]]); + let c = |i: usize| ((1.0 - ab) * source[i] + ab * mixed[i]) * as_ + (1.0 - as_) * ab * backdrop[i]; + [c(0) / ao, c(1) / ao, c(2) / ao, ao] +} + +fn screen(b: f32, s: f32) -> f32 { + b + s - b * s +} + +fn hard_light(b: f32, s: f32) -> f32 { + if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } +} + +fn lum(c: [f32; 3]) -> f32 { + LUM[0] * c[0] + LUM[1] * c[1] + LUM[2] * c[2] +} + +fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +/// Bring a colour moved off the gamut by [`set_lum`] back in, keeping its luminance. +fn clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut c = c; + if n < 0.0 { + c = c.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 { + c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + c +} + +fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +/// `c` with saturation `s`: its largest component becomes `s`, its smallest 0 and the middle one +/// keeps its proportion. +fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + if x <= n { + return [0.0; 3]; + } + c.map(|v| (v - n) * s / (x - n)) +} + #[cfg(test)] mod tests { use super::*; + fn near(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) + } + + #[test] + fn separable_modes_follow_the_reference_formulas() { + let (b, s) = ([0.2, 0.5, 0.9], [0.6, 0.25, 0.5]); + let cases: [(BlendMode, [f32; 3]); 12] = [ + (BlendMode::Normal, s), + (BlendMode::Multiply, [0.12, 0.125, 0.45]), + (BlendMode::Screen, [0.68, 0.625, 0.95]), + (BlendMode::Darken, [0.2, 0.25, 0.5]), + (BlendMode::Lighten, [0.6, 0.5, 0.9]), + (BlendMode::Difference, [0.4, 0.25, 0.4]), + (BlendMode::Exclusion, [0.56, 0.5, 0.5]), + // Overlay = Hard Light with the layers swapped: b ≤ ½ multiplies, else screens. + (BlendMode::Overlay, [0.24, 0.25, 0.9]), + (BlendMode::HardLight, [0.36, 0.25, 0.9]), + (BlendMode::ColorDodge, [0.5, 0.6666667, 1.0]), + (BlendMode::ColorBurn, [0.0, 0.0, 0.8]), + // s ≤ ½ darkens by b(1−b)(1−2s); s > ½ lightens towards D(b). + (BlendMode::SoftLight, [0.2 + 0.2 * (((16.0 * 0.2 - 12.0) * 0.2 + 4.0) * 0.2 - 0.2), 0.375, 0.9]), + ]; + for (m, want) in cases { + assert!(near(blend_rgb(m, b, s), want), "{m:?}: {:?} ≠ {want:?}", blend_rgb(m, b, s)); + } + } + + #[test] + fn non_separable_modes_keep_the_reference_luminance() { + let (b, s) = ([0.8, 0.3, 0.2], [0.1, 0.4, 0.9]); + // Color and Hue take the backdrop's luminance; Luminosity the source's. + for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color] { + assert!((lum(blend_rgb(m, b, s)) - lum(b)).abs() < 1e-5, "{m:?}"); + } + assert!((lum(blend_rgb(BlendMode::Luminosity, b, s)) - lum(s)).abs() < 1e-5); + // Saturation of grey is 0: a grey source desaturates the backdrop to its luminance. + let l = lum(b); + assert!(near(blend_rgb(BlendMode::Saturation, b, [0.5; 3]), [l; 3])); + // Hue of the source with the backdrop's saturation: blue-ish stays blue-ish. + let h = blend_rgb(BlendMode::Hue, b, s); + assert!(h[2] > h[1] && h[1] > h[0], "{h:?}"); + assert!((sat(h) - sat(b)).abs() < 1e-5); + } + + #[test] + fn composite_lays_the_blend_over_the_backdrop_by_alpha() { + let (b, s) = ([0.8, 0.4, 0.2, 1.0], [0.5, 0.5, 0.5, 0.5]); + let o = composite(BlendMode::Multiply, b, s); + // Half of the multiplied colour over half of the backdrop. + assert!(near([o[0], o[1], o[2]], [0.6, 0.3, 0.15]) && o[3] == 1.0, "{o:?}"); + // Over a transparent backdrop the source shows unblended. + let o = composite(BlendMode::Difference, [0.3, 0.3, 0.3, 0.0], s); + assert!(near([o[0], o[1], o[2]], [0.5; 3]) && (o[3] - 0.5).abs() < 1e-6, "{o:?}"); + } + #[test] fn parse_labels() { assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn)); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ec4052af4..f8d0a2404 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -112,6 +112,8 @@ mod tests_dashalign; #[cfg(test)] mod tests_fx; #[cfg(test)] +mod tests_blend; +#[cfg(test)] mod tests_stroke; #[cfg(test)] mod tests_strokeout; diff --git a/crates/pdf/src/tests_blend.rs b/crates/pdf/src/tests_blend.rs new file mode 100644 index 000000000..985be16a1 --- /dev/null +++ b/crates/pdf/src/tests_blend.rs @@ -0,0 +1,44 @@ +//! Blend modes and transparency groups in PDF export. + +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +fn rect(r: Rect, rgb: (f32, f32, f32)) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(rgb.0, rgb.1, rgb.2)), Paint::None, 0.0)) +} + +fn add(d: &mut Document, mut n: Node) { + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +/// The file's text with spaces removed (content streams uncompressed). +fn text(d: &Document) -> String { + let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).unwrap(); + String::from_utf8_lossy(&bytes).replace(' ', "") +} + +#[test] +fn every_blend_mode_is_written_under_its_pdf_name_and_read_back() { + for mode in BlendMode::ALL.into_iter().filter(|m| *m != BlendMode::Normal) { + let mut d = Document::new(100.0, 100.0); + add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.2, 0.5, 0.9))); + let mut top = rect(Rect::new(20.0, 20.0, 80.0, 80.0), (0.6, 0.25, 0.5)); + top.blend = mode; + add(&mut d, top); + let name = mode.label().replace(' ', ""); + assert!(text(&d).contains(&format!("/BM/{name}")), "{mode:?}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = BlendMode::Normal; + back.walk(|n| { + if n.blend != BlendMode::Normal { + found = n.blend; + } + }); + assert_eq!(found, mode); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 434a23b18..d41819632 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -968,7 +968,8 @@ impl Renderer { /// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask /// art black (clip) or white (no clip), optionally inverted. fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 { - let mut l = (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0; + let [kr, kg, kb] = vectorcraft_color::blend::MASK_LUM; + let mut l = (kr * r as f32 + kg * g as f32 + kb * b as f32) / 255.0; if !clip { l += 1.0 - a as f32 / 255.0; } @@ -1101,6 +1102,8 @@ fn now() -> u64 { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_blend; +#[cfg(test)] mod tests_clip; #[cfg(test)] mod tests_knockout; diff --git a/crates/render/src/tests_blend.rs b/crates/render/src/tests_blend.rs new file mode 100644 index 000000000..986dd43ed --- /dev/null +++ b/crates/render/src/tests_blend.rs @@ -0,0 +1,109 @@ +//! Blend-mode accuracy: every mode matches the reference formulas ([`blend_rgb`]) within 1/255 +//! (see [`tolerance`]), whether it is an object's blend mode, a fill's or a group's. + +use super::*; +use vectorcraft_color::blend::blend_rgb; +use vectorcraft_color::{BlendMode as Blend, Color, Paint}; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::shapes; + +/// Backdrop / source pairs (8-bit, so the expected values carry no input rounding) that take every +/// branch of the formulas: dark and light backdrops, sources on both sides of ½, greys, extremes. +const PAIRS: [([u8; 3], [u8; 3]); 6] = [ + ([51, 128, 230], [153, 64, 128]), + ([230, 26, 102], [77, 204, 179]), + ([0, 255, 128], [255, 0, 127]), + ([200, 180, 40], [20, 60, 250]), + ([128, 128, 128], [240, 30, 90]), + ([10, 90, 160], [128, 128, 128]), +]; + +fn rect(d: &mut Document, r: Rect, rgb: [u8; 3]) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb8(rgb[0], rgb[1], rgb[2])), Paint::None, 0.0)) +} + +/// Where the blend is set: on the object, on its fill, or on a group around it. +#[derive(Clone, Copy, Debug)] +enum Level { + Object, + Fill, + Group, +} + +/// An opaque backdrop with the source on top of it, blended with `mode` at `level`. +fn doc(mode: Blend, level: Level, backdrop: [u8; 3], source: [u8; 3]) -> Document { + let mut d = Document::new(20.0, 20.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), backdrop); + let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), source); + match level { + Level::Object => top.blend = mode, + Level::Fill => { + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = top.appearance.items.first_mut() { + f.blend = mode; + } + } + Level::Group => {} + } + let top = match level { + Level::Group => { + let mut g = Node::group(d.alloc_id(), vec![Arc::new(top)]); + g.blend = mode; + g + } + _ => top, + }; + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, below).unwrap(); + d.insert(Some(l), usize::MAX, top).unwrap(); + d +} + +pub(crate) fn expected(mode: Blend, backdrop: [u8; 3], source: [u8; 3]) -> [u8; 3] { + let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0); + blend_rgb(mode, f(backdrop), f(source)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// Whether `a` is within `tol`/255 of `b` in every channel. +pub(crate) fn within(a: [u8; 3], b: [u8; 3], tol: i32) -> bool { + a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol) +} + +/// Tolerance of `mode` on screen: 1/255, except Exclusion, which the rasteriser's 8-bit pipeline +/// rounds twice (b + s − 2bs). +fn tolerance(mode: Blend) -> i32 { + if mode == Blend::Exclusion { 2 } else { 1 } +} + +#[test] +fn every_mode_matches_the_reference_formulas() { + let mut r = Renderer::new(); + for mode in Blend::ALL { + for level in [Level::Object, Level::Fill, Level::Group] { + for (b, s) in PAIRS { + let px = r.render(&doc(mode, level, b, s), 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10); + let want = expected(mode, b, s); + assert!( + within([px[0], px[1], px[2]], want, tolerance(mode)) && px[3] == 255, + "{mode:?} at {level:?}, {b:?} under {s:?}: {px:?}, want {want:?}" + ); + } + } + } +} + +#[test] +fn a_translucent_source_mixes_the_blend_by_its_opacity() { + let mut r = Renderer::new(); + for mode in Blend::ALL { + let (b, s) = PAIRS[0]; + let mut d = doc(mode, Level::Object, b, s); + let top = d.layers[0].children().unwrap()[1].id; + d.node_mut(top).unwrap().opacity = 0.6; + let px = r.render(&d, 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10); + let f = |c: [u8; 3], a: f32| [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, a]; + let o = vectorcraft_color::blend::composite(mode, f(b, 1.0), f(s, 0.6)); + let want = [o[0], o[1], o[2]].map(|v| (v * 255.0).round() as u8); + // The opacity is one more 8-bit step. + assert!(within([px[0], px[1], px[2]], want, tolerance(mode) + 1), "{mode:?}: {px:?}, want {want:?}"); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 27daeeabe..df0c07a55 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -134,6 +134,9 @@ enum StrokePlan { /// A region covering any artwork (mask and filter extents). const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; +/// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the +/// canvas and PDF take it) rather than from linearised ones. +const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; pub(crate) fn blend_css(b: BlendMode) -> &'static str { match b { @@ -504,7 +507,7 @@ impl Writer<'_> { let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, &format!("")); self.def(2, &format!("")); self.def(1, ""); @@ -659,7 +662,7 @@ impl Writer<'_> { self.def(1, ""); fid }); - self.def(1, &format!("")); + self.def(1, &format!("")); if let Some(fid) = &inv { self.def(1, &format!("")); } @@ -751,7 +754,7 @@ impl Writer<'_> { let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; let mid = self.fresh_id("knockout"); - self.def(1, &format!("")); + self.def(1, &format!("")); self.def(2, &format!("")); self.def(2, &format!("")); self.defs.push_str(&art); diff --git a/crates/svg/tests/blend_export.rs b/crates/svg/tests/blend_export.rs new file mode 100644 index 000000000..503130c94 --- /dev/null +++ b/crates/svg/tests/blend_export.rs @@ -0,0 +1,95 @@ +//! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the +//! reference formulas when resvg draws the file, and masks take luminance the canvas's way. + +use vectorcraft_color::blend::blend_rgb; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, OpacityMask}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export}; +use vectorcraft_testkit::raster::{Image, render_artboard}; + +fn rect(d: &mut Document, r: Rect, rgb: [u8; 3]) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb8(rgb[0], rgb[1], rgb[2])), Paint::None, 0.0)) +} + +fn add(d: &mut Document, n: Node) { + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +/// The exported SVG rendered by resvg on white at 1 px/pt. +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() } +} + +fn within(a: [u8; 3], b: [u8; 3], tol: i32) -> bool { + a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol) +} + +#[test] +fn every_blend_mode_follows_the_reference_formulas() { + // (backdrop, source, whether a non-separable result leaves the gamut). resvg's rasteriser + // departs from the reference formulas when Hue, Saturation, Color or Luminosity have to clip + // their result back into the gamut, so those cases are checked on the canvas only + // (vectorcraft-render's blend tests). + let pairs: [([u8; 3], [u8; 3], bool); 4] = [ + ([51, 128, 230], [153, 64, 128], false), + ([230, 26, 102], [77, 204, 179], true), + ([200, 180, 40], [20, 60, 250], true), + ([10, 90, 160], [128, 128, 128], false), + ]; + for mode in BlendMode::ALL { + for (b, s, clips) in pairs { + if clips && !mode.is_separable() { + continue; + } + let mut d = Document::new(20.0, 20.0); + let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), b); + let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), s); + top.blend = mode; + add(&mut d, below); + add(&mut d, top); + let svg = export(&d, &ExportOptions::default()); + let px = resvg_render(&svg, 20, 20).over_white(10, 10); + let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0); + let want = blend_rgb(mode, f(b), f(s)).map(|v| (v * 255.0).round() as u8); + assert!(within(px, want, 1), "{mode:?}, {b:?} under {s:?}: {px:?}, want {want:?}"); + } + } +} + +/// A red square under a mask of green art (luminance 0.7152 with Rec. 709 weights, 0.59 with the +/// blend-mode weights) that covers its left half. +fn masked(clip: bool, invert: bool) -> Document { + let mut d = Document::new(40.0, 20.0); + let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), [0, 255, 0]); + let mut red = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [255, 0, 0]); + let mut m = OpacityMask::new(art, clip); + m.invert = invert; + red.mask = Some(Box::new(m)); + add(&mut d, red); + d +} + +#[test] +fn clip_and_invert_masks_match_the_canvas() { + for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] { + let d = masked(clip, invert); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains("color-interpolation=\"sRGB\""), "{svg}"); + let canvas = render_artboard(&d); + let written = resvg_render(&svg, 40, 20); + for x in [10, 30] { + let (c, w) = (canvas.over_white(x, 10), written.over_white(x, 10)); + assert!(within(c, w, 2), "clip {clip}, invert {invert}, x {x}: canvas {c:?}, svg {w:?}"); + } + // Over the green art the red shows at the art's luminance (inverted: its complement). + let lum: f32 = if invert { 1.0 - 0.7152 } else { 0.7152 }; + let g = (255.0 * (1.0 - lum)).round() as u8; + assert!(within(canvas.over_white(10, 10), [255, g, g], 2), "{:?}", canvas.over_white(10, 10)); + } +}