Skip to main content

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, 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
21/// Trait for calculating WCAG specified color contrast between colors.
22///
23/// This trait provides methods to determine how perceptually contrasting two colours are using the WCAG relative
24/// luminance calculation in Srgb color space.
25pub trait WcagColorContrast<T: WcagRelativeLuminance>: WcagRelativeLuminance + Sized
26where
27	Srgb: From<T> + From<Self>,
28{
29	/// Calculate the contrast ratio between `self` and `other`. colors according to WCAG 2.1. Returns a value between
30	/// 1:1 and 21:1
31	///
32	/// `other` must implement [Into]&lt;[Srgb]>
33	///
34	fn wcag_contrast_ratio(&self, other: T) -> f64 {
35		let lum1 = self.relative_luminance();
36		let lum2 = other.relative_luminance();
37
38		// Ensure the lighter color is in the numerator
39		let (lighter, darker) = if lum1 > lum2 { (lum1, lum2) } else { (lum2, lum1) };
40
41		(lighter + 0.05) / (darker + 0.05)
42	}
43
44	/// Get the WCAG level for the contrast between `self` and `other`.
45	///
46	/// `other` must implement [Into]&lt;[Srgb]>
47	///
48	fn wcag_level(&self, other: T) -> WcagLevel {
49		WcagLevel::from_ratio(self.wcag_contrast_ratio(other))
50	}
51
52	/// Find the closest color that meets a specific WCAG level against a background.
53	///
54	/// This function uses [ColorMix] in the specified `Space` to find the minimum adjustment needed to meet the contrast
55	/// requirement - mixing the colour at lower percentages to find the lowest percentage mix that would meet the
56	/// [WcagLevel] requirement.
57	///
58	/// Returns `None` if it is impossible to find a
59	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; // 1% tolerance for floating point precision
74		// A binary search from 0.0-100.0 of non-descrete numbers would be infinite, but
75		// given we're dealing with f32s we have a practical max precision which is EPSILON.
76		// This means we could have an upper bound of log2(H - L / EPSILON), or, in Rust parlance:
77		// const MAX_ITERATIONS: usize = (100.0 / f32::EPSILON).log2().round() as usize;
78		//
79		// But this is not const safe (log2 & round aren't const), and the result of this is 30.
80		// So this long comment is here to tell you why this number is 30:
81		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/// WCAG 2.1 color contrast levels
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum WcagLevel {
106	/// Fails all WCAG requirements (contrast < 3:1)
107	Fail,
108	/// Meets WCAG AA Large requirements (contrast >= 3:1)
109	AALarge,
110	/// Meets WCAG AA requirements (contrast >= 4.5:1)
111	AA,
112	/// Meets WCAG AAA requirements (contrast >= 7:1)
113	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	/// Get the minimum contrast ratio for this level
129	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	/// Get the level from a contrast ratio
139	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	/// Human-readable description
152	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		// Order doesn't matter
180		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		// Should find a darker version that meets AA contrast (4.5:1)
195		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		// Should find a darker version that meets AAA contrast (7.0:1)
204		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}