Skip to main content

maplibre_expr/
color.rs

1//! A minimal CSS color model and parser, sufficient for style expressions.
2
3use std::fmt;
4
5/// An RGBA color with channels stored as floats in the `0.0..=1.0` range.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct Color {
8    pub r: f64,
9    pub g: f64,
10    pub b: f64,
11    pub a: f64,
12}
13
14impl Color {
15    pub fn new(r: f64, g: f64, b: f64, a: f64) -> Color {
16        Color { r, g, b, a }
17    }
18
19    /// From 8-bit RGB channels plus a `0.0..=1.0` alpha.
20    pub fn from_rgba8(r: f64, g: f64, b: f64, a: f64) -> Color {
21        Color {
22            r: r / 255.0,
23            g: g / 255.0,
24            b: b / 255.0,
25            a,
26        }
27    }
28
29    /// The premultiplied-alpha `[r, g, b, a]` representation used when a color
30    /// value is serialized as a spec-fixture output. MapLibre stores colors
31    /// premultiplied internally, so `["interpolate", ...]` results and other
32    /// color outputs compare against `[r*a, g*a, b*a, a]`.
33    pub fn to_rgba_unit(self) -> [f64; 4] {
34        [self.r * self.a, self.g * self.a, self.b * self.a, self.a]
35    }
36
37    /// The `to-rgba` operator representation: straight (non-premultiplied)
38    /// `[r, g, b, a]` with r/g/b in `0..=255` and alpha in `0.0..=1.0`.
39    pub fn to_rgba255(self) -> [f64; 4] {
40        [self.r * 255.0, self.g * 255.0, self.b * 255.0, self.a]
41    }
42
43    /// Convert to CIE L\*a\*b\* as `[l, a, b, alpha]` (from straight rgb).
44    pub fn to_lab(self) -> [f64; 4] {
45        rgb_to_lab([self.r, self.g, self.b, self.a])
46    }
47
48    /// Build a color from CIE L\*a\*b\* `[l, a, b, alpha]`.
49    pub fn from_lab(lab: [f64; 4]) -> Color {
50        let [r, g, b, a] = lab_to_rgb(lab);
51        Color::new(r, g, b, a)
52    }
53
54    /// Convert to HCL as `[h, c, l, alpha]`; hue is `NaN` for achromatic colors.
55    pub fn to_hcl(self) -> [f64; 4] {
56        rgb_to_hcl([self.r, self.g, self.b, self.a])
57    }
58
59    /// Build a color from HCL `[h, c, l, alpha]`.
60    pub fn from_hcl(hcl: [f64; 4]) -> Color {
61        let [r, g, b, a] = hcl_to_rgb(hcl);
62        Color::new(r, g, b, a)
63    }
64
65    /// Parse a CSS color string. Supports `#rgb`/`#rrggbb`/`#rrggbbaa`,
66    /// `rgb()`/`rgba()`, `hsl()`/`hsla()`, and a table of named colors.
67    pub fn parse(input: &str) -> Option<Color> {
68        let s = input.trim();
69        if let Some(hex) = s.strip_prefix('#') {
70            return parse_hex(hex);
71        }
72        if let Some(c) = parse_functional(s) {
73            return Some(c);
74        }
75        named(s)
76    }
77}
78
79impl fmt::Display for Color {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(
82            f,
83            "rgba({},{},{},{})",
84            (self.r * 255.0).round() as u8,
85            (self.g * 255.0).round() as u8,
86            (self.b * 255.0).round() as u8,
87            self.a
88        )
89    }
90}
91
92fn parse_hex(hex: &str) -> Option<Color> {
93    let bytes = hex.as_bytes();
94    let expand = |c: u8| {
95        let v = (c as char).to_digit(16)? as f64;
96        Some(v * 16.0 + v)
97    };
98    match hex.len() {
99        3 => Some(Color::from_rgba8(
100            expand(bytes[0])?,
101            expand(bytes[1])?,
102            expand(bytes[2])?,
103            1.0,
104        )),
105        4 => Some(Color::from_rgba8(
106            expand(bytes[0])?,
107            expand(bytes[1])?,
108            expand(bytes[2])?,
109            expand(bytes[3])? / 255.0,
110        )),
111        6 => Some(Color::from_rgba8(
112            hexpair(&hex[0..2])?,
113            hexpair(&hex[2..4])?,
114            hexpair(&hex[4..6])?,
115            1.0,
116        )),
117        8 => Some(Color::from_rgba8(
118            hexpair(&hex[0..2])?,
119            hexpair(&hex[2..4])?,
120            hexpair(&hex[4..6])?,
121            hexpair(&hex[6..8])? / 255.0,
122        )),
123        _ => None,
124    }
125}
126
127fn hexpair(s: &str) -> Option<f64> {
128    u8::from_str_radix(s, 16).ok().map(|v| v as f64)
129}
130
131fn parse_functional(s: &str) -> Option<Color> {
132    let open = s.find('(')?;
133    let name = s[..open].trim().to_ascii_lowercase();
134    let inner = s[open + 1..].strip_suffix(')')?;
135
136    // Accept both legacy comma syntax (`rgb(0, 0, 255)`) and CSS Color 4
137    // whitespace syntax with a `/`-separated alpha (`rgb(0 0 255 / 0.5)`).
138    let (body, alpha_tok) = match inner.split_once('/') {
139        Some((body, alpha)) => (body, Some(alpha.trim())),
140        None => (inner, None),
141    };
142    let parts: Vec<&str> = body
143        .split(|c: char| c == ',' || c.is_whitespace())
144        .filter(|t| !t.is_empty())
145        .collect();
146    if parts.len() < 3 {
147        return None;
148    }
149    let alpha_tok = alpha_tok.or_else(|| parts.get(3).copied());
150    let a = match alpha_tok {
151        Some(t) => alpha(t)?,
152        None => 1.0,
153    };
154
155    match name.as_str() {
156        "rgb" | "rgba" => Some(Color::from_rgba8(
157            channel(parts[0])?,
158            channel(parts[1])?,
159            channel(parts[2])?,
160            a,
161        )),
162        "hsl" | "hsla" => {
163            let h = parts[0].trim_end_matches("deg").parse::<f64>().ok()?;
164            let (r, g, b) = hsl_to_rgb(h, percent(parts[1])?, percent(parts[2])?);
165            Some(Color::new(r, g, b, a))
166        }
167        _ => None,
168    }
169}
170
171/// Parse an alpha token: a plain `0.0..=1.0` number or a percentage.
172fn alpha(s: &str) -> Option<f64> {
173    if let Some(p) = s.strip_suffix('%') {
174        Some(p.trim().parse::<f64>().ok()? / 100.0)
175    } else {
176        s.parse::<f64>().ok()
177    }
178}
179
180fn channel(s: &str) -> Option<f64> {
181    if let Some(p) = s.strip_suffix('%') {
182        Some(p.trim().parse::<f64>().ok()? / 100.0 * 255.0)
183    } else {
184        s.parse::<f64>().ok()
185    }
186}
187
188fn percent(s: &str) -> Option<f64> {
189    s.strip_suffix('%')?
190        .trim()
191        .parse::<f64>()
192        .ok()
193        .map(|v| v / 100.0)
194}
195
196fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
197    let h = ((h % 360.0) + 360.0) % 360.0 / 360.0;
198    if s == 0.0 {
199        return (l, l, l);
200    }
201    let q = if l < 0.5 {
202        l * (1.0 + s)
203    } else {
204        l + s - l * s
205    };
206    let p = 2.0 * l - q;
207    (
208        hue_to_rgb(p, q, h + 1.0 / 3.0),
209        hue_to_rgb(p, q, h),
210        hue_to_rgb(p, q, h - 1.0 / 3.0),
211    )
212}
213
214fn hue_to_rgb(p: f64, q: f64, t: f64) -> f64 {
215    let t = if t < 0.0 {
216        t + 1.0
217    } else if t > 1.0 {
218        t - 1.0
219    } else {
220        t
221    };
222    if t < 1.0 / 6.0 {
223        p + (q - p) * 6.0 * t
224    } else if t < 1.0 / 2.0 {
225        q
226    } else if t < 2.0 / 3.0 {
227        p + (q - p) * (2.0 / 3.0 - t) * 6.0
228    } else {
229        p
230    }
231}
232
233// ---- CIE L*a*b* / HCL conversions ------------------------------------
234//
235// Ported from maplibre-style-spec's `color_spaces.ts` (D50 reference white),
236// so that `interpolate-lab` / `interpolate-hcl` match the reference exactly.
237// See https://observablehq.com/@mbostock/lab-and-rgb
238
239const XN: f64 = 0.96422;
240const YN: f64 = 1.0;
241const ZN: f64 = 0.82521;
242const T0: f64 = 4.0 / 29.0;
243const T1: f64 = 6.0 / 29.0;
244const T2: f64 = 3.0 * T1 * T1;
245const T3: f64 = T1 * T1 * T1;
246
247fn rgb_to_lab([r, g, b, alpha]: [f64; 4]) -> [f64; 4] {
248    let r = rgb2xyz(r);
249    let g = rgb2xyz(g);
250    let b = rgb2xyz(b);
251    let y = xyz2lab((0.2225045 * r + 0.7168786 * g + 0.0606169 * b) / YN);
252    let (x, z) = if r == g && g == b {
253        (y, y)
254    } else {
255        (
256            xyz2lab((0.4360747 * r + 0.3850649 * g + 0.1430804 * b) / XN),
257            xyz2lab((0.0139322 * r + 0.0971045 * g + 0.7141733 * b) / ZN),
258        )
259    };
260    let l = 116.0 * y - 16.0;
261    [
262        if l < 0.0 { 0.0 } else { l },
263        500.0 * (x - y),
264        200.0 * (y - z),
265        alpha,
266    ]
267}
268
269fn lab_to_rgb([l, a, b, alpha]: [f64; 4]) -> [f64; 4] {
270    let y = (l + 16.0) / 116.0;
271    let x = if a.is_nan() { y } else { y + a / 500.0 };
272    let z = if b.is_nan() { y } else { y - b / 200.0 };
273    let y = YN * lab2xyz(y);
274    let x = XN * lab2xyz(x);
275    let z = ZN * lab2xyz(z);
276    [
277        xyz2rgb(3.1338561 * x - 1.6168667 * y - 0.4906146 * z),
278        xyz2rgb(-0.9787684 * x + 1.9161415 * y + 0.033454 * z),
279        xyz2rgb(0.0719453 * x - 0.2289914 * y + 1.4052427 * z),
280        alpha,
281    ]
282}
283
284fn rgb2xyz(x: f64) -> f64 {
285    if x <= 0.04045 {
286        x / 12.92
287    } else {
288        ((x + 0.055) / 1.055).powf(2.4)
289    }
290}
291
292fn xyz2lab(t: f64) -> f64 {
293    if t > T3 {
294        t.cbrt()
295    } else {
296        t / T2 + T0
297    }
298}
299
300fn lab2xyz(t: f64) -> f64 {
301    if t > T1 {
302        t * t * t
303    } else {
304        T2 * (t - T0)
305    }
306}
307
308fn xyz2rgb(x: f64) -> f64 {
309    let x = if x <= 0.00304 {
310        12.92 * x
311    } else {
312        1.055 * x.powf(1.0 / 2.4) - 0.055
313    };
314    x.clamp(0.0, 1.0)
315}
316
317fn constrain_angle(angle: f64) -> f64 {
318    let a = angle % 360.0;
319    if a < 0.0 {
320        a + 360.0
321    } else {
322        a
323    }
324}
325
326fn rgb_to_hcl(rgb: [f64; 4]) -> [f64; 4] {
327    let [l, a, b, alpha] = rgb_to_lab(rgb);
328    let c = (a * a + b * b).sqrt();
329    let h = if (c * 10000.0).round() != 0.0 {
330        constrain_angle(b.atan2(a).to_degrees())
331    } else {
332        f64::NAN
333    };
334    [h, c, l, alpha]
335}
336
337fn hcl_to_rgb([h, c, l, alpha]: [f64; 4]) -> [f64; 4] {
338    let h = if h.is_nan() { 0.0 } else { h.to_radians() };
339    lab_to_rgb([l, h.cos() * c, h.sin() * c, alpha])
340}
341
342/// A small subset of the CSS named colors that appear in the test fixtures.
343fn named(s: &str) -> Option<Color> {
344    let rgb = match s.to_ascii_lowercase().as_str() {
345        "transparent" => return Some(Color::new(0.0, 0.0, 0.0, 0.0)),
346        "black" => (0, 0, 0),
347        "white" => (255, 255, 255),
348        "red" => (255, 0, 0),
349        "green" => (0, 128, 0),
350        "lime" => (0, 255, 0),
351        "blue" => (0, 0, 255),
352        "yellow" => (255, 255, 0),
353        "cyan" | "aqua" => (0, 255, 255),
354        "magenta" | "fuchsia" => (255, 0, 255),
355        "gray" | "grey" => (128, 128, 128),
356        "silver" => (192, 192, 192),
357        "maroon" => (128, 0, 0),
358        "olive" => (128, 128, 0),
359        "navy" => (0, 0, 128),
360        "purple" => (128, 0, 128),
361        "teal" => (0, 128, 128),
362        "orange" => (255, 165, 0),
363        _ => return None,
364    };
365    Some(Color::from_rgba8(
366        rgb.0 as f64,
367        rgb.1 as f64,
368        rgb.2 as f64,
369        1.0,
370    ))
371}