use image::Rgba;
use lightningcss::traits::Parse;
use lightningcss::values::color::CssColor;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ColorError {
#[error("empty color string")]
Empty,
#[error("color must start with '#'")]
MissingHash,
#[error("invalid color length {0}, expected 3, 4, 6, or 8")]
InvalidLength(usize),
#[error("invalid hex character '{0}'")]
InvalidHex(char),
#[error("CSS parse error: {0}")]
CssParse(String),
}
impl<T: std::fmt::Display> From<lightningcss::error::Error<T>> for ColorError {
fn from(e: lightningcss::error::Error<T>) -> Self {
ColorError::CssParse(e.to_string())
}
}
pub fn parse_color(s: &str) -> Result<Rgba<u8>, ColorError> {
if s.is_empty() {
return Err(ColorError::Empty);
}
if s.starts_with('#') {
return parse_hex_color(s);
}
parse_css_color(s)
}
fn parse_hex_color(s: &str) -> Result<Rgba<u8>, ColorError> {
let hex = &s[1..];
let len = hex.len();
for c in hex.chars() {
if !c.is_ascii_hexdigit() {
return Err(ColorError::InvalidHex(c));
}
}
match len {
3 => {
let mut chars = hex.chars();
let r = parse_hex_digit(chars.next().unwrap())? * 17;
let g = parse_hex_digit(chars.next().unwrap())? * 17;
let b = parse_hex_digit(chars.next().unwrap())? * 17;
Ok(Rgba([r, g, b, 255]))
}
4 => {
let mut chars = hex.chars();
let r = parse_hex_digit(chars.next().unwrap())? * 17;
let g = parse_hex_digit(chars.next().unwrap())? * 17;
let b = parse_hex_digit(chars.next().unwrap())? * 17;
let a = parse_hex_digit(chars.next().unwrap())? * 17;
Ok(Rgba([r, g, b, a]))
}
6 => {
let r = parse_hex_pair(&hex[0..2])?;
let g = parse_hex_pair(&hex[2..4])?;
let b = parse_hex_pair(&hex[4..6])?;
Ok(Rgba([r, g, b, 255]))
}
8 => {
let r = parse_hex_pair(&hex[0..2])?;
let g = parse_hex_pair(&hex[2..4])?;
let b = parse_hex_pair(&hex[4..6])?;
let a = parse_hex_pair(&hex[6..8])?;
Ok(Rgba([r, g, b, a]))
}
_ => Err(ColorError::InvalidLength(len)),
}
}
fn parse_css_color(s: &str) -> Result<Rgba<u8>, ColorError> {
let css_color = CssColor::parse_string(s).map_err(|e| ColorError::CssParse(e.to_string()))?;
css_color_to_rgba(css_color)
}
fn css_color_to_rgba(color: CssColor) -> Result<Rgba<u8>, ColorError> {
use lightningcss::values::color::FloatColor;
let rgb_color = color
.to_rgb()
.map_err(|_| ColorError::CssParse("cannot convert color to RGB".to_string()))?;
match rgb_color {
CssColor::RGBA(rgba) => Ok(Rgba([rgba.red, rgba.green, rgba.blue, rgba.alpha])),
CssColor::Float(float_color) => {
match float_color.as_ref() {
FloatColor::RGB(rgb) => {
let r = (rgb.r * 255.0).round() as u8;
let g = (rgb.g * 255.0).round() as u8;
let b = (rgb.b * 255.0).round() as u8;
let a = (rgb.alpha * 255.0).round() as u8;
Ok(Rgba([r, g, b, a]))
}
_ => Err(ColorError::CssParse("unexpected float color format".to_string())),
}
}
_ => Err(ColorError::CssParse("color conversion did not produce RGB".to_string())),
}
}
fn parse_hex_digit(c: char) -> Result<u8, ColorError> {
match c {
'0'..='9' => Ok(c as u8 - b'0'),
'a'..='f' => Ok(c as u8 - b'a' + 10),
'A'..='F' => Ok(c as u8 - b'A' + 10),
_ => Err(ColorError::InvalidHex(c)),
}
}
fn parse_hex_pair(s: &str) -> Result<u8, ColorError> {
let mut chars = s.chars();
let high = parse_hex_digit(chars.next().unwrap())?;
let low = parse_hex_digit(chars.next().unwrap())?;
Ok(high * 16 + low)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_rgb_short() {
assert_eq!(parse_color("#F00").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#0F0").unwrap(), Rgba([0, 255, 0, 255]));
assert_eq!(parse_color("#00F").unwrap(), Rgba([0, 0, 255, 255]));
assert_eq!(parse_color("#FFF").unwrap(), Rgba([255, 255, 255, 255]));
assert_eq!(parse_color("#000").unwrap(), Rgba([0, 0, 0, 255]));
assert_eq!(parse_color("#ABC").unwrap(), Rgba([170, 187, 204, 255]));
}
#[test]
fn test_parse_rgba_short() {
assert_eq!(parse_color("#F00F").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#F008").unwrap(), Rgba([255, 0, 0, 136]));
assert_eq!(parse_color("#0000").unwrap(), Rgba([0, 0, 0, 0]));
assert_eq!(parse_color("#FFFF").unwrap(), Rgba([255, 255, 255, 255]));
}
#[test]
fn test_parse_rrggbb() {
assert_eq!(parse_color("#FF0000").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#00FF00").unwrap(), Rgba([0, 255, 0, 255]));
assert_eq!(parse_color("#0000FF").unwrap(), Rgba([0, 0, 255, 255]));
assert_eq!(parse_color("#FFFFFF").unwrap(), Rgba([255, 255, 255, 255]));
assert_eq!(parse_color("#000000").unwrap(), Rgba([0, 0, 0, 255]));
assert_eq!(parse_color("#AABBCC").unwrap(), Rgba([170, 187, 204, 255]));
}
#[test]
fn test_parse_rrggbbaa() {
assert_eq!(parse_color("#FF0000FF").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#FF000080").unwrap(), Rgba([255, 0, 0, 128]));
assert_eq!(parse_color("#FF000000").unwrap(), Rgba([255, 0, 0, 0]));
assert_eq!(parse_color("#00000000").unwrap(), Rgba([0, 0, 0, 0]));
}
#[test]
fn test_case_insensitive() {
assert_eq!(parse_color("#f00").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#ff0000").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#aAbBcC").unwrap(), Rgba([170, 187, 204, 255]));
}
#[test]
fn test_error_empty() {
assert_eq!(parse_color(""), Err(ColorError::Empty));
}
#[test]
fn test_error_invalid_format() {
assert!(parse_color("FF0000").is_err());
assert!(parse_color("F00").is_err());
assert!(parse_color("notacolor").is_err());
}
#[test]
fn test_error_invalid_length() {
assert_eq!(parse_color("#F"), Err(ColorError::InvalidLength(1)));
assert_eq!(parse_color("#FF"), Err(ColorError::InvalidLength(2)));
assert_eq!(parse_color("#FFFFF"), Err(ColorError::InvalidLength(5)));
assert_eq!(parse_color("#FFFFFFF"), Err(ColorError::InvalidLength(7)));
assert_eq!(parse_color("#FFFFFFFFF"), Err(ColorError::InvalidLength(9)));
}
#[test]
fn test_error_invalid_hex() {
assert_eq!(parse_color("#GGG"), Err(ColorError::InvalidHex('G')));
assert_eq!(parse_color("#XYZ"), Err(ColorError::InvalidHex('X')));
assert_eq!(parse_color("#12345G"), Err(ColorError::InvalidHex('G')));
assert_eq!(parse_color("#not-a-color"), Err(ColorError::InvalidHex('n')));
}
#[test]
fn test_fixture_color_formats() {
assert_eq!(parse_color("#F00").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#FF0000").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#FF0000FF").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("#F00F").unwrap(), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_css_parse_error_display() {
let err = ColorError::CssParse("unexpected token".to_string());
assert_eq!(err.to_string(), "CSS parse error: unexpected token");
}
#[test]
fn test_css_parse_error_equality() {
let err1 = ColorError::CssParse("test".to_string());
let err2 = ColorError::CssParse("test".to_string());
let err3 = ColorError::CssParse("different".to_string());
assert_eq!(err1, err2);
assert_ne!(err1, err3);
}
#[test]
fn test_color_mix_oklch_basic() {
let result = parse_color("color-mix(in oklch, red, blue)");
assert!(result.is_ok(), "color-mix(in oklch, red, blue) should parse");
let color = result.unwrap();
assert!(color.0[0] > 100, "Should have significant red component");
assert!(color.0[2] > 100, "Should have significant blue component");
}
#[test]
fn test_color_mix_oklch_percentages() {
let result = parse_color("color-mix(in oklch, red 70%, blue)");
assert!(result.is_ok(), "color-mix with percentages should parse");
let color = result.unwrap();
assert!(color.0[0] > color.0[2], "70% red should dominate");
let result2 = parse_color("color-mix(in oklch, red 30%, blue)");
assert!(result2.is_ok());
let color2 = result2.unwrap();
assert!(color2.0[2] > color2.0[0], "70% blue should dominate");
}
#[test]
fn test_color_mix_srgb() {
let result = parse_color("color-mix(in srgb, #ff0000 50%, #0000ff)");
assert!(result.is_ok(), "color-mix(in srgb, ...) should parse");
let color = result.unwrap();
assert!(color.0[0] > 100, "Should have red component");
assert!(color.0[2] > 100, "Should have blue component");
}
#[test]
fn test_color_mix_hsl() {
let result = parse_color("color-mix(in hsl, red, blue)");
assert!(result.is_ok(), "color-mix(in hsl, ...) should parse");
}
#[test]
fn test_color_mix_with_named_colors() {
let result = parse_color("color-mix(in oklch, coral, steelblue)");
assert!(result.is_ok(), "color-mix with named colors should work");
}
#[test]
fn test_color_mix_with_hex() {
let result = parse_color("color-mix(in oklch, #ff6347, #4682b4)");
assert!(result.is_ok(), "color-mix with hex colors should work");
}
#[test]
fn test_color_mix_with_rgb_functional() {
let result = parse_color("color-mix(in oklch, rgb(255, 0, 0), rgb(0, 0, 255))");
assert!(result.is_ok(), "color-mix with rgb() should work");
}
#[test]
fn test_color_mix_with_hsl_functional() {
let result = parse_color("color-mix(in oklch, hsl(0, 100%, 50%), hsl(240, 100%, 50%))");
assert!(result.is_ok(), "color-mix with hsl() should work");
}
#[test]
fn test_color_mix_white_black() {
let result = parse_color("color-mix(in oklch, white, black)");
assert!(result.is_ok());
let color = result.unwrap();
let diff_rg = (color.0[0] as i16 - color.0[1] as i16).abs();
let diff_rb = (color.0[0] as i16 - color.0[2] as i16).abs();
assert!(diff_rg < 30, "R and G should be similar for gray");
assert!(diff_rb < 30, "R and B should be similar for gray");
}
#[test]
fn test_color_mix_100_percent() {
let result = parse_color("color-mix(in oklch, red 100%, blue)");
assert!(result.is_ok());
let color = result.unwrap();
assert!(color.0[0] > 250, "100% red should be pure red");
assert!(color.0[2] < 10, "100% red should have no blue");
}
#[test]
fn test_color_mix_0_percent() {
let result = parse_color("color-mix(in oklch, red 0%, blue)");
assert!(result.is_ok());
let color = result.unwrap();
assert!(color.0[2] > color.0[0], "0% red should be more blue than red: {:?}", color);
}
#[test]
fn test_color_mix_with_alpha() {
let result = parse_color("color-mix(in oklch, rgba(255, 0, 0, 0.5), rgba(0, 0, 255, 0.5))");
assert!(result.is_ok(), "color-mix with alpha should work");
let color = result.unwrap();
assert!(color.0[3] < 255, "Mixed alpha should be less than 255");
}
#[test]
fn test_color_mix_oklch_longer_hue() {
let result = parse_color("color-mix(in oklch longer hue, red, blue)");
assert!(result.is_ok(), "color-mix with longer hue should parse");
}
#[test]
fn test_color_mix_oklch_shorter_hue() {
let result = parse_color("color-mix(in oklch shorter hue, red, blue)");
assert!(result.is_ok(), "color-mix with shorter hue should parse");
}
#[test]
fn test_parse_rgb_functional() {
assert_eq!(parse_color("rgb(255, 0, 0)").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("rgb(0, 255, 0)").unwrap(), Rgba([0, 255, 0, 255]));
assert_eq!(parse_color("rgb(0, 0, 255)").unwrap(), Rgba([0, 0, 255, 255]));
assert_eq!(parse_color("rgb(100%, 0%, 0%)").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("rgb(0%, 100%, 0%)").unwrap(), Rgba([0, 255, 0, 255]));
assert_eq!(parse_color("rgb(255 0 0)").unwrap(), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_parse_rgba_functional() {
assert_eq!(parse_color("rgba(255, 0, 0, 1)").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("rgba(255, 0, 0, 0.5)").unwrap(), Rgba([255, 0, 0, 128]));
assert_eq!(parse_color("rgba(255, 0, 0, 0)").unwrap(), Rgba([255, 0, 0, 0]));
assert_eq!(parse_color("rgb(255 0 0 / 50%)").unwrap(), Rgba([255, 0, 0, 128]));
assert_eq!(parse_color("rgb(255 0 0 / 0.5)").unwrap(), Rgba([255, 0, 0, 128]));
}
#[test]
fn test_parse_hsl_functional() {
assert_eq!(parse_color("hsl(0, 100%, 50%)").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("hsl(120, 100%, 50%)").unwrap(), Rgba([0, 255, 0, 255]));
assert_eq!(parse_color("hsl(240, 100%, 50%)").unwrap(), Rgba([0, 0, 255, 255]));
assert_eq!(parse_color("hsl(0deg 100% 50%)").unwrap(), Rgba([255, 0, 0, 255]));
}
#[test]
fn test_parse_hsla_functional() {
assert_eq!(parse_color("hsla(0, 100%, 50%, 1)").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("hsla(0, 100%, 50%, 0.5)").unwrap(), Rgba([255, 0, 0, 128]));
assert_eq!(parse_color("hsl(0 100% 50% / 50%)").unwrap(), Rgba([255, 0, 0, 128]));
}
#[test]
fn test_parse_hwb_functional() {
assert_eq!(parse_color("hwb(0 0% 0%)").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("hwb(120 0% 0%)").unwrap(), Rgba([0, 255, 0, 255]));
assert_eq!(parse_color("hwb(240 0% 0%)").unwrap(), Rgba([0, 0, 255, 255]));
assert_eq!(parse_color("hwb(0 100% 0%)").unwrap(), Rgba([255, 255, 255, 255]));
assert_eq!(parse_color("hwb(0 0% 100%)").unwrap(), Rgba([0, 0, 0, 255]));
}
#[test]
fn test_parse_oklch_functional() {
let red = parse_color("oklch(0.628 0.258 29.23)").unwrap();
assert!(red.0[0] > 250); assert!(red.0[1] < 10); assert!(red.0[2] < 10); }
#[test]
fn test_parse_named_colors() {
assert_eq!(parse_color("red").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("green").unwrap(), Rgba([0, 128, 0, 255])); assert_eq!(parse_color("blue").unwrap(), Rgba([0, 0, 255, 255]));
assert_eq!(parse_color("white").unwrap(), Rgba([255, 255, 255, 255]));
assert_eq!(parse_color("black").unwrap(), Rgba([0, 0, 0, 255]));
assert_eq!(parse_color("transparent").unwrap(), Rgba([0, 0, 0, 0]));
assert_eq!(parse_color("coral").unwrap(), Rgba([255, 127, 80, 255]));
assert_eq!(parse_color("hotpink").unwrap(), Rgba([255, 105, 180, 255]));
assert_eq!(parse_color("steelblue").unwrap(), Rgba([70, 130, 180, 255]));
}
#[test]
fn test_named_colors_case_insensitive() {
assert_eq!(parse_color("Red").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("RED").unwrap(), Rgba([255, 0, 0, 255]));
assert_eq!(parse_color("HotPink").unwrap(), Rgba([255, 105, 180, 255]));
}
#[test]
fn test_color_mix_basic() {
let purple = parse_color("color-mix(in srgb, red 50%, blue)").unwrap();
assert!(purple.0[0] > 100 && purple.0[0] < 150); assert!(purple.0[1] < 20); assert!(purple.0[2] > 100 && purple.0[2] < 150); assert_eq!(purple.0[3], 255); }
#[test]
fn test_color_mix_percentages() {
let dark_red = parse_color("color-mix(in srgb, red 70%, black)").unwrap();
assert!(dark_red.0[0] > 150 && dark_red.0[0] < 200);
assert!(dark_red.0[1] < 10);
assert!(dark_red.0[2] < 10);
}
#[test]
fn test_color_mix_oklch() {
let result = parse_color("color-mix(in oklch, #FF0000 70%, black)").unwrap();
assert!(result.0[0] > 100); assert!(result.0[0] < 255); }
#[test]
fn test_color_mix_white_black_srgb() {
let gray = parse_color("color-mix(in srgb, white, black)").unwrap();
assert!(gray.0[0] > 100 && gray.0[0] < 150);
assert!(gray.0[1] > 100 && gray.0[1] < 150);
assert!(gray.0[2] > 100 && gray.0[2] < 150);
}
#[test]
fn test_color_mix_highlight_generation() {
let highlight = parse_color("color-mix(in srgb, #3366CC 70%, white)").unwrap();
assert!(highlight.0[0] > 51); assert!(highlight.0[1] > 102); assert!(highlight.0[2] > 204); }
#[test]
fn test_color_mix_shadow_generation() {
let shadow = parse_color("color-mix(in oklch, #FFCC99 70%, black)").unwrap();
assert!(shadow.0[0] < 255);
assert!(shadow.0[1] < 204);
assert!(shadow.0[2] < 153);
}
}