chromashift/
wcag.rs

1use crate::{ColorMix, Srgb};
2use core::fmt;
3
4pub trait WcagRelativeLuminance: Sized + Copy
5where
6	Srgb: From<Self>,
7{
8	/// Calculate the relative luminance of a color
9	/// [according to WCAG 2.1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html)
10	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
28/// Trait for calculating WCAG specified color contrast between colors.
29///
30/// This trait provides methods to determine how perceptually contrasting two colours are using the WCAG relative
31/// luminance calculation in Srgb color space.
32pub trait WcagColorContrast<T: WcagRelativeLuminance>: WcagRelativeLuminance + Sized
33where
34	Srgb: From<T> + From<Self>,
35{
36	/// Calculate the contrast ratio between `self` and `other`. colors according to WCAG 2.1. Returns a value between
37	/// 1:1 and 21:1
38	///
39	/// `other` must implement [Into]&lt;[Srgb]>
40	///
41	fn wcag_contrast_ratio(&self, other: T) -> f64 {
42		let lum1 = self.relative_luminance();
43		let lum2 = other.relative_luminance();
44
45		// Ensure the lighter color is in the numerator
46		let (lighter, darker) = if lum1 > lum2 { (lum1, lum2) } else { (lum2, lum1) };
47
48		(lighter + 0.05) / (darker + 0.05)
49	}
50
51	/// Get the WCAG level for the contrast between `self` and `other`.
52	///
53	/// `other` must implement [Into]&lt;[Srgb]>
54	///
55	fn wcag_level(&self, other: T) -> WcagLevel {
56		WcagLevel::from_ratio(self.wcag_contrast_ratio(other))
57	}
58
59	/// Find the closest color that meets a specific WCAG level against a background.
60	///
61	/// This function uses [ColorMix] in the specified `Space` to find the minimum adjustment needed to meet the contrast
62	/// requirement - mixing the colour at lower percentages to find the lowest percentage mix that would meet the
63	/// [WcagLevel] requirement.
64	///
65	/// Returns `None` if it is impossible to find a
66	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; // 1% tolerance for floating point precision
81		// A binary search from 0.0-100.0 of non-descrete numbers would be infinite, but
82		// given we're dealing with f32s we have a practical max precision which is EPSILON.
83		// This means we could have an upper bound of log2(H - L / EPSILON), or, in Rust parlance:
84		// const MAX_ITERATIONS: usize = (100.0 / f32::EPSILON).log2().round() as usize;
85		//
86		// But this is not const safe (log2 & round aren't const), and the result of this is 30.
87		// So this long comment is here to tell you why this number is 30:
88		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/// WCAG 2.1 color contrast levels
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum WcagLevel {
113	/// Fails all WCAG requirements (contrast < 3:1)
114	Fail,
115	/// Meets WCAG AA Large requirements (contrast >= 3:1)
116	AALarge,
117	/// Meets WCAG AA requirements (contrast >= 4.5:1)
118	AA,
119	/// Meets WCAG AAA requirements (contrast >= 7:1)
120	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	/// Get the minimum contrast ratio for this level
136	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	/// Get the level from a contrast ratio
146	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	/// Human-readable description
159	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		// Order doesn't matter
187		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		// Should find a darker version that meets AA contrast (4.5:1)
202		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		// Should find a darker version that meets AAA contrast (7.0:1)
211		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}