diff --git a/crates/separations/src/cms.rs b/crates/separations/src/cms.rs index bf0d8d85..88b3c592 100644 --- a/crates/separations/src/cms.rs +++ b/crates/separations/src/cms.rs @@ -141,3 +141,21 @@ pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { t.convert_f32(&rgb, 3, &mut lab, 3, false).ok()?; Some([lab[0] * 100.0, lab[1] * 255.0 - 128.0, lab[2] * 255.0 - 128.0]) } + +/// sRGB of CIE L\*a\*b\* (D50) colours, relative colorimetric, three values per colour in +/// both; `None` if craft-cms can't convert or the lengths disagree. +pub(crate) fn lab_to_srgb8(lab: &[f32], rgb: &mut [u8]) -> Option<()> { + static T: OnceLock>> = OnceLock::new(); + if !lab.len().is_multiple_of(3) || lab.len() != rgb.len() { + return None; + } + let t = CmsConversion::transform(&T, Builtin::LabD50.profile(), builtin::srgb(), false)?; + // The Lab D50 identity profile's device values (ICC v4 Lab encoding). + let encoded: Vec = lab.as_chunks::<3>().0.iter().flat_map(|[l, a, b]| [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]).collect(); + let mut out = vec![0.0_f32; encoded.len()]; + t.convert_f32(&encoded, 3, &mut out, 3, false).ok()?; + for (byte, v) in rgb.iter_mut().zip(out) { + *byte = if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + } + Some(()) +} diff --git a/crates/separations/src/composite.rs b/crates/separations/src/composite.rs new file mode 100644 index 00000000..ab606245 --- /dev/null +++ b/crates/separations/src/composite.rs @@ -0,0 +1,134 @@ +//! A colour preview composed from plates, as a Separations Preview shows the page. +//! +//! The preview is built from the plates alone, after the renderer has decided per plate what +//! overprints and what knocks out, so it shows overprint as printed without changing any +//! paint's blend mode. + +use craft_pdf::interpret::color::{CmykConversion, cmyk_to_srgb8}; + +use crate::cms::lab_to_srgb8; +use crate::{Alternate, InkId, Plates, Process}; + +/// Pixels converted per call into the CMYK profile, to bound the scratch buffers. +const CHUNK: usize = 16 * 1024; + +fn on(enabled: &[bool], index: usize) -> bool { + enabled.get(index).copied().unwrap_or(true) +} + +/// CMYK bytes to sRGB bytes through `cms`, else through the bundled profile pages are drawn +/// with; paper white if neither converts. +fn to_srgb(cms: &dyn CmykConversion, cmyk: &[u8], rgb: &mut [u8]) { + if cms.to_srgb8(cmyk, rgb).or_else(|| cmyk_to_srgb8(cmyk, rgb)).is_none() { + rgb.fill(255); + } +} + +/// Spot ink `alternate` alone at each tint byte, in sRGB: a Lab alternate runs in a straight +/// line from paper white (L\* 100) to the solid, a CMYK one scales its components, as a +/// `Separation` colour space's usual tint transform does. +fn appearance(alternate: Alternate, cms: &dyn CmykConversion) -> Option> { + let mut rgb = vec![0_u8; 256 * 3]; + match alternate { + Alternate::Lab([l, a, b]) => { + let lab: Vec = (0..=255_u8) + .flat_map(|t| { + let t = f32::from(t) / 255.0; + [100.0 + t * (l - 100.0), t * a, t * b] + }) + .collect(); + lab_to_srgb8(&lab, &mut rgb)?; + } + Alternate::Cmyk(solid) => { + let cmyk: Vec = (0..=255_u8) + .flat_map(|t| solid.map(|c| (f32::from(t) * if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }).round() as u8)) + .collect(); + to_srgb(cms, &cmyk, &mut rgb); + } + } + Some(rgb.as_chunks::<3>().0.to_vec()) +} + +impl Plates { + /// The enabled inks as they print, as sRGB8 (three bytes per pixel, row-major, + /// `width × height` pixels). `enabled` is indexed by [`InkId::index`]; inks past its end + /// are enabled, so `&[]` shows every ink. + /// + /// The process plates convert through `cms` ([`CmsConversion`](crate::CmsConversion) for + /// the printing condition the plates were made for, see [`Plates::conversion`]), relative + /// colorimetric with black point compensation, or through the bundled CMYK profile when + /// `cms` can't. Each spot plate is then multiplied over them by the spot's appearance at + /// its tint, from its alternate. Spots whose Lab alternate craft-cms can't convert are + /// left out. + pub fn composite(&self, enabled: &[bool], cms: &dyn CmykConversion) -> Vec { + let n = (self.width as usize).saturating_mul(self.height as usize); + let mut rgb = vec![255_u8; n.saturating_mul(3)]; + let process: Vec> = Process::ALL + .iter() + .map(|p| { + let id = InkId::process(*p); + self.plane(id).filter(|_| on(enabled, id.index())) + }) + .collect(); + let mut cmyk = Vec::with_capacity(CHUNK * 4); + for (c, out) in rgb.chunks_mut(CHUNK * 3).enumerate() { + let start = c * CHUNK; + let len = out.len() / 3; + cmyk.clear(); + for i in start..start + len { + cmyk.extend(process.iter().map(|plane| plane.and_then(|p| p.get(i)).copied().unwrap_or(0))); + } + to_srgb(cms, &cmyk, out); + } + + for (id, spot) in self.inks.spots().filter(|(id, _)| on(enabled, id.index())) { + let Some(plane) = self.plane(id).filter(|p| p.iter().any(|t| *t > 0)) else { continue }; + let Some(ramp) = appearance(spot.alternate, cms) else { continue }; + for (px, tint) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(plane) { + if let Some(ink) = ramp.get(usize::from(*tint)).filter(|_| *tint > 0) { + for (c, s) in px.iter_mut().zip(ink) { + *c = ((u16::from(*c) * u16::from(*s) + 127) / 255) as u8; + } + } + } + } + rgb + } +} + +#[cfg(test)] +mod tests { + use crate::CmsConversion; + use crate::cms::srgb8_to_lab; + use crate::coverage::tests::synthetic; + + fn near(got: &[u8], expected: [u8; 3], tolerance: u8) -> bool { + got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= tolerance) + } + + #[test] + fn a_black_and_gold_composite_multiplies_gold_over_black() { + const GOLD: [u8; 3] = [212, 175, 55]; + let lab = srgb8_to_lab(GOLD).unwrap(); + // Black alone, Gold alone, Gold over black, Gold at 50%. + let p = synthetic(4, 1, lab, [vec![0; 4], vec![0; 4], vec![0; 4], vec![255, 0, 255, 0], vec![0, 255, 255, 128]]); + let cms = CmsConversion::fogra39(); + let rgb = p.composite(&[], &cms); + assert_eq!(rgb.len(), 12); + + let black = &rgb[0..3]; + let (lo, hi) = (black.iter().min().unwrap(), black.iter().max().unwrap()); + assert!(*hi < 80 && hi - lo < 12, "Coated FOGRA39 black is a dark neutral: {black:?}"); + assert!(near(&rgb[3..6], GOLD, 2), "Gold: {:?}", &rgb[3..6]); + let over: Vec = black.iter().zip(GOLD).map(|(k, g)| ((u16::from(*k) * u16::from(g) + 127) / 255) as u8).collect(); + assert!(near(&rgb[6..9], [over[0], over[1], over[2]], 2), "Gold over black: {:?}", &rgb[6..9]); + let half = &rgb[9..12]; + assert!(half.iter().zip(GOLD).all(|(h, g)| *h > g && *h < 255) && half[2] > 110 && half[2] < 200, "Gold at 50%: {half:?}"); + + let no_black = p.composite(&[true, true, true, false], &cms); + assert!(near(&no_black[0..3], [255, 255, 255], 1), "paper: {:?}", &no_black[0..3]); + assert!(near(&no_black[6..9], GOLD, 2)); + let no_gold = p.composite(&[true, true, true, true, false], &cms); + assert_eq!(&no_gold[6..9], black); + } +} diff --git a/crates/separations/src/coverage.rs b/crates/separations/src/coverage.rs new file mode 100644 index 00000000..253c18ff --- /dev/null +++ b/crates/separations/src/coverage.rs @@ -0,0 +1,295 @@ +//! Ink coverage read from plates: per ink at a point and over a page, total area coverage +//! (TAC) per pixel, and the pixels over a TAC limit. +//! +//! Inks are chosen with an `enabled` list indexed by [`InkId::index`], as a Separations +//! Preview's toggles hold them: inks past its end are enabled, so `&[]` enables every ink. +//! Percentages are of one ink's 100%, so a TAC runs to 100 × the number of inks. + +use crate::{InkId, Plates}; + +/// The most regions [`Above::regions`] lists. +pub const MAX_REGIONS: usize = 256; + +/// Coverage at one pixel, in percent. +#[derive(Clone, Debug, PartialEq)] +pub struct Coverage { + /// Each ink's tint, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The total area coverage: the sum of the enabled inks' tints. + pub total: f32, +} + +/// One ink's coverage over a page, in percent. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct InkStats { + /// The highest tint. + pub max: f32, + /// The mean tint over every pixel of the page. + pub mean: f32, + /// The share of the page the ink prints on (any tint above 0). + pub area: f32, +} + +/// A page's coverage figures, in percent. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Stats { + /// Each ink's figures, in [`InkSet::iter`](crate::InkSet::iter) order, enabled or not. + pub inks: Vec, + /// The highest TAC of the enabled inks. + pub max_total: f32, + /// The first pixel (in row order) with [`max_total`](Self::max_total); `None` for an empty + /// page. + pub max_total_at: Option<(u32, u32)>, + /// The mean TAC of the enabled inks. + pub mean_total: f32, +} + +/// The pixels whose TAC is over a limit. +#[derive(Clone, Debug, PartialEq)] +pub struct Above { + pub width: u32, + pub height: u32, + /// One entry per pixel, row-major: `true` where the TAC is over the limit. + pub mask: Vec, + /// The number of pixels over the limit. + pub count: usize, +} + +/// A connected group of pixels over the limit (4-connected), with its bounding box in pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Region { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + /// The pixels over the limit in the region. + pub pixels: u32, +} + +fn on(enabled: &[bool], index: usize) -> bool { + enabled.get(index).copied().unwrap_or(true) +} + +fn percent(tint: u64) -> f32 { + (tint as f64 * 100.0 / 255.0) as f32 +} + +impl Plates { + fn pixels(&self) -> usize { + (self.width as usize).saturating_mul(self.height as usize) + } + + fn index_of(&self, x: u32, y: u32) -> Option { + (x < self.width && y < self.height).then(|| y as usize * self.width as usize + x as usize) + } + + fn enabled_planes<'a>(&'a self, enabled: &'a [bool]) -> impl Iterator> + 'a { + self.planes.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, p)| p) + } + + /// The pixel under a point given as fractions `0.0..1.0` of the page's width and height, + /// as a pointer over a preview gives it. + pub fn pixel_at(&self, u: f32, v: f32) -> Option<(u32, u32)> { + if !(0.0..1.0).contains(&u) || !(0.0..1.0).contains(&v) { + return None; + } + let x = ((u * self.width as f32) as u32).min(self.width.checked_sub(1)?); + let y = ((v * self.height as f32) as u32).min(self.height.checked_sub(1)?); + Some((x, y)) + } + + /// Each ink's tint and the TAC of the enabled inks at pixel (`x`, `y`). + pub fn coverage_at(&self, x: u32, y: u32, enabled: &[bool]) -> Option { + let at = self.index_of(x, y)?; + let tints: Vec = self.planes.iter().map(|p| p.get(at).copied().map_or(0, u64::from)).collect(); + let total = tints.iter().enumerate().filter(|(i, _)| on(enabled, *i)).map(|(_, t)| t).sum(); + Some(Coverage { inks: tints.into_iter().map(percent).collect(), total: percent(total) }) + } + + /// Ink `id`'s tint at pixel (`x`, `y`) in percent. + pub fn percent_at(&self, id: InkId, x: u32, y: u32) -> Option { + self.tint(id, x, y).map(|t| percent(u64::from(t))) + } + + /// The TAC of the enabled inks at every pixel, row-major, as a sum of tint bytes (255 per + /// ink at 100%). + fn totals(&self, enabled: &[bool]) -> Vec { + let mut totals = vec![0_u32; self.pixels()]; + for plane in self.enabled_planes(enabled) { + for (sum, tint) in totals.iter_mut().zip(plane) { + *sum = sum.saturating_add(u32::from(*tint)); + } + } + totals + } + + /// The TAC of the enabled inks at every pixel, row-major, in percent. + pub fn tac(&self, enabled: &[bool]) -> Vec { + self.totals(enabled).into_iter().map(|t| percent(u64::from(t))).collect() + } + + /// Each ink's figures over the page, and the TAC of the enabled inks. + pub fn stats(&self, enabled: &[bool]) -> Stats { + let n = self.pixels(); + let mean = |sum: u64| if n == 0 { 0.0 } else { percent(sum) / n as f32 }; + let inks = self + .planes + .iter() + .map(|plane| { + let plane = plane.get(..n).unwrap_or(plane); + let max = plane.iter().copied().max().unwrap_or(0); + let sum: u64 = plane.iter().map(|t| u64::from(*t)).sum(); + let inked = plane.iter().filter(|t| **t > 0).count(); + let area = if n == 0 { 0.0 } else { inked as f32 * 100.0 / n as f32 }; + InkStats { max: percent(u64::from(max)), mean: mean(sum), area } + }) + .collect(); + let totals = self.totals(enabled); + // The first pixel with the highest total: `max_by_key` keeps the last of equals. + let top = totals.iter().enumerate().rev().max_by_key(|(_, t)| **t); + let width = self.width as usize; + let max_total_at = top.and_then(|(at, _)| Some((u32::try_from(at % width).ok()?, u32::try_from(at / width).ok()?))); + Stats { + inks, + max_total: top.map_or(0.0, |(_, t)| percent(u64::from(*t))), + max_total_at, + mean_total: mean(totals.iter().map(|t| u64::from(*t)).sum()), + } + } + + /// The pixels whose TAC of the enabled inks is over `limit` percent (such as 300 for + /// coated paper). A limit that isn't finite marks nothing. + pub fn above(&self, limit: f32, enabled: &[bool]) -> Above { + let limit = if limit.is_finite() { limit.max(0.0) } else { f32::MAX }; + let mask: Vec = self.totals(enabled).into_iter().map(|t| percent(u64::from(t)) > limit).collect(); + let count = mask.iter().filter(|m| **m).count(); + Above { width: self.width, height: self.height, mask, count } + } +} + +impl Above { + /// The connected regions of marked pixels, largest first (ties in row order of their first + /// pixel), at most [`MAX_REGIONS`] of them. + pub fn regions(&self) -> Vec { + let (w, h) = (self.width as usize, self.height as usize); + if self.mask.len() != w.saturating_mul(h) { + return Vec::new(); + } + let mut seen = vec![false; self.mask.len()]; + let mut regions = Vec::new(); + let mut stack = Vec::new(); + for start in 0..self.mask.len() { + if !self.mask.get(start).copied().unwrap_or(false) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let (mut x0, mut y0, mut x1, mut y1, mut pixels) = (start % w, start / w, start % w, start / w, 0_u32); + if let Some(s) = seen.get_mut(start) { + *s = true; + } + stack.push(start); + while let Some(at) = stack.pop() { + let (x, y) = (at % w, at / w); + pixels = pixels.saturating_add(1); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + let next = [(x > 0).then(|| at - 1), (x + 1 < w).then(|| at + 1), (y > 0).then(|| at - w), (y + 1 < h).then(|| at + w)]; + for n in next.into_iter().flatten() { + if self.mask.get(n).copied().unwrap_or(false) + && let Some(s) = seen.get_mut(n).filter(|s| !**s) + { + *s = true; + stack.push(n); + } + } + } + // The mask is at most `width × height` with both `u32`, so coordinates fit. + regions.push(Region { x: x0 as u32, y: y0 as u32, width: (x1 - x0 + 1) as u32, height: (y1 - y0 + 1) as u32, pixels }); + } + regions.sort_by_key(|r| std::cmp::Reverse(r.pixels)); + regions.truncate(MAX_REGIONS); + regions + } + + /// Paints the marked pixels of an sRGB8 image of the same size (three bytes per pixel, + /// such as [`Plates::composite`]'s) in `colour`, as a TAC warning. + pub fn paint(&self, rgb: &mut [u8], colour: [u8; 3]) { + for (px, _) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(&self.mask).filter(|(_, m)| **m) { + *px = colour; + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{Alternate, Conversion, Ink, InkSet, Spot}; + + /// Plates of `width × height` pixels for the process inks and a Gold spot (Lab alternate + /// `gold`), from planes given in `C M Y K Gold` order. + pub(crate) fn synthetic(width: u32, height: u32, gold: [f32; 3], planes: [Vec; 5]) -> Plates { + let mut inks = InkSet::new(); + inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Lab(gold), solidity: None })); + Plates { inks, unseparated: Vec::new(), width, height, dpi: 72.0, conversion: Conversion::DeviceFormulas, planes: planes.to_vec() } + } + + fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 + } + + #[test] + fn coverage_tac_and_regions_over_a_limit() { + // 3 × 2 pixels: rich black with Gold at (0,0), (1,0) (Gold at 50%) and (2,1); black + // alone at (2,0). + let p = synthetic( + 3, + 2, + [70.0, 5.0, 60.0], + [vec![0; 6], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 0, 0, 0, 255], vec![255, 255, 255, 0, 0, 255], vec![255, 128, 0, 0, 0, 255]], + ); + let at = p.coverage_at(1, 0, &[]).unwrap(); + assert!(close(at.inks[4], 50.196) && close(at.total, 350.196), "{at:?}"); + assert!(close(p.coverage_at(1, 0, &[true, true, true, true, false]).unwrap().total, 300.0), "Gold hidden"); + assert_eq!(p.coverage_at(3, 0, &[]), None); + assert_eq!(p.pixel_at(0.99, 0.6), Some((2, 1))); + assert_eq!(p.pixel_at(1.0, 0.0), None); + assert!(close(p.tac(&[])[2], 100.0)); + + let s = p.stats(&[]); + assert!(close(s.max_total, 400.0) && s.max_total_at == Some((0, 0)), "{s:?}"); + assert!(close(s.mean_total, 3188.0 / 1530.0 * 100.0), "{s:?}"); + let k = s.inks[3]; + assert!(close(k.max, 100.0) && close(k.mean, 66.667) && close(k.area, 66.667), "{k:?}"); + let gold = s.inks[4]; + assert!(close(gold.mean, 638.0 / 1530.0 * 100.0) && close(gold.area, 50.0), "{gold:?}"); + assert_eq!(s.inks[0], InkStats::default()); + + let over = p.above(300.0, &[]); + assert_eq!(over.count, 3); + assert_eq!(over.mask, [true, true, false, false, false, true]); + let regions = over.regions(); + assert_eq!(regions, [Region { x: 0, y: 0, width: 2, height: 1, pixels: 2 }, Region { x: 2, y: 1, width: 1, height: 1, pixels: 1 }]); + assert_eq!(p.above(300.0, &[true, true, true, true, false]).count, 0, "exactly at the limit is not over it"); + assert_eq!(p.above(f32::NAN, &[]).count, 0); + + let mut rgb = vec![0_u8; 18]; + over.paint(&mut rgb, [255, 0, 255]); + assert_eq!(&rgb[..6], [255, 0, 255, 255, 0, 255]); + assert_eq!(&rgb[6..9], [0, 0, 0]); + } + + #[test] + fn regions_join_four_connected_pixels_and_keep_the_largest() { + // A ring around an unmarked centre is one region; the diagonal neighbour is another. + #[rustfmt::skip] + let mask = vec![ + true, true, true, false, + true, false, true, false, + true, true, true, false, + false, false, false, true, + ]; + let above = Above { width: 4, height: 4, count: 9, mask }; + assert_eq!(above.regions(), [Region { x: 0, y: 0, width: 3, height: 3, pixels: 8 }, Region { x: 3, y: 3, width: 1, height: 1, pixels: 1 }]); + let empty = Plates { width: 0, height: 0, planes: Vec::new(), ..synthetic(0, 0, [0.0; 3], Default::default()) }; + assert_eq!(empty.stats(&[]).max_total_at, None); + assert!(empty.above(10.0, &[]).regions().is_empty()); + } +} diff --git a/crates/separations/src/lib.rs b/crates/separations/src/lib.rs index 73a131c7..41f0d610 100644 --- a/crates/separations/src/lib.rs +++ b/crates/separations/src/lib.rs @@ -1,12 +1,16 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -//! Print separations for the Craft apps: an ink model shared by the writers, plates rendered -//! from a PDF, and process colour conversion through craft-cms. See the README. +//! Print separations for the Craft apps: an ink model and ink manager shared by the writers, +//! plates rendered from a PDF, coverage and a colour preview read from the plates, and process +//! colour conversion through craft-cms. See the README. pub mod cms; +pub mod composite; +pub mod coverage; pub mod ink; pub mod plates; pub use cms::{CmsConversion, ProfileError}; +pub use coverage::{Above, Coverage, InkStats, MAX_REGIONS, Region, Stats}; pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name}; pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/separations/tests/plates.rs b/crates/separations/tests/plates.rs index fb035e57..85c5737f 100644 --- a/crates/separations/tests/plates.rs +++ b/crates/separations/tests/plates.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use craft_cms::{Intent, Profile, Transform, builtin}; -use craft_separations::{Alternate, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates}; +use craft_separations::{Alternate, CmsConversion, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates}; /// A 40 × 40 pt page with `content` and `resources`, `catalog` entries, and `objects` /// numbered from 5 (each a whole object body). @@ -69,6 +69,19 @@ fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() { assert_eq!(over.tint(black(), 20, 20), Some(255), "overprinted"); let off = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options { overprint: false, ..Options::default() }).unwrap(); assert_eq!(off.tint(black(), 20, 20), Some(0), "overprint off: knocked out"); + + // The preview reads the plates: knocked-out Gold shows pale on paper, overprinted Gold + // darkens the black under it. + let cms = CmsConversion::fogra39(); + let px = |rgb: &[u8], x: usize, y: usize| -> [u8; 3] { rgb[(y * 40 + x) * 3..][..3].try_into().unwrap() }; + let (knockout, overprint) = (p.composite(&[], &cms), over.composite(&[], &cms)); + let [r, g, b] = px(&knockout, 20, 20); + assert!(r > 200 && g > 180 && b < g, "50% Gold on paper: {:?}", [r, g, b]); + let k = px(&knockout, 2, 2); + assert!(k.iter().all(|c| *c < 80), "black: {k:?}"); + assert_eq!(px(&overprint, 2, 2), k); + let dark = px(&overprint, 20, 20); + assert!(dark.iter().zip(k).all(|(d, k)| *d <= k) && dark[2] < k[2], "Gold over black: {dark:?}, black {k:?}"); } /// What `profile` prints for sRGB red, as tint bytes.