Skip to main content

svgtypes/
color.rs

1// Copyright 2021 the SVG Types Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4use crate::{ByteExt, Error, Stream, colors};
5
6#[cfg(not(feature = "std"))]
7use kurbo::common::FloatFuncs;
8
9/// Representation of the [`<color>`] type.
10///
11/// [`<color>`]: https://www.w3.org/TR/css-color-3/
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13#[allow(missing_docs)]
14pub struct Color {
15    pub red: u8,
16    pub green: u8,
17    pub blue: u8,
18    pub alpha: u8,
19}
20
21impl Color {
22    /// Constructs a new `Color` from RGB values.
23    #[inline]
24    pub fn new_rgb(red: u8, green: u8, blue: u8) -> Self {
25        Self {
26            red,
27            green,
28            blue,
29            alpha: 255,
30        }
31    }
32
33    /// Constructs a new `Color` from RGBA values.
34    #[inline]
35    pub fn new_rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
36        Self {
37            red,
38            green,
39            blue,
40            alpha,
41        }
42    }
43
44    /// Constructs a new `Color` set to black.
45    #[inline]
46    pub fn black() -> Self {
47        Self::new_rgb(0, 0, 0)
48    }
49
50    /// Constructs a new `Color` set to white.
51    #[inline]
52    pub fn white() -> Self {
53        Self::new_rgb(255, 255, 255)
54    }
55
56    /// Constructs a new `Color` set to gray.
57    #[inline]
58    pub fn gray() -> Self {
59        Self::new_rgb(128, 128, 128)
60    }
61
62    /// Constructs a new `Color` set to red.
63    #[inline]
64    pub fn red() -> Self {
65        Self::new_rgb(255, 0, 0)
66    }
67
68    /// Constructs a new `Color` set to green.
69    #[inline]
70    pub fn green() -> Self {
71        Self::new_rgb(0, 128, 0)
72    }
73
74    /// Constructs a new `Color` set to blue.
75    #[inline]
76    pub fn blue() -> Self {
77        Self::new_rgb(0, 0, 255)
78    }
79}
80
81impl core::str::FromStr for Color {
82    type Err = Error;
83
84    /// Parses [CSS3](https://www.w3.org/TR/css-color-3/) `Color` from a string.
85    ///
86    /// # Errors
87    ///
88    ///  - Returns error if a color has an invalid format.
89    ///  - Returns error if `<color>` is followed by `<icccolor>`. It's not supported.
90    ///
91    /// # Notes
92    ///
93    ///  - Any non-`hexdigit` bytes will be treated as `0`.
94    ///  - The [SVG 1.1 spec] has an error.
95    ///    There should be a `number`, not an `integer` for percent values ([details]).
96    ///  - It also supports 4 digits and 8 digits hex notation from the
97    ///    [CSS Color Module Level 4][css-color-4-hex].
98    ///
99    /// [SVG 1.1 spec]: https://www.w3.org/TR/SVG11/types.html#DataTypeColor
100    /// [details]: https://lists.w3.org/Archives/Public/www-svg/2014Jan/0109.html
101    /// [css-color-4-hex]: https://www.w3.org/TR/css-color-4/#hex-notation
102    fn from_str(text: &str) -> Result<Self, Error> {
103        let mut s = Stream::from(text);
104        let color = s.parse_color()?;
105
106        // Check that we are at the end of the stream. Otherwise color can be followed by icccolor,
107        // which is not supported.
108        s.skip_spaces();
109        if !s.at_end() {
110            return Err(Error::UnexpectedData(s.calc_char_pos()));
111        }
112
113        Ok(color)
114    }
115}
116
117impl Stream<'_> {
118    /// Tries to parse a color, but doesn't advance on error.
119    pub fn try_parse_color(&mut self) -> Option<Color> {
120        let mut s = *self;
121        if let Ok(color) = s.parse_color() {
122            *self = s;
123            Some(color)
124        } else {
125            None
126        }
127    }
128
129    /// Parses a color.
130    pub fn parse_color(&mut self) -> Result<Color, Error> {
131        self.skip_spaces();
132
133        let mut color = Color::black();
134
135        if self.curr_byte()? == b'#' {
136            // See https://www.w3.org/TR/css-color-4/#hex-notation
137            self.advance(1);
138            let color_str = self.consume_bytes(|_, c| c.is_hex_digit()).as_bytes();
139            // get color data len until first space or stream end
140            match color_str.len() {
141                6 => {
142                    // #rrggbb
143                    color.red = hex_pair(color_str[0], color_str[1]);
144                    color.green = hex_pair(color_str[2], color_str[3]);
145                    color.blue = hex_pair(color_str[4], color_str[5]);
146                }
147                8 => {
148                    // #rrggbbaa
149                    color.red = hex_pair(color_str[0], color_str[1]);
150                    color.green = hex_pair(color_str[2], color_str[3]);
151                    color.blue = hex_pair(color_str[4], color_str[5]);
152                    color.alpha = hex_pair(color_str[6], color_str[7]);
153                }
154                3 => {
155                    // #rgb
156                    color.red = short_hex(color_str[0]);
157                    color.green = short_hex(color_str[1]);
158                    color.blue = short_hex(color_str[2]);
159                }
160                4 => {
161                    // #rgba
162                    color.red = short_hex(color_str[0]);
163                    color.green = short_hex(color_str[1]);
164                    color.blue = short_hex(color_str[2]);
165                    color.alpha = short_hex(color_str[3]);
166                }
167                _ => {
168                    return Err(Error::InvalidValue);
169                }
170            }
171        } else {
172            // TODO: remove allocation
173            let name = self.consume_ascii_ident().to_ascii_lowercase();
174            if name == "rgb" || name == "rgba" {
175                self.consume_byte(b'(')?;
176
177                let mut is_percent = false;
178                let value = self.parse_number()?;
179                if self.starts_with(b"%") {
180                    self.advance(1);
181                    is_percent = true;
182                }
183                self.skip_spaces();
184                self.parse_list_separator();
185
186                if is_percent {
187                    // The division and multiply are explicitly not collapsed, to ensure the red
188                    // component has the same rounding behavior as the green and blue components.
189                    color.red = ((value / 100.0) * 255.0).round() as u8;
190                    color.green = (self.parse_list_number_or_percent()? * 255.0).round() as u8;
191                    color.blue = (self.parse_list_number_or_percent()? * 255.0).round() as u8;
192                } else {
193                    color.red = value.round() as u8;
194                    color.green = self.parse_list_number()?.round() as u8;
195                    color.blue = self.parse_list_number()?.round() as u8;
196                }
197
198                self.skip_spaces();
199                if !self.starts_with(b")") {
200                    color.alpha = (self.parse_list_number()? * 255.0).round() as u8;
201                }
202
203                self.skip_spaces();
204                self.consume_byte(b')')?;
205            } else if name == "hsl" || name == "hsla" {
206                self.consume_byte(b'(')?;
207
208                let mut hue = self.parse_list_number()?;
209                hue = ((hue % 360.0) + 360.0) % 360.0;
210
211                let saturation = f64_bound(0.0, self.parse_list_number_or_percent()?, 1.0);
212                let lightness = f64_bound(0.0, self.parse_list_number_or_percent()?, 1.0);
213
214                color = hsl_to_rgb(hue as f32 / 60.0, saturation as f32, lightness as f32);
215
216                self.skip_spaces();
217                if !self.starts_with(b")") {
218                    color.alpha = (self.parse_list_number()? * 255.0).round() as u8;
219                }
220
221                self.skip_spaces();
222                self.consume_byte(b')')?;
223            } else {
224                match colors::from_str(&name) {
225                    Some(c) => {
226                        color = c;
227                    }
228                    None => {
229                        return Err(Error::InvalidValue);
230                    }
231                }
232            }
233        }
234
235        Ok(color)
236    }
237}
238
239#[inline]
240fn from_hex(c: u8) -> u8 {
241    match c {
242        b'0'..=b'9' => c - b'0',
243        b'a'..=b'f' => c - b'a' + 10,
244        b'A'..=b'F' => c - b'A' + 10,
245        _ => b'0',
246    }
247}
248
249#[inline]
250fn short_hex(c: u8) -> u8 {
251    let h = from_hex(c);
252    (h << 4) | h
253}
254
255#[inline]
256fn hex_pair(c1: u8, c2: u8) -> u8 {
257    let h1 = from_hex(c1);
258    let h2 = from_hex(c2);
259    (h1 << 4) | h2
260}
261
262// `hue` is in a 0..6 range, while `saturation` and `lightness` are in a 0..=1 range.
263// Based on https://www.w3.org/TR/css-color-3/#hsl-color
264fn hsl_to_rgb(hue: f32, saturation: f32, lightness: f32) -> Color {
265    let t2 = if lightness <= 0.5 {
266        lightness * (saturation + 1.0)
267    } else {
268        lightness + saturation - (lightness * saturation)
269    };
270
271    let t1 = lightness * 2.0 - t2;
272    let red = hue_to_rgb(t1, t2, hue + 2.0);
273    let green = hue_to_rgb(t1, t2, hue);
274    let blue = hue_to_rgb(t1, t2, hue - 2.0);
275    Color::new_rgb(
276        (red * 255.0).round() as u8,
277        (green * 255.0).round() as u8,
278        (blue * 255.0).round() as u8,
279    )
280}
281
282fn hue_to_rgb(t1: f32, t2: f32, mut hue: f32) -> f32 {
283    if hue < 0.0 {
284        hue += 6.0;
285    }
286    if hue >= 6.0 {
287        hue -= 6.0;
288    }
289
290    if hue < 1.0 {
291        (t2 - t1) * hue + t1
292    } else if hue < 3.0 {
293        t2
294    } else if hue < 4.0 {
295        (t2 - t1) * (4.0 - hue) + t1
296    } else {
297        t1
298    }
299}
300
301#[inline]
302fn f64_bound(min: f64, val: f64, max: f64) -> f64 {
303    debug_assert!(val.is_finite());
304    val.clamp(min, max)
305}
306
307#[rustfmt::skip]
308#[cfg(test)]
309mod tests {
310    use alloc::string::ToString;
311    use core::str::FromStr;
312    use crate::Color;
313
314    macro_rules! test {
315        ($name:ident, $text:expr, $color:expr) => {
316            #[test]
317            fn $name() {
318                assert_eq!(Color::from_str($text).unwrap(), $color);
319            }
320        };
321    }
322
323    test!(
324        rrggbb,
325        "#ff0000",
326        Color::new_rgb(255, 0, 0)
327    );
328
329    test!(
330        rrggbb_upper,
331        "#FF0000",
332        Color::new_rgb(255, 0, 0)
333    );
334
335    test!(
336        rgb_hex,
337        "#f00",
338        Color::new_rgb(255, 0, 0)
339    );
340
341    test!(
342        rrggbbaa,
343        "#ff0000ff",
344        Color::new_rgba(255, 0, 0, 255)
345    );
346
347    test!(
348        rrggbbaa_upper,
349        "#FF0000FF",
350        Color::new_rgba(255, 0, 0, 255)
351    );
352
353    test!(
354        rgba_hex,
355        "#f00f",
356        Color::new_rgba(255, 0, 0, 255)
357    );
358
359    test!(
360        rrggbb_spaced,
361        "  #ff0000  ",
362        Color::new_rgb(255, 0, 0)
363    );
364
365    test!(
366        rgb_numeric,
367        "rgb(254, 203, 231)",
368        Color::new_rgb(254, 203, 231)
369    );
370
371    test!(
372        rgb_numeric_spaced,
373        " rgb( 77 , 77 , 77 ) ",
374        Color::new_rgb(77, 77, 77)
375    );
376
377    test!(
378        rgb_percentage,
379        "rgb(50%, 50%, 50%)",
380        Color::new_rgb(128, 128, 128)
381    );
382
383    test!(
384        rgb_percentage_overflow,
385        "rgb(140%, -10%, 130%)",
386        Color::new_rgb(255, 0, 255)
387    );
388
389    test!(
390        rgb_percentage_float,
391        "rgb(33.333%,46.666%,93.333%)",
392        Color::new_rgb(85, 119, 238)
393    );
394
395    test!(
396        rgb_numeric_upper_case,
397        "RGB(254, 203, 231)",
398        Color::new_rgb(254, 203, 231)
399    );
400
401    test!(
402        rgb_numeric_mixed_case,
403        "RgB(254, 203, 231)",
404        Color::new_rgb(254, 203, 231)
405    );
406
407    test!(
408        rgb_numeric_red_float,
409        "rgb(3.141592653, 110, 201)",
410        Color::new_rgb(3, 110, 201)
411    );
412
413    test!(
414        rgb_numeric_green_float,
415        "rgb(254, 150.829521289232389, 210)",
416        Color::new_rgb(254, 151, 210)
417    );
418
419    test!(
420        rgb_numeric_blue_float,
421        "rgb(96, 255, 0.2)",
422        Color::new_rgb(96, 255, 0)
423    );
424
425    test!(
426        rgb_numeric_all_float,
427        "rgb(0.0, 129.82, 231.092)",
428        Color::new_rgb(0, 130, 231)
429    );
430
431    test!(
432        rgb_numeric_all_float_with_alpha,
433        "rgb(0.0, 129.82, 231.092, 0.5)",
434        Color::new_rgba(0, 130, 231, 128)
435    );
436
437    test!(
438        rgb_numeric_all_float_overflow,
439        "rgb(290.2, 255.9, 300.0)",
440        Color::new_rgb(255, 255, 255)
441    );
442
443    test!(
444        name_red,
445        "red",
446        Color::new_rgb(255, 0, 0)
447    );
448
449    test!(
450        name_red_spaced,
451        " red ",
452        Color::new_rgb(255, 0, 0)
453    );
454
455    test!(
456        name_red_upper_case,
457        "RED",
458        Color::new_rgb(255, 0, 0)
459    );
460
461    test!(
462        name_red_mixed_case,
463        "ReD",
464        Color::new_rgb(255, 0, 0)
465    );
466
467    test!(
468        name_cornflowerblue,
469        "cornflowerblue",
470        Color::new_rgb(100, 149, 237)
471    );
472
473    test!(
474        transparent,
475        "transparent",
476        Color::new_rgba(0, 0, 0, 0)
477    );
478
479    test!(
480        rgba_half,
481        "rgba(10, 20, 30, 0.5)",
482        Color::new_rgba(10, 20, 30, 128)
483    );
484
485    test!(
486        rgba_numeric_red_float,
487        "rgba(3.141592653, 110, 201, 1.0)",
488        Color::new_rgba(3, 110, 201, 255)
489    );
490
491    test!(
492        rgba_numeric_all_float,
493        "rgba(0.0, 129.82, 231.092, 1.5)",
494        Color::new_rgba(0, 130, 231, 255)
495    );
496
497    test!(
498        rgba_negative,
499        "rgba(10, 20, 30, -2)",
500        Color::new_rgba(10, 20, 30, 0)
501    );
502
503    test!(
504        rgba_large_alpha,
505        "rgba(10, 20, 30, 2)",
506        Color::new_rgba(10, 20, 30, 255)
507    );
508
509    test!(
510        rgb_with_alpha,
511        "rgb(10, 20, 30, 0.5)",
512        Color::new_rgba(10, 20, 30, 128)
513    );
514
515    test!(
516        hsl_green,
517        "hsl(120, 100%, 75%)",
518        Color::new_rgba(128, 255, 128, 255)
519    );
520
521    test!(
522        hsl_yellow,
523        "hsl(60, 100%, 50%)",
524        Color::new_rgba(255, 255, 0, 255)
525    );
526
527    test!(
528        hsl_hue_360,
529        "hsl(360, 100%, 100%)",
530        Color::new_rgba(255, 255, 255, 255)
531    );
532
533    test!(
534        hsl_out_of_bounds,
535        "hsl(800, 150%, -50%)",
536        Color::new_rgba(0, 0, 0, 255)
537    );
538
539    test!(
540        hsla_green,
541        "hsla(120, 100%, 75%, 0.5)",
542        Color::new_rgba(128, 255, 128, 128)
543    );
544
545    test!(
546        hsl_with_alpha,
547        "hsl(120, 100%, 75%, 0.5)",
548        Color::new_rgba(128, 255, 128, 128)
549    );
550
551    test!(
552        hsl_to_rgb_red_round_up,
553        "hsl(230, 57%, 54%)",
554        Color::new_rgba(71, 93, 205, 255)
555    );
556
557    test!(
558        hsl_with_hue_float,
559        "hsl(120.152, 100%, 75%)",
560        Color::new_rgba(128, 255, 128, 255)
561    );
562
563    test!(
564        hsla_with_hue_float,
565        "hsla(120.152, 100%, 75%, 0.5)",
566        Color::new_rgba(128, 255, 128, 128)
567    );
568
569    macro_rules! test_err {
570        ($name:ident, $text:expr, $err:expr) => {
571            #[test]
572            fn $name() {
573                assert_eq!(Color::from_str($text).unwrap_err().to_string(), $err);
574            }
575        };
576    }
577
578    test_err!(
579        not_a_color_1,
580        "text",
581        "invalid value"
582    );
583
584    test_err!(
585        icc_color_not_supported_1,
586        "#CD853F icc-color(acmecmyk, 0.11, 0.48, 0.83, 0.00)",
587        "unexpected data at position 9"
588    );
589
590    test_err!(
591        icc_color_not_supported_2,
592        "red icc-color(acmecmyk, 0.11, 0.48, 0.83, 0.00)",
593        "unexpected data at position 5"
594    );
595
596    test_err!(
597        invalid_input_1,
598        "rgb(-0\x0d",
599        "unexpected end of stream"
600    );
601
602    test_err!(
603        invalid_input_2,
604        "#9ߞpx! ;",
605        "invalid value"
606    );
607
608    test_err!(
609        rgba_with_percent_alpha,
610        "rgba(10, 20, 30, 5%)",
611        "expected ')' not '%' at position 19"
612    );
613
614    test_err!(
615        rgb_mixed_units,
616        "rgb(140%, -10mm, 130pt)",
617        "invalid number at position 14"
618    );
619}