#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub fn from_hex(s: &str) -> Option<Rgb> {
let h = s.strip_prefix('#')?;
let (r, g, b) = match h.len() {
6 => (
u8::from_str_radix(&h[0..2], 16).ok()?,
u8::from_str_radix(&h[2..4], 16).ok()?,
u8::from_str_radix(&h[4..6], 16).ok()?,
),
3 => {
let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
(d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
}
_ => return None,
};
Some(Rgb { r, g, b })
}
pub fn to_hex(self) -> String {
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
pub fn tuple(self) -> (u8, u8, u8) {
(self.r, self.g, self.b)
}
}
#[derive(Clone, Copy, Debug)]
pub struct Oklab {
pub l: f32,
pub a: f32,
pub b: f32,
}
fn srgb_to_linear(c: u8) -> f32 {
let c = c as f32 / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn linear_to_srgb(c: f32) -> u8 {
let c = c.clamp(0.0, 1.0);
let v = if c <= 0.0031308 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
};
(v * 255.0).round().clamp(0.0, 255.0) as u8
}
impl Rgb {
#[allow(clippy::excessive_precision)]
pub fn to_oklab(self) -> Oklab {
let (r, g, b) = (
srgb_to_linear(self.r),
srgb_to_linear(self.g),
srgb_to_linear(self.b),
);
let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), 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)]
pub fn from_oklab(c: Oklab) -> Rgb {
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, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
Rgb {
r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
}
}
}
pub(crate) fn rel_luminance(c: Rgb) -> f32 {
0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
}
pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 {
let (la, lb) = (rel_luminance(a), rel_luminance(b));
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
pub fn readable_on(bg: Rgb) -> Rgb {
let white = Rgb {
r: 255,
g: 255,
b: 255,
};
let black = Rgb { r: 0, g: 0, b: 0 };
if wcag_contrast(white, bg) >= wcag_contrast(black, bg) {
white
} else {
black
}
}
pub fn lighten(c: Rgb, delta: f32) -> Rgb {
let mut lab = c.to_oklab();
lab.l = (lab.l + delta).clamp(0.0, 1.0);
Rgb::from_oklab(lab)
}
pub fn darken(c: Rgb, delta: f32) -> Rgb {
lighten(c, -delta)
}
pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
let (x, y) = (a.to_oklab(), b.to_oklab());
Rgb::from_oklab(Oklab {
l: x.l + (y.l - x.l) * t,
a: x.a + (y.a - x.a) * t,
b: x.b + (y.b - x.b) * t,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_hex_roundtrip() {
assert_eq!(
Rgb::from_hex("#6196FF").unwrap(),
Rgb {
r: 0x61,
g: 0x96,
b: 0xff
}
);
assert_eq!(
Rgb::from_hex("#abc").unwrap(),
Rgb {
r: 0xaa,
g: 0xbb,
b: 0xcc
}
);
assert_eq!(
Rgb {
r: 0x61,
g: 0x96,
b: 0xff
}
.to_hex(),
"#6196ff"
);
assert!(Rgb::from_hex("not-a-color").is_none());
}
#[test]
fn oklab_roundtrips_within_tolerance() {
for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
let c = Rgb::from_hex(hex).unwrap();
let back = Rgb::from_oklab(c.to_oklab());
assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
}
}
#[test]
fn wcag_contrast_known_pairs() {
let white = Rgb {
r: 255,
g: 255,
b: 255,
};
let black = Rgb { r: 0, g: 0, b: 0 };
assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
}
#[test]
fn readable_on_picks_by_wcag() {
assert_eq!(
readable_on(Rgb {
r: 255,
g: 255,
b: 255
}),
Rgb { r: 0, g: 0, b: 0 }
);
assert_eq!(
readable_on(Rgb { r: 0, g: 0, b: 0 }),
Rgb {
r: 255,
g: 255,
b: 255
}
);
let action = Rgb::from_hex("#6196ff").unwrap();
assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
}
#[test]
fn lighten_darken_move_oklab_lightness() {
let c = Rgb::from_hex("#6196ff").unwrap();
let l0 = c.to_oklab().l;
assert!(lighten(c, 0.05).to_oklab().l > l0);
assert!(darken(c, 0.05).to_oklab().l < l0);
}
#[test]
fn mix_endpoints_and_midpoint() {
let a = Rgb::from_hex("#000000").unwrap();
let b = Rgb::from_hex("#6196ff").unwrap();
assert_eq!(mix(a, b, 0.0), a);
assert_eq!(mix(a, b, 1.0), b);
let mid = mix(a, b, 0.5).to_oklab().l;
assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
}
}