Files
craft-libs/crates/cms/src/synth.rs
T

591 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Synthetic CMYK output profiles.
//!
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
//! this crate ships a CMYK profile it generates itself from a documented printer model:
//!
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
//! gain) per ink. The eight K-free primaries use the published ISO 12647-2 coated-paper Lab
//! aim values (paper, C, M, Y, the three two-colour overprints) and an estimated three-colour
//! black; overprints with black are derived multiplicatively with a first-surface reflection
//! floor.
//! * **AToB** (all intents): 11⁴ `lut16` table of the forward model, relative colorimetric
//! (paper → L* 100).
//! * **BToA1** (colorimetric): for every Lab node, black generation (GCR) from the gray
//! component of the K-free solution, then a bounded least-squares solve for C, M, Y with the
//! total ink limit; out-of-gamut colours land on the closest reachable colour (clipping).
//! * **BToA0/BToA2** (perceptual/saturation): the target is first compressed into the gamut —
//! L* is scaled onto the device black, chroma is softly compressed against a gamut boundary
//! descriptor sampled from the forward model — then solved as above.
//!
//! The model is plausible for coated offset, not a measured characterisation: use a real
//! profile from your print provider for production print.
//!
//! [`cmyk_profile_from_model`] builds the same tables from any [`Forward`] model, inverting its
//! sampled AToB table; the Coated FOGRA39 built-in is made that way from a model fitted to
//! Fogra's measurements (`tests/fogra39.rs`).
use crate::clut::Clut;
use crate::curve::Curve;
use crate::math;
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent, invalid_arg};
pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
/// Parameters of the synthetic profile.
#[derive(Clone, Debug)]
pub struct CmykParams {
pub description: String,
/// Copyright tag text.
pub copyright: String,
/// Total area coverage limit (3.0 = 300 %).
pub tac: f64,
/// Gray component (min of C, M, Y of the K-free solution) where black generation starts.
pub gcr_start: f64,
/// Maximum black.
pub k_max: f64,
/// Exponent of the black generation curve above `gcr_start`.
pub gcr_gamma: f64,
/// Tone value increase at 50 % for C, M, Y, K.
pub tvi: [f64; 4],
/// Grid points of the AToB (4D) and BToA (3D) tables.
pub grid_a2b: usize,
pub grid_b2a: usize,
/// Map paper and PCS white onto each other exactly: the AToB maps no ink to L* 100,
/// a* = b* = 0, and the BToA input curves put that white on a grid node (`grid_b2a` must
/// be odd), which holds no ink. Without them white falls between nodes of the legacy Lab
/// encoding and separates to a trace of ink. Off by default, which keeps the bytes of the
/// shipped Photocraft profile.
pub white_on_node: bool,
}
impl Default for CmykParams {
fn default() -> Self {
CmykParams {
description: DESCRIPTION.into(),
copyright: crate::builtin::COPYRIGHT.into(),
tac: 3.0,
gcr_start: 0.2,
k_max: 0.95,
gcr_gamma: 1.1,
tvi: [0.14, 0.14, 0.14, 0.17],
grid_a2b: 11,
grid_b2a: 21,
white_on_node: false,
}
}
}
/// The printer model.
#[derive(Clone, Debug)]
pub struct CmykModel {
/// Absolute XYZ (D50) of the 16 overprints, indexed by ink bit mask (C=1, M=2, Y=4, K=8),
/// stored as `X^(1/n)` for the Yule–Nielsen sum.
prim_n: [[f64; 3]; 16],
/// Absolute XYZ of the paper.
pub paper: [f64; 3],
tvi: [f64; 4],
}
const YN: f64 = 2.0;
impl CmykModel {
pub fn coated(tvi: [f64; 4]) -> Self {
let lab = |l: f64, a: f64, b: f64| math::lab_to_xyz([l, a, b], math::D50);
// ISO 12647-2 (coated, paper type 1/2) Lab aims, D50/2°; CMY overprint estimated.
let w = lab(95.0, 0.0, -2.0);
let base: [[f64; 3]; 8] = [
w, // paper
lab(55.0, -37.0, -50.0), // C
lab(48.0, 74.0, -3.0), // M
lab(24.0, 22.0, -46.0), // C+M (blue)
lab(89.0, -5.0, 93.0), // Y
lab(50.0, -65.0, 27.0), // C+Y (green)
lab(47.0, 68.0, 48.0), // M+Y (red)
lab(23.0, 0.0, 0.0), // C+M+Y
];
let k = lab(16.0, 0.0, 0.0);
// Overprints with black: multiplicative in reflectance above a first-surface floor.
let floor = 0.006;
let mut prim = [[0.0; 3]; 16];
for (m, slot) in prim.iter_mut().enumerate() {
let p = base[m & 7];
*slot = if m & 8 == 0 {
p
} else {
std::array::from_fn(|i| {
let s = floor * w[i];
s + (p[i] - s) * (k[i] - s) / (w[i] - s)
})
};
}
let prim_n = prim.map(|p| p.map(|v| v.max(0.0).powf(1.0 / YN)));
CmykModel { prim_n, paper: w, tvi }
}
/// Absolute XYZ of an ink combination (each coverage in `[0, 1]`).
pub fn xyz(&self, cmyk: [f64; 4]) -> [f64; 3] {
let a: [f64; 4] = std::array::from_fn(|i| {
let v = cmyk[i].clamp(0.0, 1.0);
(v + 4.0 * self.tvi[i] * v * (1.0 - v)).clamp(0.0, 1.0)
});
let mut s = [0.0; 3];
for (m, prim) in self.prim_n.iter().enumerate() {
let mut w = 1.0;
for (i, ai) in a.iter().enumerate() {
w *= if m & (1 << i) != 0 { *ai } else { 1.0 - ai };
}
if w == 0.0 {
continue;
}
for (acc, p) in s.iter_mut().zip(prim) {
*acc += w * p;
}
}
s.map(|v| v.powf(YN))
}
/// ICC-relative Lab (paper = L* 100).
pub fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
let x = self.xyz(cmyk);
let rel = [x[0] * math::D50[0] / self.paper[0], x[1] * math::D50[1] / self.paper[1], x[2] * math::D50[2] / self.paper[2]];
math::xyz_to_lab(rel, math::D50)
}
}
/// A printer model: CMYK coverages (each in `[0, 1]`) → ICC-relative Lab (paper = L* 100).
/// The separation solves and the gamut boundary invert it.
pub trait Forward: Sync {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3];
}
impl Forward for CmykModel {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
CmykModel::lab(self, cmyk)
}
}
/// A forward model read from a sampled AToB table: 4 inputs, legacy (`lut16`) Lab outputs, as
/// [`sample_a2b`] makes it.
struct TableForward(Clut);
impl Forward for TableForward {
fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] {
let mut o = [0.0f32; 3];
self.0.interp(&cmyk.map(|v| v.clamp(0.0, 1.0) as f32), &mut o);
decode_v2(o.map(f64::from))
}
}
/// Bounded least-squares solve for C, M, Y given K, minimising ΔE76 to `target`, subject to
/// `C + M + Y ≤ limit` (projected Levenberg–Marquardt with a numerical Jacobian).
fn solve_cmy<F: Forward + ?Sized>(model: &F, target: [f64; 3], k: f64, limit: f64, seeds: &[[f64; 3]]) -> ([f64; 3], f64) {
let project = |mut x: [f64; 3]| {
for _ in 0..4 {
for v in &mut x {
*v = v.clamp(0.0, 1.0);
}
let s: f64 = x.iter().sum();
if s <= limit + 1e-12 {
break;
}
let active = x.iter().filter(|v| **v > 0.0).count().max(1) as f64;
let d = (s - limit) / active;
for v in &mut x {
if *v > 0.0 {
*v -= d;
}
}
}
x
};
let err = |x: [f64; 3]| {
let l = model.lab([x[0], x[1], x[2], k]);
[l[0] - target[0], l[1] - target[1], l[2] - target[2]]
};
let norm2 = |e: [f64; 3]| e[0] * e[0] + e[1] * e[1] + e[2] * e[2];
let mut best = ([0.0; 3], f64::INFINITY);
for seed in seeds {
let mut x = project(*seed);
let mut e = err(x);
let mut f = norm2(e);
let mut lambda = 1e-3;
for _ in 0..60 {
// Jacobian (3×3) by forward differences.
let h = 1e-5;
let mut j = [[0.0; 3]; 3];
for c in 0..3 {
let mut xp = x;
xp[c] += h;
let ep = err(xp);
for r in 0..3 {
j[r][c] = (ep[r] - e[r]) / h;
}
}
// (JᵀJ + λ diag) δ = −Jᵀe
let mut a = [[0.0; 3]; 3];
let mut g = [0.0; 3];
for r in 0..3 {
for c in 0..3 {
a[r][c] = (0..3).map(|k| j[k][r] * j[k][c]).sum();
}
g[r] = -(0..3).map(|k| j[k][r] * e[k]).sum::<f64>();
}
let mut improved = false;
for _ in 0..8 {
let mut al = a;
for (d, row) in al.iter_mut().enumerate() {
row[d] += lambda * (1.0 + a[d][d]);
}
let Some(inv) = math::invert(&al) else {
lambda *= 10.0;
continue;
};
let step = math::apply(&inv, g);
let xn = project([x[0] + step[0], x[1] + step[1], x[2] + step[2]]);
let en = err(xn);
let fnew = norm2(en);
if fnew < f {
let moved = (0..3).map(|i| (xn[i] - x[i]).abs()).fold(0.0, f64::max);
x = xn;
e = en;
f = fnew;
lambda = (lambda * 0.3).max(1e-9);
improved = moved > 1e-9;
break;
}
lambda *= 10.0;
}
if !improved || f < 1e-10 {
break;
}
}
if f < best.1 {
best = (x, f);
}
if best.1 < 1e-6 {
break;
}
}
(best.0, best.1.sqrt())
}
const SEEDS: [[f64; 3]; 5] = [[0.3, 0.3, 0.3], [0.05, 0.05, 0.05], [0.8, 0.8, 0.8], [0.9, 0.2, 0.2], [0.2, 0.9, 0.9]];
/// Colorimetric separation of a relative Lab target: GCR black generation then a CMY solve.
pub fn separate<F: Forward + ?Sized>(model: &F, p: &CmykParams, target: [f64; 3], warm: Option<[f64; 4]>) -> ([f64; 4], f64) {
let mut seeds: Vec<[f64; 3]> = warm.map(|w| vec![[w[0], w[1], w[2]]]).unwrap_or_default();
seeds.extend_from_slice(&SEEDS);
let (cmy0, _) = solve_cmy(model, target, 0.0, 3.0, &seeds);
let g = cmy0[0].min(cmy0[1]).min(cmy0[2]);
let k = if g <= p.gcr_start { 0.0 } else { p.k_max * ((g - p.gcr_start) / (1.0 - p.gcr_start)).powf(p.gcr_gamma) };
let mut seeds2 = vec![[(cmy0[0] - k).max(0.0), (cmy0[1] - k).max(0.0), (cmy0[2] - k).max(0.0)], cmy0];
seeds2.extend_from_slice(&seeds);
let (cmy, de) = solve_cmy(model, target, k, (p.tac - k).max(0.0), &seeds2);
([cmy[0], cmy[1], cmy[2], k], de)
}
/// Gamut boundary descriptor: maximum chroma per (L*, hue) bin, sampled from the model.
struct Gbd {
l_bins: usize,
h_bins: usize,
cmax: Vec<f64>,
black_l: f64,
}
impl Gbd {
fn new<F: Forward + ?Sized>(model: &F, p: &CmykParams) -> Self {
let (l_bins, h_bins) = (41usize, 72usize);
let mut cmax = vec![0.0f64; l_bins * h_bins];
let n = 21;
let mut black_l = 100.0f64;
for ci in 0..n {
for mi in 0..n {
for yi in 0..n {
for ki in 0..11 {
let v = [ci as f64 / (n - 1) as f64, mi as f64 / (n - 1) as f64, yi as f64 / (n - 1) as f64, ki as f64 / 10.0];
if v.iter().sum::<f64>() > p.tac + 1e-9 {
continue;
}
let lab = model.lab(v);
black_l = black_l.min(lab[0]);
let c = lab[1].hypot(lab[2]);
let h = lab[2].atan2(lab[1]).rem_euclid(std::f64::consts::TAU);
let li = ((lab[0] / 100.0) * (l_bins - 1) as f64).round().clamp(0.0, (l_bins - 1) as f64) as usize;
let hi = ((h / std::f64::consts::TAU) * h_bins as f64).round() as usize % h_bins;
let e = &mut cmax[li * h_bins + hi];
*e = e.max(c);
}
}
}
}
// Fill holes and smooth: max over a 3×3 neighbourhood, then a box blur.
let get = |v: &Vec<f64>, l: isize, h: isize| {
let l = l.clamp(0, l_bins as isize - 1) as usize;
let h = h.rem_euclid(h_bins as isize) as usize;
v[l * h_bins + h]
};
let mut filled = cmax.clone();
for l in 0..l_bins as isize {
for h in 0..h_bins as isize {
let mut m = 0.0f64;
for dl in -1..=1 {
for dh in -1..=1 {
m = m.max(get(&cmax, l + dl, h + dh));
}
}
filled[l as usize * h_bins + h as usize] = m;
}
}
let mut smooth = filled.clone();
for l in 0..l_bins as isize {
for h in 0..h_bins as isize {
let mut s = 0.0;
for dl in -1..=1 {
for dh in -1..=1 {
s += get(&filled, l + dl, h + dh);
}
}
smooth[l as usize * h_bins + h as usize] = s / 9.0;
}
}
Gbd { l_bins, h_bins, cmax: smooth, black_l }
}
fn max_chroma(&self, l: f64, h: f64) -> f64 {
let lp = (l / 100.0).clamp(0.0, 1.0) * (self.l_bins - 1) as f64;
let hp = (h.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU) * self.h_bins as f64;
let (l0, h0) = (lp.floor() as usize, hp.floor() as usize);
let (fl, fh) = (lp - l0 as f64, hp - h0 as f64);
let l1 = (l0 + 1).min(self.l_bins - 1);
let (h0m, h1m) = (h0 % self.h_bins, (h0 + 1) % self.h_bins);
let v = |l: usize, h: usize| self.cmax[l * self.h_bins + h];
let a = v(l0, h0m) + (v(l0, h1m) - v(l0, h0m)) * fh;
let b = v(l1, h0m) + (v(l1, h1m) - v(l1, h0m)) * fh;
a + (b - a) * fl
}
/// Perceptual gamut compression of a relative Lab colour.
fn compress(&self, lab: [f64; 3]) -> [f64; 3] {
let l = self.black_l + lab[0].clamp(0.0, 100.0) * (100.0 - self.black_l) / 100.0;
let c = lab[1].hypot(lab[2]);
if c < 1e-9 {
return [l, 0.0, 0.0];
}
let h = lab[2].atan2(lab[1]);
let cm = self.max_chroma(l, h).max(1e-3);
let knee = 0.8 * cm;
let c2 = if c <= knee { c } else { knee + (cm - knee) * ((c - knee) / (cm - knee)).tanh() };
[l, c2 * h.cos(), c2 * h.sin()]
}
}
fn lab_node_legacy(i: usize, n: usize) -> f64 {
i as f64 / (n - 1) as f64
}
/// Decodes a legacy (lut16) Lab node coordinate triple.
fn decode_v2(v: [f64; 3]) -> [f64; 3] {
[v[0] * 100.0 * 65535.0 / 65280.0, v[1] * 65535.0 / 256.0 - 128.0, v[2] * 65535.0 / 256.0 - 128.0]
}
fn encode_v2(lab: [f64; 3]) -> [f32; 3] {
[
(lab[0] / (100.0 * 65535.0 / 65280.0)).clamp(0.0, 1.0) as f32,
((lab[1] + 128.0) * 256.0 / 65535.0).clamp(0.0, 1.0) as f32,
((lab[2] + 128.0) * 256.0 / 65535.0).clamp(0.0, 1.0) as f32,
]
}
#[cfg(not(target_arch = "wasm32"))]
fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
use rayon::prelude::*;
(0..n).into_par_iter().map(f).collect()
}
#[cfg(target_arch = "wasm32")]
fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
(0..n).map(f).collect()
}
/// Grid coordinate of a legacy-encoded PCS value through the [`CmykParams::white_on_node`]
/// input curves: L* 0–100 spans the L axis (higher L* clips to the top node) and a*/b* −128 to
/// +128 span theirs, so L* 100, a* = b* = 0 is the top centre node of an odd grid.
fn white_node_curve(l_axis: bool, x: f64) -> f64 {
if l_axis { (x * 65535.0 / 65280.0).min(1.0) } else { x * 65535.0 / 65536.0 }
}
fn b2a_table<F: Forward + ?Sized>(model: &F, p: &CmykParams, gbd: Option<&Gbd>) -> Result<Clut, CmsError> {
let n = p.grid_b2a.max(2);
let nodes = n * n * n;
let step = (n - 1) as f64;
let vals = par_map(n * n, |row| {
// One (L, a) row along b, warm-starting each solve from its neighbour.
let (li, ai) = (row / n, row % n);
let mut warm = None;
let mut out = Vec::with_capacity(n * 4);
for bi in 0..n {
let lab = if p.white_on_node {
// The inverse of `white_node_curve` at the node.
[100.0 * li as f64 / step, 256.0 * ai as f64 / step - 128.0, 256.0 * bi as f64 / step - 128.0]
} else {
decode_v2([lab_node_legacy(li, n), lab_node_legacy(ai, n), lab_node_legacy(bi, n)])
};
let target = match gbd {
Some(g) => g.compress(lab),
None => lab,
};
let (cmyk, _) = if p.white_on_node && lab == [100.0, 0.0, 0.0] { ([0.0; 4], 0.0) } else { separate(model, p, target, warm) };
warm = Some(cmyk);
out.extend(cmyk.map(|v| v as f32));
}
out
});
let data: Vec<f32> = vals.into_iter().flatten().collect();
debug_assert_eq!(data.len(), nodes * 4);
Clut::new(vec![n; 3], 4, data)
}
/// Refuses grids below 2 nodes or above what a [`Clut`] holds, before anything is sampled.
fn check_grids(p: &CmykParams) -> Result<(), CmsError> {
let fits = |grid: usize, dims: u32, outputs: usize| {
grid >= 2 && grid.checked_pow(dims).and_then(|n| n.checked_mul(outputs)).is_some_and(|n| n <= crate::clut::MAX_VALUES)
};
if !fits(p.grid_a2b, 4, 3) || !fits(p.grid_b2a, 3, 4) {
return Err(invalid_arg(format!("CMYK profile grids {}⁴/{}³ need at least 2 nodes and must fit a CLUT", p.grid_a2b, p.grid_b2a)));
}
if p.white_on_node && p.grid_b2a.is_multiple_of(2) {
return Err(invalid_arg(format!("white on a node needs an odd BToA grid, not {}", p.grid_b2a)));
}
Ok(())
}
/// Input curves of a `lut16` table: identity, or the [`white_node_curve`]s for a BToA table.
fn lut16(clut: Clut, white_on_node: bool) -> Lut {
let (i, o) = (clut.inputs, clut.outputs);
let input = if white_on_node {
// mft2's longest input tables; white lies between samples, within a u16 step of its node.
let table = |l_axis: bool| Curve::Table((0..4096).map(|j| white_node_curve(l_axis, j as f64 / 4095.0) as f32).collect());
vec![table(true), table(false), table(false)]
} else {
vec![Curve::Table(vec![0.0, 1.0]); i]
};
Lut {
kind: LutKind::Lut16,
inputs: i,
outputs: o,
stages: vec![Stage::Curves(input), Stage::Clut(clut), Stage::Curves(vec![Curve::Table(vec![0.0, 1.0]); o])],
}
}
/// Generates a synthetic CMYK profile. Grids are refused as in [`cmyk_profile_from_model`].
pub fn cmyk_profile(p: &CmykParams) -> Result<Profile, CmsError> {
check_grids(p)?;
let model = CmykModel::coated(p.tvi);
let a2b = sample_a2b(&model, p)?;
lut_profile(&model, a2b, model.paper, p)
}
/// Samples a forward model into an AToB table with `p.grid_a2b`⁴ nodes and legacy Lab
/// encoding.
fn sample_a2b<F: Forward + ?Sized>(model: &F, p: &CmykParams) -> Result<Clut, CmsError> {
Clut::sample(vec![p.grid_a2b.max(2); 4], 3, |inp, out| {
let lab = if p.white_on_node && inp.iter().all(|v| *v == 0.0) {
[100.0, 0.0, 0.0]
} else {
model.lab([inp[0] as f64, inp[1] as f64, inp[2] as f64, inp[3] as f64])
};
out.copy_from_slice(&encode_v2(lab));
})
}
/// A CMYK profile of any forward model on paper with absolute XYZ `paper`: the AToB table
/// samples `model` on `p.grid_a2b`⁴ nodes, and the BToA tables invert that table rather than the
/// model, so Lab → CMYK → Lab round trips through the profile are as close as the separation
/// allows. Grids below 2 nodes or too large for a [`Clut`], and a zero, negative or
/// non-finite `paper`, are refused with [`CmsError::InvalidArgument`].
pub fn cmyk_profile_from_model<F: Forward + ?Sized>(model: &F, paper: [f64; 3], p: &CmykParams) -> Result<Profile, CmsError> {
check_grids(p)?;
if !paper.iter().all(|v| v.is_finite() && *v > 0.0) {
return Err(invalid_arg(format!("paper XYZ {paper:?}")));
}
let a2b = sample_a2b(model, p)?;
let forward = TableForward(a2b.clone());
lut_profile(&forward, a2b, paper, p)
}
fn lut_profile<F: Forward + ?Sized>(model: &F, a2b: Clut, paper: [f64; 3], p: &CmykParams) -> Result<Profile, CmsError> {
let gbd = Gbd::new(model, p);
let colorimetric = b2a_table(model, p, None)?;
let perceptual = b2a_table(model, p, Some(&gbd))?;
let a2b = lut16(a2b, false);
let (b0, b1) = (lut16(perceptual, p.white_on_node), lut16(colorimetric, p.white_on_node));
Ok(Profile {
version: (4, 0x30),
class: ProfileClass::Output,
color_space: ColorSpace::Cmyk,
pcs: Pcs::Lab,
rendering_intent: Intent::Perceptual,
description: p.description.clone(),
copyright: p.copyright.clone(),
white_point: paper.map(math::s15f16_round),
black_point: None,
chad: None,
matrix: None,
trc: None,
gray_trc: None,
a2b: [Some(a2b.clone()), Some(a2b.clone()), Some(a2b)],
b2a: [Some(b0.clone()), Some(b1), Some(b0)],
bytes: None,
hash: Default::default(),
}
.with_encoded_bytes())
}
/// The default coated profile (what `profiles/photocraft-coated-cmyk.icc` contains).
pub fn coated_cmyk() -> Result<Profile, CmsError> {
cmyk_profile(&CmykParams::default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_anchors() {
let m = CmykModel::coated(CmykParams::default().tvi);
let w = m.lab([0.0; 4]);
assert!((w[0] - 100.0).abs() < 1e-9 && w[1].abs() < 1e-9 && w[2].abs() < 1e-9, "{w:?}");
let k = m.lab([0.0, 0.0, 0.0, 1.0]);
assert!(k[0] > 12.0 && k[0] < 22.0, "{k:?}");
let rich = m.lab([0.75, 0.65, 0.6, 0.9]);
assert!(rich[0] < k[0], "{rich:?}");
// Monotonic in each ink.
for ink in 0..4 {
let mut prev = 101.0;
for i in 0..=20 {
let mut v = [0.2, 0.2, 0.2, 0.2];
v[ink] = i as f64 / 20.0;
let l = m.lab(v)[0];
assert!(l < prev + 1e-9, "ink {ink} step {i}: {l} >= {prev}");
prev = l;
}
}
}
#[test]
fn separation_hits_in_gamut_targets() {
let m = CmykModel::coated(CmykParams::default().tvi);
let p = CmykParams::default();
let reachable = [[0.3, 0.4, 0.1, 0.1], [0.6, 0.1, 0.5, 0.0], [0.05, 0.1, 0.7, 0.0], [0.5, 0.45, 0.45, 0.4]].map(|v| m.lab(v));
for target in [[50.0, 0.0, 0.0]].into_iter().chain(reachable) {
let (cmyk, de) = separate(&m, &p, target, None);
assert!(de < 1.5, "{target:?} -> {cmyk:?} ΔE {de}");
assert!(cmyk.iter().sum::<f64>() <= p.tac + 1e-6);
}
}
}