Skip to main content

bern_theme/
color.rs

1//! Color spaces used by the algorithm: 8-bit sRGB for output, OKLab and
2//! OKLCH for perceptual adjustments, and relative luminance for contrast.
3//!
4//! The OKLab matrices are Björn Ottosson's published reference values.
5
6use std::fmt;
7
8/// An 8-bit sRGB color, the only encoding snapshots emit.
9#[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    /// Parse `#RRGGBB`.
25    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    /// `#rrggbb`.
35    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    /// Relative luminance from correctly linearized sRGB.
44    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
60/// `(Y lighter + 0.05) / (Y darker + 0.05)`.
61pub 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/// Perceptual lightness `l` (0–1), chroma `c` and hue `h` in degrees.
120#[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    /// Map into sRGB by lowering chroma at the same lightness and hue,
150    /// then encode as 8-bit sRGB. The result is the color that will be
151    /// displayed; callers measure contrast on it, not on the OKLCH input.
152    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        // #767676 is the classic lightest grey with 4.5:1 on white.
184        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        // Reference: sRGB red is about L 0.628, C 0.258, h 29.2.
198        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}