separations: coverage, TAC regions and a composite preview from plates

Per-ink coverage at a pixel and over a page, TAC per pixel, the pixels and
connected regions over a TAC limit, and an sRGB composite of chosen plates:
process plates through the printing condition, spot plates multiplied over
them by their alternate's appearance. Ported from the data parts of
pdfcraft #2770 separations.rs.

Co-authored-by: Guillem Hermida <66697068+guillehr2@users.noreply.github.com>
This commit is contained in:
aionggandGuillem Hermida committed 2026-10-20 03:22:29 +00:00
1 parent 126440b274
commit 3c6089bcfc
5 files changed
+467 -3

No files matched your search

+18
View File
@@ -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<Option<Arc<Transform>>> = 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<f32> = 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(())
}
+134
View File
@@ -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<Vec<[u8; 3]>> {
let mut rgb = vec![0_u8; 256 * 3];
match alternate {
Alternate::Lab([l, a, b]) => {
let lab: Vec<f32> = (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<u8> = (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<u8> {
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<Option<&[u8]>> = 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<u8> = 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);
}
}
+295
View File
@@ -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<f32>,
/// 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<InkStats>,
/// 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<bool>,
/// 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<usize> {
(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<Item = &'a Vec<u8>> + '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<Coverage> {
let at = self.index_of(x, y)?;
let tints: Vec<u64> = 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<f32> {
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<u32> {
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<f32> {
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<bool> = 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<Region> {
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<u8>; 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());
}
}
+6 -2
View File
@@ -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};
+14 -1
View File
@@ -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.