Skip to main content

xmpp_parsers/
consistent_color.rs

1// Copyright (c) 2017 David Palm, as part of the rust-hsluv crate.
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in all
11// copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19// SOFTWARE.
20
21//! XEP-0392: Consistent Color Generation
22//!
23//! This module contains helpers to generate unique colors for:
24//!
25//! - MUC user nicknames with [`Rgb::from_resource`]
26//! - Roster contacts and non-anonymous MUC members with [`Rgb::from_barejid`]
27
28use sha1::{Digest, Sha1};
29
30use crate::jid::{BareJid, ResourceRef};
31
32use core::{cmp::Ordering, convert::Infallible, f64::consts::PI, str::FromStr};
33
34const EPSILON: f64 = 0.0088564516;
35const KAPPA: f64 = 903.2962962;
36const M: [[f64; 3]; 3] = [
37    [3.240969941904521, -1.537383177570093, -0.498610760293],
38    [-0.96924363628087, 1.87596750150772, 0.041555057407175],
39    [0.055630079696993, -0.20397695888897, 1.056971514242878],
40];
41
42const REF_Y: f64 = 1.0;
43const REF_U: f64 = 0.19783000664283;
44const REF_V: f64 = 0.46831999493879;
45
46/// Consistent color in RGB format, produced from a hashed string.
47pub struct Rgb {
48    /// Red component
49    pub red: u8,
50    /// Green component
51    pub green: u8,
52    /// Blue component
53    pub blue: u8,
54}
55
56impl Rgb {
57    fn new(red: u8, green: u8, blue: u8) -> Self {
58        Self { red, green, blue }
59    }
60
61    /// Hash a [`ResourceRef`], usually a pseudonymous MUC nickname, to a RGB color.
62    pub fn from_resource(nick: &ResourceRef) -> Self {
63        // UNWRAP: 100% safe because it's infallible
64        nick.as_str().parse().unwrap()
65    }
66
67    /// Hash a [`BareJid`] to a RGB color, usually a roster contact or a non-anonymous MUC member.
68    pub fn from_barejid(jid: &BareJid) -> Self {
69        // UNWRAP: 100% safe because it's infallible
70        jid.as_str().parse().unwrap()
71    }
72
73    /// Format to hexadecimal string (eg. #abcdef)
74    pub fn to_hex(&self) -> String {
75        format!("#{:02x}{:02x}{:02x}", self.red, self.green, self.blue)
76    }
77}
78
79impl FromStr for Rgb {
80    type Err = Infallible;
81
82    fn from_str(input: &str) -> Result<Self, Self::Err> {
83        let hash = Sha1::digest(input.as_bytes());
84
85        // Treat the output as little endian and extract the least-significant 16 bits. (These are the first two bytes of the output, with the second byte being the most significant one.)
86        let mut hash_bytes = [0u8; 2];
87        hash_bytes.copy_from_slice(&hash[..2]);
88
89        // Divide the value by 65536 (use float division) and multiply it by 360 (to map it to degrees in a full circle).
90        let hash_value = u16::from_le_bytes(hash_bytes);
91        let hue_angle = hash_value as f64 / 65536.0 * 360.0;
92
93        let (r, g, b) = hsluv_to_rgb((hue_angle, 100.0, 50.0));
94        Ok(Self::new(r, g, b))
95    }
96}
97
98/// Convert HSLUV to RGB
99fn hsluv_to_rgb(hsl: (f64, f64, f64)) -> (u8, u8, u8) {
100    // Floaty RGB
101    let rgb = xyz_to_rgb(luv_to_xyz(lch_to_luv(hsluv_to_lch(hsl))));
102
103    // Inty RGB
104    (clamp(rgb.0), clamp(rgb.1), clamp(rgb.2))
105}
106
107fn clamp(v: f64) -> u8 {
108    let mut rounded = (v * 1000.0).round() / 1000.0;
109    if rounded < 0.0 {
110        rounded = 0.0;
111    }
112    if rounded > 1.0 {
113        rounded = 1.0;
114    }
115    (rounded * 255.0).round() as u8
116}
117
118fn hsluv_to_lch(hsl: (f64, f64, f64)) -> (f64, f64, f64) {
119    let (h, s, l) = hsl;
120    match l {
121        l if l > 99.9999999 => (100.0, 0.0, h),
122        l if l < 0.00000001 => (0.0, 0.0, h),
123        _ => {
124            let mx = max_chroma_for(l, h);
125            let c = mx / 100.0 * s;
126            (l, c, h)
127        }
128    }
129}
130
131fn max_chroma_for(l: f64, h: f64) -> f64 {
132    let hrad = h / 360.0 * PI * 2.0;
133
134    let mut lengths: Vec<f64> = get_bounds(l)
135        .iter()
136        .map(|line| length_of_ray_until_intersect(hrad, line))
137        .filter(|length| length > &0.0)
138        .collect();
139
140    lengths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
141    lengths[0]
142}
143
144fn length_of_ray_until_intersect(theta: f64, line: &(f64, f64)) -> f64 {
145    let (m1, b1) = *line;
146    let length = b1 / (theta.sin() - m1 * theta.cos());
147    if length < 0.0 {
148        -0.0001
149    } else {
150        length
151    }
152}
153
154fn luv_to_xyz(luv: (f64, f64, f64)) -> (f64, f64, f64) {
155    let (l, u, v) = luv;
156
157    if l == 0.0 {
158        return (0.0, 0.0, 0.0);
159    }
160
161    let var_y = f_inv(l);
162    let var_u = u / (13.0 * l) + REF_U;
163    let var_v = v / (13.0 * l) + REF_V;
164
165    let y = var_y * REF_Y;
166    let x = 0.0 - (9.0 * y * var_u) / ((var_u - 4.0) * var_v - var_u * var_v);
167    let z = (9.0 * y - (15.0 * var_v * y) - (var_v * x)) / (3.0 * var_v);
168
169    (x, y, z)
170}
171
172fn lch_to_luv(lch: (f64, f64, f64)) -> (f64, f64, f64) {
173    let (l, c, h) = lch;
174    let hrad = degrees_to_radians(h);
175    let u = hrad.cos() * c;
176    let v = hrad.sin() * c;
177
178    (l, u, v)
179}
180
181fn xyz_to_rgb(xyz: (f64, f64, f64)) -> (f64, f64, f64) {
182    let xyz_vec = vec![xyz.0, xyz.1, xyz.2];
183    let abc: Vec<f64> = M
184        .iter()
185        .map(|i| from_linear(dot_product(&i.to_vec(), &xyz_vec)))
186        .collect();
187    (abc[0], abc[1], abc[2])
188}
189
190fn dot_product(a: &[f64], b: &[f64]) -> f64 {
191    a.iter().zip(b.iter()).map(|(i, j)| i * j).sum()
192}
193
194fn get_bounds(l: f64) -> Vec<(f64, f64)> {
195    let sub1 = ((l + 16.0).powi(3)) / 1560896.0;
196    let sub2 = match sub1 {
197        s if s > EPSILON => s,
198        _ => l / KAPPA,
199    };
200
201    let mut bounds = Vec::new();
202
203    for ms in &M {
204        let (m1, m2, m3) = (ms[0], ms[1], ms[2]);
205        for t in 0..2 {
206            let top1 = (284517.0 * m1 - 94839.0 * m3) * sub2;
207            let top2 = (838422.0 * m3 + 769860.0 * m2 + 731718.0 * m1) * l * sub2
208                - 769860.0 * f64::from(t) * l;
209            let bottom = (632260.0 * m3 - 126452.0 * m2) * sub2 + 126452.0 * f64::from(t);
210
211            bounds.push((top1 / bottom, top2 / bottom));
212        }
213    }
214    bounds
215}
216
217fn f_inv(t: f64) -> f64 {
218    if t > 8.0 {
219        REF_Y * ((t + 16.0) / 116.0).powf(3.0)
220    } else {
221        REF_Y * t / KAPPA
222    }
223}
224
225fn degrees_to_radians(deg: f64) -> f64 {
226    deg * PI / 180.0
227}
228
229fn from_linear(c: f64) -> f64 {
230    if c <= 0.0031308 {
231        12.92 * c
232    } else {
233        1.055 * (c.powf(1.0 / 2.4)) - 0.055
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::{clamp, Rgb};
240
241    #[test]
242    fn test_13_1() {
243        struct TestVector<'a> {
244            pub text: &'a str,
245            #[allow(dead_code)]
246            pub hextext: &'a str,
247            #[allow(dead_code)]
248            pub angle: f64,
249            #[allow(dead_code)]
250            pub hue: f64,
251            pub r: f64,
252            pub g: f64,
253            pub b: f64,
254        }
255
256        impl<'a> TestVector<'a> {
257            const fn new(
258                text: &'a str,
259                hextext: &'a str,
260                angle: f64,
261                hue: f64,
262                r: f64,
263                g: f64,
264                b: f64,
265            ) -> TestVector<'a> {
266                TestVector {
267                    text,
268                    hextext,
269                    angle,
270                    hue,
271                    r,
272                    g,
273                    b,
274                }
275            }
276        }
277
278        const VECTORS: &'static [&'static TestVector<'static>] = &[
279            &TestVector::new(
280                "Romeo",
281                "526f6d656f",
282                327.255249,
283                327.255249,
284                0.865,
285                0.000,
286                0.686,
287            ),
288            &TestVector::new(
289                "juliet@capulet.lit",
290                "6a756c69657440636170756c65742e6c6974",
291                209.410400,
292                209.410400,
293                0.000,
294                0.515,
295                0.573,
296            ),
297            &TestVector::new(
298                "😺", "f09f98ba", 331.199341, 331.199341, 0.872, 0.000, 0.659,
299            ),
300            &TestVector::new(
301                "council",
302                "636f756e63696c",
303                359.994507,
304                359.994507,
305                0.918,
306                0.000,
307                0.394,
308            ),
309            &TestVector::new(
310                "Board",
311                "426f617264",
312                171.430664,
313                171.430664,
314                0.000,
315                0.527,
316                0.457,
317            ),
318        ];
319
320        for vector in VECTORS {
321            let rgb: Rgb = vector.text.parse().unwrap();
322            assert_eq!(rgb.red, clamp(vector.r));
323            assert_eq!(rgb.green, clamp(vector.g));
324            assert_eq!(rgb.blue, clamp(vector.b));
325        }
326    }
327}