| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 11 |
pub struct Rgb { |
| 12 |
pub r: u8, |
| 13 |
pub g: u8, |
| 14 |
pub b: u8, |
| 15 |
} |
| 16 |
|
| 17 |
impl Rgb { |
| 18 |
|
| 19 |
pub fn from_hex(s: &str) -> Option<Rgb> { |
| 20 |
let h = s.strip_prefix('#')?; |
| 21 |
let (r, g, b) = match h.len() { |
| 22 |
6 => ( |
| 23 |
u8::from_str_radix(&h[0..2], 16).ok()?, |
| 24 |
u8::from_str_radix(&h[2..4], 16).ok()?, |
| 25 |
u8::from_str_radix(&h[4..6], 16).ok()?, |
| 26 |
), |
| 27 |
3 => { |
| 28 |
let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17); |
| 29 |
(d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?) |
| 30 |
} |
| 31 |
_ => return None, |
| 32 |
}; |
| 33 |
Some(Rgb { r, g, b }) |
| 34 |
} |
| 35 |
|
| 36 |
|
| 37 |
pub fn to_hex(self) -> String { |
| 38 |
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) |
| 39 |
} |
| 40 |
|
| 41 |
pub fn tuple(self) -> (u8, u8, u8) { |
| 42 |
(self.r, self.g, self.b) |
| 43 |
} |
| 44 |
} |
| 45 |
|
| 46 |
|
| 47 |
#[derive(Clone, Copy, Debug)] |
| 48 |
pub struct Oklab { |
| 49 |
pub l: f32, |
| 50 |
pub a: f32, |
| 51 |
pub b: f32, |
| 52 |
} |
| 53 |
|
| 54 |
fn srgb_to_linear(c: u8) -> f32 { |
| 55 |
let c = c as f32 / 255.0; |
| 56 |
if c <= 0.04045 { |
| 57 |
c / 12.92 |
| 58 |
} else { |
| 59 |
((c + 0.055) / 1.055).powf(2.4) |
| 60 |
} |
| 61 |
} |
| 62 |
|
| 63 |
fn linear_to_srgb(c: f32) -> u8 { |
| 64 |
let c = c.clamp(0.0, 1.0); |
| 65 |
let v = if c <= 0.0031308 { |
| 66 |
c * 12.92 |
| 67 |
} else { |
| 68 |
1.055 * c.powf(1.0 / 2.4) - 0.055 |
| 69 |
}; |
| 70 |
(v * 255.0).round().clamp(0.0, 255.0) as u8 |
| 71 |
} |
| 72 |
|
| 73 |
impl Rgb { |
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
#[allow(clippy::excessive_precision)] |
| 80 |
pub fn to_oklab(self) -> Oklab { |
| 81 |
let (r, g, b) = ( |
| 82 |
srgb_to_linear(self.r), |
| 83 |
srgb_to_linear(self.g), |
| 84 |
srgb_to_linear(self.b), |
| 85 |
); |
| 86 |
let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; |
| 87 |
let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; |
| 88 |
let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; |
| 89 |
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt()); |
| 90 |
Oklab { |
| 91 |
l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, |
| 92 |
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, |
| 93 |
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, |
| 94 |
} |
| 95 |
} |
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
#[allow(clippy::excessive_precision)] |
| 101 |
pub fn from_oklab(c: Oklab) -> Rgb { |
| 102 |
let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b; |
| 103 |
let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b; |
| 104 |
let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b; |
| 105 |
let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); |
| 106 |
Rgb { |
| 107 |
r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), |
| 108 |
g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), |
| 109 |
b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), |
| 110 |
} |
| 111 |
} |
| 112 |
} |
| 113 |
|
| 114 |
|
| 115 |
pub(crate) fn rel_luminance(c: Rgb) -> f32 { |
| 116 |
0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) |
| 117 |
} |
| 118 |
|
| 119 |
|
| 120 |
pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 { |
| 121 |
let (la, lb) = (rel_luminance(a), rel_luminance(b)); |
| 122 |
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; |
| 123 |
(hi + 0.05) / (lo + 0.05) |
| 124 |
} |
| 125 |
|
| 126 |
|
| 127 |
|
| 128 |
pub fn readable_on(bg: Rgb) -> Rgb { |
| 129 |
let white = Rgb { |
| 130 |
r: 255, |
| 131 |
g: 255, |
| 132 |
b: 255, |
| 133 |
}; |
| 134 |
let black = Rgb { r: 0, g: 0, b: 0 }; |
| 135 |
if wcag_contrast(white, bg) >= wcag_contrast(black, bg) { |
| 136 |
white |
| 137 |
} else { |
| 138 |
black |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
|
| 143 |
pub fn lighten(c: Rgb, delta: f32) -> Rgb { |
| 144 |
let mut lab = c.to_oklab(); |
| 145 |
lab.l = (lab.l + delta).clamp(0.0, 1.0); |
| 146 |
Rgb::from_oklab(lab) |
| 147 |
} |
| 148 |
|
| 149 |
|
| 150 |
pub fn darken(c: Rgb, delta: f32) -> Rgb { |
| 151 |
lighten(c, -delta) |
| 152 |
} |
| 153 |
|
| 154 |
|
| 155 |
pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb { |
| 156 |
let (x, y) = (a.to_oklab(), b.to_oklab()); |
| 157 |
Rgb::from_oklab(Oklab { |
| 158 |
l: x.l + (y.l - x.l) * t, |
| 159 |
a: x.a + (y.a - x.a) * t, |
| 160 |
b: x.b + (y.b - x.b) * t, |
| 161 |
}) |
| 162 |
} |
| 163 |
|
| 164 |
#[cfg(test)] |
| 165 |
mod tests { |
| 166 |
use super::*; |
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
#[test] |
| 171 |
fn rgb_hex_roundtrip() { |
| 172 |
assert_eq!( |
| 173 |
Rgb::from_hex("#6196FF").unwrap(), |
| 174 |
Rgb { |
| 175 |
r: 0x61, |
| 176 |
g: 0x96, |
| 177 |
b: 0xff |
| 178 |
} |
| 179 |
); |
| 180 |
assert_eq!( |
| 181 |
Rgb::from_hex("#abc").unwrap(), |
| 182 |
Rgb { |
| 183 |
r: 0xaa, |
| 184 |
g: 0xbb, |
| 185 |
b: 0xcc |
| 186 |
} |
| 187 |
); |
| 188 |
assert_eq!( |
| 189 |
Rgb { |
| 190 |
r: 0x61, |
| 191 |
g: 0x96, |
| 192 |
b: 0xff |
| 193 |
} |
| 194 |
.to_hex(), |
| 195 |
"#6196ff" |
| 196 |
); |
| 197 |
assert!(Rgb::from_hex("not-a-color").is_none()); |
| 198 |
} |
| 199 |
|
| 200 |
#[test] |
| 201 |
fn oklab_roundtrips_within_tolerance() { |
| 202 |
for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] { |
| 203 |
let c = Rgb::from_hex(hex).unwrap(); |
| 204 |
let back = Rgb::from_oklab(c.to_oklab()); |
| 205 |
|
| 206 |
assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r"); |
| 207 |
assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g"); |
| 208 |
assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b"); |
| 209 |
} |
| 210 |
} |
| 211 |
|
| 212 |
#[test] |
| 213 |
fn wcag_contrast_known_pairs() { |
| 214 |
let white = Rgb { |
| 215 |
r: 255, |
| 216 |
g: 255, |
| 217 |
b: 255, |
| 218 |
}; |
| 219 |
let black = Rgb { r: 0, g: 0, b: 0 }; |
| 220 |
assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01); |
| 221 |
assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01); |
| 222 |
} |
| 223 |
|
| 224 |
#[test] |
| 225 |
fn readable_on_picks_by_wcag() { |
| 226 |
assert_eq!( |
| 227 |
readable_on(Rgb { |
| 228 |
r: 255, |
| 229 |
g: 255, |
| 230 |
b: 255 |
| 231 |
}), |
| 232 |
Rgb { r: 0, g: 0, b: 0 } |
| 233 |
); |
| 234 |
assert_eq!( |
| 235 |
readable_on(Rgb { r: 0, g: 0, b: 0 }), |
| 236 |
Rgb { |
| 237 |
r: 255, |
| 238 |
g: 255, |
| 239 |
b: 255 |
| 240 |
} |
| 241 |
); |
| 242 |
|
| 243 |
let action = Rgb::from_hex("#6196ff").unwrap(); |
| 244 |
assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 }); |
| 245 |
} |
| 246 |
|
| 247 |
#[test] |
| 248 |
fn lighten_darken_move_oklab_lightness() { |
| 249 |
let c = Rgb::from_hex("#6196ff").unwrap(); |
| 250 |
let l0 = c.to_oklab().l; |
| 251 |
assert!(lighten(c, 0.05).to_oklab().l > l0); |
| 252 |
assert!(darken(c, 0.05).to_oklab().l < l0); |
| 253 |
} |
| 254 |
|
| 255 |
#[test] |
| 256 |
fn mix_endpoints_and_midpoint() { |
| 257 |
let a = Rgb::from_hex("#000000").unwrap(); |
| 258 |
let b = Rgb::from_hex("#6196ff").unwrap(); |
| 259 |
assert_eq!(mix(a, b, 0.0), a); |
| 260 |
assert_eq!(mix(a, b, 1.0), b); |
| 261 |
|
| 262 |
let mid = mix(a, b, 0.5).to_oklab().l; |
| 263 |
assert!(mid > a.to_oklab().l && mid < b.to_oklab().l); |
| 264 |
} |
| 265 |
} |
| 266 |
|