use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinRgba {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Srgba {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hsl {
pub h: f32,
pub s: f32,
pub l: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CieXyz {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl CieXyz {
pub const D65_WHITE: Self = Self {
x: 0.95047,
y: 1.0,
z: 1.08883,
};
pub const D50_WHITE: Self = Self {
x: 0.96422,
y: 1.0,
z: 0.82521,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CieLab {
pub l: f64,
pub a: f64,
pub b: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cmyk {
pub c: f32,
pub m: f32,
pub y: f32,
pub k: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Oklab {
pub l: f32,
pub a: f32,
pub b: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Oklch {
pub l: f32,
pub c: f32,
pub h: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ColorSpace {
Srgb,
LinearRgb,
DisplayP3,
Bt601,
Bt709,
Bt2020,
CieXyz,
}
impl std::fmt::Display for ColorSpace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Srgb => write!(f, "sRGB"),
Self::LinearRgb => write!(f, "Linear RGB"),
Self::DisplayP3 => write!(f, "Display P3"),
Self::Bt601 => write!(f, "BT.601"),
Self::Bt709 => write!(f, "BT.709"),
Self::Bt2020 => write!(f, "BT.2020"),
Self::CieXyz => write!(f, "CIE XYZ"),
}
}
}
#[inline]
#[must_use]
pub fn srgb_to_linear(c: u8) -> f32 {
let s = c as f32 / 255.0;
if s <= 0.04045 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
}
#[inline]
#[must_use]
pub fn linear_to_srgb(c: f32) -> u8 {
let s = if c <= 0.0031308 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
};
(s * 255.0 + 0.5).clamp(0.0, 255.0) as u8
}
impl From<Srgba> for LinRgba {
#[inline]
fn from(c: Srgba) -> Self {
Self {
r: srgb_to_linear(c.r),
g: srgb_to_linear(c.g),
b: srgb_to_linear(c.b),
a: c.a as f32 / 255.0,
}
}
}
impl From<LinRgba> for Srgba {
#[inline]
fn from(c: LinRgba) -> Self {
Self {
r: linear_to_srgb(c.r),
g: linear_to_srgb(c.g),
b: linear_to_srgb(c.b),
a: (c.a * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
}
}
}
impl From<Srgba> for Hsl {
#[inline]
fn from(c: Srgba) -> Self {
let r = c.r as f32 / 255.0;
let g = c.g as f32 / 255.0;
let b = c.b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let l = (max + min) / 2.0;
if d < 1e-6 {
return Hsl { h: 0.0, s: 0.0, l };
}
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if (max - r).abs() < 1e-6 {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if (max - g).abs() < 1e-6 {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
Hsl { h: h * 60.0, s, l }
}
}
#[inline]
fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 {
if t < 0.0 {
t += 1.0;
}
if t > 1.0 {
t -= 1.0;
}
if t < 1.0 / 6.0 {
return p + (q - p) * 6.0 * t;
}
if t < 0.5 {
return q;
}
if t < 2.0 / 3.0 {
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
}
p
}
impl From<Hsl> for Srgba {
#[inline]
fn from(c: Hsl) -> Self {
if c.s < 1e-6 {
let v = (c.l * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
return Srgba {
r: v,
g: v,
b: v,
a: 255,
};
}
let q = if c.l < 0.5 {
c.l * (1.0 + c.s)
} else {
c.l + c.s - c.l * c.s
};
let p = 2.0 * c.l - q;
let h = c.h / 360.0;
let r = hue_to_rgb(p, q, h + 1.0 / 3.0);
let g = hue_to_rgb(p, q, h);
let b = hue_to_rgb(p, q, h - 1.0 / 3.0);
Srgba {
r: (r * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
g: (g * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
b: (b * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
a: 255,
}
}
}
const SRGB_TO_XYZ: [[f64; 3]; 3] = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
];
const XYZ_TO_SRGB: [[f64; 3]; 3] = [
[3.2404542, -1.5371385, -0.4985314],
[-0.9692660, 1.8760108, 0.0415560],
[0.0556434, -0.2040259, 1.0572252],
];
impl From<LinRgba> for CieXyz {
#[inline]
fn from(c: LinRgba) -> Self {
let r = c.r as f64;
let g = c.g as f64;
let b = c.b as f64;
CieXyz {
x: SRGB_TO_XYZ[0][0] * r + SRGB_TO_XYZ[0][1] * g + SRGB_TO_XYZ[0][2] * b,
y: SRGB_TO_XYZ[1][0] * r + SRGB_TO_XYZ[1][1] * g + SRGB_TO_XYZ[1][2] * b,
z: SRGB_TO_XYZ[2][0] * r + SRGB_TO_XYZ[2][1] * g + SRGB_TO_XYZ[2][2] * b,
}
}
}
impl From<CieXyz> for LinRgba {
#[inline]
fn from(c: CieXyz) -> Self {
LinRgba {
r: (XYZ_TO_SRGB[0][0] * c.x + XYZ_TO_SRGB[0][1] * c.y + XYZ_TO_SRGB[0][2] * c.z) as f32,
g: (XYZ_TO_SRGB[1][0] * c.x + XYZ_TO_SRGB[1][1] * c.y + XYZ_TO_SRGB[1][2] * c.z) as f32,
b: (XYZ_TO_SRGB[2][0] * c.x + XYZ_TO_SRGB[2][1] * c.y + XYZ_TO_SRGB[2][2] * c.z) as f32,
a: 1.0,
}
}
}
const D65_XN: f64 = 0.95047;
const D65_YN: f64 = 1.00000;
const D65_ZN: f64 = 1.08883;
const LAB_EPSILON: f64 = 0.008856; const LAB_KAPPA: f64 = 903.3;
#[inline]
fn lab_f(t: f64) -> f64 {
if t > LAB_EPSILON {
t.cbrt()
} else {
(LAB_KAPPA * t + 16.0) / 116.0
}
}
#[inline]
fn lab_f_inv(t: f64) -> f64 {
if t > 6.0 / 29.0 {
t * t * t
} else {
3.0 * (6.0 / 29.0) * (6.0 / 29.0) * (t - 4.0 / 29.0)
}
}
impl From<CieXyz> for CieLab {
#[inline]
fn from(c: CieXyz) -> Self {
let fx = lab_f(c.x / D65_XN);
let fy = lab_f(c.y / D65_YN);
let fz = lab_f(c.z / D65_ZN);
CieLab {
l: 116.0 * fy - 16.0,
a: 500.0 * (fx - fy),
b: 200.0 * (fy - fz),
}
}
}
impl From<CieLab> for CieXyz {
#[inline]
fn from(c: CieLab) -> Self {
let fy = (c.l + 16.0) / 116.0;
let fx = c.a / 500.0 + fy;
let fz = fy - c.b / 200.0;
CieXyz {
x: D65_XN * lab_f_inv(fx),
y: D65_YN * lab_f_inv(fy),
z: D65_ZN * lab_f_inv(fz),
}
}
}
impl From<Srgba> for CieLab {
#[inline]
fn from(c: Srgba) -> Self {
let lin: LinRgba = c.into();
let xyz: CieXyz = lin.into();
xyz.into()
}
}
const P3_TO_SRGB: [[f64; 3]; 3] = [
[1.2249401, -0.2249402, 0.0000001],
[-0.0420569, 1.0420571, -0.0000002],
[-0.0196376, -0.0786361, 1.0982735],
];
const SRGB_TO_P3: [[f64; 3]; 3] = [
[0.8224622, 0.1775380, -0.0000002],
[0.0331942, 0.9668058, 0.0000000],
[0.0170608, 0.0723740, 0.9105650],
];
#[inline]
#[must_use]
pub fn p3_to_linear_srgb(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
(
P3_TO_SRGB[0][0] * r + P3_TO_SRGB[0][1] * g + P3_TO_SRGB[0][2] * b,
P3_TO_SRGB[1][0] * r + P3_TO_SRGB[1][1] * g + P3_TO_SRGB[1][2] * b,
P3_TO_SRGB[2][0] * r + P3_TO_SRGB[2][1] * g + P3_TO_SRGB[2][2] * b,
)
}
#[inline]
#[must_use]
pub fn linear_srgb_to_p3(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
(
SRGB_TO_P3[0][0] * r + SRGB_TO_P3[0][1] * g + SRGB_TO_P3[0][2] * b,
SRGB_TO_P3[1][0] * r + SRGB_TO_P3[1][1] * g + SRGB_TO_P3[1][2] * b,
SRGB_TO_P3[2][0] * r + SRGB_TO_P3[2][1] * g + SRGB_TO_P3[2][2] * b,
)
}
#[inline]
#[must_use]
pub fn cmyk_to_srgb(c: &Cmyk) -> Srgba {
let r = (255.0 * (1.0 - c.c) * (1.0 - c.k) + 0.5).clamp(0.0, 255.0) as u8;
let g = (255.0 * (1.0 - c.m) * (1.0 - c.k) + 0.5).clamp(0.0, 255.0) as u8;
let b = (255.0 * (1.0 - c.y) * (1.0 - c.k) + 0.5).clamp(0.0, 255.0) as u8;
Srgba { r, g, b, a: 255 }
}
#[inline]
#[must_use]
pub fn srgb_to_cmyk(c: &Srgba) -> Cmyk {
let r = c.r as f32 / 255.0;
let g = c.g as f32 / 255.0;
let b = c.b as f32 / 255.0;
let k = 1.0 - r.max(g).max(b);
if k >= 1.0 - 1e-6 {
return Cmyk {
c: 0.0,
m: 0.0,
y: 0.0,
k: 1.0,
};
}
let inv_k = 1.0 / (1.0 - k);
Cmyk {
c: (1.0 - r - k) * inv_k,
m: (1.0 - g - k) * inv_k,
y: (1.0 - b - k) * inv_k,
k,
}
}
#[allow(clippy::excessive_precision)]
impl From<LinRgba> for Oklab {
#[inline]
fn from(c: LinRgba) -> Self {
let l = 0.4122214708_f32 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
let m = 0.2119034982_f32 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
let s = 0.0883024619_f32 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
let l_ = l.cbrt();
let m_ = m.cbrt();
let s_ = s.cbrt();
Oklab {
l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
}
}
}
#[allow(clippy::excessive_precision)]
impl From<Oklab> for LinRgba {
#[inline]
fn from(c: Oklab) -> Self {
let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b;
let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b;
let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b;
let l = l_ * l_ * l_;
let m = m_ * m_ * m_;
let s = s_ * s_ * s_;
LinRgba {
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
a: 1.0,
}
}
}
impl From<Oklab> for Oklch {
#[inline]
fn from(c: Oklab) -> Self {
let chroma = (c.a * c.a + c.b * c.b).sqrt();
let hue = if chroma < 1e-8 {
0.0
} else {
c.b.atan2(c.a).to_degrees().rem_euclid(360.0)
};
Oklch {
l: c.l,
c: chroma,
h: hue,
}
}
}
impl From<Oklch> for Oklab {
#[inline]
fn from(c: Oklch) -> Self {
let h_rad = c.h.to_radians();
Oklab {
l: c.l,
a: c.c * h_rad.cos(),
b: c.c * h_rad.sin(),
}
}
}
impl From<Srgba> for Oklab {
#[inline]
fn from(c: Srgba) -> Self {
let lin: LinRgba = c.into();
lin.into()
}
}
#[must_use]
pub fn color_temperature(kelvin: f32) -> [f32; 3] {
if kelvin.is_nan() {
return [1.0, 1.0, 1.0]; }
let temp = kelvin.clamp(1000.0, 40000.0) / 100.0;
let r = if temp <= 66.0 {
1.0
} else {
(329.699 * (temp - 60.0).powf(-0.133_205) / 255.0).clamp(0.0, 1.0)
};
let g = if temp <= 66.0 {
((99.4708 * temp.ln() - 161.1196) / 255.0).clamp(0.0, 1.0)
} else {
(288.1222 * (temp - 60.0).powf(-0.075_515) / 255.0).clamp(0.0, 1.0)
};
let b = if temp >= 66.0 {
1.0
} else if temp <= 19.0 {
0.0
} else {
((138.5177 * (temp - 10.0).ln() - 305.0448) / 255.0).clamp(0.0, 1.0)
};
[r, g, b]
}
#[inline]
#[must_use]
pub fn delta_e_cie76(a: &CieLab, b: &CieLab) -> f64 {
let dl = a.l - b.l;
let da = a.a - b.a;
let db = a.b - b.b;
(dl * dl + da * da + db * db).sqrt()
}
#[inline]
#[must_use]
pub fn delta_e_cie94(a: &CieLab, b: &CieLab) -> f64 {
let dl = a.l - b.l;
let da = a.a - b.a;
let db = a.b - b.b;
let c1 = (a.a * a.a + a.b * a.b).sqrt();
let c2 = (b.a * b.a + b.b * b.b).sqrt();
let dc = c1 - c2;
let dh_sq = da * da + db * db - dc * dc;
let dh_sq = dh_sq.max(0.0);
let sl = 1.0;
let sc = 1.0 + 0.045 * c1;
let sh = 1.0 + 0.015 * c1;
((dl / sl).powi(2) + (dc / sc).powi(2) + dh_sq / (sh * sh)).sqrt()
}
#[inline]
#[must_use]
pub fn delta_e_ciede2000(lab1: &CieLab, lab2: &CieLab) -> f64 {
use std::f64::consts::PI;
let c1_star = (lab1.a * lab1.a + lab1.b * lab1.b).sqrt();
let c2_star = (lab2.a * lab2.a + lab2.b * lab2.b).sqrt();
let c_bar = (c1_star + c2_star) / 2.0;
let c_bar_7 = c_bar.powi(7);
let g = 0.5 * (1.0 - (c_bar_7 / (c_bar_7 + 6103515625.0_f64)).sqrt());
let a1_prime = lab1.a * (1.0 + g);
let a2_prime = lab2.a * (1.0 + g);
let c1_prime = (a1_prime * a1_prime + lab1.b * lab1.b).sqrt();
let c2_prime = (a2_prime * a2_prime + lab2.b * lab2.b).sqrt();
let h1_prime = lab1.b.atan2(a1_prime).to_degrees().rem_euclid(360.0);
let h2_prime = lab2.b.atan2(a2_prime).to_degrees().rem_euclid(360.0);
let dl_prime = lab2.l - lab1.l;
let dc_prime = c2_prime - c1_prime;
let dh_prime_deg = if c1_prime * c2_prime < 1e-10 {
0.0
} else if (h2_prime - h1_prime).abs() <= 180.0 {
h2_prime - h1_prime
} else if h2_prime - h1_prime > 180.0 {
h2_prime - h1_prime - 360.0
} else {
h2_prime - h1_prime + 360.0
};
let dh_prime = 2.0 * (c1_prime * c2_prime).sqrt() * (dh_prime_deg / 2.0 * PI / 180.0).sin();
let l_bar_prime = (lab1.l + lab2.l) / 2.0;
let c_bar_prime = (c1_prime + c2_prime) / 2.0;
let h_bar_prime = if c1_prime * c2_prime < 1e-10 {
h1_prime + h2_prime
} else if (h1_prime - h2_prime).abs() <= 180.0 {
(h1_prime + h2_prime) / 2.0
} else if h1_prime + h2_prime < 360.0 {
(h1_prime + h2_prime + 360.0) / 2.0
} else {
(h1_prime + h2_prime - 360.0) / 2.0
};
let t = 1.0 - 0.17 * ((h_bar_prime - 30.0) * PI / 180.0).cos()
+ 0.24 * ((2.0 * h_bar_prime) * PI / 180.0).cos()
+ 0.32 * ((3.0 * h_bar_prime + 6.0) * PI / 180.0).cos()
- 0.20 * ((4.0 * h_bar_prime - 63.0) * PI / 180.0).cos();
let l_diff = l_bar_prime - 50.0;
let sl = 1.0 + 0.015 * l_diff * l_diff / (20.0 + l_diff * l_diff).sqrt();
let sc = 1.0 + 0.045 * c_bar_prime;
let sh = 1.0 + 0.015 * c_bar_prime * t;
let d_theta = 30.0 * (-((h_bar_prime - 275.0) / 25.0).powi(2)).exp();
let c_bar_prime_7 = c_bar_prime.powi(7);
let rc = 2.0 * (c_bar_prime_7 / (c_bar_prime_7 + 6103515625.0_f64)).sqrt();
let rt = -(2.0 * d_theta * PI / 180.0).sin() * rc;
let l_term = dl_prime / sl;
let c_term = dc_prime / sc;
let h_term = dh_prime / sh;
(l_term * l_term + c_term * c_term + h_term * h_term + rt * c_term * h_term).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn srgb_linear_roundtrip() {
for v in [0u8, 1, 50, 128, 200, 255] {
let lin = srgb_to_linear(v);
let back = linear_to_srgb(lin);
assert!(
(v as i16 - back as i16).unsigned_abs() <= 1,
"v={v} back={back}"
);
}
}
#[test]
fn srgba_to_linrgba_black() {
let c: LinRgba = Srgba {
r: 0,
g: 0,
b: 0,
a: 255,
}
.into();
assert_eq!(c.r, 0.0);
assert_eq!(c.g, 0.0);
assert_eq!(c.b, 0.0);
assert!((c.a - 1.0).abs() < 1e-3);
}
#[test]
fn srgba_to_linrgba_white() {
let c: LinRgba = Srgba {
r: 255,
g: 255,
b: 255,
a: 255,
}
.into();
assert!((c.r - 1.0).abs() < 1e-3);
}
#[test]
fn hsl_from_red() {
let hsl: Hsl = Srgba {
r: 255,
g: 0,
b: 0,
a: 255,
}
.into();
assert!((hsl.h - 0.0).abs() < 1.0);
assert!((hsl.s - 1.0).abs() < 1e-3);
assert!((hsl.l - 0.5).abs() < 1e-3);
}
#[test]
fn hsl_from_gray() {
let hsl: Hsl = Srgba {
r: 128,
g: 128,
b: 128,
a: 255,
}
.into();
assert!((hsl.s - 0.0).abs() < 1e-3);
}
#[test]
fn hsl_roundtrip() {
for (r, g, b) in [(255, 0, 0), (0, 255, 0), (0, 0, 255), (128, 64, 200)] {
let orig = Srgba { r, g, b, a: 255 };
let hsl: Hsl = orig.into();
let back: Srgba = hsl.into();
assert!((orig.r as i16 - back.r as i16).unsigned_abs() <= 1, "r");
assert!((orig.g as i16 - back.g as i16).unsigned_abs() <= 1, "g");
assert!((orig.b as i16 - back.b as i16).unsigned_abs() <= 1, "b");
}
}
#[test]
fn hsl_gray_roundtrip() {
let orig = Srgba {
r: 128,
g: 128,
b: 128,
a: 255,
};
let hsl: Hsl = orig.into();
let back: Srgba = hsl.into();
assert_eq!(back.r, back.g);
assert_eq!(back.g, back.b);
}
#[test]
fn xyz_white_y_is_one() {
let white = LinRgba {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
let xyz: CieXyz = white.into();
assert!((xyz.y - 1.0).abs() < 0.01);
}
#[test]
fn xyz_roundtrip() {
let orig = LinRgba {
r: 0.5,
g: 0.3,
b: 0.8,
a: 1.0,
};
let xyz: CieXyz = orig.into();
let back: LinRgba = xyz.into();
assert!((orig.r - back.r).abs() < 1e-3);
assert!((orig.g - back.g).abs() < 1e-3);
assert!((orig.b - back.b).abs() < 1e-3);
}
#[test]
fn lab_white_is_100() {
let white: CieLab = Srgba {
r: 255,
g: 255,
b: 255,
a: 255,
}
.into();
assert!((white.l - 100.0).abs() < 0.5);
assert!(white.a.abs() < 1.0);
assert!(white.b.abs() < 5.0);
}
#[test]
fn lab_black_is_zero() {
let black: CieLab = Srgba {
r: 0,
g: 0,
b: 0,
a: 255,
}
.into();
assert!(black.l.abs() < 0.5);
}
#[test]
fn lab_roundtrip() {
let orig = Srgba {
r: 128,
g: 64,
b: 200,
a: 255,
};
let lab: CieLab = orig.into();
let xyz: CieXyz = lab.into();
let lin: LinRgba = xyz.into();
let back: Srgba = lin.into();
assert!((orig.r as i16 - back.r as i16).unsigned_abs() <= 1);
assert!((orig.g as i16 - back.g as i16).unsigned_abs() <= 1);
assert!((orig.b as i16 - back.b as i16).unsigned_abs() <= 1);
}
#[test]
fn p3_srgb_roundtrip() {
let (r, g, b) = linear_srgb_to_p3(0.5, 0.3, 0.8);
let (r2, g2, b2) = p3_to_linear_srgb(r, g, b);
assert!((0.5 - r2).abs() < 1e-4);
assert!((0.3 - g2).abs() < 1e-4);
assert!((0.8 - b2).abs() < 1e-4);
}
#[test]
fn p3_red_outside_srgb() {
let (r, _, _) = p3_to_linear_srgb(1.0, 0.0, 0.0);
assert!(r > 1.0, "P3 red should exceed sRGB gamut");
}
#[test]
fn cmyk_roundtrip() {
let orig = Srgba {
r: 200,
g: 100,
b: 50,
a: 255,
};
let cmyk = srgb_to_cmyk(&orig);
let back = cmyk_to_srgb(&cmyk);
assert!((orig.r as i16 - back.r as i16).unsigned_abs() <= 1);
assert!((orig.g as i16 - back.g as i16).unsigned_abs() <= 1);
assert!((orig.b as i16 - back.b as i16).unsigned_abs() <= 1);
}
#[test]
fn cmyk_black() {
let cmyk = srgb_to_cmyk(&Srgba {
r: 0,
g: 0,
b: 0,
a: 255,
});
assert!((cmyk.k - 1.0).abs() < 0.01);
}
#[test]
fn temperature_daylight_neutral() {
let d = color_temperature(6600.0);
assert!((d[0] - 1.0).abs() < 0.02);
}
#[test]
fn temperature_warm_has_more_red() {
let w = color_temperature(3000.0);
assert!(w[0] > w[2], "warm light should have more red than blue");
}
#[test]
fn temperature_cool_has_more_blue() {
let c = color_temperature(10000.0);
assert!(c[2] > c[0], "cool light should have more blue than red");
}
#[test]
fn delta_e_76_identical() {
let a = CieLab {
l: 50.0,
a: 25.0,
b: -10.0,
};
assert!(delta_e_cie76(&a, &a) < 1e-10);
}
#[test]
fn delta_e_76_known() {
let a = CieLab {
l: 50.0,
a: 0.0,
b: 0.0,
};
let b = CieLab {
l: 53.0,
a: 4.0,
b: 0.0,
};
let de = delta_e_cie76(&a, &b);
assert!((de - 5.0).abs() < 0.01); }
#[test]
fn delta_e_94_positive() {
let a = CieLab {
l: 50.0,
a: 25.0,
b: 0.0,
};
let b = CieLab {
l: 50.0,
a: 0.0,
b: 0.0,
};
assert!(delta_e_cie94(&a, &b) > 0.0);
}
#[test]
fn delta_e_2000_identical() {
let a = CieLab {
l: 50.0,
a: 25.0,
b: -10.0,
};
assert!(delta_e_ciede2000(&a, &a) < 1e-10);
}
#[test]
fn delta_e_2000_known_pair() {
let a = CieLab {
l: 50.0,
a: 2.6772,
b: -79.7751,
};
let b = CieLab {
l: 50.0,
a: 0.0,
b: -82.7485,
};
let de = delta_e_ciede2000(&a, &b);
assert!((de - 2.0425).abs() < 0.01, "got {de}");
}
#[test]
fn oklab_white() {
let white: Oklab = Srgba {
r: 255,
g: 255,
b: 255,
a: 255,
}
.into();
assert!((white.l - 1.0).abs() < 0.01, "L={}", white.l);
assert!(white.a.abs() < 0.01, "a={}", white.a);
assert!(white.b.abs() < 0.01, "b={}", white.b);
}
#[test]
fn oklab_black() {
let black: Oklab = Srgba {
r: 0,
g: 0,
b: 0,
a: 255,
}
.into();
assert!(black.l.abs() < 0.01, "L={}", black.l);
assert!(black.a.abs() < 0.01, "a={}", black.a);
assert!(black.b.abs() < 0.01, "b={}", black.b);
}
#[test]
fn oklab_roundtrip() {
for (r, g, b) in [(255, 0, 0), (0, 255, 0), (0, 0, 255), (128, 64, 200)] {
let orig = Srgba { r, g, b, a: 255 };
let lin: LinRgba = orig.into();
let lab: Oklab = lin.into();
let back_lin: LinRgba = lab.into();
let back: Srgba = back_lin.into();
assert!(
(orig.r as i16 - back.r as i16).unsigned_abs() <= 1,
"r: orig={} back={}",
orig.r,
back.r
);
assert!(
(orig.g as i16 - back.g as i16).unsigned_abs() <= 1,
"g: orig={} back={}",
orig.g,
back.g
);
assert!(
(orig.b as i16 - back.b as i16).unsigned_abs() <= 1,
"b: orig={} back={}",
orig.b,
back.b
);
}
}
#[test]
fn oklch_roundtrip() {
let orig = Oklab {
l: 0.6,
a: 0.15,
b: -0.08,
};
let lch: Oklch = orig.into();
let back: Oklab = lch.into();
assert!((orig.l - back.l).abs() < 1e-5, "l");
assert!((orig.a - back.a).abs() < 1e-5, "a");
assert!((orig.b - back.b).abs() < 1e-5, "b");
}
#[test]
fn oklch_red_hue() {
let red: Oklab = Srgba {
r: 255,
g: 0,
b: 0,
a: 255,
}
.into();
let lch: Oklch = red.into();
assert!(
lch.h >= 20.0 && lch.h <= 30.0,
"red hue={}, expected 20-30 degrees",
lch.h
);
}
}