pub mod bpc;
use bpc::{
BpcParams, apply_bpc_f64, apply_bpc_rgb_u8, compute_bpc_params, detect_source_black_point,
};
use moxcms::{
CmsError, ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor,
TransformOptions,
};
use std::collections::HashMap;
use std::sync::Arc;
pub type ProfileHash = [u8; 32];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BpcMode {
Off,
On,
#[default]
Auto,
}
impl BpcMode {
#[inline]
pub fn is_enabled(self) -> bool {
matches!(self, BpcMode::On | BpcMode::Auto)
}
}
#[derive(Clone, Default)]
pub struct IccCacheOptions {
pub bpc_mode: BpcMode,
pub source_cmyk_profile: Option<Vec<u8>>,
}
struct GrayToRgbIdentity;
impl TransformExecutor<u8> for GrayToRgbIdentity {
fn transform(&self, src: &[u8], dst: &mut [u8]) -> Result<(), CmsError> {
for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
rgb[0] = *g;
rgb[1] = *g;
rgb[2] = *g;
}
Ok(())
}
}
impl TransformExecutor<f64> for GrayToRgbIdentity {
fn transform(&self, src: &[f64], dst: &mut [f64]) -> Result<(), CmsError> {
for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
rgb[0] = *g;
rgb[1] = *g;
rgb[2] = *g;
}
Ok(())
}
}
#[derive(Clone)]
struct Clut4 {
grid_n: u8,
data: Arc<Vec<u8>>,
}
#[derive(Clone)]
struct CachedTransform {
transform_8bit: Arc<dyn TransformExecutor<u8> + Send + Sync>,
transform_f64: Arc<dyn TransformExecutor<f64> + Send + Sync>,
n: u32,
is_lab: bool,
clut4: Option<Clut4>,
bpc_params: Option<BpcParams>,
}
#[derive(Clone)]
pub struct IccCache {
profiles: HashMap<ProfileHash, Arc<ColorProfile>>,
transforms: HashMap<ProfileHash, CachedTransform>,
color_cache: HashMap<(u64, [u16; 4]), (f64, f64, f64)>,
default_cmyk_hash: Option<ProfileHash>,
system_cmyk_bytes: Option<Arc<Vec<u8>>>,
raw_bytes: HashMap<ProfileHash, Arc<Vec<u8>>>,
srgb_profile: ColorProfile,
reverse_cmyk_f64: Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>,
bpc_mode: BpcMode,
}
impl Default for IccCache {
fn default() -> Self {
Self::new()
}
}
#[inline]
fn bpc_post_correct(rgb: [f64; 3], params: Option<&BpcParams>) -> [f64; 3] {
match params {
Some(p) => apply_bpc_f64(rgb, p),
None => rgb,
}
}
impl IccCache {
pub fn new() -> Self {
Self::new_with_options(IccCacheOptions::default())
}
pub fn new_with_options(opts: IccCacheOptions) -> Self {
let mut cache = Self {
profiles: HashMap::new(),
transforms: HashMap::new(),
color_cache: HashMap::new(),
default_cmyk_hash: None,
system_cmyk_bytes: None,
raw_bytes: HashMap::new(),
srgb_profile: ColorProfile::new_srgb(),
reverse_cmyk_f64: None,
bpc_mode: opts.bpc_mode,
};
if let Some(bytes) = opts.source_cmyk_profile {
cache.load_cmyk_profile_bytes(&bytes);
}
cache
}
#[inline]
pub fn bpc_mode(&self) -> BpcMode {
self.bpc_mode
}
pub fn hash_profile(bytes: &[u8]) -> ProfileHash {
use sha2::{Digest, Sha256};
Sha256::digest(bytes).into()
}
pub fn register_profile(&mut self, bytes: &[u8]) -> Option<ProfileHash> {
self.register_profile_with_n(bytes, None)
}
pub fn register_profile_with_n(
&mut self,
bytes: &[u8],
expected_n: Option<u32>,
) -> Option<ProfileHash> {
use sha2::{Digest, Sha256};
let hash: ProfileHash = Sha256::digest(bytes).into();
if self.transforms.contains_key(&hash) {
return Some(hash);
}
self.raw_bytes
.entry(hash)
.or_insert_with(|| Arc::new(bytes.to_vec()));
let profile = match ColorProfile::new_from_slice(bytes) {
Ok(p) => p,
Err(e) => {
eprintln!("[ICC] Failed to parse profile: {e}");
return None;
}
};
let n = match profile.color_space {
DataColorSpace::Gray => 1u32,
DataColorSpace::Rgb => 3,
DataColorSpace::Cmyk => 4,
DataColorSpace::Lab => 3,
_ => {
eprintln!(
"[ICC] Unsupported profile color space: {:?}",
profile.color_space
);
return None;
}
};
if let Some(expected) = expected_n {
if n != expected {
return None;
}
}
let (src_layout_8, src_layout_f64) = match n {
1 => (Layout::Gray, Layout::Gray),
3 => (Layout::Rgb, Layout::Rgb),
4 => (Layout::Rgba, Layout::Rgba),
_ => return None,
};
let dst_layout_8 = Layout::Rgb;
let dst_layout_f64 = Layout::Rgb;
let intents = [
RenderingIntent::RelativeColorimetric,
RenderingIntent::Perceptual,
RenderingIntent::AbsoluteColorimetric,
RenderingIntent::Saturation,
];
let mut transform_8bit = None;
for &intent in &intents {
let options = TransformOptions {
rendering_intent: intent,
..TransformOptions::default()
};
match profile.create_transform_8bit(
src_layout_8,
&self.srgb_profile,
dst_layout_8,
options,
) {
Ok(t) => {
transform_8bit = Some(t);
break;
}
Err(_) => continue,
}
}
let transform_8bit = match transform_8bit {
Some(t) => t,
None if n == 1 => {
return self.register_gray_identity(hash, profile);
}
None => {
eprintln!(
"[ICC] Failed to create 8-bit transform (cs={:?})",
profile.color_space
);
return None;
}
};
let mut transform_f64 = None;
for &intent in &intents {
let options = TransformOptions {
rendering_intent: intent,
..TransformOptions::default()
};
match profile.create_transform_f64(
src_layout_f64,
&self.srgb_profile,
dst_layout_f64,
options,
) {
Ok(t) => {
transform_f64 = Some(t);
break;
}
Err(_) => continue,
}
}
let transform_f64 = match transform_f64 {
Some(t) => t,
None if n == 1 => {
return self.register_gray_identity(hash, profile);
}
None => {
eprintln!(
"[ICC] Failed to create f64 transform (cs={:?})",
profile.color_space
);
return None;
}
};
let is_lab = profile.color_space == DataColorSpace::Lab;
let bpc_params = if n == 4 && self.bpc_mode.is_enabled() {
detect_source_black_point(transform_8bit.as_ref())
.map(|sbp| compute_bpc_params(sbp, [0.0; 3], bpc::WP_D50))
} else {
None
};
let clut4 = if n == 4 {
let c = bake_clut4(transform_8bit.as_ref(), 17, bpc_params.as_ref());
if std::env::var_os("STET_ICC_VERIFY").is_some()
&& let Some(ref clut) = c
{
verify_clut4(clut, transform_8bit.as_ref(), bpc_params.as_ref());
}
c
} else {
None
};
self.profiles.insert(hash, Arc::new(profile));
self.transforms.insert(
hash,
CachedTransform {
transform_8bit,
transform_f64,
n,
is_lab,
clut4,
bpc_params,
},
);
Some(hash)
}
fn register_gray_identity(
&mut self,
hash: ProfileHash,
profile: ColorProfile,
) -> Option<ProfileHash> {
self.profiles.insert(hash, Arc::new(profile));
self.transforms.insert(
hash,
CachedTransform {
transform_8bit: Arc::new(GrayToRgbIdentity),
transform_f64: Arc::new(GrayToRgbIdentity),
n: 1,
is_lab: false,
clut4: None,
bpc_params: None,
},
);
Some(hash)
}
pub fn convert_color(
&mut self,
hash: &ProfileHash,
components: &[f64],
) -> Option<(f64, f64, f64)> {
let cached = self.transforms.get(hash)?;
let n = cached.n as usize;
let is_lab = cached.is_lab;
let mut src = vec![0.0f64; n];
for (i, s) in src.iter_mut().enumerate() {
let v = components.get(i).copied().unwrap_or(0.0);
*s = if is_lab {
match i {
0 => (v / 100.0).clamp(0.0, 1.0),
_ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
}
} else {
v.clamp(0.0, 1.0)
};
}
let hash_prefix = u64::from_le_bytes(hash[..8].try_into().ok()?);
let mut quantized = [0u16; 4];
for (i, &c) in src.iter().take(4).enumerate() {
quantized[i] = (c * 65535.0).round() as u16;
}
let cache_key = (hash_prefix, quantized);
if let Some(&cached) = self.color_cache.get(&cache_key) {
return Some(cached);
}
let mut dst = [0.0f64; 3];
if cached.transform_f64.transform(&src, &mut dst).is_err() {
return None;
}
let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
let result = (
dst[0].clamp(0.0, 1.0),
dst[1].clamp(0.0, 1.0),
dst[2].clamp(0.0, 1.0),
);
if self.color_cache.len() < 65536 {
self.color_cache.insert(cache_key, result);
}
Some(result)
}
pub fn convert_color_readonly(
&self,
hash: &ProfileHash,
components: &[f64],
) -> Option<(f64, f64, f64)> {
let cached = self.transforms.get(hash)?;
let n = cached.n as usize;
let is_lab = cached.is_lab;
let mut src = vec![0.0f64; n];
for (i, s) in src.iter_mut().enumerate() {
let v = components.get(i).copied().unwrap_or(0.0);
*s = if is_lab {
match i {
0 => (v / 100.0).clamp(0.0, 1.0),
_ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
}
} else {
v.clamp(0.0, 1.0)
};
}
let mut dst = [0.0f64; 3];
if cached.transform_f64.transform(&src, &mut dst).is_err() {
return None;
}
let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
Some((
dst[0].clamp(0.0, 1.0),
dst[1].clamp(0.0, 1.0),
dst[2].clamp(0.0, 1.0),
))
}
pub fn convert_image_8bit(
&self,
hash: &ProfileHash,
samples: &[u8],
pixel_count: usize,
) -> Option<Vec<u8>> {
let cached = self.transforms.get(hash)?;
let n = cached.n as usize;
let expected_len = pixel_count * n;
if samples.len() < expected_len {
return None;
}
if let Some(clut) = &cached.clut4 {
return Some(apply_clut4_cmyk_to_rgb(
clut,
&samples[..expected_len],
pixel_count,
));
}
let src = &samples[..expected_len];
let mut dst = vec![0u8; pixel_count * 3];
match cached.transform_8bit.transform(src, &mut dst) {
Ok(()) => {
if let Some(p) = cached.bpc_params.as_ref() {
for px in dst.chunks_exact_mut(3) {
let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
px[0] = out[0];
px[1] = out[1];
px[2] = out[2];
}
}
Some(dst)
}
Err(e) => {
eprintln!("[ICC] Image transform failed: {e}");
None
}
}
}
pub fn search_system_cmyk_profile(&mut self) {
if let Some(bytes) = find_system_cmyk_profile()
&& let Some(hash) = self.register_profile(&bytes)
{
eprintln!("[ICC] Loaded system CMYK profile");
self.system_cmyk_bytes = Some(Arc::new(bytes));
self.default_cmyk_hash = Some(hash);
}
}
pub fn load_cmyk_profile_bytes(&mut self, bytes: &[u8]) {
if let Some(hash) = self.register_profile(bytes) {
self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
self.default_cmyk_hash = Some(hash);
}
}
pub fn default_cmyk_hash(&self) -> Option<&ProfileHash> {
self.default_cmyk_hash.as_ref()
}
pub fn has_profile(&self, hash: &ProfileHash) -> bool {
self.transforms.contains_key(hash)
}
pub fn get_profile_bytes(&self, hash: &ProfileHash) -> Option<Arc<Vec<u8>>> {
self.raw_bytes.get(hash).cloned()
}
pub fn system_cmyk_bytes(&self) -> Option<&Arc<Vec<u8>>> {
self.system_cmyk_bytes.as_ref()
}
pub fn set_system_cmyk(&mut self, bytes: &[u8], hash: ProfileHash) {
self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
self.default_cmyk_hash = Some(hash);
}
pub fn set_default_cmyk_hash(&mut self, hash: ProfileHash) {
self.default_cmyk_hash = Some(hash);
}
pub fn suspend_default_cmyk(&mut self) -> Option<ProfileHash> {
self.default_cmyk_hash.take()
}
pub fn restore_default_cmyk(&mut self, hash: Option<ProfileHash>) {
self.default_cmyk_hash = hash;
}
#[inline]
pub fn convert_cmyk(&mut self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
let hash = *self.default_cmyk_hash.as_ref()?;
self.convert_color(&hash, &[c, m, y, k])
}
pub fn convert_cmyk_readonly(&self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
let hash = self.default_cmyk_hash.as_ref()?;
let cached = self.transforms.get(hash)?;
let src = [
c.clamp(0.0, 1.0),
m.clamp(0.0, 1.0),
y.clamp(0.0, 1.0),
k.clamp(0.0, 1.0),
];
let mut dst = [0.0f64; 3];
if cached.transform_f64.transform(&src, &mut dst).is_err() {
return None;
}
let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
Some((
dst[0].clamp(0.0, 1.0),
dst[1].clamp(0.0, 1.0),
dst[2].clamp(0.0, 1.0),
))
}
fn ensure_reverse_cmyk_transform(&mut self) -> Option<()> {
if self.reverse_cmyk_f64.is_some() {
return Some(());
}
let hash = *self.default_cmyk_hash.as_ref()?;
let cmyk_profile = self.profiles.get(&hash)?.clone();
let intents = [
RenderingIntent::RelativeColorimetric,
RenderingIntent::Perceptual,
RenderingIntent::AbsoluteColorimetric,
RenderingIntent::Saturation,
];
for &intent in &intents {
let options = TransformOptions {
rendering_intent: intent,
..TransformOptions::default()
};
if let Ok(t) = self.srgb_profile.create_transform_f64(
Layout::Rgb,
&cmyk_profile,
Layout::Rgba,
options,
) {
self.reverse_cmyk_f64 = Some(t);
return Some(());
}
}
None
}
pub fn prepare_reverse_cmyk(&mut self) {
let _ = self.ensure_reverse_cmyk_transform();
}
pub fn convert_rgb_to_cmyk_readonly(&self, r: f64, g: f64, b: f64) -> Option<[f64; 4]> {
let reverse = self.reverse_cmyk_f64.as_ref()?;
let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
let mut cmyk = [0.0f64; 4];
reverse.transform(&src_rgb, &mut cmyk).ok()?;
Some([
cmyk[0].clamp(0.0, 1.0),
cmyk[1].clamp(0.0, 1.0),
cmyk[2].clamp(0.0, 1.0),
cmyk[3].clamp(0.0, 1.0),
])
}
pub fn round_trip_rgb_via_cmyk(&mut self, r: f64, g: f64, b: f64) -> Option<(f64, f64, f64)> {
self.ensure_reverse_cmyk_transform()?;
let hash = *self.default_cmyk_hash.as_ref()?;
let reverse = self.reverse_cmyk_f64.as_ref()?;
let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
let mut cmyk = [0.0f64; 4];
reverse.transform(&src_rgb, &mut cmyk).ok()?;
let forward = self.transforms.get(&hash)?;
let mut dst = [0.0f64; 3];
forward.transform_f64.transform(&cmyk, &mut dst).ok()?;
let dst = bpc_post_correct(dst, forward.bpc_params.as_ref());
Some((
dst[0].clamp(0.0, 1.0),
dst[1].clamp(0.0, 1.0),
dst[2].clamp(0.0, 1.0),
))
}
pub fn disable(&mut self) {
self.profiles.clear();
self.transforms.clear();
self.color_cache.clear();
self.raw_bytes.clear();
self.default_cmyk_hash = None;
self.system_cmyk_bytes = None;
self.reverse_cmyk_f64 = None;
}
}
fn bake_clut4(
transform: &(dyn TransformExecutor<u8> + Send + Sync),
grid_n: u8,
bpc_params: Option<&BpcParams>,
) -> Option<Clut4> {
let n = grid_n as usize;
if !(2..=33).contains(&n) {
return None;
}
let total = n * n * n * n;
let mut src = Vec::with_capacity(total * 4);
let step = |i: usize| -> u8 {
((i as u32 * 255) / (n as u32 - 1)) as u8
};
for k in 0..n {
let kv = step(k);
for y in 0..n {
let yv = step(y);
for m in 0..n {
let mv = step(m);
for c in 0..n {
let cv = step(c);
src.extend_from_slice(&[cv, mv, yv, kv]);
}
}
}
}
let mut dst = vec![0u8; total * 3];
transform.transform(&src, &mut dst).ok()?;
if let Some(p) = bpc_params {
for px in dst.chunks_exact_mut(3) {
let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
px[0] = out[0];
px[1] = out[1];
px[2] = out[2];
}
}
Some(Clut4 {
grid_n,
data: Arc::new(dst),
})
}
fn apply_clut4_cmyk_to_rgb(clut: &Clut4, src: &[u8], pixel_count: usize) -> Vec<u8> {
let n = clut.grid_n as usize;
let nm1 = (n - 1) as u32;
let lut = clut.data.as_slice();
let stride_c: usize = 3;
let stride_m: usize = n * stride_c;
let stride_y: usize = n * stride_m;
let stride_k: usize = n * stride_y;
let mut out = vec![0u8; pixel_count * 3];
#[inline(always)]
fn axis(v: u8, nm1: u32) -> (usize, usize, u32) {
let scaled = v as u32 * nm1;
let lo = scaled / 255;
let frac = scaled - lo * 255;
let hi = if lo < nm1 { lo + 1 } else { lo };
(lo as usize, hi as usize, frac)
}
for i in 0..pixel_count {
let o = i * 4;
let c = src[o];
let m = src[o + 1];
let y = src[o + 2];
let k = src[o + 3];
let (ci, ci1, fc) = axis(c, nm1);
let (mi, mi1, fm) = axis(m, nm1);
let (yi, yi1, fy) = axis(y, nm1);
let (ki, ki1, fk) = axis(k, nm1);
let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
if fm >= fy {
((1, 0, 0), (1, 1, 0), fc, fm, fy)
} else if fc >= fy {
((1, 0, 0), (1, 0, 1), fc, fy, fm)
} else {
((0, 0, 1), (1, 0, 1), fy, fc, fm)
}
} else if fc >= fy {
((0, 1, 0), (1, 1, 0), fm, fc, fy)
} else if fm >= fy {
((0, 1, 0), (0, 1, 1), fm, fy, fc)
} else {
((0, 0, 1), (0, 1, 1), fy, fm, fc)
};
let corner = |d: (u8, u8, u8)| -> usize {
let (dc, dm, dy) = d;
let cx = if dc == 0 { ci } else { ci1 };
let mx = if dm == 0 { mi } else { mi1 };
let yx = if dy == 0 { yi } else { yi1 };
yx * stride_y + mx * stride_m + cx * stride_c
};
let o000 = corner((0, 0, 0));
let o111 = corner((1, 1, 1));
let oa = corner(a_dxmy);
let ob = corner(b_dxmy);
let base_lo = ki * stride_k;
let base_hi = ki1 * stride_k;
let tetra_channel = |base: usize, ch: usize| -> i32 {
let v000 = lut[base + o000 + ch] as i32;
let va = lut[base + oa + ch] as i32;
let vb = lut[base + ob + ch] as i32;
let v111 = lut[base + o111 + ch] as i32;
v000 * 255 + (va - v000) * w1 as i32 + (vb - va) * w2 as i32 + (v111 - vb) * w3 as i32
};
let r_lo = tetra_channel(base_lo, 0);
let g_lo = tetra_channel(base_lo, 1);
let b_lo = tetra_channel(base_lo, 2);
let (r_hi, g_hi, b_hi) = if ki == ki1 {
(r_lo, g_lo, b_lo)
} else {
(
tetra_channel(base_hi, 0),
tetra_channel(base_hi, 1),
tetra_channel(base_hi, 2),
)
};
let inv_fk = (255 - fk) as i32;
let fk_i = fk as i32;
let round = 255 * 255 / 2;
let finish = |lo: i32, hi: i32| -> u8 {
let combined = lo * inv_fk + hi * fk_i + round;
let v = combined / (255 * 255);
v.clamp(0, 255) as u8
};
let di = i * 3;
out[di] = finish(r_lo, r_hi);
out[di + 1] = finish(g_lo, g_hi);
out[di + 2] = finish(b_lo, b_hi);
}
out
}
fn verify_clut4(
clut: &Clut4,
transform: &(dyn TransformExecutor<u8> + Send + Sync),
bpc_params: Option<&BpcParams>,
) {
const N_SAMPLES: usize = 4096;
let mut rng: u64 = 0xa8b3c4d5e6f70819;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rng
};
let mut cmyk = Vec::with_capacity(N_SAMPLES * 4);
for _ in 0..N_SAMPLES {
let r = next();
cmyk.extend_from_slice(&[
(r & 0xff) as u8,
((r >> 8) & 0xff) as u8,
((r >> 16) & 0xff) as u8,
((r >> 24) & 0xff) as u8,
]);
}
let mut reference = vec![0u8; N_SAMPLES * 3];
if transform.transform(&cmyk, &mut reference).is_err() {
eprintln!("[ICC VERIFY] reference transform failed");
return;
}
if let Some(p) = bpc_params {
for px in reference.chunks_exact_mut(3) {
let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
px[0] = out[0];
px[1] = out[1];
px[2] = out[2];
}
}
let interp = apply_clut4_cmyk_to_rgb(clut, &cmyk, N_SAMPLES);
let mut dists: Vec<f64> = Vec::with_capacity(N_SAMPLES);
let mut max_ch: u8 = 0;
for i in 0..N_SAMPLES {
let dr = interp[i * 3] as i32 - reference[i * 3] as i32;
let dg = interp[i * 3 + 1] as i32 - reference[i * 3 + 1] as i32;
let db = interp[i * 3 + 2] as i32 - reference[i * 3 + 2] as i32;
let d = ((dr * dr + dg * dg + db * db) as f64).sqrt();
dists.push(d);
max_ch = max_ch
.max(dr.unsigned_abs() as u8)
.max(dg.unsigned_abs() as u8)
.max(db.unsigned_abs() as u8);
}
dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = dists[N_SAMPLES / 2];
let p99 = dists[(N_SAMPLES * 99) / 100];
let max = dists[N_SAMPLES - 1];
eprintln!(
"[ICC VERIFY] N=17 CLUT vs direct moxcms (sRGB u8): median={:.2}, p99={:.2}, max={:.2}, max_per_channel={}",
median, p99, max, max_ch
);
}
pub fn find_system_cmyk_profile_bytes() -> Option<Arc<Vec<u8>>> {
find_system_cmyk_profile().map(Arc::new)
}
fn find_system_cmyk_profile() -> Option<Vec<u8>> {
#[cfg(target_os = "linux")]
{
let paths = [
"/usr/share/color/icc/ghostscript/default_cmyk.icc",
"/usr/share/color/icc/ghostscript/ps_cmyk.icc",
"/usr/share/color/icc/colord/FOGRA39L_coated.icc",
];
for path in &paths {
if let Ok(bytes) = std::fs::read(path) {
return Some(bytes);
}
}
if let Ok(entries) = glob::glob("/usr/share/color/icc/colord/SWOP*.icc") {
for entry in entries.flatten() {
if let Ok(bytes) = std::fs::read(&entry) {
return Some(bytes);
}
}
}
}
#[cfg(target_os = "macos")]
{
let dirs = [
"/Library/ColorSync/Profiles",
"/System/Library/ColorSync/Profiles",
];
if let Some(home) = std::env::var_os("HOME") {
let home_dir = std::path::PathBuf::from(home).join("Library/ColorSync/Profiles");
if let Some(bytes) = scan_dir_for_cmyk_icc(&home_dir) {
return Some(bytes);
}
}
for dir in &dirs {
if let Some(bytes) = scan_dir_for_cmyk_icc(std::path::Path::new(dir)) {
return Some(bytes);
}
}
}
#[cfg(target_os = "windows")]
{
if let Some(sysroot) = std::env::var_os("SYSTEMROOT") {
let dir = std::path::PathBuf::from(sysroot).join("System32/spool/drivers/color");
if let Some(bytes) = scan_dir_for_cmyk_icc(&dir) {
return Some(bytes);
}
}
}
None
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn scan_dir_for_cmyk_icc(dir: &std::path::Path) -> Option<Vec<u8>> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext.eq_ignore_ascii_case("icc") || ext.eq_ignore_ascii_case("icm") {
if let Ok(bytes) = std::fs::read(&path) {
if bytes.len() >= 20 && &bytes[16..20] == b"CMYK" {
return Some(bytes);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_icc_cache_new() {
let cache = IccCache::new();
assert!(cache.default_cmyk_hash.is_none());
assert!(cache.profiles.is_empty());
assert_eq!(cache.bpc_mode(), BpcMode::Auto);
}
#[test]
fn test_icc_cache_options_default_matches_new() {
let cache = IccCache::new_with_options(IccCacheOptions::default());
assert_eq!(cache.bpc_mode(), BpcMode::Auto);
assert!(cache.default_cmyk_hash.is_none());
}
#[test]
fn test_icc_cache_options_bpc_off() {
let cache = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::Off,
source_cmyk_profile: None,
});
assert_eq!(cache.bpc_mode(), BpcMode::Off);
assert!(!cache.bpc_mode().is_enabled());
}
#[test]
fn test_icc_cache_options_preloads_cmyk_profile() {
let Some(cmyk_bytes) = find_system_cmyk_profile() else {
return;
};
let cache = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::On,
source_cmyk_profile: Some(cmyk_bytes.clone()),
});
assert!(cache.default_cmyk_hash().is_some());
assert_eq!(cache.bpc_mode(), BpcMode::On);
}
#[test]
fn test_bpc_darkens_pure_k_per_color() {
let Some(cmyk_bytes) = find_system_cmyk_profile() else {
return;
};
let mut off = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::Off,
source_cmyk_profile: Some(cmyk_bytes.clone()),
});
let off_rgb = off.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
let mut on = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::On,
source_cmyk_profile: Some(cmyk_bytes),
});
let on_rgb = on.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
if (on_rgb.1 - off_rgb.1).abs() < 0.005 {
eprintln!(
"Skipping: system CMYK profile's black point is already ~zero; \
BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
);
return;
}
assert!(
on_rgb.1 + 0.03 < off_rgb.1,
"BPC should darken K=1 by ≥0.03 (~8 RGB levels): off={off_rgb:?} on={on_rgb:?}"
);
assert!(
on_rgb.0 < 0.25 && on_rgb.1 < 0.25 && on_rgb.2 < 0.25,
"Expected deep gray after BPC, got {on_rgb:?}"
);
}
#[test]
fn test_bpc_white_anchored_per_color() {
let Some(cmyk_bytes) = find_system_cmyk_profile() else {
return;
};
let mut cache = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::On,
source_cmyk_profile: Some(cmyk_bytes),
});
let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
assert!(r > 0.99 && g > 0.99 && b > 0.99, "({r}, {g}, {b})");
}
#[test]
fn test_bpc_image_clut_path_darkens_pure_k() {
let Some(cmyk_bytes) = find_system_cmyk_profile() else {
return;
};
let off = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::Off,
source_cmyk_profile: Some(cmyk_bytes.clone()),
});
let on = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::On,
source_cmyk_profile: Some(cmyk_bytes),
});
let off_hash = *off.default_cmyk_hash().unwrap();
let on_hash = *on.default_cmyk_hash().unwrap();
let pixel = [0u8, 0, 0, 255]; let off_rgb = off.convert_image_8bit(&off_hash, &pixel, 1).unwrap();
let on_rgb = on.convert_image_8bit(&on_hash, &pixel, 1).unwrap();
if (on_rgb[1] as i32 - off_rgb[1] as i32).abs() <= 1 {
eprintln!(
"Skipping: system CMYK profile's black point is already ~zero; \
BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
);
return;
}
assert!(
(on_rgb[1] as i32) + 8 < (off_rgb[1] as i32),
"CLUT BPC should darken K=1 image green by ≥8 levels: off={off_rgb:?} on={on_rgb:?}"
);
assert!(
on_rgb[0] < 64 && on_rgb[1] < 64 && on_rgb[2] < 64,
"Expected deep gray after CLUT BPC, got {on_rgb:?}"
);
}
#[test]
fn test_bpc_off_image_matches_per_color_off() {
let Some(cmyk_bytes) = find_system_cmyk_profile() else {
return;
};
let mut cache = IccCache::new_with_options(IccCacheOptions {
bpc_mode: BpcMode::Off,
source_cmyk_profile: Some(cmyk_bytes),
});
let hash = *cache.default_cmyk_hash().unwrap();
let pixel = [0u8, 0, 0, 255];
let img = cache.convert_image_8bit(&hash, &pixel, 1).unwrap();
let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
let pc = [
(r * 255.0).round() as i32,
(g * 255.0).round() as i32,
(b * 255.0).round() as i32,
];
assert!((img[0] as i32 - pc[0]).abs() <= 2, "img={img:?} pc={pc:?}");
assert!((img[1] as i32 - pc[1]).abs() <= 2, "img={img:?} pc={pc:?}");
assert!((img[2] as i32 - pc[2]).abs() <= 2, "img={img:?} pc={pc:?}");
}
#[test]
fn test_register_invalid_profile() {
let mut cache = IccCache::new();
assert!(cache.register_profile(b"not a valid ICC profile").is_none());
}
#[test]
fn test_srgb_identity_transform() {
let srgb = ColorProfile::new_srgb();
let bytes = srgb.encode().unwrap();
let mut cache = IccCache::new();
let hash = cache.register_profile(&bytes).unwrap();
let (r, g, b) = cache.convert_color(&hash, &[1.0, 0.0, 0.0]).unwrap();
assert!((r - 1.0).abs() < 0.02, "r={r}");
assert!(g < 0.02, "g={g}");
assert!(b < 0.02, "b={b}");
let (r, g, b) = cache.convert_color(&hash, &[1.0, 1.0, 1.0]).unwrap();
assert!((r - 1.0).abs() < 0.02);
assert!((g - 1.0).abs() < 0.02);
assert!((b - 1.0).abs() < 0.02);
}
#[test]
fn test_srgb_image_transform() {
let srgb = ColorProfile::new_srgb();
let bytes = srgb.encode().unwrap();
let mut cache = IccCache::new();
let hash = cache.register_profile(&bytes).unwrap();
let src = [255u8, 0, 0, 0, 255, 0];
let result = cache.convert_image_8bit(&hash, &src, 2).unwrap();
assert_eq!(result.len(), 6);
assert!(result[0] > 240);
assert!(result[1] < 15);
assert!(result[2] < 15);
}
#[test]
fn test_convert_rgb_to_cmyk_readonly() {
let Some(cmyk_bytes) = find_system_cmyk_profile() else {
return;
};
let mut cache = IccCache::new();
let hash = cache.register_profile(&cmyk_bytes).unwrap();
cache.set_default_cmyk_hash(hash);
assert!(cache.convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0).is_none());
cache.prepare_reverse_cmyk();
let cmyk = cache
.convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0)
.expect("reverse transform should be available after prepare");
assert!(
cmyk[3] > 0.5,
"expected K>0.5 for sRGB black, got cmyk={cmyk:?}"
);
let cmyk = cache
.convert_rgb_to_cmyk_readonly(1.0, 1.0, 1.0)
.expect("reverse transform should be available");
for (i, v) in cmyk.iter().enumerate() {
assert!(*v < 0.05, "expected near-zero ink at chan {i}, got {v}");
}
}
}