use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
WrongLength(usize),
InvalidHex(String),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::WrongLength(n) => {
write!(f, "hex color must have 3 or 6 digits, found {n}")
}
ParseError::InvalidHex(s) => write!(f, "invalid hex color: {s:?}"),
}
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
#[must_use]
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub fn from_hex(s: &str) -> Result<Self, ParseError> {
let digits = s.strip_prefix('#').unwrap_or(s);
if !digits.is_ascii() {
return Err(ParseError::InvalidHex(s.to_owned()));
}
let bytes = digits.as_bytes();
let expanded: [u8; 6] = match bytes.len() {
3 => [bytes[0], bytes[0], bytes[1], bytes[1], bytes[2], bytes[2]],
6 => [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]],
n => return Err(ParseError::WrongLength(n)),
};
let channel = |hi: u8, lo: u8| -> Result<u8, ParseError> {
let pair = [hi, lo];
let text =
core::str::from_utf8(&pair).map_err(|_| ParseError::InvalidHex(s.to_owned()))?;
u8::from_str_radix(text, 16).map_err(|_| ParseError::InvalidHex(s.to_owned()))
};
Ok(Self::new(
channel(expanded[0], expanded[1])?,
channel(expanded[2], expanded[3])?,
channel(expanded[4], expanded[5])?,
))
}
#[must_use]
pub fn relative_luminance(&self) -> f64 {
fn linearize(channel: u8) -> f64 {
let c = f64::from(channel) / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * linearize(self.r) + 0.7152 * linearize(self.g) + 0.0722 * linearize(self.b)
}
}
#[must_use]
pub fn contrast_ratio(a: Rgb, b: Rgb) -> f64 {
let la = a.relative_luminance();
let lb = b.relative_luminance();
let (lighter, darker) = if la >= lb { (la, lb) } else { (lb, la) };
(lighter + 0.05) / (darker + 0.05)
}
pub fn contrast_hex(a: &str, b: &str) -> Result<f64, ParseError> {
Ok(contrast_ratio(Rgb::from_hex(a)?, Rgb::from_hex(b)?))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WcagLevel {
Fail,
AA,
AAA,
}
impl fmt::Display for WcagLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
WcagLevel::Fail => "Fail",
WcagLevel::AA => "AA",
WcagLevel::AAA => "AAA",
})
}
}
#[must_use]
pub fn level(ratio: f64, large_text: bool) -> WcagLevel {
let (aa, aaa) = if large_text { (3.0, 4.5) } else { (4.5, 7.0) };
if ratio >= aaa {
WcagLevel::AAA
} else if ratio >= aa {
WcagLevel::AA
} else {
WcagLevel::Fail
}
}