mod cie;
mod cmyk_table;
mod device;
mod icc;
mod indexed;
mod load;
mod special;
mod srgb_table;
mod value;
pub(crate) use cie::{CalGray, CalRgb, Lab};
pub use device::adobe_cmyk_to_srgb;
pub(crate) use icc::{IccBased, IccProfile, is_valid_icc_components};
pub use icc::{cmyk_profile_bytes, srgb_profile_bytes};
pub(crate) use indexed::Indexed;
pub(crate) use load::ColorSpaceCache;
pub use load::load_colorspace;
pub(crate) use special::{DeviceN, MAX_PATTERN_COMPONENTS};
pub use special::{PatternSpace, Separation};
pub use value::{ColorValue, PatternValue, SetComponentsError};
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Rgb {
pub r: f32,
pub g: f32,
pub b: f32,
}
impl Rgb {
pub const BLACK: Self = Self {
r: 0.0,
g: 0.0,
b: 0.0,
};
#[must_use]
pub fn to_bytes(self) -> [u8; 3] {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the product to 0..=255"
)]
let enc = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
[enc(self.r), enc(self.g), enc(self.b)]
}
#[must_use]
pub fn to_bytes_truncating(self) -> [u8; 3] {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamp bounds the product to 0..=255"
)]
let enc = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u8;
[enc(self.r), enc(self.g), enc(self.b)]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Family {
Unknown = 0,
DeviceGray = 1,
DeviceRgb = 2,
DeviceCmyk = 3,
CalGray = 4,
CalRgb = 5,
Lab = 6,
IccBased = 7,
Separation = 8,
DeviceN = 9,
Indexed = 10,
Pattern = 11,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ColorSpace {
DeviceGray,
DeviceRgb,
DeviceCmyk,
CalGray(Box<CalGray>),
CalRgb(Box<CalRgb>),
Lab(Box<Lab>),
IccBased(Box<IccBased>),
Indexed(Box<Indexed>),
Separation(Box<Separation>),
DeviceN(Box<DeviceN>),
Pattern(Box<PatternSpace>),
}
impl ColorSpace {
#[must_use]
pub fn family(&self) -> Family {
match self {
Self::DeviceGray => Family::DeviceGray,
Self::DeviceRgb => Family::DeviceRgb,
Self::DeviceCmyk => Family::DeviceCmyk,
Self::CalGray(_) => Family::CalGray,
Self::CalRgb(_) => Family::CalRgb,
Self::Lab(_) => Family::Lab,
Self::IccBased(_) => Family::IccBased,
Self::Indexed(_) => Family::Indexed,
Self::Separation(_) => Family::Separation,
Self::DeviceN(_) => Family::DeviceN,
Self::Pattern(_) => Family::Pattern,
}
}
#[must_use]
pub fn n_components(&self) -> usize {
match self {
Self::DeviceGray | Self::CalGray(_) | Self::Indexed(_) | Self::Separation(_) => 1,
Self::DeviceRgb | Self::CalRgb(_) | Self::Lab(_) => 3,
Self::DeviceCmyk => 4,
Self::IccBased(icc) => usize::from(icc.n),
Self::DeviceN(cs) => cs.names.len(),
Self::Pattern(cs) => cs.n_components(),
}
}
#[must_use]
pub fn is_special(&self) -> bool {
matches!(
self,
Self::Separation(_) | Self::DeviceN(_) | Self::Indexed(_) | Self::Pattern(_)
)
}
#[must_use]
pub(crate) fn needs_image_conversion(&self) -> bool {
matches!(self, Self::Separation(_) | Self::DeviceN(_))
}
#[must_use]
pub fn is_normal(&self) -> bool {
match self {
Self::DeviceGray
| Self::DeviceRgb
| Self::DeviceCmyk
| Self::CalGray(_)
| Self::CalRgb(_) => true,
Self::IccBased(icc) => icc.is_normal(),
_ => false,
}
}
#[must_use]
pub fn to_rgb(&self, comps: &[f32]) -> Rgb {
self.try_to_rgb(comps).unwrap_or(Rgb::BLACK)
}
#[must_use]
pub fn try_to_rgb(&self, comps: &[f32]) -> Option<Rgb> {
match self {
Self::DeviceGray => Some(device::gray_to_rgb(comps)),
Self::DeviceRgb => Some(device::rgb_to_rgb(comps)),
Self::DeviceCmyk => Some(device::cmyk_to_rgb(comps)),
Self::CalGray(_) => Some(cie::cal_gray_to_rgb(comps)),
Self::CalRgb(cs) => Some(cie::cal_rgb_to_rgb(cs, comps)),
Self::Lab(_) => Some(cie::lab_to_rgb(comps)),
Self::IccBased(icc) => Some(icc.to_rgb(comps)),
Self::Indexed(cs) => cs.to_rgb(comps),
Self::Separation(cs) => cs.to_rgb(comps),
Self::DeviceN(cs) => cs.to_rgb(comps),
Self::Pattern(_) => None,
}
}
#[must_use]
pub fn default_value(&self, index: usize) -> (f32, f32, f32) {
match self {
Self::Lab(lab) => lab.default_value(index),
Self::Indexed(cs) => (0.0, 0.0, f32::from(cs.max_index)),
Self::Separation(_) | Self::DeviceN(_) => (1.0, 0.0, 1.0),
_ => (0.0, 0.0, 1.0),
}
}
#[must_use]
pub fn default_color(&self) -> Vec<f32> {
(0..self.n_components())
.map(|i| self.default_value(i).0)
.collect()
}
pub fn translate_image_line(
&self,
dest: &mut [u8],
samples: &[u8],
pixels: usize,
trans_mask: bool,
) {
match self {
Self::DeviceGray | Self::CalGray(_) => {
for i in 0..pixels {
let v = samples.get(i).copied().unwrap_or(0);
if let Some(px) = dest.get_mut(i * 3..i * 3 + 3) {
px.fill(v);
}
}
}
Self::DeviceRgb | Self::CalRgb(_) => reverse_rgb(dest, samples, pixels),
Self::DeviceCmyk => {
Self::translate_cmyk_line(dest, samples, pixels, trans_mask);
}
Self::Lab(_) => {
for i in 0..pixels {
let Some(&[l, a, b]) = triple(samples, i) else {
continue;
};
let comps = [
f32::from(l) * 100.0 / 255.0,
f32::from(a) - 128.0,
f32::from(b) - 128.0,
];
write_bgr(dest, i, cie::lab_to_rgb(&comps));
}
}
Self::IccBased(icc) => {
if icc.profile.is_srgb() {
reverse_rgb(dest, samples, pixels);
} else if icc.profile.is_supported() {
self.translate_generic_line(dest, samples, pixels);
} else if let Some(base) = &icc.base {
base.translate_image_line(dest, samples, pixels, false);
} else {
for i in 0..pixels {
write_bgr(dest, i, Rgb::BLACK);
}
}
}
_ => self.translate_generic_line(dest, samples, pixels),
}
}
#[expect(
clippy::many_single_char_names,
reason = "cyan, magenta, yellow and black are single-letter by convention"
)]
fn translate_cmyk_line(dest: &mut [u8], samples: &[u8], pixels: usize, trans_mask: bool) {
for i in 0..pixels {
let Some(&[c8, m8, y8, k8]) = samples
.get(i * 4..i * 4 + 4)
.and_then(|s| <&[u8; 4]>::try_from(s).ok())
else {
continue;
};
let (c, m, y, k) = (u32::from(c8), u32::from(m8), u32::from(y8), u32::from(k8));
#[expect(
clippy::cast_possible_truncation,
reason = "every arm's arithmetic stays within a byte"
)]
let bgr = if trans_mask {
let kk = 255 - k;
[
(((255 - c) * kk) / 255) as u8,
(((255 - m) * kk) / 255) as u8,
(((255 - y) * kk) / 255) as u8,
]
} else {
let [r, g, b] = device::adobe_cmyk_to_srgb(c8, m8, y8, k8);
[b, g, r]
};
write_bytes(dest, i, bgr);
}
}
fn translate_generic_line(&self, dest: &mut [u8], samples: &[u8], pixels: usize) {
let n = self.n_components();
let divisor = if matches!(self, Self::Indexed(_)) {
1.0
} else {
255.0
};
let mut comps = vec![0.0f32; n.max(1)];
for i in 0..pixels {
for (j, slot) in comps.iter_mut().enumerate() {
*slot = f32::from(samples.get(i * n + j).copied().unwrap_or(0)) / divisor;
}
write_bgr(dest, i, self.to_rgb(&comps));
}
}
}
fn write_bgr(dest: &mut [u8], index: usize, rgb: Rgb) {
let [r, g, b] = rgb.to_bytes_truncating();
write_bytes(dest, index, [b, g, r]);
}
fn write_bytes(dest: &mut [u8], index: usize, bytes: [u8; 3]) {
if let Some(px) = dest.get_mut(index * 3..index * 3 + 3) {
px.copy_from_slice(&bytes);
}
}
fn triple(samples: &[u8], index: usize) -> Option<&[u8; 3]> {
samples
.get(index * 3..index * 3 + 3)
.and_then(|s| <&[u8; 3]>::try_from(s).ok())
}
fn reverse_rgb(dest: &mut [u8], samples: &[u8], pixels: usize) {
for i in 0..pixels {
let Some(&[r, g, b]) = triple(samples, i) else {
continue;
};
write_bytes(dest, i, [b, g, r]);
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{ColorSpace, Family, Rgb};
#[test]
fn the_bulk_cmyk_arm_is_the_adobe_table() {
let cs = ColorSpace::DeviceCmyk;
let src = [128u8, 64, 0, 64];
let mut dest = [0u8; 3];
cs.translate_image_line(&mut dest, &src, 1, false);
let [r, g, b] = super::device::adobe_cmyk_to_srgb(128, 64, 0, 64);
assert_eq!(dest, [b, g, r]);
let would_have_been = [191u8, 127, 63];
assert_ne!(
dest, would_have_been,
"the Adobe table must not agree with the naive formula here"
);
}
#[test]
fn trans_mask_takes_the_naive_un_inversion() {
let cs = ColorSpace::DeviceCmyk;
let src = [128u8, 64, 0, 64];
let mut a = [0u8; 3];
cs.translate_image_line(&mut a, &src, 1, true);
assert_eq!(a, [95, 143, 191]);
}
#[test]
fn component_counts_match_the_families() {
assert_eq!(ColorSpace::DeviceGray.n_components(), 1);
assert_eq!(ColorSpace::DeviceRgb.n_components(), 3);
assert_eq!(ColorSpace::DeviceCmyk.n_components(), 4);
assert_eq!(ColorSpace::DeviceGray.family(), Family::DeviceGray);
assert!(!ColorSpace::DeviceRgb.is_special());
assert!(ColorSpace::DeviceRgb.is_normal());
}
#[test]
fn cal_gray_translates_one_byte_per_pixel_replicated() {
let cs = ColorSpace::CalGray(Box::new(super::CalGray {
white_point: [0.9505, 1.0, 1.089],
black_point: [0.0; 3],
gamma: 1.0,
}));
let src = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 128, 128, 128];
let mut dest = [0u8; 12];
cs.translate_image_line(&mut dest, &src, 4, false);
assert_eq!(dest, [255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
}
#[test]
fn cal_rgb_bulk_is_only_a_red_blue_swap() {
let cs = ColorSpace::CalRgb(Box::new(super::CalRgb {
white_point: [0.9505, 1.0, 1.089],
black_point: [0.0; 3],
gamma: Some([2.2, 2.2, 2.2]),
matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
}));
let src = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 128, 128, 128];
let mut dest = [0u8; 12];
cs.translate_image_line(&mut dest, &src, 4, false);
assert_eq!(dest, [0, 0, 255, 0, 255, 0, 255, 0, 0, 128, 128, 128]);
}
#[test]
fn rgb_encodings_round_and_truncate_differently() {
let c = Rgb {
r: 0.5,
g: 0.5,
b: 0.5,
};
assert_eq!(c.to_bytes(), [128, 128, 128]);
assert_eq!(c.to_bytes_truncating(), [127, 127, 127]);
let c = Rgb {
r: -1.0,
g: 2.0,
b: 0.0,
};
assert_eq!(c.to_bytes(), [0, 255, 0]);
assert_eq!(c.to_bytes_truncating(), [0, 255, 0]);
}
#[test]
fn separation_and_device_n_start_at_full_colorant() {
let sep = ColorSpace::Separation(Box::new(super::Separation {
none: false,
alternate: Some(Box::new(ColorSpace::DeviceGray)),
tint: None,
}));
assert_eq!(sep.default_color(), vec![1.0]);
assert_eq!(ColorSpace::DeviceRgb.default_color(), vec![0.0, 0.0, 0.0]);
assert_eq!(ColorSpace::DeviceCmyk.default_color(), vec![0.0; 4]);
}
#[test]
fn pattern_has_no_scalar_colour() {
let cs = ColorSpace::Pattern(Box::default());
assert!(cs.try_to_rgb(&[0.5]).is_none());
assert_eq!(cs.to_rgb(&[0.5]), Rgb::BLACK);
}
}