mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bcc30ded5a
commit
a1a5c79b03
@@ -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<Self> {
|
||||
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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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 `<mask>`: 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!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
|
||||
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
|
||||
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
|
||||
self.def(1, "</mask>");
|
||||
@@ -659,7 +662,7 @@ impl Writer<'_> {
|
||||
self.def(1, "</filter>");
|
||||
fid
|
||||
});
|
||||
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" {BIG}>"));
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
|
||||
if let Some(fid) = &inv {
|
||||
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
|
||||
}
|
||||
@@ -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!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" {BIG}>"));
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
|
||||
self.def(2, &format!("<rect {BIG} fill=\"white\"/>"));
|
||||
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
|
||||
self.defs.push_str(&art);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user