scarlet/
csscolor.rs

1//! This file uses the CSS numeric parsing in `cssnumeric.rs` to parse CSS functional color notation
2//! according to the W3 specification. The only difference is that arithmetic is not supported to
3//! specify colors. Its end goal is the implementation of FromStr for RGB, HSL, and HSV colors,
4//! although the specific `impl` blocks are in their respective source files. You can see the full
5//! spec here: [https://www.w3.org/TR/css-color-3/](https://www.w3.org/TR/css-color-3/). One quick caveat:
6//! as is relatively standard, percents are only integral: "45.5%" will be treated as invalid.
7
8pub(crate) use cssnumeric::CSSParseError;
9use cssnumeric::{parse_css_number, CSSNumeric};
10
11/// Given a string, attempts to parse as a CSS numeric. If successful, interprets the number given as
12/// a component of an RGB color, clamping accordingly. Returns the appropriate `u8`: e.g., "102%" maps
13/// to 255, and "34.5" maps to 35. Gives an error on invalid input.
14fn parse_rgb_num(num: &str) -> Result<u8, CSSParseError> {
15    let parsed_num = parse_css_number(num)?;
16    match parsed_num {
17        // integer: clamp to 0-255 and use directly
18        CSSNumeric::Integer(val) => {
19            if val >= 255 {
20                Ok(255u8)
21            } else if val <= 0 {
22                Ok(0u8)
23            } else {
24                Ok(val as u8)
25            }
26        }
27        CSSNumeric::Float(val) => {
28            // interpret between 0 and 1, clamping
29            let clamped = if val <= 0. {
30                0.
31            } else if val >= 1. {
32                1.
33            } else {
34                val
35            };
36            // return that value as u8
37            // the minus bit is to adjust rounding so that, e.g., 50% maps to 127 not 128
38            Ok((clamped * 255. - 0.000001).round() as u8)
39        }
40        CSSNumeric::Percentage(val) => {
41            // clamp between 0 and 100
42            let clamped = if val <= 0 {
43                0
44            } else if val >= 100 {
45                100
46            } else {
47                val
48            };
49            // divide by 100 and then multiply by 255, or equivalently multiply by 2.55
50            Ok((clamped as f64 * 2.55).round() as u8)
51        }
52    }
53}
54
55/// Parses a string of the form "rgb(r, g, b)", where r, g, and b are numbers, returning a tuple of
56/// u8s for the three components. Gives a CSSParseError on invalid input.
57pub(crate) fn parse_rgb_str(num: &str) -> Result<(u8, u8, u8), CSSParseError> {
58    // must have at least 10 characters
59    // has to start with "rgb(" or not a valid color
60    if !num.starts_with("rgb(") || num.len() < 10 {
61        return Err(CSSParseError::InvalidColorSyntax);
62    }
63    // remove first four chars, put in Vec
64    let mut chars: Vec<char> = num.chars().skip(4).collect();
65    // check for and remove parenthesis
66    if chars.iter().last().unwrap() != &')' {
67        return Err(CSSParseError::InvalidColorSyntax);
68    }
69    chars.pop();
70
71    // test for disallowed characters
72    if chars.iter().any(|&c| !"0123456789+-,. %".contains(c)) {
73        println!("hi");
74        return Err(CSSParseError::InvalidColorSyntax);
75    }
76    // this now requires a very specific format: three commas, a parenthesis at the end, and spaces
77    // in between
78    // check for commas (the right number of them) and split into numbers, remove whitespace,
79    // parse, and recombine
80    let split_iter = chars.split(|c| c == &',');
81    // now remove surrounding whitespace and pass to number parsing, propagating errors
82    let mut nums: Vec<u8> = vec![];
83    for split in split_iter {
84        nums.push(parse_rgb_num(split.iter().collect::<String>().trim())?);
85    }
86    if nums.len() != 3 {
87        return Err(CSSParseError::InvalidColorSyntax);
88    }
89    Ok((nums[0], nums[1], nums[2]))
90}
91
92/// Parses an HSL or HSV tuple, given after "hsl" or "hsv" in normal CSS, such as "(250, 50%, 50%)"
93/// into a tuple (f64, f64, f64) such that the first float lies within the range 0-360 and the other
94/// two lie within the range 0-1. Gives a CSSParseError if invalid.
95pub(crate) fn parse_hsl_hsv_tuple(tup: &str) -> Result<(f64, f64, f64), CSSParseError> {
96    // must have '(' at start and ')' at end: remove them, and store in chars vec
97    if !tup.starts_with('(') || !tup.ends_with(')') {
98        return Err(CSSParseError::InvalidColorSyntax);
99    }
100    let mut chars: Vec<char> = tup.chars().skip(1).collect();
101    chars.pop();
102
103    // split with commas: must be 3 distinct things
104    let split_iter = chars.split(|c| c == &',');
105    let mut numerics: Vec<CSSNumeric> = vec![];
106    for split in split_iter {
107        numerics.push(parse_css_number(split.iter().collect::<String>().trim())?);
108    }
109    if numerics.len() != 3 {
110        return Err(CSSParseError::InvalidColorSyntax);
111    }
112    // hue is special: require float or integer, normalize to 0-360
113    let hue: f64 = match numerics[0] {
114        CSSNumeric::Integer(val) => {
115            let mut clamped = val;
116            while clamped < 0 {
117                clamped += 360;
118            }
119            while clamped >= 360 {
120                clamped -= 360;
121            }
122            clamped as f64
123        }
124        CSSNumeric::Float(val) => {
125            let mut clamped = val;
126            while clamped < 0. {
127                clamped += 360.;
128            }
129            while clamped >= 360. {
130                clamped -= 360.;
131            }
132            clamped
133        }
134        _ => return Err(CSSParseError::InvalidColorSyntax),
135    };
136    // saturation and lightness/value all work the same way: clamp between 0 and 1 and expect a
137    // percentage
138    let sat: f64 = match numerics[1] {
139        CSSNumeric::Percentage(val) => {
140            if val < 0 {
141                0.
142            } else if val > 100 {
143                1.
144            } else {
145                (val as f64) / 100.
146            }
147        }
148        _ => return Err(CSSParseError::InvalidColorSyntax),
149    };
150    let l_or_v: f64 = match numerics[2] {
151        CSSNumeric::Percentage(val) => {
152            if val < 0 {
153                0.
154            } else if val > 100 {
155                1.
156            } else {
157                (val as f64) / 100.
158            }
159        }
160        _ => return Err(CSSParseError::InvalidColorSyntax),
161    };
162    // now return
163    Ok((hue, sat, l_or_v))
164}
165
166#[cfg(test)]
167mod tests {
168    #[allow(unused_imports)]
169    use super::*;
170
171    #[test]
172    fn test_rgb_num_parsing() {
173        // test integers
174        assert_eq!(104u8, parse_rgb_num("104").unwrap());
175        assert_eq!(255u8, parse_rgb_num("234923").unwrap());
176        // test floats
177        assert_eq!(123u8, parse_rgb_num(".48235").unwrap());
178        assert_eq!(255u8, parse_rgb_num("1.04").unwrap());
179        // test percents
180        assert_eq!(122u8, parse_rgb_num("48%").unwrap());
181        assert_eq!(255u8, parse_rgb_num("115%").unwrap());
182        // test errors
183        assert_eq!(
184            Err(CSSParseError::InvalidNumericCharacters),
185            parse_rgb_num("abc")
186        );
187        assert_eq!(
188            Err(CSSParseError::InvalidNumericSyntax),
189            parse_rgb_num("123%%")
190        );
191    }
192
193    #[test]
194    fn test_rgb_str_parsing() {
195        // test integers and percents all at once
196        let rgb = parse_rgb_str("rgb(125, 20%, 0.5)").unwrap();
197        assert_eq!(rgb, (125, 51, 127));
198        // test clamping in every direction
199        let rgb = parse_rgb_str("rgb(-125, -20%, 10.5)").unwrap();
200        assert_eq!(rgb, (0, 0, 255));
201        // test error on bad syntax
202        assert_eq!(
203            Err(CSSParseError::InvalidColorSyntax),
204            parse_rgb_str("rgB(123, 33, 2)")
205        );
206        assert_eq!(
207            Err(CSSParseError::InvalidColorSyntax),
208            parse_rgb_str("rgb(123, 123, 41, 22)")
209        );
210        assert_eq!(
211            Err(CSSParseError::InvalidColorSyntax),
212            parse_rgb_str("rgB(())")
213        );
214    }
215
216    #[test]
217    fn test_hslv_str_parsing() {
218        // test normal
219        let hsl = parse_hsl_hsv_tuple("(123, 40%, 40%)").unwrap();
220        assert_eq!(hsl.0.round() as u8, 123u8);
221        assert_eq!((hsl.1 * 100.).round() as u8, 40u8);
222        assert_eq!((hsl.2 * 100.).round() as u8, 40u8);
223        // test hue angle stuff
224        let hsl = parse_hsl_hsv_tuple("(-597, 40%, 40%)").unwrap();
225        assert_eq!(hsl.0.round() as u8, 123u8);
226        assert_eq!((hsl.1 * 100.).round() as u8, 40u8);
227        assert_eq!((hsl.2 * 100.).round() as u8, 40u8);
228        let hsl = parse_hsl_hsv_tuple("(1203, 40%, 40%)").unwrap();
229        assert_eq!(hsl.0.round() as u8, 123u8);
230        assert_eq!((hsl.1 * 100.).round() as u8, 40u8);
231        assert_eq!((hsl.2 * 100.).round() as u8, 40u8);
232        // test percentage clamping
233        let hsl = parse_hsl_hsv_tuple("(123, 140%, -40%)").unwrap();
234        assert_eq!(hsl.0.round() as u8, 123u8);
235        assert_eq!((hsl.1 * 100.).round() as u8, 100u8);
236        assert_eq!((hsl.2 * 100.).round() as u8, 0u8);
237        // test error
238        assert_eq!(
239            parse_hsl_hsv_tuple("(14%, 140%, 12%)"),
240            Err(CSSParseError::InvalidColorSyntax)
241        );
242    }
243}