use std::sync::Arc;
#[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,
#[cfg(feature = "icc")]
inner: Option<QcmsHolder>,
}
#[cfg(feature = "icc")]
struct QcmsHolder {
inner: qcms::Transform,
}
#[cfg(feature = "icc")]
fn qcms_intent(intent: RenderingIntent) -> qcms::Intent {
match intent {
RenderingIntent::Perceptual => qcms::Intent::Perceptual,
RenderingIntent::RelativeColorimetric => qcms::Intent::RelativeColorimetric,
RenderingIntent::Saturation => qcms::Intent::Saturation,
RenderingIntent::AbsoluteColorimetric => qcms::Intent::AbsoluteColorimetric,
}
}
#[cfg(feature = "icc")]
fn try_build_qcms_holder(
profile_bytes: &[u8],
n_components: u8,
intent: RenderingIntent,
) -> Option<QcmsHolder> {
let src = qcms::Profile::new_from_slice(profile_bytes, false)?;
let dst = qcms::Profile::new_sRGB();
let i = qcms_intent(intent);
let src_ty = match n_components {
1 => qcms::DataType::Gray8,
3 => qcms::DataType::RGB8,
4 => qcms::DataType::CMYK,
_ => return None,
};
qcms::Transform::new_to(&src, &dst, src_ty, qcms::DataType::RGB8, i)
.map(|inner| QcmsHolder { inner })
}
impl Transform {
pub fn new_srgb_target(profile: Arc<IccProfile>, intent: RenderingIntent) -> Self {
#[cfg(feature = "icc")]
{
let inner = try_build_qcms_holder(profile.bytes(), profile.n_components(), intent);
Self {
source_profile: profile,
intent,
inner,
}
}
#[cfg(not(feature = "icc"))]
{
Self {
source_profile: profile,
intent,
}
}
}
pub fn convert_cmyk_pixel(&self, c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
#[cfg(feature = "icc")]
{
if let Some(holder) = &self.inner {
if self.source_profile.n_components() == 4 {
let src = [c, m, y, k];
let mut dst = [0u8; 3];
holder.inner.convert(&src, &mut dst);
return dst;
}
}
}
crate::extractors::images::cmyk_pixel_to_rgb(c, m, y, k)
}
pub fn convert_cmyk_buffer(&self, cmyk: &[u8]) -> Vec<u8> {
#[cfg(feature = "icc")]
{
if let Some(holder) = &self.inner {
if self.source_profile.n_components() == 4 {
let pixels = cmyk.len() / 4;
let mut out = vec![0u8; pixels * 3];
holder.inner.convert(cmyk, &mut out);
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> {
#[cfg(feature = "icc")]
{
if let Some(holder) = &self.inner {
if self.source_profile.n_components() == 3 {
let mut out = vec![0u8; rgb.len()];
holder.inner.convert(rgb, &mut out);
return out;
}
}
}
rgb.to_vec()
}
pub fn convert_gray_buffer(&self, gray: &[u8]) -> Vec<u8> {
#[cfg(feature = "icc")]
{
if let Some(holder) = &self.inner {
if self.source_profile.n_components() == 1 {
let mut out = vec![0u8; gray.len() * 3];
holder.inner.convert(gray, &mut out);
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_profile.n_components()
}
pub fn has_cmm(&self) -> bool {
#[cfg(feature = "icc")]
{
self.inner.is_some()
}
#[cfg(not(feature = "icc"))]
{
false
}
}
}
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_profile.n_components)
.field("cmm_live", &self.has_cmm())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
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]);
}
}