diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs new file mode 100644 index 000000000..a8c22c81f --- /dev/null +++ b/crates/render/src/blendspace.rs @@ -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>); + static LUT: std::sync::Mutex> = 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 = 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| { + 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 = 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)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..9842b369c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, /// 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, 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());