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