1use std::fmt;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub struct Srgb {
11 pub r: u8,
12 pub g: u8,
13 pub b: u8,
14}
15
16impl Srgb {
17 pub const WHITE: Srgb = Srgb::new(255, 255, 255);
18 pub const BLACK: Srgb = Srgb::new(0, 0, 0);
19
20 pub const fn new(r: u8, g: u8, b: u8) -> Self {
21 Self { r, g, b }
22 }
23
24 pub fn parse(text: &str) -> Option<Self> {
26 let hex = text.strip_prefix('#')?;
27 if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
28 return None;
29 }
30 let channel = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
31 Some(Self::new(channel(0)?, channel(2)?, channel(4)?))
32 }
33
34 pub fn hex(self) -> String {
36 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
37 }
38
39 fn linear(self) -> [f64; 3] {
40 [self.r, self.g, self.b].map(|channel| to_linear(f64::from(channel) / 255.0))
41 }
42
43 pub fn luminance(self) -> f64 {
45 let [r, g, b] = self.linear();
46 0.2126 * r + 0.7152 * g + 0.0722 * b
47 }
48
49 pub fn oklch(self) -> Oklch {
50 Oklab::from_linear(self.linear()).lch()
51 }
52}
53
54impl fmt::Display for Srgb {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.write_str(&self.hex())
57 }
58}
59
60pub fn contrast(a: Srgb, b: Srgb) -> f64 {
62 let (x, y) = (a.luminance(), b.luminance());
63 (x.max(y) + 0.05) / (x.min(y) + 0.05)
64}
65
66fn to_linear(value: f64) -> f64 {
67 if value <= 0.04045 {
68 value / 12.92
69 } else {
70 ((value + 0.055) / 1.055).powf(2.4)
71 }
72}
73
74fn from_linear(value: f64) -> f64 {
75 if value <= 0.003_130_8 {
76 value * 12.92
77 } else {
78 1.055 * value.powf(1.0 / 2.4) - 0.055
79 }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct Oklab {
84 pub l: f64,
85 pub a: f64,
86 pub b: f64,
87}
88
89impl Oklab {
90 fn from_linear([r, g, b]: [f64; 3]) -> Self {
91 let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt();
92 let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt();
93 let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt();
94 Self {
95 l: 0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
96 a: 1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
97 b: 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
98 }
99 }
100
101 fn to_linear(self) -> [f64; 3] {
102 let l = (self.l + 0.396_337_777_4 * self.a + 0.215_803_757_3 * self.b).powi(3);
103 let m = (self.l - 0.105_561_345_8 * self.a - 0.063_854_172_8 * self.b).powi(3);
104 let s = (self.l - 0.089_484_177_5 * self.a - 1.291_485_548_0 * self.b).powi(3);
105 [
106 4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s,
107 -1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s,
108 -0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701_0 * s,
109 ]
110 }
111
112 fn lch(self) -> Oklch {
113 let c = (self.a * self.a + self.b * self.b).sqrt();
114 let h = self.b.atan2(self.a).to_degrees().rem_euclid(360.0);
115 Oklch { l: self.l, c, h }
116 }
117}
118
119#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct Oklch {
122 pub l: f64,
123 pub c: f64,
124 pub h: f64,
125}
126
127impl Oklch {
128 pub const fn new(l: f64, c: f64, h: f64) -> Self {
129 Self { l, c, h }
130 }
131
132 fn lab(self) -> Oklab {
133 let h = self.h.to_radians();
134 Oklab {
135 l: self.l,
136 a: self.c * h.cos(),
137 b: self.c * h.sin(),
138 }
139 }
140
141 fn in_gamut(self) -> bool {
142 const EPSILON: f64 = 1e-6;
143 self.lab()
144 .to_linear()
145 .iter()
146 .all(|channel| (-EPSILON..=1.0 + EPSILON).contains(channel))
147 }
148
149 pub fn to_srgb(self) -> Srgb {
153 let l = self.l.clamp(0.0, 1.0);
154 let mut color = Oklch::new(l, self.c.max(0.0), self.h);
155 if !color.in_gamut() {
156 let (mut low, mut high) = (0.0, color.c);
157 for _ in 0..32 {
158 let middle = (low + high) / 2.0;
159 if Oklch::new(l, middle, self.h).in_gamut() {
160 low = middle;
161 } else {
162 high = middle;
163 }
164 }
165 color = Oklch::new(l, low, self.h);
166 }
167 let [r, g, b] = color
168 .lab()
169 .to_linear()
170 .map(|channel| (from_linear(channel.clamp(0.0, 1.0)) * 255.0).round() as u8);
171 Srgb::new(r, g, b)
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn contrast_matches_the_wcag_reference_points() {
181 assert!((contrast(Srgb::WHITE, Srgb::BLACK) - 21.0).abs() < 1e-9);
182 assert!((contrast(Srgb::WHITE, Srgb::WHITE) - 1.0).abs() < 1e-9);
183 let grey = Srgb::parse("#767676").unwrap();
185 assert!(contrast(grey, Srgb::WHITE) >= 4.5);
186 assert!(contrast(Srgb::parse("#777777").unwrap(), Srgb::WHITE) < 4.5);
187 }
188
189 #[test]
190 fn oklch_round_trips_in_gamut_colors() {
191 for hex in [
192 "#000000", "#ffffff", "#2f6fe4", "#e4572e", "#76b041", "#808080",
193 ] {
194 let color = Srgb::parse(hex).unwrap();
195 assert_eq!(color.oklch().to_srgb(), color, "{hex}");
196 }
197 let red = Srgb::new(255, 0, 0).oklch();
199 assert!((red.l - 0.628).abs() < 0.001 && (red.c - 0.258).abs() < 0.001);
200 assert!((red.h - 29.23).abs() < 0.05);
201 }
202
203 #[test]
204 fn out_of_gamut_colors_keep_lightness_and_hue() {
205 let wanted = Oklch::new(0.7, 0.4, 145.0);
206 let mapped = wanted.to_srgb().oklch();
207 assert!((mapped.l - 0.7).abs() < 0.01);
208 assert!((mapped.h - 145.0).abs() < 2.0);
209 assert!(mapped.c < 0.4);
210 }
211
212 #[test]
213 fn hex_parsing_is_strict() {
214 assert_eq!(Srgb::parse("#2F6FE4"), Some(Srgb::new(0x2f, 0x6f, 0xe4)));
215 assert_eq!(Srgb::parse("2f6fe4"), None);
216 assert_eq!(Srgb::parse("#2f6fe"), None);
217 assert_eq!(Srgb::parse("#2f6fgg"), None);
218 }
219}