svgtypes/
color.rs

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