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, g, b] = Srgb::SPACE.transfer.decode_all([red, green, blue]);
13 WCAG_WEIGHTS[0] * r + WCAG_WEIGHTS[1] * g + WCAG_WEIGHTS[2] * b
14 }
15}
16
17const WCAG_WEIGHTS: [f64; 3] = [0.2126, 0.7152, 0.0722];
18
19impl<C: Copy> WcagRelativeLuminance for C where Srgb: From<C> {}
20
21pub trait WcagColorContrast<T: WcagRelativeLuminance>: WcagRelativeLuminance + Sized
26where
27 Srgb: From<T> + From<Self>,
28{
29 fn wcag_contrast_ratio(&self, other: T) -> f64 {
35 let lum1 = self.relative_luminance();
36 let lum2 = other.relative_luminance();
37
38 let (lighter, darker) = if lum1 > lum2 { (lum1, lum2) } else { (lum2, lum1) };
40
41 (lighter + 0.05) / (darker + 0.05)
42 }
43
44 fn wcag_level(&self, other: T) -> WcagLevel {
49 WcagLevel::from_ratio(self.wcag_contrast_ratio(other))
50 }
51
52 fn find_minimum_contrast<Space>(&self, other: T, level: WcagLevel) -> Option<Space>
60 where
61 Self: WcagColorContrast<Space>,
62 Space: ColorMix<Self, T> + From<Self> + From<T> + Copy + PartialEq + std::fmt::Debug,
63 Srgb: From<Space>,
64 crate::Hex: From<Space>,
65 {
66 let current_ratio = self.wcag_contrast_ratio(other);
67 if current_ratio <= level.min_ratio() {
68 return None;
69 }
70 let mut low = 0.0;
71 let mut high = 100.0;
72 let mut best_color: Option<Space> = None;
73 let tolerance = 0.001; const MAX_ITERATIONS: usize = 30;
82 for _ in 0..MAX_ITERATIONS {
83 if high - low < tolerance {
84 break;
85 }
86 let mid = (low + high) / 2.0;
87 let candidate = Space::mix(*self, other, mid);
88 let candidate_ratio = (self.wcag_contrast_ratio(candidate) * 100.0).round() / 100.0;
89
90 if candidate_ratio > level.min_ratio() {
91 best_color = Some(candidate);
92 high = mid;
93 } else {
94 low = mid;
95 }
96 }
97 best_color
98 }
99}
100
101impl<C: Copy, T: Copy> WcagColorContrast<T> for C where Srgb: From<C> + From<T> {}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum WcagLevel {
106 Fail,
108 AALarge,
110 AA,
112 AAA,
114}
115
116impl fmt::Display for WcagLevel {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::Fail => write!(f, "Fail"),
120 Self::AALarge => write!(f, "AA Large"),
121 Self::AA => write!(f, "AA"),
122 Self::AAA => write!(f, "AAA"),
123 }
124 }
125}
126
127impl WcagLevel {
128 pub const fn min_ratio(self) -> f64 {
130 match self {
131 WcagLevel::Fail => 0.0,
132 WcagLevel::AALarge => 3.0,
133 WcagLevel::AA => 4.5,
134 WcagLevel::AAA => 7.0,
135 }
136 }
137
138 pub const fn from_ratio(ratio: f64) -> Self {
140 if ratio >= 7.0 {
141 WcagLevel::AAA
142 } else if ratio >= 4.5 {
143 WcagLevel::AA
144 } else if ratio >= 3.0 {
145 WcagLevel::AALarge
146 } else {
147 WcagLevel::Fail
148 }
149 }
150
151 pub const fn description(self) -> &'static str {
153 match self {
154 WcagLevel::Fail => "Fails WCAG requirements",
155 WcagLevel::AALarge => "WCAG AA Large (3:1)",
156 WcagLevel::AA => "WCAG AA (4.5:1)",
157 WcagLevel::AAA => "WCAG AAA (7:1)",
158 }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::*;
166
167 #[test]
168 fn test_relative_luminance() {
169 assert_eq!(Named::White.relative_luminance(), 1.0);
170 assert_eq!(Named::Black.relative_luminance(), 0.0);
171 assert_eq!(Srgb::from_u8(128, 128, 128, 100.0).relative_luminance(), 0.21586050011389923);
172 }
173
174 #[test]
175 fn test_contrast_ratio() {
176 assert_eq!(Named::White.wcag_contrast_ratio(Named::Black), 21.0);
177 assert_eq!(Named::White.wcag_contrast_ratio(Named::White), 1.0);
178
179 let ratio1 = Named::White.wcag_contrast_ratio(Named::Black);
181 let ratio2 = Named::Black.wcag_contrast_ratio(Named::White);
182 assert_eq!(ratio1, ratio2);
183 }
184
185 #[test]
186 fn test_wcag_levels() {
187 assert_eq!(Named::White.wcag_level(Named::Black), WcagLevel::AAA);
188 assert_eq!(Named::White.wcag_level(Named::White), WcagLevel::Fail);
189 assert_eq!(Named::Rebeccapurple.wcag_level(Named::Gold), WcagLevel::AA);
190 }
191
192 #[test]
193 fn test_find_minimum_contrast_aa() {
194 let min = Named::Rebeccapurple.find_minimum_contrast::<Srgb>(Named::White, WcagLevel::AA);
196 assert!(min.is_some());
197 let ratio = Named::Rebeccapurple.wcag_contrast_ratio(min.unwrap());
198 assert_eq!((ratio * 10.0).round() / 10.0, 4.5, "Should hit the actual contrast");
199 }
200
201 #[test]
202 fn test_find_minimum_contrast_aaa() {
203 let min = Named::Rebeccapurple.find_minimum_contrast::<Srgb>(Named::White, WcagLevel::AAA);
205 assert!(min.is_some());
206 let ratio = Named::Rebeccapurple.wcag_contrast_ratio(min.unwrap());
207 assert_eq!((ratio * 10.0).round() / 10.0, 7.0, "Should hit the actual contrast");
208 }
209}