This commit is contained in:
bflatastic
2026-10-09 11:43:42 +07:00
5 changed files with 175 additions and 3 deletions
+86 -1
View File
@@ -1,7 +1,7 @@
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use moxcms::{
ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
@@ -27,6 +27,9 @@ fn intent_index(i: Intent) -> usize {
type Xf = Option<Arc<TransformF32Executor>>;
/// A direct transform's cache key: destination name, intent index, black-point compensation.
type DirectKey = (String, usize, bool);
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
pub struct IccProfile {
pub name: String,
@@ -37,6 +40,21 @@ pub struct IccProfile {
srgb: ColorProfile,
to_srgb: [OnceLock<Xf>; 4],
from_srgb: [OnceLock<Xf>; 4],
/// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent
/// and black-point compensation.
direct: Mutex<Vec<(DirectKey, Option<Arc<Direct>>)>>,
}
/// A transform from one profile into an RGB profile without passing through sRGB: into the
/// destination's primaries with linear tone curves, where black-point compensation is a linear
/// remap, then onto its own curves.
struct Direct {
to_linear: Arc<TransformF32Executor>,
/// Linear → the destination's curves; `None` when the destination isn't a matrix profile
/// (the first transform goes straight into it, without compensation).
to_dest: Option<Arc<TransformF32Executor>>,
/// The source black's luminance in the linear destination (0: no compensation).
black: f32,
}
impl std::fmt::Debug for IccProfile {
@@ -72,6 +90,7 @@ impl IccProfile {
srgb: ColorProfile::new_srgb(),
to_srgb: Default::default(),
from_srgb: Default::default(),
direct: Mutex::new(Vec::new()),
})
}
@@ -135,6 +154,72 @@ impl IccProfile {
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
}
/// Device values (RGB / CMYK / Gray, 0..1) → `dest`'s RGB, converted directly with `intent`:
/// not through sRGB, so colours outside sRGB keep their place in a wider space. With `bpc`
/// (and an intent other than absolute), this profile's black maps onto the destination's
/// black (black-point compensation, the darkest ink the profile prints becoming RGB 0 rather
/// than a dark grey). `None` when `dest` isn't RGB or no transform can be built.
pub fn to_rgb_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option<[f32; 3]> {
let t = self.direct_xf(dest, intent, bpc && intent != Intent::AbsoluteColorimetric)?;
let mut src = v.to_vec();
src.resize(self.channels(), 0.0);
let mut lin = [0.0f32; 3];
t.to_linear.transform(&src, &mut lin).ok()?;
let Some(to_dest) = &t.to_dest else { return Some(lin.map(|x| x.clamp(0.0, 1.0))) };
if t.black > 0.0 {
lin = lin.map(|x| (x - t.black) / (1.0 - t.black));
}
let lin = lin.map(|x| x.clamp(0.0, 1.0));
let mut out = [0.0f32; 3];
to_dest.transform(&lin, &mut out).ok()?;
Some(out.map(|x| x.clamp(0.0, 1.0)))
}
fn direct_xf(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Arc<Direct>> {
if dest.kind != ProfileKind::Rgb {
return None;
}
let key = (dest.name.clone(), intent_index(intent), bpc);
let mut cache = self.direct.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, t)) = cache.iter().find(|(k, _)| *k == key) {
return t.clone();
}
let t = self.build_direct(dest, intent, bpc).map(Arc::new);
cache.push((key, t.clone()));
t
}
fn build_direct(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Direct> {
let d = &dest.profile;
let matrix = d.red_trc.is_some() && d.green_trc.is_some() && d.blue_trc.is_some();
if !matrix {
let xf = self.profile.create_transform_f32(self.layout(), d, Layout::Rgb, Self::opts(intent)).ok()?;
return Some(Direct { to_linear: xf, to_dest: None, black: 0.0 });
}
let mut linear = d.clone();
for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] {
*trc = Some(ToneReprCurve::Parametric(vec![1.0]));
}
// A coding-independent code point names the transfer curve outright (moxcms's sRGB has
// one) and would override the linear curves.
linear.cicp = None;
let to_linear = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?;
let to_dest = linear.create_transform_f32(Layout::Rgb, d, Layout::Rgb, Self::opts(Intent::RelativeColorimetric)).ok()?;
// The source black: the darkest colour the profile reproduces, found as lcms does for an
// output profile (black sent into the profile and read back), made neutral.
let black = if bpc && self.kind == ProfileKind::Cmyk {
let inks = self.from_srgb([0.0; 3], Intent::RelativeColorimetric)?;
let mut lin = [0.0f32; 3];
to_linear.transform(&inks, &mut lin).ok()?;
let y = |c: &moxcms::Xyzd| c.y as f32;
let lum = lin[0] * y(&d.red_colorant) + lin[1] * y(&d.green_colorant) + lin[2] * y(&d.blue_colorant);
lum.clamp(0.0, 0.5)
} else {
0.0
};
Some(Direct { to_linear, to_dest: Some(to_dest), black })
}
/// Whether transforms can be built for this profile (checked at registration).
pub fn usable(&self) -> bool {
self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
+32 -1
View File
@@ -356,10 +356,37 @@ impl Cms {
}
}
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour). An ICC
/// CMYK profile converts as [`Self::cmyk_to_rgb`] does into sRGB (with the settings'
/// black-point compensation), so what shows is what an RGB export writes.
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
if let (false, CmykSpace::Icc(p)) = (absolute, &self.cmyk)
&& let Some(srgb) = builtin_rgb_cache(SRGB)
&& let Some(v) = p.to_rgb_of(&srgb, &cmyk.map(|v| v.clamp(0.0, 1.0)), Intent::RelativeColorimetric, self.settings.bpc)
{
return v;
}
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
}
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
/// RGB space holds (Wide Gamut RGB keeps CMYK cyan that sRGB can't show), and with the
/// settings' black-point compensation. The built-in CMYK spaces go through sRGB as before
/// (Generic CMYK compensates in its own model).
pub fn cmyk_to_rgb(&self, cmyk: [f32; 4], intent: Intent) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
let src = match &self.cmyk {
CmykSpace::Icc(p) => Some(p.clone()),
CmykSpace::Generic(_) | CmykSpace::Device => None,
};
let dest = match &self.rgb {
RgbSpace::Icc(p) => Some(p.clone()),
RgbSpace::Srgb => builtin_rgb_cache(SRGB),
};
src.zip(dest)
.and_then(|(s, d)| s.to_rgb_of(&d, &cmyk, intent, self.settings.bpc))
.unwrap_or_else(|| self.srgb_to_rgb(self.cmyk_to_srgb(cmyk, false)))
}
/// Display sRGB → working CMYK with `intent`.
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc)
@@ -424,6 +451,10 @@ impl Cms {
let [c, m, y, k] = self.to_cmyk(c, intent);
Color::Cmyk { c, m, y, k }
}
(Color::Cmyk { c, m, y, k }, Model::Rgb) => {
let [r, g, b] = self.cmyk_to_rgb([*c, *m, *y, *k], intent);
Color::Rgb { r, g, b }
}
(_, Model::Rgb) => {
let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c));
Color::Rgb { r, g, b }
+51
View File
@@ -60,3 +60,54 @@ fn a_loaded_profile_is_embedded_as_loaded() {
register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap();
assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]);
}
/// Settings with an ICC CMYK profile (Generic CMYK's, loaded as a file is) and Wide Gamut RGB.
fn icc_cmyk(bpc: bool) -> Cms {
let name = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Test ICC CMYK".into())).unwrap().name;
Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: name, bpc, ..ColorSettings::default() }).unwrap()
}
#[test]
fn icc_cmyk_converts_into_a_wide_rgb_space_directly_without_clipping_to_srgb() {
let wide = icc_cmyk(true);
let cyan = [1.0, 0.0, 0.0, 0.0];
let direct = wide.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
// Through sRGB, cyan clips to sRGB's gamut first: in the wider space that reads as a red
// channel well above zero.
let hub = wide.srgb_to_rgb(wide.cmyk_to_srgb(cyan, false));
assert!(direct[0] + 0.15 < hub[0], "direct {direct:?}, through sRGB {hub:?}");
// Same colour either way where sRGB holds it: a mid grey and paper white.
for inks in [[0.0, 0.0, 0.0, 0.5], [0.0; 4]] {
let (d, h) = (wide.cmyk_to_rgb(inks, Intent::RelativeColorimetric), wide.srgb_to_rgb(wide.cmyk_to_srgb(inks, false)));
assert!(d.iter().zip(h).all(|(a, b)| (a - b).abs() < 0.02), "{inks:?}: direct {d:?}, through sRGB {h:?}");
}
// Cms::convert takes the direct route for CMYK.
let Color::Rgb { r, .. } = wide.convert(&Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 }, Model::Rgb, Intent::RelativeColorimetric) else {
panic!("RGB")
};
assert!((r - direct[0]).abs() < 1e-6);
}
#[test]
fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
let rich = [0.75, 0.68, 0.67, 0.9];
let (on, off) = (icc_cmyk(true).cmyk_to_rgb(rich, Intent::RelativeColorimetric), icc_cmyk(false).cmyk_to_rgb(rich, Intent::RelativeColorimetric));
// Without compensation the profile's black is a dark grey; with it, rich black goes most of
// the way to RGB black (not all: it isn't quite the darkest ink the profile prints).
assert!(off.iter().all(|v| *v > 0.1), "without: {off:?}");
assert!(on.iter().zip(off).all(|(a, b)| *a < 0.08 && *a < b * 0.5), "with: {on:?}, without: {off:?}");
// The screen shows what an sRGB conversion writes, compensated alike.
let srgb = |bpc| {
let c = icc_cmyk(bpc);
Cms::new(&ColorSettings { rgb: SRGB.into(), ..c.settings().clone() }).unwrap()
};
for bpc in [true, false] {
let c = srgb(bpc);
assert_eq!(c.cmyk_to_srgb(rich, false), c.cmyk_to_rgb(rich, Intent::RelativeColorimetric), "bpc {bpc}");
}
// White is untouched, and absolute colorimetric never compensates.
let white = icc_cmyk(true).cmyk_to_rgb([0.0; 4], Intent::RelativeColorimetric);
assert!(white.iter().all(|v| *v > 0.99), "{white:?}");
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
assert_eq!(abs(true), abs(false));
}
+5
View File
@@ -375,6 +375,11 @@ impl Dest {
};
Color::Cmyk { c, m, y, k }
}
// CMYK straight into the destination RGB (the destination shares the source's CMYK).
(_, Color::Cmyk { c, m, y, k }) => {
let [r, g, b] = self.cms.cmyk_to_rgb([*c, *m, *y, *k], intent);
Color::Rgb { r, g, b }
}
_ => {
let [r, g, b] = self.cms.srgb_to_rgb(source.display_rgb(c));
Color::Rgb { r, g, b }