#![forbid(unsafe_code)]
pub mod backend;
use std::sync::Arc;
#[allow(unused_imports)]
use backend::{ActiveIccBackend, IccBackend, TransformFlags};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
pub enum RenderingIntent {
Perceptual,
#[default]
RelativeColorimetric,
Saturation,
AbsoluteColorimetric,
}
impl RenderingIntent {
pub fn from_pdf_name(name: &str) -> Self {
match name {
"Perceptual" => Self::Perceptual,
"Saturation" => Self::Saturation,
"AbsoluteColorimetric" => Self::AbsoluteColorimetric,
_ => Self::RelativeColorimetric,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IccHeader {
pub version: u32,
pub device_class: [u8; 4],
pub color_space: [u8; 4],
pub pcs: [u8; 4],
}
impl IccHeader {
const ACSP: [u8; 4] = *b"acsp";
pub fn parse(bytes: &[u8]) -> Option<Self> {
if bytes.len() < 128 {
return None;
}
let sig = [bytes[36], bytes[37], bytes[38], bytes[39]];
if sig != Self::ACSP {
return None;
}
let version = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let device_class = [bytes[12], bytes[13], bytes[14], bytes[15]];
let color_space = [bytes[16], bytes[17], bytes[18], bytes[19]];
let pcs = [bytes[20], bytes[21], bytes[22], bytes[23]];
Some(Self {
version,
device_class,
color_space,
pcs,
})
}
pub fn input_components(&self) -> Option<u8> {
match &self.color_space {
b"GRAY" => Some(1),
b"RGB " => Some(3),
b"Lab " | b"XYZ " => Some(3),
b"CMYK" => Some(4),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IccProfile {
bytes: Arc<Vec<u8>>,
n_components: u8,
header: IccHeader,
}
impl IccProfile {
pub fn parse(bytes: Vec<u8>, declared_n: u8) -> Option<Self> {
let header = IccHeader::parse(&bytes)?;
if let Some(hdr_n) = header.input_components() {
if hdr_n != declared_n {
return None;
}
}
Some(Self {
bytes: Arc::new(bytes),
n_components: declared_n,
header,
})
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn n_components(&self) -> u8 {
self.n_components
}
pub fn header(&self) -> &IccHeader {
&self.header
}
pub fn content_hash(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.bytes.hash(&mut h);
h.finish()
}
}
pub struct Transform {
source_profile: Arc<IccProfile>,
intent: RenderingIntent,
source_components: u8,
inner: Option<<ActiveIccBackend as IccBackend>::SrgbTransform>,
}
impl Transform {
pub fn new_srgb_target(profile: Arc<IccProfile>, intent: RenderingIntent) -> Self {
let n = profile.n_components();
let inner = <ActiveIccBackend as IccBackend>::build_srgb_transform(
&profile,
intent,
TransformFlags::press_default(),
);
Self {
source_profile: profile,
intent,
source_components: n,
inner,
}
}
pub fn convert_cmyk_pixel(&self, c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
if let Some(holder) = &self.inner {
if self.source_components == 4 {
if let Some(rgb) =
<ActiveIccBackend as IccBackend>::convert_cmyk_pixel(holder, [c, m, y, k])
{
return rgb;
}
}
}
crate::extractors::images::cmyk_pixel_to_rgb(c, m, y, k)
}
pub fn convert_cmyk_buffer(&self, cmyk: &[u8]) -> Vec<u8> {
if let Some(holder) = &self.inner {
if self.source_components == 4 {
if let Some(out) =
<ActiveIccBackend as IccBackend>::convert_cmyk_buffer(holder, cmyk)
{
return out;
}
}
}
let mut out = Vec::with_capacity((cmyk.len() / 4) * 3);
for ch in cmyk.chunks_exact(4) {
let rgb = self.convert_cmyk_pixel(ch[0], ch[1], ch[2], ch[3]);
out.extend_from_slice(&rgb);
}
out
}
pub fn convert_rgb_buffer(&self, rgb: &[u8]) -> Vec<u8> {
if let Some(holder) = &self.inner {
if self.source_components == 3 {
if let Some(out) = <ActiveIccBackend as IccBackend>::convert_rgb_buffer(holder, rgb)
{
return out;
}
}
}
rgb.to_vec()
}
pub fn convert_gray_buffer(&self, gray: &[u8]) -> Vec<u8> {
if let Some(holder) = &self.inner {
if self.source_components == 1 {
if let Some(out) =
<ActiveIccBackend as IccBackend>::convert_gray_buffer(holder, gray)
{
return out;
}
}
}
let mut out = Vec::with_capacity(gray.len() * 3);
for &g in gray {
out.extend_from_slice(&[g, g, g]);
}
out
}
pub fn source_n_components(&self) -> u8 {
self.source_components
}
pub fn has_cmm(&self) -> bool {
self.inner.is_some()
}
}
impl std::fmt::Debug for Transform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Transform")
.field("intent", &self.intent)
.field("profile_bytes", &self.source_profile.bytes.len())
.field("n_components", &self.source_components)
.field("cmm_live", &self.has_cmm())
.field("backend", &backend::active_backend_name())
.finish()
}
}
pub struct CmykRetargetTransform {
#[allow(dead_code)]
src_profile: Arc<IccProfile>,
#[allow(dead_code)]
dst_profile: Arc<IccProfile>,
intent: RenderingIntent,
inner: <ActiveIccBackend as IccBackend>::CmykRetarget,
}
impl CmykRetargetTransform {
pub fn new(
src_profile: Arc<IccProfile>,
dst_profile: Arc<IccProfile>,
intent: RenderingIntent,
) -> Option<Self> {
Self::new_with_flags(src_profile, dst_profile, intent, TransformFlags::press_default())
}
pub fn new_with_flags(
src_profile: Arc<IccProfile>,
dst_profile: Arc<IccProfile>,
intent: RenderingIntent,
flags: TransformFlags,
) -> Option<Self> {
let inner = <ActiveIccBackend as IccBackend>::build_cmyk_retarget(
&src_profile,
&dst_profile,
intent,
flags,
)?;
Some(Self {
src_profile,
dst_profile,
intent,
inner,
})
}
pub fn retarget_pixel(&self, cmyk: [f32; 4]) -> [f32; 4] {
<ActiveIccBackend as IccBackend>::retarget_cmyk_pixel(&self.inner, cmyk)
}
pub fn intent(&self) -> RenderingIntent {
self.intent
}
}
impl std::fmt::Debug for CmykRetargetTransform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CmykRetargetTransform")
.field("intent", &self.intent)
.field("src_bytes", &self.src_profile.bytes.len())
.field("dst_bytes", &self.dst_profile.bytes.len())
.field("backend", &backend::active_backend_name())
.finish()
}
}
pub const fn active_backend_supports_cmyk_retarget() -> bool {
cfg!(feature = "icc-lcms2")
}
pub struct SrgbToCmykTransform {
#[allow(dead_code)]
dst_profile: Arc<IccProfile>,
intent: RenderingIntent,
inner: <ActiveIccBackend as IccBackend>::SrgbToCmykTransform,
}
impl SrgbToCmykTransform {
pub fn new(dst_profile: Arc<IccProfile>, intent: RenderingIntent) -> Option<Self> {
Self::new_with_flags(dst_profile, intent, TransformFlags::press_default())
}
pub fn new_with_flags(
dst_profile: Arc<IccProfile>,
intent: RenderingIntent,
flags: TransformFlags,
) -> Option<Self> {
let inner =
<ActiveIccBackend as IccBackend>::build_srgb_to_cmyk(&dst_profile, intent, flags)?;
Some(Self {
dst_profile,
intent,
inner,
})
}
pub fn convert_pixel(&self, rgb: [f32; 3]) -> [f32; 4] {
<ActiveIccBackend as IccBackend>::convert_srgb_to_cmyk_pixel(&self.inner, rgb)
}
pub fn intent(&self) -> RenderingIntent {
self.intent
}
}
impl std::fmt::Debug for SrgbToCmykTransform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SrgbToCmykTransform")
.field("intent", &self.intent)
.field("dst_bytes", &self.dst_profile.bytes.len())
.field("backend", &backend::active_backend_name())
.finish()
}
}
pub const fn active_backend_supports_srgb_to_cmyk() -> bool {
cfg!(feature = "icc-lcms2")
}
const CMYK_CORNERS: [[f32; 3]; 16] = [
[1.0, 1.0, 1.0], [0.1373, 0.1216, 0.1255], [1.0, 0.9490, 0.0], [0.1098, 0.1020, 0.0], [0.9255, 0.0, 0.5490], [0.1412, 0.0, 0.0], [0.9294, 0.1098, 0.1412], [0.1333, 0.0, 0.0], [0.0, 0.6784, 0.9373], [0.0, 0.0588, 0.1412], [0.0, 0.6510, 0.3137], [0.0, 0.0745, 0.0], [0.1804, 0.1922, 0.5725], [0.0, 0.0, 0.0078], [0.2118, 0.2118, 0.2235], [0.0, 0.0, 0.0], ];
pub fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (f32, f32, f32) {
let (c, m, y, k) = (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 acc = [0.0f32; 3];
for (i, corner) in CMYK_CORNERS.iter().enumerate() {
let w = if i & 8 != 0 { c } else { 1.0 - c }
* if i & 4 != 0 { m } else { 1.0 - m }
* if i & 2 != 0 { y } else { 1.0 - y }
* if i & 1 != 0 { k } else { 1.0 - k };
if w == 0.0 {
continue;
}
for j in 0..3 {
acc[j] += w * corner[j];
}
}
(acc[0].clamp(0.0, 1.0), acc[1].clamp(0.0, 1.0), acc[2].clamp(0.0, 1.0))
}
fn solve3(a: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> {
let det3 = |m: [[f32; 3]; 3]| {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
};
let det = det3(a);
if det.abs() < 1e-9 {
return None;
}
let mut out = [0.0f32; 3];
for i in 0..3 {
let mut m = a;
for r in 0..3 {
m[r][i] = b[r];
}
out[i] = det3(m) / det;
}
Some(out)
}
pub fn rgb_to_cmyk(r: f32, g: f32, b: f32) -> (f32, f32, f32, f32) {
let target = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
let fwd = |x: [f32; 3]| {
let (fr, fg, fb) = cmyk_to_rgb(x[0], x[1], x[2], 0.0);
[fr, fg, fb]
};
let resid = |x: [f32; 3]| {
let f = fwd(x);
(target[0] - f[0]).abs() + (target[1] - f[1]).abs() + (target[2] - f[2]).abs()
};
let mut x = [1.0 - target[0], 1.0 - target[1], 1.0 - target[2]];
let mut best_x = x;
let mut best_r = resid(x);
const EPS: f32 = 1e-3;
for _ in 0..16 {
if best_r < 1.0 / 255.0 {
break;
}
let f0 = fwd(x);
let res = [target[0] - f0[0], target[1] - f0[1], target[2] - f0[2]];
let mut j = [[0.0f32; 3]; 3];
for c in 0..3 {
let step = if x[c] + EPS <= 1.0 { EPS } else { -EPS };
let mut xp = x;
xp[c] += step;
let fp = fwd(xp);
for (row, jr) in j.iter_mut().enumerate() {
jr[c] = (fp[row] - f0[row]) / step;
}
}
let Some(delta) = solve3(j, res) else { break };
for (k, xk) in x.iter_mut().enumerate() {
*xk = (*xk + delta[k]).clamp(0.0, 1.0);
}
let rr = resid(x);
if rr < best_r {
best_r = rr;
best_x = x;
}
}
(best_x[0], best_x[1], best_x[2], 0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_to_cmyk_round_trips_in_gamut_and_stays_in_range() {
let q = |v: f32| (v * 255.0).round() as i32;
for &(c0, m0, y0) in &[
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
(0.5, 0.2, 0.0),
(0.3, 0.3, 0.3),
(0.8, 0.4, 0.1),
(1.0, 1.0, 1.0),
] {
let (r, g, b) = cmyk_to_rgb(c0, m0, y0, 0.0);
let (c, m, y, k) = rgb_to_cmyk(r, g, b);
assert_eq!(k, 0.0, "separation uses K=0");
let (rr, gg, bb) = cmyk_to_rgb(c, m, y, k);
for (got, want) in [(rr, r), (gg, g), (bb, b)] {
assert!(
(q(got) - q(want)).abs() <= 3,
"in-gamut round-trip off: cmy ({c0},{m0},{y0}) rgb ({r},{g},{b}) -> ({rr},{gg},{bb})"
);
}
}
let (c, m, y, k) = rgb_to_cmyk(0.0, 0.0, 1.0);
for v in [c, m, y, k] {
assert!((0.0..=1.0).contains(&v), "out-of-gamut CMYK stays in range");
}
}
#[test]
fn cmyk_uses_process_inks_not_the_naive_complement() {
let q = |v: f32| (v * 255.0).round() as u8;
let rgb = |c, m, y, k| {
let (r, g, b) = cmyk_to_rgb(c, m, y, k);
[q(r), q(g), q(b)]
};
assert_eq!(rgb(0.0, 0.0, 0.0, 1.0), [35, 31, 32]);
assert_eq!(rgb(1.0, 0.0, 0.0, 0.0), [0, 173, 239]);
assert_eq!(rgb(0.0, 1.0, 0.0, 0.0), [236, 0, 140]);
assert_eq!(rgb(0.0, 0.0, 1.0, 0.0), [255, 242, 0]);
assert_eq!(rgb(0.0, 0.0, 0.0, 0.0), [255, 255, 255]);
assert_eq!(rgb(1.0, 1.0, 1.0, 1.0), [0, 0, 0]);
assert_eq!(rgb(0.669, 0.0, 0.381, 0.0), [84, 197, 172]);
}
fn minimal_header(cs: &[u8; 4], n_bytes: usize) -> Vec<u8> {
let mut v = vec![0u8; n_bytes.max(128)];
v[8..12].copy_from_slice(&0x04200000u32.to_be_bytes());
v[12..16].copy_from_slice(b"prtr");
v[16..20].copy_from_slice(cs);
v[20..24].copy_from_slice(b"Lab ");
v[36..40].copy_from_slice(b"acsp");
v
}
#[test]
fn header_parse_requires_acsp_signature() {
let mut bytes = minimal_header(b"CMYK", 128);
bytes[36..40].copy_from_slice(b"xxxx");
assert!(IccHeader::parse(&bytes).is_none());
}
#[test]
fn header_parse_rejects_short_input() {
let bytes = vec![0u8; 127];
assert!(IccHeader::parse(&bytes).is_none());
}
#[test]
fn header_identifies_cmyk_as_4_components() {
let bytes = minimal_header(b"CMYK", 128);
let h = IccHeader::parse(&bytes).expect("valid header");
assert_eq!(h.input_components(), Some(4));
assert_eq!(&h.color_space, b"CMYK");
assert_eq!(&h.device_class, b"prtr");
}
#[test]
fn profile_parse_rejects_n_mismatch() {
let bytes = minimal_header(b"CMYK", 128);
assert!(IccProfile::parse(bytes, 3).is_none());
}
#[test]
fn profile_parse_accepts_matching_n() {
let bytes = minimal_header(b"CMYK", 128);
let p = IccProfile::parse(bytes, 4).expect("should parse");
assert_eq!(p.n_components(), 4);
}
#[test]
fn intent_default_is_relative_colorimetric() {
assert_eq!(RenderingIntent::default(), RenderingIntent::RelativeColorimetric);
}
#[test]
fn intent_from_pdf_name_falls_back_to_relative_colorimetric() {
assert_eq!(
RenderingIntent::from_pdf_name("WhateverNotReal"),
RenderingIntent::RelativeColorimetric,
);
assert_eq!(RenderingIntent::from_pdf_name("Perceptual"), RenderingIntent::Perceptual,);
assert_eq!(RenderingIntent::from_pdf_name("Saturation"), RenderingIntent::Saturation,);
assert_eq!(
RenderingIntent::from_pdf_name("AbsoluteColorimetric"),
RenderingIntent::AbsoluteColorimetric,
);
}
#[test]
fn phase1_transform_preserves_srgb_white() {
let bytes = minimal_header(b"CMYK", 128);
let p = Arc::new(IccProfile::parse(bytes, 4).unwrap());
let t = Transform::new_srgb_target(p, RenderingIntent::RelativeColorimetric);
assert_eq!(t.convert_cmyk_pixel(0, 0, 0, 0), [255, 255, 255]);
assert_eq!(t.convert_cmyk_pixel(255, 255, 255, 255), [0, 0, 0]);
}
#[test]
fn active_backend_retarget_capability_matches_feature() {
let cap = active_backend_supports_cmyk_retarget();
#[cfg(feature = "icc-lcms2")]
assert!(cap, "icc-lcms2 build must report retarget capable");
#[cfg(not(feature = "icc-lcms2"))]
assert!(!cap, "non-lcms2 build must report retarget UNcapable");
}
}