1use std::fmt;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct Color {
25 pub l: f64,
28 pub c: f64,
31 pub h: f64,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ColorError {
41 MissingHash,
43 BadLength,
45 BadDigit,
47}
48
49impl fmt::Display for ColorError {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 ColorError::MissingHash => f.write_str("hex color must start with '#'"),
53 ColorError::BadLength => {
54 f.write_str("hex color must be '#rgb' or '#rrggbb' (4 or 7 chars)")
55 }
56 ColorError::BadDigit => f.write_str("hex color contains a non-hex character"),
57 }
58 }
59}
60
61impl std::error::Error for ColorError {}
62
63impl Color {
64 pub fn from_oklch(l: f64, c: f64, h: f64) -> Self {
67 Color {
68 l,
69 c,
70 h: ((h % 360.0) + 360.0) % 360.0,
71 }
72 }
73
74 pub fn from_hex(hex: &str) -> Result<Self, ColorError> {
76 let bytes = hex.as_bytes();
77 if bytes.is_empty() || bytes[0] != b'#' {
78 return Err(ColorError::MissingHash);
79 }
80 let body = &hex[1..];
81 let (r, g, b) = match body.len() {
82 3 => {
83 let r = expand_nibble(byte(body, 0)?);
84 let g = expand_nibble(byte(body, 1)?);
85 let b = expand_nibble(byte(body, 2)?);
86 (r, g, b)
87 }
88 6 => {
89 let r = pair(body, 0)?;
90 let g = pair(body, 2)?;
91 let b = pair(body, 4)?;
92 (r, g, b)
93 }
94 _ => return Err(ColorError::BadLength),
95 };
96 let srgb = [r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0];
97 Ok(srgb_to_oklch(srgb))
98 }
99
100 pub fn to_hex(&self) -> String {
104 let [r, g, b] = oklch_to_srgb(*self);
105 let r = quantize(r);
106 let g = quantize(g);
107 let b = quantize(b);
108 format!("#{r:02x}{g:02x}{b:02x}")
109 }
110
111 pub fn mix(&self, other: &Color, amount: f64) -> Color {
118 let a = amount.clamp(0.0, 1.0);
119 let (la, aa, ba) = oklch_to_oklab(self.l, self.c, self.h);
120 let (lb, ab, bb) = oklch_to_oklab(other.l, other.c, other.h);
121 let l = la + (lb - la) * a;
122 let ax = aa + (ab - aa) * a;
123 let bx = ba + (bb - ba) * a;
124 let (l, c, h) = oklab_to_oklch(l, ax, bx);
125 Color { l, c, h }
126 }
127
128 pub fn lighten(&self, amount: f64) -> Color {
131 let white = Color {
132 l: 1.0,
133 c: 0.0,
134 h: 0.0,
135 };
136 self.mix(&white, amount)
137 }
138
139 pub fn darken(&self, amount: f64) -> Color {
144 let near_black = Color::from_hex("#111111").expect("constant");
146 self.mix(&near_black, amount)
147 }
148}
149
150fn byte(s: &str, i: usize) -> Result<u8, ColorError> {
153 let c = s.as_bytes()[i] as char;
154 c.to_digit(16).map(|d| d as u8).ok_or(ColorError::BadDigit)
155}
156
157fn pair(s: &str, i: usize) -> Result<u8, ColorError> {
158 let hi = byte(s, i)?;
159 let lo = byte(s, i + 1)?;
160 Ok((hi << 4) | lo)
161}
162
163fn expand_nibble(n: u8) -> u8 {
164 (n << 4) | n
165}
166
167fn quantize(v: f64) -> u8 {
168 (v.clamp(0.0, 1.0) * 255.0).round() as u8
169}
170
171fn srgb_decode(v: f64) -> f64 {
174 if v <= 0.04045 {
175 v / 12.92
176 } else {
177 ((v + 0.055) / 1.055).powf(2.4)
178 }
179}
180
181fn srgb_encode(v: f64) -> f64 {
182 if v <= 0.0031308 {
183 v * 12.92
184 } else {
185 1.055 * v.powf(1.0 / 2.4) - 0.055
186 }
187}
188
189fn linear_srgb_to_oklab(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
195 let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
196 let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
197 let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
198
199 let l_ = l.cbrt();
200 let m_ = m.cbrt();
201 let s_ = s.cbrt();
202
203 let big_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
204 let big_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
205 let big_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
206 (big_l, big_a, big_b)
207}
208
209fn oklab_to_linear_srgb(big_l: f64, big_a: f64, big_b: f64) -> (f64, f64, f64) {
210 let l_ = big_l + 0.3963377774 * big_a + 0.2158037573 * big_b;
211 let m_ = big_l - 0.1055613458 * big_a - 0.0638541728 * big_b;
212 let s_ = big_l - 0.0894841775 * big_a - 1.2914855480 * big_b;
213
214 let l = l_ * l_ * l_;
215 let m = m_ * m_ * m_;
216 let s = s_ * s_ * s_;
217
218 let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
219 let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
220 let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
221 (r, g, b)
222}
223
224fn oklab_to_oklch(l: f64, a: f64, b: f64) -> (f64, f64, f64) {
227 let c = (a * a + b * b).sqrt();
228 let h_rad = b.atan2(a);
229 let mut h_deg = h_rad.to_degrees();
230 if h_deg < 0.0 {
231 h_deg += 360.0;
232 }
233 (l, c, h_deg)
234}
235
236fn oklch_to_oklab(l: f64, c: f64, h: f64) -> (f64, f64, f64) {
237 let h_rad = h.to_radians();
238 let a = c * h_rad.cos();
239 let b = c * h_rad.sin();
240 (l, a, b)
241}
242
243fn srgb_to_oklch(srgb: [f64; 3]) -> Color {
246 let r = srgb_decode(srgb[0]);
247 let g = srgb_decode(srgb[1]);
248 let b = srgb_decode(srgb[2]);
249 let (ll, la, lb) = linear_srgb_to_oklab(r, g, b);
250 let (l, c, h) = oklab_to_oklch(ll, la, lb);
251 Color { l, c, h }
252}
253
254fn oklch_to_srgb(color: Color) -> [f64; 3] {
255 let (ll, la, lb) = oklch_to_oklab(color.l, color.c, color.h);
256 let (r, g, b) = oklab_to_linear_srgb(ll, la, lb);
257 [srgb_encode(r), srgb_encode(g), srgb_encode(b)]
258}
259
260pub(crate) fn linear_srgb_of(color: &Color) -> [f64; 3] {
264 let (ll, la, lb) = oklch_to_oklab(color.l, color.c, color.h);
265 let (r, g, b) = oklab_to_linear_srgb(ll, la, lb);
266 [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]
267}
268
269pub fn hue_distance(a: f64, b: f64) -> f64 {
272 let d = (a - b).abs() % 360.0;
273 if d > 180.0 {
274 360.0 - d
275 } else {
276 d
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn approx(a: f64, b: f64, eps: f64) -> bool {
285 (a - b).abs() < eps
286 }
287
288 #[test]
289 fn parse_six_digit_hex() {
290 let c = Color::from_hex("#3f6089").unwrap();
291 assert!(c.l > 0.30 && c.l < 0.65, "L out of mid band: {}", c.l);
294 assert!(c.c > 0.05);
295 assert!(c.h > 240.0 && c.h < 280.0, "got H={}", c.h);
296 }
297
298 #[test]
299 fn parse_three_digit_hex_expands() {
300 let short = Color::from_hex("#f00").unwrap();
301 let long = Color::from_hex("#ff0000").unwrap();
302 assert!(approx(short.l, long.l, 1e-9));
304 assert!(approx(short.c, long.c, 1e-9));
305 assert!(approx(short.h, long.h, 1e-9));
306 }
307
308 #[test]
309 fn round_trip_hex_to_hex() {
310 for hex in [
311 "#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#3f6089", "#0d9488",
312 ] {
313 let c = Color::from_hex(hex).unwrap();
314 assert_eq!(c.to_hex(), hex, "round trip lost {hex}");
315 }
316 }
317
318 #[test]
319 fn bad_input_returns_error_no_panic() {
320 assert_eq!(Color::from_hex(""), Err(ColorError::MissingHash));
321 assert_eq!(Color::from_hex("3f6089"), Err(ColorError::MissingHash));
322 assert_eq!(Color::from_hex("#12"), Err(ColorError::BadLength));
323 assert_eq!(Color::from_hex("#zzzzzz"), Err(ColorError::BadDigit));
324 }
325
326 #[test]
327 fn mix_halfway_is_perceptually_centered() {
328 let black = Color::from_hex("#000000").unwrap();
329 let white = Color::from_hex("#ffffff").unwrap();
330 let mid = black.mix(&white, 0.5);
331 assert!(approx(mid.l, 0.5, 0.02), "got L={}", mid.l);
333 assert!(mid.c < 1e-6, "mid of two grays should be achromatic");
334 }
335
336 #[test]
337 fn mix_self_is_self() {
338 let c = Color::from_hex("#0d9488").unwrap();
339 let m = c.mix(&Color::from_hex("#ffffff").unwrap(), 0.0);
340 assert_eq!(c.to_hex(), m.to_hex());
341 }
342
343 #[test]
344 fn out_of_gamut_clamps_not_wraps() {
345 let oog = Color::from_oklch(0.5, 0.35, 30.0);
348 let hex = oog.to_hex();
349 for ch in (hex.as_bytes()[1..]).chunks(2) {
350 let s = std::str::from_utf8(ch).unwrap();
351 assert!(u8::from_str_radix(s, 16).is_ok());
352 }
353 }
354
355 #[test]
356 fn hue_distance_wraps_correctly() {
357 assert!((hue_distance(10.0, 350.0) - 20.0).abs() < 1e-9);
358 assert!((hue_distance(0.0, 180.0) - 180.0).abs() < 1e-9);
359 assert!((hue_distance(45.0, 45.0)).abs() < 1e-9);
360 }
361}