M3.90: blend modes and opacity composite inks in CMYK documents

A CMYK document that shows transparency is drawn once per ink plane
(complemented C, M, Y; complemented K as a grey) so every blend mode,
opacity, group, knockout and effect composites inks, PDF-style; the
planes' inks are shown through the working CMYK profile (cached 4-D XYZ
table). Cyan multiplied over magenta now shows C100 M100. Opacity masks
keep screen luminance; RGB colours and placed images are separated
first. RGB documents and opaque CMYK documents keep the screen-colour
path.

PDF export of CMYK documents writes its transparency groups with
/CS /DeviceCMYK (luminosity mask groups stay RGB) and puts top-level
transparency in a non-isolated page-wide group so it blends in CMYK too.
Flatten Transparency composites CMYK documents ink by ink and makes CMYK
regions. Non-separable modes no longer give NaN for greys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 04:30:06 +07:00
co-authored by Claude Opus 5.5
parent 6ee994239d
commit 9537eca5f7
18 changed files with 911 additions and 143 deletions
+13 -10
View File
@@ -17,6 +17,8 @@ use vectorcraft_geom::{Affine, Point, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink;
/// Device pixels per grid cell.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the box's longer side.
@@ -38,9 +40,9 @@ fn hash_color(c: &Color, h: &mut impl Hasher) {
}
}
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` (and the active colour settings,
/// which turn colours into display RGB).
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16) -> u64 {
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` painted with `ink` (and the active
/// colour settings, which turn colours into display RGB or inks).
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in &f.points {
(p.at.x.to_bits(), p.at.y.to_bits(), p.opacity.to_bits(), p.spread.to_bits()).hash(&mut h);
@@ -48,13 +50,14 @@ fn key(f: &Freeform, b: Rect, cols: u16, rows: u16) -> u64 {
}
f.lines.hash(&mut h);
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize).hash(&mut h);
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize, ink).hash(&mut h);
h.finish()
}
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied).
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16) -> Pixmap {
let field = f.field(spread_scale(b));
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied, colours as `ink`
/// paints them).
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap {
let field = f.field_with(spread_scale(b), &|c| ink.rgb(c));
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
let data = (0..rows as usize * cols as usize)
.map(|k| {
@@ -75,7 +78,7 @@ fn cells_along(len: f64, side: f64, cells: f64) -> u16 {
/// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current
/// transform maps to pixels) as the context paint. Returns false for an empty box.
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect) -> bool {
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
let b = bounds.abs();
let side = b.width().max(b.height());
if !(side > 1e-9 && side.is_finite()) {
@@ -87,9 +90,9 @@ pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bou
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells));
let f = g.freeform_on(b);
let k = key(&f, b, cols, rows);
let k = key(&f, b, cols, rows, ink);
let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| {
let pm = Arc::new(rasterize(&f, b, cols, rows));
let pm = Arc::new(rasterize(&f, b, cols, rows, ink));
GRIDS.with(|c| {
let mut c = c.borrow_mut();
if c.len() >= MAX_GRIDS {
+8 -7
View File
@@ -29,6 +29,7 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
use crate::ink::Ink;
use crate::{Frame, Renderer, blend_mode, fill_rule, paint};
fn visible(effects: &[Effect]) -> bool {
@@ -178,8 +179,8 @@ fn blur_filter(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
}
fn pcolor(c: &vectorcraft_doc::color::Color) -> peniko::Color {
let [r, g, b] = c.to_rgb();
fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
let [r, g, b] = ink.rgb(c);
peniko::Color::new([r, g, b, 1.0])
}
@@ -285,7 +286,7 @@ impl Renderer {
};
let c = f.view.as_coeffs();
let linear = [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()];
let key = (n as *const Node as usize, i);
let key = (n as *const Node as usize, i, f.ink);
let stamp = self.stamp;
let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n);
let (image, x, y) = match hit {
@@ -314,7 +315,7 @@ impl Renderer {
let (wu, hu) = (w as usize, h as usize);
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
blur_alpha(&mut a, wu, hu, blur / 2.0 / f.px);
let [cr, cg, cb] = color.to_rgb();
let [cr, cg, cb] = f.ink.rgb(&color);
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
for (px, av) in pm.data_mut().iter_mut().zip(&a) {
let al = (av * opacity).clamp(0.0, 255.0);
@@ -466,7 +467,7 @@ impl Renderer {
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
_ => continue,
};
let filter = shadow_filter(0.0, 0.0, blur, pcolor(&color));
let filter = shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color));
// The offset moves the content rather than the filter: vello_cpu drops layer content
// that lies entirely outside the viewport before filtering.
let reach = reach + Vec2::new(dx, dy);
@@ -520,7 +521,7 @@ impl Renderer {
let Some((g, rule)) = content.outline else { return };
for fx in rfx {
let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue };
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color));
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(f.ink, color));
self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| {
// The blend layer goes outside the clip: a clip layer is isolated, so a blend
// inside it would mix with nothing instead of the content below.
@@ -639,7 +640,7 @@ impl Renderer {
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, ib, f.doc) {
if paint::set_paint(ctx, &fl.paint, ib, f) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(ig);
}
+293
View File
@@ -0,0 +1,293 @@
//! Blending in CMYK documents.
//!
//! In a CMYK document blend modes and opacity composite inks, not screen colours: Multiply of cyan
//! over magenta prints both inks (blue, C100 M100 through the CMYK profile), where screen RGB would
//! give the product of the two display colours. The renderer composites additive values, so a CMYK
//! document that shows transparency is drawn twice, once per ink plane
//! ([`vectorcraft_color::blend::cmyk_planes`]): every colour painted as its complemented C, M and
//! Y ([`Ink::Cmy`]), then as its complemented K, a grey ([`Ink::K`]). Blend modes, opacity,
//! groups, knockouts and effects composite the planes exactly as they composite RGB, which is CMYK
//! blending. [`compose`] reads each pixel's inks back and shows them through the working CMYK
//! profile (a cached 4-D lookup table). Opacity masks keep taking their luminance from screen
//! colours; RGB colours and placed images are separated into the working CMYK space first.
//!
//! RGB documents, and CMYK documents without transparency, draw screen colours ([`Ink::Display`]).
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use vectorcraft_color::Color;
use vectorcraft_color::blend::{cmyk_planes, planes_cmyk};
use vectorcraft_color::cms::{self, Cms, ProofLut, lab};
use vectorcraft_doc::{ColorMode, Document};
use vello_cpu::Pixmap;
use vello_cpu::peniko;
use crate::{RenderOptions, Renderer};
/// What a frame paints for colours.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub(crate) enum Ink {
/// Screen colours.
#[default]
Display,
/// The complemented C, M and Y inks.
Cmy,
/// The complemented K ink, as a grey.
K,
}
impl Ink {
/// The three values painted for colour `c`.
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
match self {
Ink::Display => c.to_rgb(),
_ => self.plane(inks(c)),
}
}
/// [`Self::rgb`] as a paint, at `alpha`.
pub(crate) fn color(self, c: &Color, alpha: f32) -> peniko::Color {
let [r, g, b] = self.rgb(c);
peniko::Color::from_rgba8(q(r), q(g), q(b), q(alpha))
}
/// A fixed screen colour (pasteboard, guides, tile edges) as a paint.
pub(crate) fn fixed(self, rgba: [u8; 4]) -> peniko::Color {
let [r, g, b, a] = self.fixed8(rgba);
peniko::Color::from_rgba8(r, g, b, a)
}
fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
match self {
Ink::Display => [r, g, b, a],
_ => {
let lab = lab::srgb_to_lab([r, g, b].map(|v| v as f32 / 255.0));
let [r, g, b] = self.plane(inks(&Color::Lab { l: lab.l, a: lab.a, b: lab.b })).map(q);
[r, g, b, a]
}
}
}
fn plane(self, inks: [f32; 4]) -> [f32; 3] {
cmyk_planes(inks)[(self == Ink::K) as usize]
}
}
fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// Whether `doc` is drawn by ink planes: a CMYK document that shows transparency, unless drawn
/// in Outline view or Separations Preview (which paints plates).
pub(crate) fn blends_in_cmyk(doc: &Document, opts: &RenderOptions) -> bool {
doc.color_mode == ColorMode::Cmyk
&& !opts.outline
&& opts.proof.as_ref().is_none_or(|p| p.separations.is_none())
&& (doc.layers.iter().any(|l| l.shows_transparency())
|| doc.symbols.iter().any(|s| s.art.shows_transparency())
|| doc.patterns.iter().any(|p| p.art.iter().any(|a| a.shows_transparency())))
}
/// The inks of `c` in the working CMYK space: CMYK as it is, grey as black ink, RGB and Lab
/// separated with the colour settings' intent (cached: separating searches the profile).
fn inks(c: &Color) -> [f32; 4] {
let key = match *c {
Color::Cmyk { c, m, y, k } => return [c, m, y, k],
Color::Gray { k } => return [0.0, 0.0, 0.0, k],
Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits()],
Color::Lab { l, a, b } => [1, l.to_bits(), a.to_bits(), b.to_bits()],
};
let cms = cms::active();
SEPARATED.with(|s| {
let (owner, seen) = &mut *s.borrow_mut();
if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 4096 {
seen.clear();
*owner = Some(cms.clone());
}
*seen.entry(key).or_insert_with(|| cms.to_cmyk(c, cms.settings().intent))
})
}
/// Separated colours, for the colour settings they were separated with.
type Separated = (Option<Arc<Cms>>, HashMap<[u32; 4], [f32; 4]>);
thread_local! {
static SEPARATED: RefCell<Separated> = RefCell::default();
}
/// Grid points per ink of [`InkLut`].
const LUT_N: usize = 17;
/// Inks → display sRGB through the working CMYK profile: a 17⁴ lookup table of XYZ, interpolated
/// quadrilinearly (inks mix close to linearly in light, and out-of-gamut colours are clipped
/// after interpolating, not before).
struct InkLut {
data: Vec<[f32; 3]>,
}
impl InkLut {
fn build(cms: &Cms) -> Self {
let s = (LUT_N - 1) as f32;
let mut data = Vec::with_capacity(LUT_N.pow(4));
for c in 0..LUT_N {
for m in 0..LUT_N {
for y in 0..LUT_N {
for k in 0..LUT_N {
data.push(lab::lab_to_xyz(cms.cmyk_to_lab([c, m, y, k].map(|i| i as f32 / s))));
}
}
}
}
Self { data }
}
fn apply(&self, inks: [f32; 4]) -> [f32; 3] {
const STRIDE: [usize; 4] = [LUT_N * LUT_N * LUT_N, LUT_N * LUT_N, LUT_N, 1];
let p = inks.map(|v| v.clamp(0.0, 1.0) * (LUT_N - 1) as f32);
let i = p.map(|v| (v as usize).min(LUT_N - 2));
let base: usize = (0..4).map(|d| i[d] * STRIDE[d]).sum();
let mut out = [0.0f32; 3];
for corner in 0..16 {
let (mut w, mut at) = (1.0f32, base);
for d in 0..4 {
let t = p[d] - i[d] as f32;
if corner >> d & 1 == 1 {
w *= t;
at += STRIDE[d];
} else {
w *= 1.0 - t;
}
}
if w > 0.0 {
for (o, v) in out.iter_mut().zip(self.data[at]) {
*o += w * v;
}
}
}
lab::xyz_to_srgb(out)
}
}
/// Placed images as painted with [`Ink::Cmy`] and [`Ink::K`], per image key, with the tables they
/// were separated by.
pub(crate) type InkImages = HashMap<String, (Arc<[ProofLut; 2]>, [Arc<Pixmap>; 2])>;
/// A table built for the colour settings it was built with.
type Cached<T> = Mutex<Option<(Arc<Cms>, Arc<T>)>>;
static DISPLAY: Cached<InkLut> = Mutex::new(None);
/// Screen colours → [`Ink::Cmy`] and [`Ink::K`] values (placed images).
static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(None);
/// The table in `slot` for the active colour settings, built by `build` when they changed.
fn cached<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> Arc<T> {
let cms = cms::active();
let mut s = slot.lock().unwrap_or_else(|e| e.into_inner());
match &*s {
Some((owner, t)) if Arc::ptr_eq(owner, &cms) => t.clone(),
_ => {
let t = Arc::new(build(&cms));
*s = Some((cms, t.clone()));
t
}
}
}
/// The picture of a CMYK document from its ink planes (premultiplied RGBA8 drawn with
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile. Where
/// only the frame's `background` shows it keeps its exact screen colour.
pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<u8> {
let lut = cached(&DISPLAY, InkLut::build);
let bg = background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
let mut out = Vec::with_capacity(cmy.len());
// Runs of one colour convert once.
let mut last: Option<([u8; 8], [u8; 4])> = None;
for (p, q) in cmy.chunks_exact(4).zip(k.chunks_exact(4)) {
let key = [p[0], p[1], p[2], p[3], q[0], q[1], q[2], q[3]];
let px = match (&last, &bg) {
_ if p[3] == 0 => [0; 4],
(Some((k0, v)), _) if *k0 == key => *v,
(_, Some((bc, bk, b))) if p == bc && q == bk => *b,
_ => {
let un = |v: u8, a: u8| if a == 0 { 1.0 } else { (v as f32 / a as f32).min(1.0) };
let a = p[3];
let rgb = lut.apply(planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3])));
let pm = |v: f32| (v.clamp(0.0, 1.0) * a as f32).round() as u8;
let v = [pm(rgb[0]), pm(rgb[1]), pm(rgb[2]), a];
last = Some((key, v));
v
}
};
out.extend_from_slice(&px);
}
out
}
impl Renderer {
/// Placed image `key` (decoded as `pm`) as painted with `ink`, cached.
pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc<Pixmap>, ink: Ink) -> Arc<Pixmap> {
if ink == Ink::Display {
return pm.clone();
}
let luts = cached(&SEPARATE, |cms| {
let intent = cms.settings().intent;
[Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent))))
});
let at = (ink == Ink::K) as usize;
if let Some((owner, planes)) = self.ink_images.get(key)
&& Arc::ptr_eq(owner, &luts)
{
return planes[at].clone();
}
let planes = [0, 1].map(|i| {
let mut out = Pixmap::new(pm.width(), pm.height());
for (o, s) in out.data_mut().iter_mut().zip(pm.data()) {
let a = s.a;
if a == 0 {
continue;
}
let un = |v: u8| (v as f32 / a as f32).min(1.0);
let [r, g, b] = luts[i].apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8);
*o = vello_cpu::color::PremulRgba8 { r, g, b, a };
}
Arc::new(out)
});
let out = planes[at].clone();
self.ink_images.insert(key.to_string(), (luts, planes));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ink_table_matches_the_profile() {
let cms = cms::active();
let lut = InkLut::build(&cms);
let mut worst = 0.0f32;
// Between the grid points (where interpolation errs most) and on them.
for i in 0..=20 {
for j in 0..=20 {
let inks = [i as f32 / 20.0, j as f32 / 20.0, ((i * 7 + j * 3) % 21) as f32 / 20.0, ((i + j * 5) % 21) as f32 / 20.0];
let (a, b) = (lut.apply(inks), cms.cmyk_to_srgb(inks, false));
worst = worst.max((0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max));
}
}
// Within two 8-bit levels: far below a visible difference.
assert!(worst * 255.0 < 2.5, "worst error {} levels", worst * 255.0);
}
#[test]
fn fixed_colours_and_paper() {
// White is paper on both planes; the display plane keeps screen colours exactly.
assert_eq!(Ink::Cmy.fixed8([255; 4]), [255; 4]);
assert_eq!(Ink::K.fixed8([255; 4]), [255; 4]);
assert_eq!(Ink::Display.fixed8([12, 34, 56, 78]), [12, 34, 56, 78]);
assert_eq!(Ink::K.rgb(&Color::gray(0.25)), [0.75; 3]);
assert_eq!(Ink::Cmy.rgb(&Color::cmyk(0.1, 0.2, 0.3, 0.4)), [0.9, 0.8, 0.7]);
}
}
+79 -54
View File
@@ -10,6 +10,7 @@ mod brush_fx;
mod freeform;
mod fx;
mod group;
mod ink;
mod live;
mod paint;
mod pattern;
@@ -25,6 +26,7 @@ use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
use group::Composite;
use ink::Ink;
pub use brush_fx::instance_art;
pub use effects::stroke::width_outline;
@@ -243,7 +245,7 @@ pub struct Renderer {
/// Evaluated blends/envelopes and tessellated meshes.
live: live::LiveCache,
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
shadows: PtrMap<(usize, usize), fx::ShadowEntry>,
shadows: PtrMap<(usize, usize, Ink), fx::ShadowEntry>,
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
knockout: bool,
/// Address of the knockout-group element being drawn as its knockout shape: at full object
@@ -264,6 +266,8 @@ pub struct Renderer {
clip_paths: Vec<(BezPath, FillRule, Affine)>,
/// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]).
clip_paints: PtrMap<usize, ClipPaintEntry>,
/// Placed images as painted in ink planes (see [`ink`]).
ink_images: ink::InkImages,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -290,6 +294,8 @@ struct Frame<'a> {
/// Size of one output pixel in document units.
px: f64,
opts: &'a RenderOptions,
/// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]).
ink: Ink,
}
impl Renderer {
@@ -319,6 +325,7 @@ impl Renderer {
blends: PtrMap::default(),
clip_paths: vec![],
clip_paints: PtrMap::default(),
ink_images: Default::default(),
}
}
@@ -336,48 +343,24 @@ impl Renderer {
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts };
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display };
self.stamp += 1;
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) {
self.stamp += 1;
let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect();
self.stats.micros = now().saturating_sub(start);
return Rendered { width: w as u32, height: h as u32, pixels };
}
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
let mut ctx = match slot {
Some(mut c) if c.width() == w && c.height() == h => {
c.reset();
c
}
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
};
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
}
if opts.artboards && !opts.outline {
ctx.set_transform(view);
ctx.set_paint(peniko::Color::WHITE);
for ab in &doc.artboards {
ctx.fill_rect(&ab.rect);
}
}
self.stamp += 1;
(self.nested, self.backdrop) = (0, None);
self.clip_paths.clear();
if opts.trim && !doc.artboards.is_empty() {
let mut clip = BezPath::new();
for ab in &doc.artboards {
clip.extend(ab.rect.path_elements(0.1));
}
let blends = self.children_blend(&doc.layers);
let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() };
self.group(&mut ctx, &frame, trim, &mut |r, c, fr| r.draw_page(c, fr));
let mut pixels = if ink::blends_in_cmyk(doc, opts) {
// Blending in CMYK: one frame per ink plane, then their inks shown on screen.
let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h);
let stats = self.stats;
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
self.stats = stats;
ink::compose(&cmy, &k, opts.background)
} else {
self.draw_page(&mut ctx, &frame);
}
self.draw_frame(&frame, w, h)
};
// Drop cache entries not seen for a few frames.
let g = self.stamp;
if self.geom.len() > 1024 {
@@ -389,6 +372,48 @@ impl Renderer {
if self.shadows.len() > 256 {
self.shadows.retain(|_, e| g - e.stamp <= 3);
}
proof::post(&mut pixels, opts);
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels }
}
/// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art.
fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec<u8> {
let (threads, opts) = (self.threads, f.opts);
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
let mut ctx = match slot {
Some(mut c) if c.width() == w && c.height() == h => {
c.reset();
c
}
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
};
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(f.ink.fixed(bg));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
}
if opts.artboards && !opts.outline {
// Paper: white on screen, no ink on the ink planes.
ctx.set_transform(f.view);
ctx.set_paint(peniko::Color::WHITE);
for ab in &f.doc.artboards {
ctx.fill_rect(&ab.rect);
}
}
(self.nested, self.backdrop) = (0, None);
self.clip_paths.clear();
if opts.trim && !f.doc.artboards.is_empty() {
let mut clip = BezPath::new();
for ab in &f.doc.artboards {
clip.extend(ab.rect.path_elements(0.1));
}
let blends = self.children_blend(&f.doc.layers);
let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() };
self.group(&mut ctx, f, trim, &mut |r, c, fr| r.draw_page(c, fr));
} else {
self.draw_page(&mut ctx, f);
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut self.resources);
@@ -397,10 +422,7 @@ impl Renderer {
} else {
self.ctx = Some(ctx);
}
let mut pixels = pm.data_as_u8_slice().to_vec();
proof::post(&mut pixels, opts);
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels }
pm.data_as_u8_slice().to_vec()
}
/// The page's art: the layers (or the pattern being edited), inside the page group when the
@@ -456,7 +478,7 @@ impl Renderer {
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
}
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default(), ink: Ink::Display };
(self.knockout, self.nested, self.backdrop) = (false, 0, None);
self.clip_paths.clear();
self.draw_node(&mut ctx, &frame, n, true);
@@ -588,10 +610,11 @@ impl Renderer {
/// as luminance ([`mask_value`]).
fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec<u8> {
let mut mctx = single_threaded_context(w, h);
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
// and its luminance is that of its screen colours.
let outer =
(std::mem::take(&mut self.knockout), std::mem::take(&mut self.nested), self.backdrop.take(), std::mem::take(&mut self.clip_paths));
self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true);
self.draw_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art, true);
(self.knockout, self.nested, self.backdrop, self.clip_paths) = outer;
mctx.flush();
let mut pm = Pixmap::new(w, h);
@@ -798,7 +821,7 @@ impl Renderer {
let mut screen = bp.clone();
screen.apply_affine(f.view);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]));
ctx.set_paint(f.ink.fixed(rgba));
ctx.stroke_path(&screen);
}
@@ -829,8 +852,8 @@ impl Renderer {
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, bounds, f.doc) {
self.fold_alpha(ctx, &fl.paint);
if paint::set_paint(ctx, &fl.paint, bounds, f) {
self.fold_alpha(ctx, f.ink, &fl.paint);
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
}
@@ -852,12 +875,11 @@ impl Renderer {
}
/// Multiply the folded object opacity into a solid paint.
fn fold_alpha(&self, ctx: &mut RenderContext, p: &vectorcraft_color::Paint) {
fn fold_alpha(&self, ctx: &mut RenderContext, ink: Ink, p: &vectorcraft_color::Paint) {
if self.alpha < 1.0
&& let vectorcraft_color::Paint::Solid { color, .. } = p
{
let [r, g, b, a] = color.to_rgba8(self.alpha);
ctx.set_paint(peniko::Color::from_rgba8(r, g, b, a));
ctx.set_paint(ink.color(color, self.alpha));
}
}
@@ -886,8 +908,8 @@ impl Renderer {
}
// One paint box for the line and the heads, so a gradient runs on into the heads.
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
if paint::set_paint(ctx, &st.paint, paint_bounds, f.doc) {
self.fold_alpha(ctx, &st.paint);
if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
self.fold_alpha(ctx, f.ink, &st.paint);
ctx.set_fill_rule(peniko::Fill::NonZero);
match self.cached_stroke(f, &pieces.line, st, width) {
Some(o) => ctx.fill_path(&o),
@@ -994,8 +1016,8 @@ impl Renderer {
}
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
if paint::set_paint(ctx, &run.style.fill, g.bounds, f.doc) {
self.fold_alpha(ctx, &run.style.fill);
if paint::set_paint(ctx, &run.style.fill, g.bounds, f) {
self.fold_alpha(ctx, f.ink, &run.style.fill);
overprint(ctx, run.style.overprint_fill);
ctx.fill_path(path);
}
@@ -1024,7 +1046,7 @@ impl Renderer {
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, tb, f.doc) {
if paint::set_paint(ctx, &fl.paint, tb, f) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(all);
}
@@ -1060,6 +1082,7 @@ impl Renderer {
pm
}
};
let pm = self.ink_image(&im.key, &pm, f.ink);
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
@@ -1213,6 +1236,8 @@ mod tests_charstroke;
#[cfg(test)]
mod tests_clip;
#[cfg(test)]
mod tests_cmykblend;
#[cfg(test)]
mod tests_container;
#[cfg(test)]
mod tests_freeform;
+1 -2
View File
@@ -171,8 +171,7 @@ impl Renderer {
}
}
bp.close_path();
let [r, g, bl, al] = q.color.to_rgba8(q.opacity * alpha);
ctx.set_paint(peniko::Color::from_rgba8(r, g, bl, al));
ctx.set_paint(f.ink.color(&q.color, q.opacity * alpha));
ctx.fill_path(&bp);
}
}
+8 -11
View File
@@ -1,28 +1,25 @@
//! Paint conversion (solid, gradients, patterns) and image decoding.
use vectorcraft_color::{GradientKind, Paint};
use vectorcraft_doc::Document;
use vectorcraft_geom::{Affine, Rect, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::peniko::{self, ColorStop};
fn color(c: &vectorcraft_color::Color, alpha: f32) -> peniko::Color {
let [r, g, b, a] = c.to_rgba8(alpha);
peniko::Color::from_rgba8(r, g, b, a)
}
use crate::Frame;
/// Set the context paint. Returns false if nothing should be drawn.
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Document) -> bool {
/// Set the context paint (colours as frame `f` paints them). Returns false if nothing should be
/// drawn.
pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Frame) -> bool {
match p {
Paint::None => false,
Paint::Solid { color: c, .. } => {
ctx.set_paint(color(c, 1.0));
ctx.set_paint(f.ink.color(c, 1.0));
true
}
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds),
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds, f.ink),
Paint::Gradient(g) => {
let geom = g.resolve(bounds);
let stops: Vec<ColorStop> = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect();
let stops: Vec<ColorStop> = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, f.ink.color(c, *a)))).collect();
if stops.is_empty() {
return false;
}
@@ -50,7 +47,7 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Documen
ctx.set_paint(grad);
true
}
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, doc),
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f),
}
}
+26 -18
View File
@@ -4,8 +4,9 @@
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
//! document. Tiles are cached per pattern name and invalidated when the definition changes (art
//! `Arc` identity, tile, tile type, overlap) — the pattern's "revision".
//! document. Tiles are cached per pattern name (and ink plane, see [`crate::ink`]) and invalidated
//! when the definition changes (art `Arc` identity, tile, tile type, overlap) — the pattern's
//! "revision".
//!
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
@@ -17,9 +18,10 @@ use std::sync::Arc;
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
use vectorcraft_doc::{Document, Node};
use vectorcraft_geom::{Affine, Rect, Shape};
use vello_cpu::peniko::{self, Extend, ImageQuality, ImageSampler};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink;
use crate::{Frame, RenderOptions, Rendered, Renderer};
/// Largest super-tile raster side (pixels).
@@ -36,7 +38,8 @@ struct TileEntry {
#[derive(Default)]
struct PatternCache {
tiles: HashMap<String, TileEntry>,
/// Per ink ([`Ink`] as index).
tiles: [HashMap<String, TileEntry>; 3],
renderer: Option<Box<Renderer>>,
depth: u32,
}
@@ -58,8 +61,9 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
p2.min(cap).max(1e-3)
}
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap.
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap {
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as
/// `ink` paints them.
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> Pixmap {
let (w, h) = (w.max(1), h.max(1));
let mut ctx = crate::single_threaded_context(w, h);
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
@@ -76,6 +80,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
visible: v.inverse().transform_rect_bbox(px_rect),
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
opts: &opts,
ink,
};
for a in &def.art {
r.draw_arc(&mut ctx, &frame, a);
@@ -114,10 +119,13 @@ fn with_tile_renderer<T>(f: impl FnOnce(&mut Renderer) -> T) -> Option<T> {
Some(out)
}
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels).
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, doc: &Document) -> bool {
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels), as
/// frame `f` paints it.
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, f: &Frame) -> bool {
let (doc, ink) = (f.doc, f.ink);
let missing = || ink.fixed([128, 128, 128, 255]);
let Some(def) = doc.pattern(name) else {
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
ctx.set_paint(missing());
return true;
};
if def.art.is_empty() {
@@ -129,8 +137,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap);
let hit = CACHE.with(|c| {
c.borrow()
.tiles
c.borrow().tiles[ink as usize]
.get(name)
.filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art))
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
@@ -140,18 +147,19 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
None => {
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h)) else {
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
ctx.set_paint(missing());
return true;
};
let pm = Arc::new(pm);
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.tiles.len() > 64 {
c.tiles.clear();
let tiles = &mut c.tiles[ink as usize];
if tiles.len() > 64 {
tiles.clear();
}
c.tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
});
(pm, sx, sy)
}
@@ -169,7 +177,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re
let side = w.max(h);
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
let px = size.clamp(1, 1024) as u16;
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px))?;
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
}
@@ -203,7 +211,7 @@ impl Renderer {
bounds.apply_affine(f.view);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(vello_cpu::kurbo::Stroke::new(1.0).with_dashes(0.0, [4.0, 3.0]));
ctx.set_paint(peniko::Color::from_rgba8(r, g, b, 255));
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
ctx.stroke_path(&bounds);
}
true
+123
View File
@@ -0,0 +1,123 @@
//! Blending in CMYK documents ([`crate::ink`]): blend modes and opacity composite inks.
use super::*;
use vectorcraft_color::blend::MASK_LUM;
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
use vectorcraft_doc::{Appearance, ColorMode, ImageBlob, ImageObject, Node, OpacityMask};
use vectorcraft_geom::shapes;
fn rect(d: &mut Document, r: Rect, c: Color) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(c), 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();
}
/// `below` over the whole 20 pt page and `top` (10 pt in the middle) with `mode` and `opacity`.
fn doc(mode: ColorMode, below: Option<Color>, top: Color, blend: Blend, opacity: f32) -> Document {
let mut d = Document::new_with_mode(20.0, 20.0, mode);
if let Some(b) = below {
let n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), b);
add(&mut d, n);
}
let mut n = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), top);
(n.blend, n.opacity) = (blend, opacity);
add(&mut d, n);
d
}
/// The centre pixel, rendered on white.
fn centre(d: &Document) -> [u8; 4] {
Renderer::new().render_region(d, d.artboards[0].rect, 1.0, true).pixel(10, 10)
}
/// Screen colour of inks `cmyk`.
fn shown(cmyk: [f32; 4]) -> [u8; 4] {
Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0)
}
fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
const CYAN: Color = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
const MAGENTA: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.0 };
#[test]
fn cyan_multiplied_over_magenta_prints_both_inks() {
let got = centre(&doc(ColorMode::Cmyk, Some(MAGENTA), CYAN, Blend::Multiply, 1.0));
let want = shown([1.0, 1.0, 0.0, 0.0]);
assert!(near(got, want, 1), "{got:?} ≠ C100 M100 {want:?}");
// Multiplying the screen colours instead gives a visibly different blue.
let (c, m) = (CYAN.to_rgb(), MAGENTA.to_rgb());
let screen = [0, 1, 2].map(|i| (c[i] * m[i] * 255.0).round() as i32);
assert!((0..3).any(|i| (screen[i] - want[i] as i32).abs() > 10), "{screen:?} vs {want:?}");
}
#[test]
fn opacity_mixes_inks() {
let got = centre(&doc(ColorMode::Cmyk, None, CYAN, Blend::Normal, 0.5));
assert!(near(got, shown([0.5, 0.0, 0.0, 0.0]), 2), "{got:?}");
}
#[test]
fn black_blends_on_its_own_plane() {
let k = |k| Color::cmyk(0.0, 0.0, 0.0, k);
// Multiply adds black ink: K50 over K50 leaves 25% of the paper.
let got = centre(&doc(ColorMode::Cmyk, Some(k(0.5)), k(0.5), Blend::Multiply, 1.0));
assert!(near(got, shown([0.0, 0.0, 0.0, 0.75]), 2), "{got:?}");
// Luminosity takes the source's black, Color keeps the backdrop's.
let got = centre(&doc(ColorMode::Cmyk, Some(CYAN), k(0.6), Blend::Luminosity, 1.0));
assert!(near(got, shown([0.0, 0.0, 0.0, 0.6]), 3), "{got:?}");
// (Over a backdrop of black ink alone the C, M, Y plane is paper: its luminance takes the
// source's colour out.)
let got = centre(&doc(ColorMode::Cmyk, Some(k(0.5)), CYAN, Blend::Color, 1.0));
assert!(near(got, shown([0.0, 0.0, 0.0, 0.5]), 2), "{got:?}");
}
#[test]
fn rgb_documents_and_opaque_cmyk_documents_keep_screen_colours() {
let rgb = |c: &Color| {
let [r, g, b] = c.to_rgb();
Color::rgb(r, g, b)
};
// RGB documents multiply screen colours.
let got = centre(&doc(ColorMode::Rgb, Some(rgb(&MAGENTA)), rgb(&CYAN), Blend::Multiply, 1.0));
let (c, m) = (CYAN.to_rgb(), MAGENTA.to_rgb());
let want = [0, 1, 2].map(|i| (c[i] * m[i] * 255.0).round() as u8);
assert!(near(got, [want[0], want[1], want[2], 255], 1), "{got:?} ≠ {want:?}");
// A CMYK document without transparency paints each colour as it is.
let d = doc(ColorMode::Cmyk, Some(MAGENTA), CYAN, Blend::Normal, 1.0);
assert!(!ink::blends_in_cmyk(&d, &RenderOptions::default()));
assert_eq!(centre(&d), CYAN.to_rgba8(1.0));
}
#[test]
fn opacity_masks_take_screen_luminance() {
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
let mut n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), CYAN);
n.mask = Some(Box::new(OpacityMask::new(art, true)));
add(&mut d, n);
let [r, g, b] = CYAN.to_rgb();
let cover = MASK_LUM[0] * r + MASK_LUM[1] * g + MASK_LUM[2] * b;
let got = centre(&d);
assert!(near(got, shown([0.0, 0.0, 0.0, cover]), 3), "{got:?} ≠ K{cover}");
}
#[test]
fn placed_images_are_separated_not_blackened() {
let mut d = doc(ColorMode::Cmyk, None, CYAN, Blend::Normal, 0.5);
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None }));
add(&mut d, n);
let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1);
let cms = vectorcraft_color::cms::active();
let [r, g, b] = cms.cmyk_to_srgb(cms.srgb_to_cmyk([1.0, 0.0, 0.0], cms.settings().intent), false).map(|v| (v * 255.0).round() as u8);
assert!(near(got, [r, g, b, 255], 4), "{got:?} ≠ {:?}", [r, g, b]);
}