use teksilo_tokens::Color;
pub(crate) fn parse_color(value: &str) -> Option<Color> {
let v = value.trim();
if v.is_empty() {
return None;
}
if let Some(hex) = v.strip_prefix('#') {
return parse_hex(hex);
}
if let Some(rest) = strip_fn(v, "rgba").or_else(|| strip_fn(v, "rgb")) {
return parse_rgb_fn(rest);
}
if let Some(rest) = strip_fn(v, "hsla").or_else(|| strip_fn(v, "hsl")) {
return parse_hsl_fn(rest);
}
if v.eq_ignore_ascii_case("transparent") {
return Some(Color::TRANSPARENT);
}
parse_named(v)
}
fn parse_hex(hex: &str) -> Option<Color> {
let h = hex.trim();
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let nib = |i: usize| -> Option<f32> {
let c = h.as_bytes().get(i)?;
let d = (*c as char).to_digit(16)? as f32;
Some((d * 16.0 + d) / 255.0)
};
let byte = |i: usize| -> Option<f32> {
let s = h.get(i..i + 2)?;
Some(u8::from_str_radix(s, 16).ok()? as f32 / 255.0)
};
match h.len() {
3 => Some(Color::new(nib(0)?, nib(1)?, nib(2)?, 1.0)),
4 => Some(Color::new(nib(0)?, nib(1)?, nib(2)?, nib(3)?)),
6 => Some(Color::new(byte(0)?, byte(2)?, byte(4)?, 1.0)),
8 => Some(Color::new(byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
_ => None,
}
}
fn strip_fn<'a>(value: &'a str, name: &str) -> Option<&'a str> {
let v = value.trim();
if v.len() <= name.len() || !v[..name.len()].eq_ignore_ascii_case(name) {
return None;
}
let rest = v[name.len()..].trim_start();
rest.strip_prefix('(')?.strip_suffix(')')
}
fn split_args(args: &str) -> (Vec<&str>, Option<&str>) {
let (main, alpha) = match args.split_once('/') {
Some((m, a)) => (m, Some(a.trim())),
None => (args, None),
};
let parts: Vec<&str> = main
.split(|c: char| c == ',' || c.is_ascii_whitespace())
.filter(|s| !s.is_empty())
.collect();
if alpha.is_none() && parts.len() == 4 {
return (parts[..3].to_vec(), Some(parts[3]));
}
(parts, alpha)
}
fn parse_channel(s: &str) -> Option<f32> {
let t = s.trim();
let v = match t.strip_suffix('%') {
Some(p) => p.trim().parse::<f32>().ok()? / 100.0,
None => t.parse::<f32>().ok()? / 255.0,
};
Some(v.clamp(0.0, 1.0))
}
pub(crate) fn parse_alpha(s: &str) -> Option<f32> {
let t = s.trim();
let v = match t.strip_suffix('%') {
Some(p) => p.trim().parse::<f32>().ok()? / 100.0,
None => t.parse::<f32>().ok()?,
};
Some(v.clamp(0.0, 1.0))
}
fn parse_rgb_fn(args: &str) -> Option<Color> {
let (parts, alpha) = split_args(args);
if parts.len() != 3 {
return None;
}
let a = match alpha {
Some(s) => parse_alpha(s)?,
None => 1.0,
};
Some(Color::new(
parse_channel(parts[0])?,
parse_channel(parts[1])?,
parse_channel(parts[2])?,
a,
))
}
fn parse_hsl_fn(args: &str) -> Option<Color> {
let (parts, alpha) = split_args(args);
if parts.len() != 3 {
return None;
}
let h_raw = parts[0].trim();
let hue = if let Some(t) = h_raw.strip_suffix("turn") {
t.trim().parse::<f32>().ok()? * 360.0
} else if let Some(r) = h_raw.strip_suffix("rad") {
r.trim().parse::<f32>().ok()?.to_degrees()
} else {
h_raw.trim_end_matches("deg").trim().parse::<f32>().ok()?
};
let pct = |s: &str| -> Option<f32> {
Some(
s.trim()
.trim_end_matches('%')
.trim()
.parse::<f32>()
.ok()?
.clamp(0.0, 100.0)
/ 100.0,
)
};
let a = match alpha {
Some(s) => parse_alpha(s)?,
None => 1.0,
};
let c = Color::from_hsl(hue, pct(parts[1])?, pct(parts[2])?);
Some(c.with_alpha(a))
}
fn parse_named(name: &str) -> Option<Color> {
let lower = name.to_ascii_lowercase();
let idx = NAMED_COLORS
.binary_search_by(|(n, _)| (*n).cmp(lower.as_str()))
.ok()?;
let rgb = NAMED_COLORS[idx].1;
Some(Color::new(
((rgb >> 16) & 0xFF) as f32 / 255.0,
((rgb >> 8) & 0xFF) as f32 / 255.0,
(rgb & 0xFF) as f32 / 255.0,
1.0,
))
}
#[rustfmt::skip]
const NAMED_COLORS: &[(&str, u32)] = &[
("aliceblue", 0xF0F8FF), ("antiquewhite", 0xFAEBD7), ("aqua", 0x00FFFF),
("aquamarine", 0x7FFFD4), ("azure", 0xF0FFFF), ("beige", 0xF5F5DC),
("bisque", 0xFFE4C4), ("black", 0x000000), ("blanchedalmond", 0xFFEBCD),
("blue", 0x0000FF), ("blueviolet", 0x8A2BE2), ("brown", 0xA52A2A),
("burlywood", 0xDEB887), ("cadetblue", 0x5F9EA0), ("chartreuse", 0x7FFF00),
("chocolate", 0xD2691E), ("coral", 0xFF7F50), ("cornflowerblue", 0x6495ED),
("cornsilk", 0xFFF8DC), ("crimson", 0xDC143C), ("cyan", 0x00FFFF),
("darkblue", 0x00008B), ("darkcyan", 0x008B8B), ("darkgoldenrod", 0xB8860B),
("darkgray", 0xA9A9A9), ("darkgreen", 0x006400), ("darkgrey", 0xA9A9A9),
("darkkhaki", 0xBDB76B), ("darkmagenta", 0x8B008B), ("darkolivegreen", 0x556B2F),
("darkorange", 0xFF8C00), ("darkorchid", 0x9932CC), ("darkred", 0x8B0000),
("darksalmon", 0xE9967A), ("darkseagreen", 0x8FBC8F), ("darkslateblue", 0x483D8B),
("darkslategray", 0x2F4F4F), ("darkslategrey", 0x2F4F4F), ("darkturquoise", 0x00CED1),
("darkviolet", 0x9400D3), ("deeppink", 0xFF1493), ("deepskyblue", 0x00BFFF),
("dimgray", 0x696969), ("dimgrey", 0x696969), ("dodgerblue", 0x1E90FF),
("firebrick", 0xB22222), ("floralwhite", 0xFFFAF0), ("forestgreen", 0x228B22),
("fuchsia", 0xFF00FF), ("gainsboro", 0xDCDCDC), ("ghostwhite", 0xF8F8FF),
("gold", 0xFFD700), ("goldenrod", 0xDAA520), ("gray", 0x808080),
("green", 0x008000), ("greenyellow", 0xADFF2F), ("grey", 0x808080),
("honeydew", 0xF0FFF0), ("hotpink", 0xFF69B4), ("indianred", 0xCD5C5C),
("indigo", 0x4B0082), ("ivory", 0xFFFFF0), ("khaki", 0xF0E68C),
("lavender", 0xE6E6FA), ("lavenderblush", 0xFFF0F5), ("lawngreen", 0x7CFC00),
("lemonchiffon", 0xFFFACD), ("lightblue", 0xADD8E6), ("lightcoral", 0xF08080),
("lightcyan", 0xE0FFFF), ("lightgoldenrodyellow", 0xFAFAD2), ("lightgray", 0xD3D3D3),
("lightgreen", 0x90EE90), ("lightgrey", 0xD3D3D3), ("lightpink", 0xFFB6C1),
("lightsalmon", 0xFFA07A), ("lightseagreen", 0x20B2AA), ("lightskyblue", 0x87CEFA),
("lightslategray", 0x778899), ("lightslategrey", 0x778899), ("lightsteelblue", 0xB0C4DE),
("lightyellow", 0xFFFFE0), ("lime", 0x00FF00), ("limegreen", 0x32CD32),
("linen", 0xFAF0E6), ("magenta", 0xFF00FF), ("maroon", 0x800000),
("mediumaquamarine", 0x66CDAA), ("mediumblue", 0x0000CD), ("mediumorchid", 0xBA55D3),
("mediumpurple", 0x9370DB), ("mediumseagreen", 0x3CB371), ("mediumslateblue", 0x7B68EE),
("mediumspringgreen", 0x00FA9A), ("mediumturquoise", 0x48D1CC), ("mediumvioletred", 0xC71585),
("midnightblue", 0x191970), ("mintcream", 0xF5FFFA), ("mistyrose", 0xFFE4E1),
("moccasin", 0xFFE4B5), ("navajowhite", 0xFFDEAD), ("navy", 0x000080),
("oldlace", 0xFDF5E6), ("olive", 0x808000), ("olivedrab", 0x6B8E23),
("orange", 0xFFA500), ("orangered", 0xFF4500), ("orchid", 0xDA70D6),
("palegoldenrod", 0xEEE8AA), ("palegreen", 0x98FB98), ("paleturquoise", 0xAFEEEE),
("palevioletred", 0xDB7093), ("papayawhip", 0xFFEFD5), ("peachpuff", 0xFFDAB9),
("peru", 0xCD853F), ("pink", 0xFFC0CB), ("plum", 0xDDA0DD),
("powderblue", 0xB0E0E6), ("purple", 0x800080), ("rebeccapurple", 0x663399),
("red", 0xFF0000), ("rosybrown", 0xBC8F8F), ("royalblue", 0x4169E1),
("saddlebrown", 0x8B4513), ("salmon", 0xFA8072), ("sandybrown", 0xF4A460),
("seagreen", 0x2E8B57), ("seashell", 0xFFF5EE), ("sienna", 0xA0522D),
("silver", 0xC0C0C0), ("skyblue", 0x87CEEB), ("slateblue", 0x6A5ACD),
("slategray", 0x708090), ("slategrey", 0x708090), ("snow", 0xFFFAFA),
("springgreen", 0x00FF7F), ("steelblue", 0x4682B4), ("tan", 0xD2B48C),
("teal", 0x008080), ("thistle", 0xD8BFD8), ("tomato", 0xFF6347),
("turquoise", 0x40E0D0), ("violet", 0xEE82EE), ("wheat", 0xF5DEB3),
("white", 0xFFFFFF), ("whitesmoke", 0xF5F5F5), ("yellow", 0xFFFF00),
("yellowgreen", 0x9ACD32),
];
#[cfg(test)]
mod tests {
use super::*;
fn approx(c: Color, r: f32, g: f32, b: f32, a: f32) {
let d = 1.0 / 255.0 + 1e-4;
assert!(
(c.r() - r).abs() < d
&& (c.g() - g).abs() < d
&& (c.b() - b).abs() < d
&& (c.a() - a).abs() < d,
"expected ({r}, {g}, {b}, {a}), got ({}, {}, {}, {})",
c.r(),
c.g(),
c.b(),
c.a()
);
}
#[test]
fn hex_forms() {
approx(parse_color("#f00").unwrap(), 1.0, 0.0, 0.0, 1.0);
approx(parse_color("#FF0000").unwrap(), 1.0, 0.0, 0.0, 1.0);
approx(
parse_color("#f80").unwrap(),
1.0,
0x88 as f32 / 255.0,
0.0,
1.0,
);
approx(parse_color("#00ff0080").unwrap(), 0.0, 1.0, 0.0, 0.502);
approx(parse_color("#0f08").unwrap(), 0.0, 1.0, 0.0, 0.533);
assert!(parse_color("#12345").is_none());
assert!(parse_color("#gg0000").is_none());
}
#[test]
fn rgb_and_rgba_functions() {
approx(parse_color("rgb(255, 0, 0)").unwrap(), 1.0, 0.0, 0.0, 1.0);
approx(
parse_color("rgba(0,255,0,0.5)").unwrap(),
0.0,
1.0,
0.0,
0.5,
);
approx(
parse_color("rgb(100%, 0%, 0%)").unwrap(),
1.0,
0.0,
0.0,
1.0,
);
approx(
parse_color("rgb(0 0 255 / 25%)").unwrap(),
0.0,
0.0,
1.0,
0.25,
);
assert!(parse_color("rgb(1, 2)").is_none());
}
#[test]
fn hsl_functions() {
approx(
parse_color("hsl(0, 100%, 50%)").unwrap(),
1.0,
0.0,
0.0,
1.0,
);
approx(
parse_color("hsl(120deg 100% 50%)").unwrap(),
0.0,
1.0,
0.0,
1.0,
);
approx(
parse_color("hsla(240, 100%, 50%, 0.5)").unwrap(),
0.0,
0.0,
1.0,
0.5,
);
}
#[test]
fn named_colors_and_transparent() {
approx(parse_color("red").unwrap(), 1.0, 0.0, 0.0, 1.0);
approx(parse_color("REBECCAPURPLE").unwrap(), 0.4, 0.2, 0.6, 1.0);
assert_eq!(parse_color("gray"), parse_color("grey"));
approx(parse_color("transparent").unwrap(), 0.0, 0.0, 0.0, 0.0);
assert!(parse_color("chartreuseish").is_none());
}
#[test]
fn named_table_is_sorted() {
assert!(
NAMED_COLORS.windows(2).all(|w| w[0].0 < w[1].0),
"NAMED_COLORS must stay sorted for binary_search"
);
}
#[test]
fn keywords_are_not_colors() {
assert!(parse_color("none").is_none());
assert!(parse_color("currentColor").is_none());
assert!(parse_color("inherit").is_none());
assert!(parse_color("url(#grad)").is_none());
assert!(parse_color("").is_none());
}
}