1use crate::{ColorMix, Srgb};
2use core::fmt;
3
4pub trait WcagRelativeLuminance: Sized + Copy
5where
6 Srgb: From<Self>,
7{
8 fn relative_luminance(&self) -> f64 {
11 let Srgb { red, green, blue, .. } = (*self).into();
12 let r = red as f64 / 255.0;
13 let g = green as f64 / 255.0;
14 let b = blue as f64 / 255.0;
15 let gamma_correct = |c: f64| {
16 if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
17 };
18
19 let r_linear = gamma_correct(r);
20 let g_linear = gamma_correct(g);
21 let b_linear = gamma_correct(b);
22 0.2126 * r_linear + 0.7152 * g_linear + 0.0722 * b_linear
23 }
24}
25
26impl<C: Copy> WcagRelativeLuminance for C where Srgb: From<C> {}
27
28pub trait WcagColorContrast<T: WcagRelativeLuminance>: WcagRelativeLuminance + Sized
33where
34 Srgb: From<T> + From<Self>,
35{
36 fn wcag_contrast_ratio(&self, other: T) -> f64 {
42 let lum1 = self.relative_luminance();
43 let lum2 = other.relative_luminance();
44
45 let (lighter, darker) = if lum1 > lum2 { (lum1, lum2) } else { (lum2, lum1) };
47
48 (lighter + 0.05) / (darker + 0.05)
49 }
50
51 fn wcag_level(&self, other: T) -> WcagLevel {
56 WcagLevel::from_ratio(self.wcag_contrast_ratio(other))
57 }
58
59 fn find_minimum_contrast<Space>(&self, other: T, level: WcagLevel) -> Option<Space>
67 where
68 Self: WcagColorContrast<Space>,
69 Space: ColorMix<Self, T> + From<Self> + From<T> + Copy + PartialEq + std::fmt::Debug,
70 Srgb: From<Space>,
71 crate::Hex: From<Space>,
72 {
73 let current_ratio = self.wcag_contrast_ratio(other);
74 if current_ratio <= level.min_ratio() {
75 return None;
76 }
77 let mut low = 0.0;
78 let mut high = 100.0;
79 let mut best_color: Option<Space> = None;
80 let tolerance = 0.001; const MAX_ITERATIONS: usize = 30;
89 for _ in 0..MAX_ITERATIONS {
90 if high - low < tolerance {
91 break;
92 }
93 let mid = (low + high) / 2.0;
94 let candidate = Space::mix(*self, other, mid);
95 let candidate_ratio = (self.wcag_contrast_ratio(candidate) * 100.0).round() / 100.0;
96
97 if candidate_ratio > level.min_ratio() {
98 best_color = Some(candidate);
99 high = mid;
100 } else {
101 low = mid;
102 }
103 }
104 best_color
105 }
106}
107
108impl<C: Copy, T: Copy> WcagColorContrast<T> for C where Srgb: From<C> + From<T> {}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum WcagLevel {
113 Fail,
115 AALarge,
117 AA,
119 AAA,
121}
122
123impl fmt::Display for WcagLevel {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 match self {
126 Self::Fail => write!(f, "Fail"),
127 Self::AALarge => write!(f, "AA Large"),
128 Self::AA => write!(f, "AA"),
129 Self::AAA => write!(f, "AAA"),
130 }
131 }
132}
133
134impl WcagLevel {
135 pub const fn min_ratio(self) -> f64 {
137 match self {
138 WcagLevel::Fail => 0.0,
139 WcagLevel::AALarge => 3.0,
140 WcagLevel::AA => 4.5,
141 WcagLevel::AAA => 7.0,
142 }
143 }
144
145 pub const fn from_ratio(ratio: f64) -> Self {
147 if ratio >= 7.0 {
148 WcagLevel::AAA
149 } else if ratio >= 4.5 {
150 WcagLevel::AA
151 } else if ratio >= 3.0 {
152 WcagLevel::AALarge
153 } else {
154 WcagLevel::Fail
155 }
156 }
157
158 pub const fn description(self) -> &'static str {
160 match self {
161 WcagLevel::Fail => "Fails WCAG requirements",
162 WcagLevel::AALarge => "WCAG AA Large (3:1)",
163 WcagLevel::AA => "WCAG AA (4.5:1)",
164 WcagLevel::AAA => "WCAG AAA (7:1)",
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::*;
173
174 #[test]
175 fn test_relative_luminance() {
176 assert_eq!(Named::White.relative_luminance(), 1.0);
177 assert_eq!(Named::Black.relative_luminance(), 0.0);
178 assert_eq!(Srgb::new(128, 128, 128, 100.0).relative_luminance(), 0.21586050011389923);
179 }
180
181 #[test]
182 fn test_contrast_ratio() {
183 assert_eq!(Named::White.wcag_contrast_ratio(Named::Black), 21.0);
184 assert_eq!(Named::White.wcag_contrast_ratio(Named::White), 1.0);
185
186 let ratio1 = Named::White.wcag_contrast_ratio(Named::Black);
188 let ratio2 = Named::Black.wcag_contrast_ratio(Named::White);
189 assert_eq!(ratio1, ratio2);
190 }
191
192 #[test]
193 fn test_wcag_levels() {
194 assert_eq!(Named::White.wcag_level(Named::Black), WcagLevel::AAA);
195 assert_eq!(Named::White.wcag_level(Named::White), WcagLevel::Fail);
196 assert_eq!(Named::Rebeccapurple.wcag_level(Named::Gold), WcagLevel::AA);
197 }
198
199 #[test]
200 fn test_find_minimum_contrast_aa() {
201 let min = Named::Rebeccapurple.find_minimum_contrast::<Srgb>(Named::White, WcagLevel::AA);
203 assert!(min.is_some());
204 let ratio = Named::Rebeccapurple.wcag_contrast_ratio(min.unwrap());
205 assert_eq!((ratio * 10.0).round() / 10.0, 4.5, "Should hit the actual contrast");
206 }
207
208 #[test]
209 fn test_find_minimum_contrast_aaa() {
210 let min = Named::Rebeccapurple.find_minimum_contrast::<Srgb>(Named::White, WcagLevel::AAA);
212 assert!(min.is_some());
213 let ratio = Named::Rebeccapurple.wcag_contrast_ratio(min.unwrap());
214 assert_eq!((ratio * 10.0).round() / 10.0, 7.0, "Should hit the actual contrast");
215 }
216}