feat(render): Blend modes blend in CMYK on screen in a CMYK blend space

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 09:33:10 +02:00
committed by paaspaas00
co-authored by Claude Opus 5.5
parent e862e0a613
commit 803c898d72
2 changed files with 382 additions and 3 deletions
+312
View File
@@ -0,0 +1,312 @@
//! A CMYK transparency blend space on screen (Edit › Transparency Blend Space).
//!
//! InDesign blends a print document's transparency in CMYK: a blend mode works on each ink. So
//! Color Dodge with 100% K text turns what's under it to paper white where it has colour (black's
//! cyan, magenta and yellow are 0, their complements 1), while in RGB dodging with black changes
//! nothing. vello blends in RGB, so a spread whose top-level objects use separable blend modes is
//! composited here instead:
//! - the objects below each blended object are rendered on their own (without paper: blend modes
//! don't blend with the paper);
//! - the blended object is rendered alone with its own inks (the plate renders, so [Black] stays
//! 0/0/0/100), and blended with the backdrop pixel by pixel in the working CMYK (the backdrop
//! separated through the colour settings);
//! - the objects above are laid on top, and the result on the paper.
//!
//! PDF export needs none of this: the page group's CMYK blend space does it in the viewer.
use designcraft_color::BlendMode as DcBlend;
use designcraft_compose::Cache;
use designcraft_doc::{Document, ItemId, SpreadRef};
use designcraft_geom::{Affine, Rect};
use crate::{Placed, RenderOptions, Renderer};
/// Blend modes that blend channel by channel (Hue, Saturation, Color and Luminosity don't).
pub(crate) fn separable(b: DcBlend) -> bool {
!matches!(b, DcBlend::Normal | DcBlend::Hue | DcBlend::Saturation | DcBlend::Color | DcBlend::Luminosity)
}
/// Top-level objects of spread `sr` in drawing order (layers back to front, a document spread's
/// parent items first in each layer, as `Renderer::draw_spread` draws them), each with whether
/// it blends in CMYK here (a spread object with a separable blend mode).
fn draw_order(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> Vec<(ItemId, bool)> {
let mut out: Vec<(ItemId, bool)> = Vec::new();
let Some(sp) = doc.spread(sr) else { return out };
for layer in doc.layers.iter().rev() {
if !layer.visible || opts.printing_only && !layer.printable {
continue;
}
if let SpreadRef::Doc(si) = sr {
let first = doc.first_page_of_spread(si);
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
let Some((ppi, _)) = doc.parent_page_for(first + pi) else { continue };
let Some(parent) = doc.parents.get(ppi) else { continue };
for it in parent.items.iter().filter(|it| it.layer == layer.id && !page.overridden.contains(&it.id)) {
if !out.iter().any(|(id, _)| *id == it.id) {
out.push((it.id, false));
}
}
}
}
for it in sp.items.iter().filter(|it| it.layer == layer.id) {
let shown = !it.hidden && !opts.hidden.contains(&it.id) && !(opts.printing_only && it.nonprinting);
out.push((it.id, shown && separable(it.blend)));
}
}
out
}
/// Separable blend function `b` on additive values (backdrop `cb`, source `cs`), as in PDF.
fn blend(b: DcBlend, cb: f32, cs: f32) -> f32 {
let multiply = |a: f32, c: f32| a * c;
let screen = |a: f32, c: f32| a + c - a * c;
let hard = |cb: f32, cs: f32| if cs <= 0.5 { multiply(cb, 2.0 * cs) } else { screen(cb, 2.0 * cs - 1.0) };
match b {
DcBlend::Multiply => multiply(cb, cs),
DcBlend::Screen => screen(cb, cs),
DcBlend::Overlay => hard(cs, cb),
DcBlend::HardLight => hard(cb, cs),
DcBlend::SoftLight => {
if cs <= 0.5 {
cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb)
} else {
let d = if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() };
cb + (2.0 * cs - 1.0) * (d - cb)
}
}
DcBlend::ColorDodge => {
if cb <= 0.0 {
0.0
} else if cs >= 1.0 {
1.0
} else {
(cb / (1.0 - cs)).min(1.0)
}
}
DcBlend::ColorBurn => {
if cb >= 1.0 {
1.0
} else if cs <= 0.0 {
0.0
} else {
1.0 - ((1.0 - cb) / cs).min(1.0)
}
}
DcBlend::Darken => cb.min(cs),
DcBlend::Lighten => cb.max(cs),
DcBlend::Difference => (cb - cs).abs(),
DcBlend::Exclusion => cb + cs - 2.0 * cb * cs,
_ => cs,
}
}
/// Blend CMYK inks `source` (blend mode `mode`) over `backdrop`: a subtractive space blends the
/// complements (PDF), so the formulas see light, not ink.
pub(crate) fn blend_inks(mode: DcBlend, backdrop: [f32; 4], source: [f32; 4]) -> [f32; 4] {
let mut out = [0.0; 4];
for i in 0..4 {
out[i] = 1.0 - blend(mode, 1.0 - backdrop[i], 1.0 - source[i]).clamp(0.0, 1.0);
}
out
}
/// Working CMYK → sRGB through a 9⁴ table built once per colour settings (quadrilinear).
fn srgb_of(cmyk: [f32; 4]) -> [f32; 3] {
const N: usize = 9;
type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc<Vec<[f32; 3]>>);
static LUT: std::sync::Mutex<Option<Lut>> = std::sync::Mutex::new(None);
let settings = designcraft_color::cms::active_settings();
let table = {
let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner());
match g.as_ref() {
Some((s, t)) if *s == settings => t.clone(),
_ => {
let cms = designcraft_color::cms::active();
let step = |i: usize| i as f32 / (N - 1) as f32;
let t: Vec<[f32; 3]> = (0..N * N * N * N)
.map(|i| cms.cmyk_to_srgb([step(i / (N * N * N)), step(i / (N * N) % N), step(i / N % N), step(i % N)], false))
.collect();
let t = std::sync::Arc::new(t);
*g = Some((settings, t.clone()));
t
}
}
};
let f = cmyk.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32);
let i = f.map(|v| (v as usize).min(N - 2));
let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32, f[3] - i[3] as f32];
let mut out = [0.0f32; 3];
for corner in 0..16usize {
let bit = |n: usize| corner >> (3 - n) & 1;
let w: f32 = (0..4).map(|n| if bit(n) == 1 { d[n] } else { 1.0 - d[n] }).product();
if w == 0.0 {
continue;
}
let idx = (i[0] + bit(0)) * N * N * N + (i[1] + bit(1)) * N * N + (i[2] + bit(2)) * N + i[3] + bit(3);
if let Some(rgb) = table.get(idx) {
for k in 0..3 {
out[k] += rgb[k] * w;
}
}
}
out
}
/// Premultiplied RGBA8 → straight 0..1 colour and alpha.
fn straight(px: &[u8]) -> ([f32; 3], f32) {
let a = px.get(3).copied().unwrap_or(0) as f32 / 255.0;
if a <= 0.0 {
return ([0.0; 3], 0.0);
}
let c = |i: usize| (px.get(i).copied().unwrap_or(0) as f32 / 255.0 / a).min(1.0);
([c(0), c(1), c(2)], a)
}
impl Renderer {
/// Composite spread `pl` again in the working CMYK where its top-level objects use separable
/// blend modes, over the pages' pixels of `pixels` (`w`×`h`, premultiplied, rendered with
/// `view`). Returns whether it did.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmyk_blend_spread(
&mut self,
doc: &Document,
cache: &Cache,
pl: &Placed,
w: u32,
h: u32,
view: Affine,
opts: &RenderOptions,
pixels: &mut [u8],
) -> bool {
let order = draw_order(doc, pl.spread, opts);
if !order.iter().any(|(_, b)| *b) {
return false;
}
let Some(sp) = doc.spread(pl.spread) else { return false };
// The pages' device pixels (the pasteboard keeps the RGB result).
let area = (view * pl.xf).transform_rect_bbox(sp.bounds()).intersect(Rect::new(0.0, 0.0, w as f64, h as f64));
let (x0, y0) = (area.x0.floor().max(0.0) as u32, area.y0.floor().max(0.0) as u32);
let (x1, y1) = (area.x1.ceil().min(w as f64) as u32, area.y1.ceil().min(h as f64) as u32);
if x1 <= x0 || y1 <= y0 {
return false;
}
let (rw, rh) = (x1 - x0, y1 - y0);
let rview = Affine::translate((-(x0 as f64), -(y0 as f64))) * view;
let ids: Vec<ItemId> = order.iter().map(|(id, _)| *id).collect();
let stats = self.stats;
// The spread's objects in `show` only (on paper or not), in a region-sized image.
let sub = |me: &mut Renderer, d: &Document, show: &[ItemId], paper: bool, plate: Option<u8>| {
let mut o = opts.clone();
o.hidden.extend(ids.iter().filter(|i| !show.contains(i)));
o.paper = paper;
o.page_shadow = paper && opts.page_shadow;
o.background = if paper { opts.background } else { None };
o.blend_space_view = false;
o.plate = plate;
me.render(d, cache, std::slice::from_ref(pl), rw, rh, rview, &o).pixels
};
let first = order.iter().position(|(_, b)| *b).unwrap_or(order.len());
let mut acc = sub(self, doc, ids.get(..first).unwrap_or(&[]), false, None);
let blended: Vec<usize> = order.iter().enumerate().filter(|(_, (_, b))| *b).map(|(i, _)| i).collect();
for (j, &pos) in blended.iter().enumerate() {
let Some(&id) = ids.get(pos) else { continue };
let Some(mode) = doc.item(id).map(|it| it.blend) else { continue };
// The object alone, its blend mode left to us (opacity and effects stay).
let mut alone = doc.clone();
if let Some(it) = alone.item_mut(id) {
it.blend = DcBlend::Normal;
}
let rgb = sub(self, &alone, &[id], false, None);
let cmy = sub(self, &alone, &[id], false, Some(4));
let k = sub(self, &alone, &[id], false, Some(3));
for p in (0..acc.len()).step_by(4) {
let (Some(s), Some(c), Some(kk)) = (rgb.get(p..p + 4), cmy.get(p..p + 4), k.get(p..p + 4)) else { continue };
let (_, sa) = straight(s);
if sa <= 0.0 {
continue;
}
let Some(b) = acc.get(p..p + 4) else { continue };
let (brgb, ba) = straight(b);
if ba <= 0.0 {
// Nothing below: the object as it is.
acc[p..p + 4].copy_from_slice(s);
continue;
}
let (cs, ca) = straight(c);
let (ks, _) = straight(kk);
let source = if ca > 0.0 {
[1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2], 1.0 - ks[0]]
} else {
crate::cmyk_lut(straight(s).0)
};
let backdrop = crate::cmyk_lut(brgb);
let mixed = blend_inks(mode, backdrop, source);
// PDF compositing: the blend counts where the backdrop is.
let ar = ba + sa - ba * sa;
let t = sa / ar;
let mut out = [0.0f32; 4];
for i in 0..4 {
let m = (1.0 - ba) * source[i] + ba * mixed[i];
out[i] = (1.0 - t) * backdrop[i] + t * m;
}
let rgb_out = srgb_of(out);
let a8 = (ar * 255.0).round().clamp(0.0, 255.0);
for i in 0..3 {
acc[p + i] = (rgb_out[i].clamp(0.0, 1.0) * a8).round() as u8;
}
acc[p + 3] = a8 as u8;
}
// The objects up to the next blended one, laid on top.
let next = blended.get(j + 1).copied().unwrap_or(ids.len());
if let Some(between) = ids.get(pos + 1..next).filter(|b| !b.is_empty()) {
let n = sub(self, doc, between, false, None);
over(&mut acc, &n);
}
}
// On the paper (and the canvas background).
let mut base = sub(self, doc, &[], opts.paper, None);
over(&mut base, &acc);
for y in 0..rh {
let (src, dst) = (((y * rw) * 4) as usize, (((y0 + y) * w + x0) * 4) as usize);
let len = (rw * 4) as usize;
if let (Some(s), Some(d)) = (base.get(src..src + len), pixels.get_mut(dst..dst + len)) {
d.copy_from_slice(s);
}
}
self.stats = stats;
true
}
}
/// `top` over `dst` (premultiplied RGBA8, same size).
fn over(dst: &mut [u8], top: &[u8]) {
for (d, s) in dst.chunks_exact_mut(4).zip(top.chunks_exact(4)) {
let inv = 255 - s[3] as u32;
for i in 0..4 {
d[i] = (s[i] as u32 + (d[i] as u32 * inv + 127) / 255).min(255) as u8;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_dodge_with_black_whitens_coloured_inks_in_cmyk() {
// 100% K over a lilac (C 30 M 35): the inks go, K stays the backdrop's.
let r = blend_inks(DcBlend::ColorDodge, [0.3, 0.35, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]);
assert_eq!(r, [0.0, 0.0, 0.0, 0.0]);
// Multiply adds inks.
let r = blend_inks(DcBlend::Multiply, [0.3, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0]);
assert!((r[0] - 0.3).abs() < 1e-6 && (r[1] - 0.5).abs() < 1e-6);
// Multiplying by paper changes nothing; screening with 100% K clears the coloured inks too.
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
assert!(near(blend_inks(DcBlend::Multiply, [0.2, 0.4, 0.6, 0.1], [0.0; 4]), [0.2, 0.4, 0.6, 0.1]));
assert!(near(blend_inks(DcBlend::Screen, [0.2, 0.4, 0.6, 0.1], [0.0, 0.0, 0.0, 1.0]), [0.0, 0.0, 0.0, 0.1]));
assert!(!separable(DcBlend::Normal) && !separable(DcBlend::Luminosity) && separable(DcBlend::ColorDodge));
}
}
+70 -3
View File
@@ -23,6 +23,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
pub use vello_cpu;
mod blendspace;
pub mod damage;
mod fx;
@@ -143,6 +144,7 @@ pub struct RenderOptions {
pub tag_markers: bool,
/// Separations Preview: draw one process plate (0 = cyan … 3 = black) as grey ink coverage,
/// from each object's own colour (CMYK as authored, others through the colour settings).
/// 4 (internal, the CMYK blend space): cyan, magenta and yellow coverage in red, green, blue.
pub plate: Option<u8>,
/// View › Display Performance.
pub quality: DisplayQuality,
@@ -330,6 +332,14 @@ impl Renderer {
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) {
ctx.render(pm, &mut self.resources);
}
// A CMYK blend space blends the spreads' blend modes in CMYK (blendspace.rs).
if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() {
let stats = self.stats;
for pl in spreads {
self.cmyk_blend_spread(doc, cache, pl, w as u32, h as u32, view, opts, &mut pixels);
}
self.stats = stats;
}
if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() {
let mut img = Rendered { width: w as u32, height: h as u32, pixels };
for pl in spreads {
@@ -827,7 +837,11 @@ fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync:
let k = 255.0 / p.a as f32;
let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0));
let cmyk = cmyk_lut(rgb);
let v = ((1.0 - cmyk[plate.min(3) as usize].clamp(0.0, 1.0)) * p.a as f32).round() as u8;
let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * p.a as f32).round() as u8;
if plate == 4 {
return vello_cpu::color::PremulRgba8 { r: ink(0), g: ink(1), b: ink(2), a: p.a };
}
let v = ink(plate.min(3) as usize);
vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: p.a }
})
.collect();
@@ -860,8 +874,13 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color {
designcraft_color::Color::Cmyk { c, m, y, k } => [c, m, y, k],
_ => c.to_cmyk_managed(designcraft_color::cms::Intent::RelativeColorimetric),
};
let v = ((1.0 - cmyk[p.min(3) as usize].clamp(0.0, 1.0)) * 255.0).round() as u8;
return peniko::Color::from_rgba8(v, v, v, (alpha.clamp(0.0, 1.0) * 255.0).round() as u8);
let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * 255.0).round() as u8;
if p == 4 {
return peniko::Color::from_rgba8(ink(0), ink(1), ink(2), a);
}
let v = ink(p.min(3) as usize);
return peniko::Color::from_rgba8(v, v, v, a);
}
if RICH_BLACK.with(|r| r.get())
&& let designcraft_color::Color::Cmyk { c: cc, m, y, k } = *c
@@ -1336,6 +1355,54 @@ mod tests {
assert!(rgb[1] > 245 && rgb[0] < 10, "{rgb:?}");
}
#[test]
fn blend_modes_blend_in_cmyk_on_screen_in_a_cmyk_blend_space() {
// A lilac (RGB) rectangle, and a 100% K one over its lower half and the paper below it,
// in Color Dodge: in CMYK the black clears the lilac's inks (white), over the paper it
// shows as it is; in RGB, dodging with black changes nothing.
let mut d = Document::new(&NewDocument::default());
d.swatches.push(designcraft_color::swatch::Swatch::color("Lilac", designcraft_color::Color::rgb8(171, 154, 227)));
let lid = d.default_layer();
let rect = |d: &mut Document, r: Rect, fill: &str| {
let id = designcraft_doc::ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.fill = Fill::swatch(fill);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
id
};
rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0), "Lilac");
let black = rect(&mut d, Rect::new(150.0, 150.0, 250.0, 250.0), designcraft_color::swatch::BLACK);
d.item_mut(black).unwrap().blend = DcBlend::ColorDodge;
let cache = Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let px = |d: &Document, r: &mut Renderer, x: u32, y: u32| {
r.render_page(d, &cache, 0, 1.0, false, &RenderOptions { blend_space_view: true, ..Default::default() }).unwrap().pixel(x, y)
};
let over_lilac = px(&d, &mut r, 200, 175);
assert!(over_lilac[..3].iter().all(|v| *v > 235), "dodged to white: {over_lilac:?}");
let over_paper = px(&d, &mut r, 200, 225);
assert!(over_paper[..3].iter().all(|v| *v < 90), "black as it is: {over_paper:?}");
let lilac = px(&d, &mut r, 120, 150);
assert!(lilac[2] > lilac[1] + 25, "the lilac beside it: {lilac:?}");
// Inside a group, the group's blend mode.
let g = designcraft_doc::ItemId(d.alloc());
let it = d.remove_item(black).unwrap();
let mut grp = Item::new(g, lid, designcraft_doc::Shape::Group, Default::default());
let mut inner = (*it).clone();
inner.blend = DcBlend::Normal;
grp.content = Content::Group { items: vec![Arc::new(inner)] };
grp.blend = DcBlend::ColorDodge;
d.insert_item(SpreadRef::Doc(0), grp, None).unwrap();
let grouped = px(&d, &mut r, 200, 175);
assert!(grouped[..3].iter().all(|v| *v > 235), "{grouped:?}");
// An RGB blend space: still lilac under the black (lightened a little: [Black] shows as a
// dark grey in RGB).
d.settings.blend_space = designcraft_doc::BlendSpace::Rgb;
let rgb = px(&d, &mut r, 200, 175);
assert!(rgb[2] > rgb[1] + 25 && rgb[1] < 220, "{rgb:?}");
}
#[test]
fn knockout_group_hides_its_own_objects() {
let mut d = Document::new(&NewDocument::default());