diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 372669ca..c4338c28 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -159,6 +159,21 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4]) -> [f32; [c(0) / ao, c(1) / ao, c(2) / ao, ao] } +/// CMYK blending (CMYK documents). The formulas above work on additive values, so inks blend as +/// their complements, as subtractive colour spaces blend in PDF: a CMYK colour splits into two +/// additive planes, each blended with the formulas above, the complemented C, M and Y as one +/// colour and the complemented K as a grey. Multiply then adds inks (cyan over magenta prints +/// both: blue), and on the grey plane Hue, Saturation and Color keep the backdrop's black while +/// Luminosity takes the source's, as PDF has it for K. +pub fn cmyk_planes([c, m, y, k]: [f32; 4]) -> [[f32; 3]; 2] { + [[1.0 - c, 1.0 - m, 1.0 - y], [1.0 - k; 3]] +} + +/// The inks of blended [`cmyk_planes`]: the C, M, Y plane and the K plane's grey. +pub fn planes_cmyk(cmy: [f32; 3], k: f32) -> [f32; 4] { + [1.0 - cmy[0], 1.0 - cmy[1], 1.0 - cmy[2], 1.0 - k].map(|v| v.clamp(0.0, 1.0)) +} + fn screen(b: f32, s: f32) -> f32 { b + s - b * s } @@ -175,16 +190,17 @@ 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. +/// Bring a colour moved off the gamut by [`set_lum`] back in, keeping its luminance. A grey (all +/// components at its luminance, give or take rounding) is clamped instead of scaled by 0/0. 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)); + c = if l - n > f32::EPSILON { c.map(|v| l + (v - l) * l / (l - n)) } else { c.map(|v| v.max(0.0)) }; } if x > 1.0 { - c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + c = if x - l > f32::EPSILON { c.map(|v| l + (v - l) * (1.0 - l) / (x - l)) } else { c.map(|v| v.min(1.0)) }; } c } @@ -244,6 +260,12 @@ mod tests { 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); + // Greys stay finite where rounding puts their luminance just off their components. + for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity] { + for (b, s) in [([0.7; 3], [0.0; 3]), ([0.0; 3], [0.3; 3]), ([1.0; 3], [0.9; 3]), ([0.9; 3], [1.0; 3])] { + assert!(blend_rgb(m, b, s).iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{m:?} {b:?} {s:?}"); + } + } // 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])); @@ -264,6 +286,27 @@ mod tests { assert!(near([o[0], o[1], o[2]], [0.5; 3]) && (o[3] - 0.5).abs() < 1e-6, "{o:?}"); } + #[test] + fn cmyk_planes_blend_inks() { + let blend = |m: BlendMode, b: [f32; 4], s: [f32; 4]| { + let (pb, ps) = (cmyk_planes(b), cmyk_planes(s)); + planes_cmyk(blend_rgb(m, pb[0], ps[0]), blend_rgb(m, pb[1], ps[1])[0]) + }; + let (cyan, magenta) = ([1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]); + // Multiply adds the inks: cyan over magenta prints both. + assert_eq!(blend(BlendMode::Multiply, magenta, cyan), [1.0, 1.0, 0.0, 0.0]); + // Screen keeps the lighter (less ink) per plate. + assert_eq!(blend(BlendMode::Screen, magenta, cyan), [0.0; 4]); + let (b, s) = ([0.2, 0.6, 0.1, 0.3], [0.7, 0.1, 0.5, 0.8]); + // K: backdrop's for Hue, Saturation and Color, the source's for Luminosity. + for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color] { + assert!((blend(m, b, s)[3] - b[3]).abs() < 1e-6, "{m:?}"); + } + assert!((blend(BlendMode::Luminosity, b, s)[3] - s[3]).abs() < 1e-6); + let back = planes_cmyk(cmyk_planes(b)[0], cmyk_planes(b)[1][0]); + assert!(back.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6), "{back:?}"); + } + #[test] fn parse_labels() { assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn)); diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 4b54e8b8..68641629 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -214,7 +214,13 @@ impl Freeform { /// The colour field, with colours converted and lines tessellated once (for sampling many /// spots). pub fn field(&self, scale: f64) -> Field { - let src = |p: &FreeformPoint| Source { at: p.at, rgb: p.color.to_rgb(), opacity: p.opacity, radius: p.spread.clamp(0.0, 1.0) as f64 * scale }; + self.field_with(scale, &Color::to_rgb) + } + + /// [`Self::field`] with colours turned into the three blended values by `rgb` (CMYK + /// documents blend ink planes, see [`crate::blend::cmyk_planes`]). + pub fn field_with(&self, scale: f64, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Field { + let src = |p: &FreeformPoint| Source { at: p.at, rgb: rgb(&p.color), opacity: p.opacity, radius: p.spread.clamp(0.0, 1.0) as f64 * scale }; let points: Vec = self.points.iter().map(src).collect(); let mut pieces = vec![]; for (l, ix) in self.lines.iter().enumerate() { diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2fbdd11c..e0863f98 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1046,6 +1046,28 @@ impl Node { } } +impl Node { + /// Whether this object shows any transparency, itself or anything inside it: object, fill or + /// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient + /// stops. Hidden objects show none. + pub fn shows_transparency(&self) -> bool { + use vectorcraft_color::Paint; + let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); + let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); + self.visible + && (self.opacity < 1.0 + || self.blend != BlendMode::Normal + || self.mask.as_ref().is_some_and(|m| !m.disabled) + || effects(&self.appearance.effects) + || self + .appearance + .items + .iter() + .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) + || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency()))) + } +} + /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 18974b53..e2a8cf95 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -5,7 +5,8 @@ //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by -//! [`composite`]), over white (or over nothing with Preserve Alpha). Each region becomes a path +//! [`composite`]), over white (or over nothing with Preserve Alpha); in CMYK documents their inks +//! composite, plane by plane ([`cmyk_planes`]), and the colour is CMYK. Each region becomes a path //! filled with that flat colour. Where gradients, patterns, images, opacity masks or raster //! effects reach there is no single colour: those regions are rendered into one image per group, //! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the @@ -23,10 +24,10 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use vectorcraft_color::blend::composite; +use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; -use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; use vectorcraft_pathops as po; use vectorcraft_render::effects; @@ -43,7 +44,7 @@ pub fn specs() -> Vec { "Flatten Transparency…", ["Object"], None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?} overlapping transparent objects become one group of flat-colour regions, plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}", + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}", has_doc, flatten ), @@ -786,14 +787,15 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> P let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); let reaches: Vec> = plain.iter().map(reach).collect(); - let see_through: Vec = plain.iter().map(transparent).collect(); + let see_through: Vec = plain.iter().map(Node::shows_transparency).collect(); + let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; for g in overlapping(&reaches) { let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let flat = g.iter().any(|&i| see_through[i]).then(|| { let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); - let (regions, area) = flatten_group(&plain, o)?; + let (regions, area) = flatten_group(&plain, o, cmyk)?; let raster = area.as_ref().filter(|_| render).map(|a| render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)); Some(Flat { roots: ids.clone(), regions, area, raster }) }); @@ -806,9 +808,9 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> P } /// The regions and the image area replacing one group of overlapping objects (`plain`: the art as -/// composited); `None` when it paints nothing. -fn flatten_group(plain: &[&Node], o: &FlattenOptions) -> Option<(Regions, Option)> { - let mut b = Builder::default(); +/// composited; `cmyk`: in a CMYK document); `None` when it paints nothing. +fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Regions, Option)> { + let mut b = Builder { cmyk, ..Default::default() }; let elems: Vec = plain.iter().filter_map(|n| b.elem(n)).collect(); if b.shapes.is_empty() { return None; @@ -825,7 +827,7 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions) -> Option<(Regions, Option if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) { gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient); complex.push(k); - } else if let Some(fill) = region_fill(&elems, &r.sources, o) { + } else if let Some(fill) = region_fill(&elems, &r.sources, o, cmyk) { vector.push((k, fill)); } } @@ -860,25 +862,40 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions) -> Option<(Regions, Option Some((vector, area)) } -/// The flat colour of a region covered by `sources`; `None` where nothing paints it. -fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions) -> Option { - let mut c = Composer { sources, top: None }; +/// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where +/// nothing paints it. +fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { + let mut c = Composer { sources, top: None, plane: 0 }; let alone = c.eval(elems, CLEAR); if alone[3] < 0.5 / 255.0 { return None; } + let start = if o.preserve_alpha { CLEAR } else { WHITE }; let px = if o.preserve_alpha { alone } else { c.top = None; - c.eval(elems, WHITE) + c.eval(elems, start) }; let rgb = [px[0], px[1], px[2]]; + // The K plane composites the same way. + let k = cmyk.then(|| { + c.plane = 1; + c.eval(elems, start)[0] + }); // A paint showing as it is keeps its colour (spot, process, swatch) and overprint. - let same = |f: &Fill| px[3] >= 1.0 - 1e-4 && f.rgb.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-3); + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3); + let same = |f: &Fill| px[3] >= 1.0 - 1e-4 && near(&f.planes[0], &rgb) && k.is_none_or(|k| near(&f.planes[1][..1], &[k])); + let color = match k { + Some(k) => { + let [c, m, y, k] = planes_cmyk(rgb, k); + Color::cmyk(c, m, y, k) + } + None => Color::rgb(rgb[0], rgb[1], rgb[2]), + }; let (paint, overprint) = match c.top { Some(f) if o.preserve_overprints && same(f) => (f.paint.clone(), f.overprint), - _ => (Paint::solid(Color::rgb(rgb[0], rgb[1], rgb[2])), false), + _ => (Paint::solid(color), false), }; Some(RegionFill { paint, alpha: px[3], overprint }) } @@ -1161,19 +1178,6 @@ fn masked(n: &Node) -> bool { n.mask.as_ref().is_some_and(|m| !m.disabled) } -/// Whether plain art `n` shows any transparency: opacity, blend modes, masks, raster effects or -/// transparent gradient stops. -fn transparent(n: &Node) -> bool { - let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); - n.visible - && (n.opacity < 1.0 - || n.blend != BlendMode::Normal - || masked(n) - || has_effects(n) - || n.appearance.items.iter().any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()))) - || n.children().is_some_and(|ch| ch.iter().any(|c| transparent(c)))) -} - /// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). fn reach(n: &Node) -> Option { if !n.visible { @@ -1241,7 +1245,9 @@ enum Kind { /// A solid fill as the compositor uses it. struct Fill { - rgb: [f32; 3], + /// What it composites: its screen colour (twice), or in a CMYK document its two ink planes + /// ([`cmyk_planes`]). + planes: [[f32; 3]; 2], opacity: f32, blend: BlendMode, paint: Paint, @@ -1276,9 +1282,21 @@ enum Body { struct Builder { shapes: Vec, kinds: Vec, + /// Building a CMYK document's art. + cmyk: bool, } impl Builder { + /// What colour `c` composites as ([`Fill::planes`]). + fn planes(&self, c: &Color) -> [[f32; 3]; 2] { + if self.cmyk { + let cms = vectorcraft_color::cms::active(); + cmyk_planes(cms.to_cmyk(c, cms.settings().intent)) + } else { + [c.to_rgb(); 2] + } + } + fn shape(&mut self, path: PathData, rule: FillRule, kind: Kind) -> Option { path.bounds()?; self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64)); @@ -1313,7 +1331,7 @@ impl Builder { for item in n.appearance.items.iter().filter(|i| i.is_fill() && i.visible() && !i.paint().is_none()) { match item.paint() { Paint::Solid { color, .. } => fills.push(Fill { - rgb: color.to_rgb(), + planes: self.planes(color), opacity: item.opacity(), blend: item.blend(), paint: item.paint().clone(), @@ -1376,6 +1394,8 @@ struct Composer<'a> { sources: &'a [usize], /// The last solid fill composited. top: Option<&'a Fill>, + /// Which of the fills' [`Fill::planes`] composites. + plane: usize, } impl<'a> Composer<'a> { @@ -1422,7 +1442,8 @@ impl<'a> Composer<'a> { match &e.body { Body::Fills { shape, fills } if self.inside(*shape) => fills.iter().fold(b, |acc, f| { self.top = Some(f); - composite(f.blend, acc, [f.rgb[0], f.rgb[1], f.rgb[2], f.opacity]) + let [r, g, b] = f.planes[self.plane]; + composite(f.blend, acc, [r, g, b, f.opacity]) }), Body::Group { clip, children } if clip.is_none_or(|c| self.inside(c)) => { if ko { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d28545e9..54a4e8d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -838,6 +838,8 @@ mod tests_clippaint; #[cfg(test)] mod tests_cmdsplit; #[cfg(test)] +mod tests_cmykflatten; +#[cfg(test)] mod tests_colorguide; #[cfg(test)] mod tests_colormgmt; diff --git a/crates/engine/src/tests_cmykflatten.rs b/crates/engine/src/tests_cmykflatten.rs new file mode 100644 index 00000000..fb2c8d96 --- /dev/null +++ b/crates/engine/src/tests_cmykflatten.rs @@ -0,0 +1,74 @@ +//! Flatten Transparency in CMYK documents composites inks, as the canvas shows them (M3.90). + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Color}; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, Shape}; +use vectorcraft_render::Renderer; + +use super::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +/// A CMYK document with an unstroked rectangle of inks `cmyk` (percentages) at (10, 10) and one +/// at (60, 60), 100 pt square. +fn session(below: [f64; 4], above: [f64; 4]) -> (Session, u64, u64) { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200, "colorMode": "cmyk"})); + let mut rect = |x: f64, [c, m, y, k]: [f64; 4]| { + let id = run(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": 100, "height": 100}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": {"c": c, "m": m, "y": y, "k": k}, "ids": [id]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id]})); + id + }; + let (a, b) = (rect(10.0, below), rect(60.0, above)); + (s, a, b) +} + +/// The fill colour of the flattened region at `p` in group `group`. +fn region_color(s: &Session, group: u64, p: Point) -> Color { + let doc = &s.doc().unwrap().doc; + let g = doc.node(NodeId(group)).unwrap(); + let inside = |n: &Node| matches!(&n.kind, NodeKind::Path { path, .. } if path.to_bezpath().winding(p) != 0); + let r = g.children().unwrap().iter().find(|c| inside(c)).expect("a region there"); + let AppearanceItem::Fill(f) = &r.appearance.items[0] else { panic!("a region has one fill") }; + f.paint.color().unwrap() +} + +fn picture(s: &Session) -> vectorcraft_render::Rendered { + Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true) +} + +#[test] +fn cmyk_documents_flatten_to_ink_colours() { + let _settings = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let (mut s, a, b) = session([0.0, 100.0, 0.0, 0.0], [100.0, 0.0, 0.0, 0.0]); + run(&mut s, "transparency.set", json!({"ids": [b], "blend": "Multiply"})); + let ids = run(&mut s, "object.flattenTransparency", json!({"ids": [a, b]}))["ids"].as_array().unwrap()[0].as_u64().unwrap(); + // Multiply prints both inks where they overlap. + let near = |c: Color, want: [f32; 4]| matches!(c, Color::Cmyk { c, m, y, k } if [c, m, y, k].iter().zip(want).all(|(x, w)| (x - w).abs() < 1e-3)); + let overlap = region_color(&s, ids, Point::new(85.0, 85.0)); + assert!(near(overlap, [1.0, 1.0, 0.0, 0.0]), "{overlap:?}"); + assert!(near(region_color(&s, ids, Point::new(130.0, 130.0)), [1.0, 0.0, 0.0, 0.0])); +} + +#[test] +fn every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk() { + let _settings = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + for mode in BlendMode::ALL { + let (mut s, a, b) = session([20.0, 60.0, 10.0, 30.0], [70.0, 10.0, 50.0, 10.0]); + run(&mut s, "transparency.set", json!({"ids": [b], "blend": mode.label(), "opacity": 70})); + let before = picture(&s); + let ids = run(&mut s, "object.flattenTransparency", json!({"ids": [a, b]}))["ids"].as_array().unwrap()[0].as_u64().unwrap(); + for p in [Point::new(30.5, 30.5), Point::new(85.5, 85.5), Point::new(140.5, 140.5)] { + let c = region_color(&s, ids, p); + assert!(matches!(c, Color::Cmyk { .. }), "{mode:?}: {c:?}"); + let got = c.to_rgba8(1.0); + let want = before.pixel(p.x as u32, p.y as u32); + // The canvas rounds each ink plane to 8 bits and interpolates the profile. + assert!((0..3).all(|i| got[i].abs_diff(want[i]) <= 4), "{mode:?} at {p:?}: region {got:?}, canvas {want:?}"); + } + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 22952bfb..c60b4f39 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -18,6 +18,7 @@ use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKin use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; +use crate::lab_spot::{find, rfind}; use crate::{Compatibility, ExportReport, PdfError, PdfOptions}; /// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). @@ -72,6 +73,12 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result>", at).map_or(pdf.len(), |i| i + 2); + if !object_number(&pdf[..at]).is_some_and(|n| masks.contains(&n)) { + cmyk_group(&mut pdf[at..end]); + } + from = end; + } +} + +/// Rewrite transparency group dictionary `dict` (`<<…>>`) to blend in DeviceCMYK, at the same +/// length: leaving out the optional `/Type/Group` makes room for the longer name, spaces pad the +/// rest. +fn cmyk_group(dict: &mut [u8]) { + let Ok(text) = std::str::from_utf8(dict) else { return }; + if !(text.contains("/S/Transparency") && text.contains("/Type/Group") && text.contains("/CS/DeviceRGB")) { + return; + } + let body = text.replacen("/Type/Group", "", 1).replacen("/CS/DeviceRGB", "/CS/DeviceCMYK", 1); + let body = body.strip_suffix(">>").unwrap_or(&body); + let new = format!("{body}{}>>", " ".repeat(dict.len() - body.len() - 2)); + dict.copy_from_slice(new.as_bytes()); +} + +/// Object numbers of the groups luminosity soft masks draw (`/G n 0 R` in their dictionaries). +fn luminosity_mask_groups(pdf: &[u8]) -> Vec { + let mut out = vec![]; + let mut from = 0; + while let Some(at) = find(pdf, b"/Type/Mask", from) { + let end = find(pdf, b">>", at).unwrap_or(pdf.len()); + let dict = &pdf[at..end]; + if find(dict, b"/S/Luminosity", 0).is_some() + && let Some(g) = find(dict, b"/G ", 0) + { + let rest = &dict[g + 3..]; + out.extend(number(&rest[..rest.iter().take_while(|b| b.is_ascii_digit()).count()])); + } + from = end; + } + out +} + +/// The number of the object whose dictionary `before` ends in (its last `n 0 obj` header). +fn object_number(before: &[u8]) -> Option { + let at = rfind(before, b" 0 obj")?; + let digits = before[..at].iter().rev().take_while(|b| b.is_ascii_digit()).count(); + number(&before[at - digits..at]) +} + +fn number(digits: &[u8]) -> Option { + std::str::from_utf8(digits).ok()?.parse().ok() +} + #[cfg(not(target_arch = "wasm32"))] fn now_unix() -> Option { use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/crates/pdf/src/lab_spot.rs b/crates/pdf/src/lab_spot.rs index 8772cdbd..bb9215da 100644 --- a/crates/pdf/src/lab_spot.rs +++ b/crates/pdf/src/lab_spot.rs @@ -45,7 +45,7 @@ fn lab_separation(name: &str, lab: Lab) -> Vec { out } -fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { +pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from) } @@ -110,7 +110,7 @@ pub(crate) fn lab_alternates(pdf: Vec, spots: &[(String, Lab)]) -> Vec { out } -fn rfind(hay: &[u8], needle: &[u8]) -> Option { +pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).rposition(|w| w == needle) } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7aafaddf..34e8a4c4 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -113,6 +113,8 @@ mod tests_blend; #[cfg(test)] mod tests_charstroke; #[cfg(test)] +mod tests_cmykblend; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_fx; diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs new file mode 100644 index 00000000..a9c95bec --- /dev/null +++ b/crates/pdf/src/tests_cmykblend.rs @@ -0,0 +1,78 @@ +//! Blending in CMYK documents: transparency groups blend in DeviceCMYK. + +use std::sync::Arc; + +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, ColorMode, Document, Node, NodeId, OpacityMask}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +fn rect(r: Rect, c: Color) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(c), 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(); +} + +/// Magenta with cyan multiplied over it: in a group when `grouped`, else at the top of the page. +fn doc(mode: ColorMode, grouped: bool) -> Document { + let mut d = Document::new_with_mode(100.0, 100.0, mode); + add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::cmyk(0.0, 1.0, 0.0, 0.0))); + let mut top = rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::cmyk(1.0, 0.0, 0.0, 0.0)); + top.blend = BlendMode::Multiply; + if grouped { + top.id = d.alloc_id(); + let mut g = Node::group(NodeId(0), vec![Arc::new(top)]); + g.opacity = 0.5; + add(&mut d, g); + } else { + add(&mut d, top); + } + d +} + +/// The file's text with spaces removed (content streams uncompressed), after checking it reads. +fn text(d: &Document) -> String { + let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).unwrap(); + import(&bytes).expect("the rewritten file still reads"); + String::from_utf8_lossy(&bytes).replace(' ', "") +} + +#[test] +fn groups_of_cmyk_documents_blend_in_device_cmyk() { + for grouped in [true, false] { + let t = text(&doc(ColorMode::Cmyk, grouped)); + // Top-level blending gets a group of its own, so it blends in CMYK too. + assert!(t.contains("/S/Transparency") && t.contains("/CS/DeviceCMYK"), "grouped {grouped}: {t}"); + assert!(!t.contains("/CS/DeviceRGB"), "grouped {grouped}: {t}"); + assert!(t.contains("/BM/Multiply")); + } + // Compressed output gets the same group dictionaries. + let bytes = export(&doc(ColorMode::Cmyk, true), &PdfOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&bytes).contains("/CS/DeviceCMYK")); +} + +#[test] +fn rgb_documents_and_opaque_cmyk_pages_are_unchanged() { + let t = text(&doc(ColorMode::Rgb, true)); + assert!(t.contains("/CS/DeviceRGB") && !t.contains("/CS/DeviceCMYK"), "{t}"); + let mut d = doc(ColorMode::Cmyk, false); + let top = d.layers[0].children().unwrap()[1].id; + d.node_mut(top).unwrap().blend = BlendMode::Normal; + assert!(!text(&d).contains("/Transparency")); +} + +#[test] +fn soft_mask_groups_keep_screen_luminance() { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + let mut n = rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + n.mask = Some(Box::new(OpacityMask::new(rect(Rect::new(0.0, 0.0, 50.0, 100.0), Color::WHITE), true))); + add(&mut d, n); + let t = text(&d); + // The page's group blends in CMYK; the mask's group measures luminance in RGB. + assert!(t.contains("/CS/DeviceCMYK") && t.contains("/S/Luminosity") && t.contains("/CS/DeviceRGB"), "{t}"); +} diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index e4253079..520a363a 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -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 { diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0b5a2e6e..8d1892ce 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -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 = 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); } diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs new file mode 100644 index 00000000..b51dbb65 --- /dev/null +++ b/crates/render/src/ink.rs @@ -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>, HashMap<[u32; 4], [f32; 4]>); + +thread_local! { + static SEPARATED: RefCell = 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, [Arc; 2])>; + +/// A table built for the colour settings it was built with. +type Cached = Mutex, Arc)>>; + +static DISPLAY: Cached = 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(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> Arc { + 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 { + 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, ink: Ink) -> Arc { + 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]); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3520eea5..b9855405 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, + /// 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 { + 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 { 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; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 1d1a1f7e..31906f3a 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -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); } } diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 731dde26..cff53dc6 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -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 = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect(); + let stops: Vec = 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), } } diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 23a729e7..46217085 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -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, + /// Per ink ([`Ink`] as index). + tiles: [HashMap; 3], renderer: Option>, 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(f: impl FnOnce(&mut Renderer) -> T) -> Option { 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 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, 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]); +}