#![allow(clippy::redundant_pub_crate)]
use palette::Srgb;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct CssColor {
pub(crate) r: f32,
pub(crate) g: f32,
pub(crate) b: f32,
pub(crate) a: f32,
}
impl CssColor {
fn from_rgb_u8_alpha(r: u8, g: u8, b: u8, a: f32) -> Self {
Self {
r: f32::from(r) / 255.0,
g: f32::from(g) / 255.0,
b: f32::from(b) / 255.0,
a,
}
}
pub(crate) fn into_srgb(self) -> Srgb<f32> {
Srgb::new(self.r, self.g, self.b)
}
}
#[must_use]
pub(crate) fn parse_css_color(s: &str) -> Option<CssColor> {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.eq_ignore_ascii_case("transparent") {
return Some(CssColor {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.0,
});
}
if trimmed.starts_with('#') {
return parse_hex(trimmed);
}
if let Some(rest) = strip_ci_prefix(trimmed, "rgba") {
return parse_rgb_functional(rest);
}
if let Some(rest) = strip_ci_prefix(trimmed, "rgb") {
return parse_rgb_functional(rest);
}
None
}
fn strip_ci_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let plen = prefix.len();
if s.len() < plen {
return None;
}
let (head, tail) = s.split_at(plen);
if head.eq_ignore_ascii_case(prefix) {
Some(tail)
} else {
None
}
}
fn parse_hex(input: &str) -> Option<CssColor> {
let hex = input.strip_prefix('#').unwrap_or(input);
match hex.len() {
3 | 6 => {
let rgb: Srgb<u8> = Srgb::from_str(input).ok()?;
Some(CssColor::from_rgb_u8_alpha(
rgb.red, rgb.green, rgb.blue, 1.0,
))
}
4 => {
let red = u8::from_str_radix(&hex[0..1], 16).ok()?;
let green = u8::from_str_radix(&hex[1..2], 16).ok()?;
let blue = u8::from_str_radix(&hex[2..3], 16).ok()?;
let alpha = u8::from_str_radix(&hex[3..4], 16).ok()?;
Some(CssColor::from_rgb_u8_alpha(
red * 17,
green * 17,
blue * 17,
f32::from(alpha * 17) / 255.0,
))
}
8 => {
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
let alpha = u8::from_str_radix(&hex[6..8], 16).ok()?;
Some(CssColor::from_rgb_u8_alpha(
red,
green,
blue,
f32::from(alpha) / 255.0,
))
}
_ => None,
}
}
fn parse_rgb_functional(input: &str) -> Option<CssColor> {
let trimmed = input.trim();
let inner = trimmed.strip_prefix('(')?.strip_suffix(')')?.trim();
let parts: Vec<&str> = if inner.contains(',') {
inner.split(',').map(str::trim).collect()
} else {
inner.split_whitespace().collect()
};
let (red_s, green_s, blue_s, alpha_s) = match parts.as_slice() {
[r, g, b] => (*r, *g, *b, None),
[r, g, b, a] => (*r, *g, *b, Some(*a)),
_ => return None,
};
let alpha = match alpha_s {
Some(token) => parse_alpha(token)?,
None => 1.0,
};
let red = parse_channel(red_s)?;
let green = parse_channel(green_s)?;
let blue = parse_channel(blue_s)?;
Some(CssColor::from_rgb_u8_alpha(red, green, blue, alpha))
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn parse_channel(s: &str) -> Option<u8> {
let trimmed = s.trim();
if let Some(pct) = trimmed.strip_suffix('%') {
let v = pct.trim().parse::<f32>().ok()?;
let scaled = (v / 100.0).clamp(0.0, 1.0) * 255.0;
return Some(scaled.round().clamp(0.0, 255.0) as u8);
}
let v = trimmed.parse::<f32>().ok()?;
Some(v.round().clamp(0.0, 255.0) as u8)
}
fn parse_alpha(s: &str) -> Option<f32> {
let trimmed = s.trim();
if let Some(pct) = trimmed.strip_suffix('%') {
let v = pct.trim().parse::<f32>().ok()?;
return Some((v / 100.0).clamp(0.0, 1.0));
}
let v = trimmed.parse::<f32>().ok()?;
Some(v.clamp(0.0, 1.0))
}
#[must_use]
pub(crate) fn parse_px(s: &str) -> Option<f64> {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
if trimmed == "0" || trimmed == "-0" {
return Some(0.0);
}
let stripped = trimmed.strip_suffix("px")?;
if stripped.is_empty() {
return None;
}
stripped.parse::<f64>().ok()
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn nearest_multiple(value: f64, base: u32) -> i64 {
if base == 0 {
return value.round() as i64;
}
let base_f = f64::from(base);
let magnitude = (value.abs() / base_f).round() * base_f;
let signed = if value.is_sign_negative() {
-magnitude
} else {
magnitude
};
signed as i64
}
#[allow(clippy::float_cmp)]
#[must_use]
pub(crate) fn nearest_in_scale(value: f64, scale: &[u32]) -> Option<u32> {
if scale.is_empty() {
return None;
}
let target = value.abs();
let mut best: Option<(u32, f64)> = None;
for &candidate in scale {
let delta = (f64::from(candidate) - target).abs();
match best {
None => best = Some((candidate, delta)),
Some((current, current_delta)) => {
if delta < current_delta || (delta == current_delta && candidate < current) {
best = Some((candidate, delta));
}
}
}
}
best.map(|(value, _)| value)
}
#[cfg(test)]
mod tests {
use super::{nearest_in_scale, nearest_multiple, parse_css_color, parse_px};
#[test]
fn parse_px_accepts_supported_shapes() {
assert_eq!(parse_px("0"), Some(0.0));
assert_eq!(parse_px("-0"), Some(0.0));
assert_eq!(parse_px("0px"), Some(0.0));
assert_eq!(parse_px("16px"), Some(16.0));
assert_eq!(parse_px("4.5px"), Some(4.5));
assert_eq!(parse_px("-4px"), Some(-4.0));
assert_eq!(parse_px(" 12px "), Some(12.0));
}
#[test]
fn parse_px_rejects_unsupported_units_and_keywords() {
assert_eq!(parse_px(""), None);
assert_eq!(parse_px("auto"), None);
assert_eq!(parse_px("normal"), None);
assert_eq!(parse_px("calc(4px + 4px)"), None);
assert_eq!(parse_px("1em"), None);
assert_eq!(parse_px("2rem"), None);
assert_eq!(parse_px("50%"), None);
assert_eq!(parse_px("px"), None);
}
#[test]
fn nearest_multiple_handles_positive_values() {
assert_eq!(nearest_multiple(13.0, 4), 12);
assert_eq!(nearest_multiple(14.0, 4), 16);
assert_eq!(nearest_multiple(0.0, 4), 0);
assert_eq!(nearest_multiple(7.9, 4), 8);
}
#[test]
fn nearest_multiple_preserves_sign_for_negatives() {
assert_eq!(nearest_multiple(-13.0, 4), -12);
assert_eq!(nearest_multiple(-2.0, 4), -4);
assert_eq!(nearest_multiple(-4.0, 4), -4);
}
#[test]
fn nearest_multiple_zero_base_falls_back_to_round() {
assert_eq!(nearest_multiple(13.4, 0), 13);
assert_eq!(nearest_multiple(-2.6, 0), -3);
}
#[test]
fn nearest_in_scale_picks_closest_by_absolute_delta() {
let scale = [0, 4, 8, 12, 16, 24, 32, 48];
assert_eq!(nearest_in_scale(13.0, &scale), Some(12));
assert_eq!(nearest_in_scale(13.99, &scale), Some(12));
assert_eq!(nearest_in_scale(15.0, &scale), Some(16));
assert_eq!(nearest_in_scale(0.0, &scale), Some(0));
}
#[test]
fn nearest_in_scale_breaks_ties_toward_lower_value() {
let scale = [12, 16];
assert_eq!(nearest_in_scale(14.0, &scale), Some(12));
}
#[test]
fn nearest_in_scale_treats_negatives_like_their_magnitude() {
let scale = [0, 4, 8, 12];
assert_eq!(nearest_in_scale(-9.0, &scale), Some(8));
}
#[test]
fn nearest_in_scale_returns_none_for_empty_scale() {
assert_eq!(nearest_in_scale(13.0, &[]), None);
}
#[test]
fn parse_css_color_expands_3_digit_hex() {
let c = parse_css_color("#fff").expect("hex-3 parses");
assert!((c.r - 1.0).abs() < 1e-6);
assert!((c.g - 1.0).abs() < 1e-6);
assert!((c.b - 1.0).abs() < 1e-6);
assert!((c.a - 1.0).abs() < 1e-6);
}
#[test]
fn parse_css_color_expands_4_digit_hex_with_alpha() {
let c = parse_css_color("#f00a").expect("hex-4 parses");
assert!((c.r - 1.0).abs() < 1e-6);
assert!((c.g - 0.0).abs() < 1e-6);
assert!((c.b - 0.0).abs() < 1e-6);
assert!((c.a - (170.0 / 255.0)).abs() < 1e-6);
}
#[test]
fn parse_css_color_accepts_8_digit_hex_with_alpha() {
let c = parse_css_color("#ff00ff80").expect("hex-8 parses");
assert!((c.r - 1.0).abs() < 1e-6);
assert!((c.g - 0.0).abs() < 1e-6);
assert!((c.b - 1.0).abs() < 1e-6);
assert!((c.a - (128.0 / 255.0)).abs() < 1e-6);
}
#[test]
fn parse_css_color_handles_percentage_channels() {
let c = parse_css_color("rgb(50%, 50%, 50%)").expect("percentage rgb parses");
assert!((c.r - 0.5).abs() < 1e-2);
assert!((c.g - 0.5).abs() < 1e-2);
assert!((c.b - 0.5).abs() < 1e-2);
assert!((c.a - 1.0).abs() < 1e-6);
}
#[test]
fn parse_css_color_handles_rgba_with_fractional_alpha() {
let c = parse_css_color("rgba(255, 0, 0, 0.5)").expect("rgba parses");
assert!((c.r - 1.0).abs() < 1e-6);
assert!((c.g - 0.0).abs() < 1e-6);
assert!((c.b - 0.0).abs() < 1e-6);
assert!((c.a - 0.5).abs() < 1e-6);
}
#[test]
fn parse_css_color_handles_whitespace_separated_rgb() {
let c = parse_css_color("rgb(255 0 0)").expect("space-separated rgb parses");
assert!((c.r - 1.0).abs() < 1e-6);
assert!((c.g - 0.0).abs() < 1e-6);
assert!((c.b - 0.0).abs() < 1e-6);
assert!((c.a - 1.0).abs() < 1e-6);
}
#[test]
fn parse_css_color_returns_transparent_for_keyword() {
let c = parse_css_color("transparent").expect("transparent parses");
assert!((c.a - 0.0).abs() < 1e-6);
}
#[test]
fn parse_css_color_rejects_malformed_input() {
assert!(parse_css_color("#ff").is_none());
assert!(parse_css_color("rgb(1, 2)").is_none());
assert!(parse_css_color("not-a-color").is_none());
assert!(parse_css_color("").is_none());
}
}