1use std::fmt;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ParseError {
22 WrongLength(usize),
24 InvalidHex(String),
26}
27
28impl fmt::Display for ParseError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 ParseError::WrongLength(n) => {
32 write!(f, "hex color must have 3 or 6 digits, found {n}")
33 }
34 ParseError::InvalidHex(s) => write!(f, "invalid hex color: {s:?}"),
35 }
36 }
37}
38
39impl std::error::Error for ParseError {}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct Rgb {
44 pub r: u8,
46 pub g: u8,
48 pub b: u8,
50}
51
52impl Rgb {
53 #[must_use]
55 pub const fn new(r: u8, g: u8, b: u8) -> Self {
56 Self { r, g, b }
57 }
58
59 pub fn from_hex(s: &str) -> Result<Self, ParseError> {
67 let digits = s.strip_prefix('#').unwrap_or(s);
68 if !digits.is_ascii() {
72 return Err(ParseError::InvalidHex(s.to_owned()));
73 }
74 let bytes = digits.as_bytes();
75 let expanded: [u8; 6] = match bytes.len() {
76 3 => [bytes[0], bytes[0], bytes[1], bytes[1], bytes[2], bytes[2]],
77 6 => [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]],
78 n => return Err(ParseError::WrongLength(n)),
79 };
80 let channel = |hi: u8, lo: u8| -> Result<u8, ParseError> {
81 let pair = [hi, lo];
82 let text =
83 core::str::from_utf8(&pair).map_err(|_| ParseError::InvalidHex(s.to_owned()))?;
84 u8::from_str_radix(text, 16).map_err(|_| ParseError::InvalidHex(s.to_owned()))
85 };
86 Ok(Self::new(
87 channel(expanded[0], expanded[1])?,
88 channel(expanded[2], expanded[3])?,
89 channel(expanded[4], expanded[5])?,
90 ))
91 }
92
93 #[must_use]
95 pub fn relative_luminance(&self) -> f64 {
96 fn linearize(channel: u8) -> f64 {
97 let c = f64::from(channel) / 255.0;
98 if c <= 0.03928 {
99 c / 12.92
100 } else {
101 ((c + 0.055) / 1.055).powf(2.4)
102 }
103 }
104 0.2126 * linearize(self.r) + 0.7152 * linearize(self.g) + 0.0722 * linearize(self.b)
105 }
106}
107
108#[must_use]
111pub fn contrast_ratio(a: Rgb, b: Rgb) -> f64 {
112 let la = a.relative_luminance();
113 let lb = b.relative_luminance();
114 let (lighter, darker) = if la >= lb { (la, lb) } else { (lb, la) };
115 (lighter + 0.05) / (darker + 0.05)
116}
117
118pub fn contrast_hex(a: &str, b: &str) -> Result<f64, ParseError> {
124 Ok(contrast_ratio(Rgb::from_hex(a)?, Rgb::from_hex(b)?))
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
130pub enum WcagLevel {
131 Fail,
133 AA,
135 AAA,
137}
138
139impl fmt::Display for WcagLevel {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 f.write_str(match self {
142 WcagLevel::Fail => "Fail",
143 WcagLevel::AA => "AA",
144 WcagLevel::AAA => "AAA",
145 })
146 }
147}
148
149#[must_use]
152pub fn level(ratio: f64, large_text: bool) -> WcagLevel {
153 let (aa, aaa) = if large_text { (3.0, 4.5) } else { (4.5, 7.0) };
154 if ratio >= aaa {
155 WcagLevel::AAA
156 } else if ratio >= aa {
157 WcagLevel::AA
158 } else {
159 WcagLevel::Fail
160 }
161}