Skip to main content

ironcalc_base/
colors.rs

1#![allow(clippy::unwrap_used)]
2
3use core::cmp::max;
4use core::cmp::min;
5
6// https://gist.github.com/emanuel-sanabria-developer/5793377
7// https://github.com/ClosedXML/ClosedXML/wiki/Excel-Indexed-Colors
8
9// Warning: Excel uses a weird normalization for HSL colors (0, 255)
10// We use a more standard one but our HSL numbers will not coincide with Excel's
11
12pub fn hex_to_rgb(h: &str) -> [i32; 3] {
13    let r = i32::from_str_radix(&h[1..3], 16).unwrap();
14    let g = i32::from_str_radix(&h[3..5], 16).unwrap();
15    let b = i32::from_str_radix(&h[5..7], 16).unwrap();
16    [r, g, b]
17}
18
19pub fn rgb_to_hex(rgb: [i32; 3]) -> String {
20    format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
21}
22
23pub fn rgb_to_hsl(rgb: [i32; 3]) -> [i32; 3] {
24    let r = rgb[0];
25    let g = rgb[1];
26    let b = rgb[2];
27    let red = r as f64 / 255.0;
28    let green = g as f64 / 255.0;
29    let blue = b as f64 / 255.0;
30    let max_color = max(max(r, g), b);
31    let min_color = min(min(r, g), b);
32    let chroma = (max_color - min_color) as f64 / 255.0;
33    if chroma == 0.0 {
34        return [0, 0, (red * 100.0).round() as i32];
35    }
36
37    let hue;
38    let luminosity = (max_color + min_color) as f64 / (255.0 * 2.0);
39    let saturation = if luminosity > 0.5 {
40        0.5 * chroma / (1.0 - luminosity)
41    } else {
42        0.5 * chroma / luminosity
43    };
44    if max_color == r {
45        if green >= blue {
46            hue = 60.0 * (green - blue) / chroma;
47        } else {
48            hue = ((green - blue) / chroma + 6.0) * 60.0;
49        }
50    } else if max_color == g {
51        hue = ((blue - red) / chroma + 2.0) * 60.0;
52    } else {
53        hue = ((red - green) / chroma + 4.0) * 60.0;
54    }
55    let hue = hue.round() as i32;
56    let saturation = (saturation * 100.0).round() as i32;
57    let luminosity = (luminosity * 100.0).round() as i32;
58    [hue, saturation, luminosity]
59}
60
61fn hue_to_rgb(p: f64, q: f64, t: f64) -> f64 {
62    let mut c = t;
63    if c < 0.0 {
64        c += 1.0;
65    }
66    if c > 1.0 {
67        c -= 1.0;
68    }
69    if c < 1.0 / 6.0 {
70        return p + (q - p) * 6.0 * c;
71    };
72    if c < 0.5 {
73        return q;
74    };
75    if c < 2.0 / 3.0 {
76        return p + (q - p) * (2.0 / 3.0 - c) * 6.0;
77    };
78    p
79}
80
81pub fn hsl_to_rgb(hsl: [i32; 3]) -> [i32; 3] {
82    let hue = (hsl[0] as f64) / 360.0;
83    let saturation = (hsl[1] as f64) / 100.0;
84    let luminosity = (hsl[2] as f64) / 100.0;
85    let red;
86    let green;
87    let blue;
88
89    if saturation == 0.0 {
90        // achromatic
91        red = luminosity * 255.0;
92        green = luminosity * 255.0;
93        blue = luminosity * 255.0;
94    } else {
95        let q = if luminosity < 0.5 {
96            luminosity * (1.0 + saturation)
97        } else {
98            luminosity + saturation - luminosity * saturation
99        };
100        let p = 2.0 * luminosity - q;
101        red = 255.0 * hue_to_rgb(p, q, hue + 1.0 / 3.0);
102        green = 255.0 * hue_to_rgb(p, q, hue);
103        blue = 255.0 * hue_to_rgb(p, q, hue - 1.0 / 3.0);
104    }
105    [
106        red.round() as i32,
107        green.round() as i32,
108        blue.round() as i32,
109    ]
110}
111
112/// Applies the OOXML §18.8.3 tint algorithm to a hex color string.
113/// Positive tint lightens, negative tint darkens. Returns the hex string unchanged if tint is 0.
114pub fn hex_with_tint_to_rgb(hex: &str, tint: f64) -> String {
115    if tint == 0.0 {
116        return hex.to_string();
117    }
118    let mut hsl = rgb_to_hsl(hex_to_rgb(hex));
119    let l = hsl[2] as f64;
120    if tint < 0.0 {
121        // Lum’ = Lum * (1.0 + tint)
122        hsl[2] = (l * (1.0 + tint)).round() as i32;
123    } else {
124        // HLSMAX here would be 100, for Excel 255
125        // Lum‘ = Lum * (1.0-tint) + (HLSMAX – HLSMAX * (1.0-tint))
126        hsl[2] = (l + (100.0 - l) * tint).round() as i32;
127    };
128    rgb_to_hex(hsl_to_rgb(hsl))
129}
130
131pub fn get_indexed_color(index: i32) -> String {
132    let color_list = [
133        "#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF",
134        "#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF",
135        "#800000", "#008000", "#000080", "#808000", "#800080", "#008080", "#C0C0C0", "#808080",
136        "#9999FF", "#993366", "#FFFFCC", "#CCFFFF", "#660066", "#FF8080", "#0066CC", "#CCCCFF",
137        "#000080", "#FF00FF", "#FFFF00", "#00FFFF", "#800080", "#800000", "#008080", "#0000FF",
138        "#00CCFF", "#CCFFFF", "#CCFFCC", "#FFFF99", "#99CCFF", "#FF99CC", "#CC99FF", "#FFCC99",
139        "#3366FF", "#33CCCC", "#99CC00", "#FFCC00", "#FF9900", "#FF6600", "#666699", "#969696",
140        "#003366", "#339966", "#003300", "#333300", "#993300", "#993366", "#333399",
141        "#333333",
142        // 64, Transparent)
143    ];
144    if index > 63 {
145        return color_list[0].to_string();
146    }
147    color_list[index as usize].to_string()
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_rgb_hex() {
156        struct ColorTest {
157            hex: String,
158            rgb: [i32; 3],
159            hsl: [i32; 3],
160        }
161        let color_tests = [
162            ColorTest {
163                hex: "#FFFFFF".to_string(),
164                rgb: [255, 255, 255],
165                hsl: [0, 0, 100],
166            },
167            ColorTest {
168                hex: "#000000".to_string(),
169                rgb: [0, 0, 0],
170                hsl: [0, 0, 0],
171            },
172            ColorTest {
173                hex: "#44546A".to_string(),
174                rgb: [68, 84, 106],
175                hsl: [215, 22, 34],
176            },
177            ColorTest {
178                hex: "#E7E6E6".to_string(),
179                rgb: [231, 230, 230],
180                hsl: [0, 2, 90],
181            },
182            ColorTest {
183                hex: "#4472C4".to_string(),
184                rgb: [68, 114, 196],
185                hsl: [218, 52, 52],
186            },
187            ColorTest {
188                hex: "#ED7D31".to_string(),
189                rgb: [237, 125, 49],
190                hsl: [24, 84, 56],
191            },
192            ColorTest {
193                hex: "#A5A5A5".to_string(),
194                rgb: [165, 165, 165],
195                hsl: [0, 0, 65],
196            },
197            ColorTest {
198                hex: "#FFC000".to_string(),
199                rgb: [255, 192, 0],
200                hsl: [45, 100, 50],
201            },
202            ColorTest {
203                hex: "#5B9BD5".to_string(),
204                rgb: [91, 155, 213],
205                hsl: [209, 59, 60],
206            },
207            ColorTest {
208                hex: "#70AD47".to_string(),
209                rgb: [112, 173, 71],
210                hsl: [96, 42, 48],
211            },
212            ColorTest {
213                hex: "#0563C1".to_string(),
214                rgb: [5, 99, 193],
215                hsl: [210, 95, 39],
216            },
217            ColorTest {
218                hex: "#954F72".to_string(),
219                rgb: [149, 79, 114],
220                hsl: [330, 31, 45],
221            },
222        ];
223        for color in color_tests.iter() {
224            let rgb = color.rgb;
225            let hsl = color.hsl;
226            assert_eq!(rgb, hex_to_rgb(&color.hex));
227            assert_eq!(hsl, rgb_to_hsl(rgb));
228            assert_eq!(rgb_to_hex(rgb), color.hex);
229            // The round trip has rounding errors
230            // FIXME: We could also hardcode the hsl21 in the testcase
231            let rgb2 = hsl_to_rgb(hsl);
232            let diff =
233                (rgb2[0] - rgb[0]).abs() + (rgb2[1] - rgb[1]).abs() + (rgb2[2] - rgb[2]).abs();
234            assert!(diff < 4);
235        }
236    }
237
238    #[test]
239    fn test_tint() {
240        let accent3 = "#8064A2";
241        assert_eq!(hex_with_tint_to_rgb(accent3, 0.8), "#E5DFEC");
242        assert_eq!(hex_with_tint_to_rgb(accent3, 0.6), "#CBBFD9");
243        assert_eq!(hex_with_tint_to_rgb(accent3, 0.4), "#B3A3C8");
244    }
245
246    #[test]
247    fn test_hsl_to_rgb() {
248        // Achromatic: saturation=0 bypasses hue_to_rgb entirely
249        assert_eq!(hsl_to_rgb([0, 0, 0]), [0, 0, 0]);
250        assert_eq!(hsl_to_rgb([0, 0, 100]), [255, 255, 255]);
251        assert_eq!(hsl_to_rgb([0, 0, 50]), [128, 128, 128]);
252
253        // Primary colors
254        assert_eq!(hsl_to_rgb([0, 100, 50]), [255, 0, 0]);
255        assert_eq!(hsl_to_rgb([120, 100, 50]), [0, 255, 0]);
256        assert_eq!(hsl_to_rgb([240, 100, 50]), [0, 0, 255]);
257
258        // hue=30°: blue channel wraps up (t < 0 → c = t+1 ≥ 2/3 → return p)
259        assert_eq!(hsl_to_rgb([30, 100, 50]), [255, 128, 0]);
260
261        // hue=180° and hue=210°: green channel hits the (2/3 - c)*6 branch (c ∈ [0.5, 2/3))
262        assert_eq!(hsl_to_rgb([180, 100, 50]), [0, 255, 255]);
263        assert_eq!(hsl_to_rgb([210, 100, 50]), [0, 127, 255]);
264
265        // hue=270°: red channel wraps down (t > 1 → c = t-1 < 1/6 → first formula with c ≠ t)
266        // 127 not 128 because 6*(1/12) lands just under 0.5 in f64
267        assert_eq!(hsl_to_rgb([270, 100, 50]), [127, 0, 255]);
268    }
269
270    #[test]
271    fn test_hsl_round_trip_wrapping_hues() {
272        // Hue 240°–300° (purple/violet): red channel wraps down in hue_to_rgb (t = hue+1/3 > 1).
273        // Hue 0°–120° (warm): blue channel wraps up (t = hue-1/3 < 0).
274        for hex in [
275            "#8064A2", // ~267° — purple range
276            "#7030A0", // ~274°
277            "#4B0082", // ~275° indigo
278            "#FF6600", // ~24° orange
279            "#ED7D31", // ~24°
280            "#FFCC00", // ~48° yellow
281        ] {
282            let rgb = hex_to_rgb(hex);
283            let hsl = rgb_to_hsl(rgb);
284            let rgb2 = hsl_to_rgb(hsl);
285            let diff =
286                (rgb2[0] - rgb[0]).abs() + (rgb2[1] - rgb[1]).abs() + (rgb2[2] - rgb[2]).abs();
287            assert!(
288                diff < 6,
289                "round-trip failed for {hex}: {rgb:?} → {hsl:?} → {rgb2:?}"
290            );
291        }
292    }
293}