1#![forbid(unsafe_code)]
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct RgbColor {
35 pub red: u8,
36 pub green: u8,
37 pub blue: u8,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ContrastLevel {
42 Fail,
43 AA,
44 AAA,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ContrastError {
49 InvalidRatio,
50}
51
52fn srgb_channel_to_linear(value: u8) -> f64 {
53 let normalized = f64::from(value) / 255.0;
54
55 if normalized <= 0.040_45 {
56 normalized / 12.92
57 } else {
58 ((normalized + 0.055) / 1.055).powf(2.4)
59 }
60}
61
62#[must_use]
63pub fn relative_luminance(color: RgbColor) -> f64 {
64 0.2126 * srgb_channel_to_linear(color.red)
65 + 0.7152 * srgb_channel_to_linear(color.green)
66 + 0.0722 * srgb_channel_to_linear(color.blue)
67}
68
69#[must_use]
70pub fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
71 let foreground = relative_luminance(foreground);
72 let background = relative_luminance(background);
73 let lighter = foreground.max(background);
74 let darker = foreground.min(background);
75
76 (lighter + 0.05) / (darker + 0.05)
77}
78
79#[must_use]
80pub fn passes_normal_text_aa(ratio: f64) -> bool {
81 ratio.is_finite() && ratio >= 4.5
82}
83
84#[must_use]
85pub fn passes_normal_text_aaa(ratio: f64) -> bool {
86 ratio.is_finite() && ratio >= 7.0
87}
88
89#[must_use]
90pub fn passes_large_text_aa(ratio: f64) -> bool {
91 ratio.is_finite() && ratio >= 3.0
92}
93
94#[must_use]
95pub fn passes_large_text_aaa(ratio: f64) -> bool {
96 ratio.is_finite() && ratio >= 4.5
97}
98
99fn validate_ratio(ratio: f64) -> Result<f64, ContrastError> {
100 if !ratio.is_finite() || ratio < 1.0 {
101 Err(ContrastError::InvalidRatio)
102 } else {
103 Ok(ratio)
104 }
105}
106
107pub fn classify_normal_text(ratio: f64) -> Result<ContrastLevel, ContrastError> {
108 let ratio = validate_ratio(ratio)?;
109
110 Ok(if passes_normal_text_aaa(ratio) {
111 ContrastLevel::AAA
112 } else if passes_normal_text_aa(ratio) {
113 ContrastLevel::AA
114 } else {
115 ContrastLevel::Fail
116 })
117}
118
119pub fn classify_large_text(ratio: f64) -> Result<ContrastLevel, ContrastError> {
120 let ratio = validate_ratio(ratio)?;
121
122 Ok(if passes_large_text_aaa(ratio) {
123 ContrastLevel::AAA
124 } else if passes_large_text_aa(ratio) {
125 ContrastLevel::AA
126 } else {
127 ContrastLevel::Fail
128 })
129}
130
131#[cfg(test)]
132mod tests {
133 use super::{
134 ContrastError, ContrastLevel, RgbColor, classify_large_text, classify_normal_text,
135 contrast_ratio, passes_large_text_aa, passes_large_text_aaa, passes_normal_text_aa,
136 passes_normal_text_aaa, relative_luminance,
137 };
138
139 #[test]
140 fn computes_expected_contrast_examples() {
141 let black = RgbColor {
142 red: 0,
143 green: 0,
144 blue: 0,
145 };
146 let white = RgbColor {
147 red: 255,
148 green: 255,
149 blue: 255,
150 };
151 let ratio = contrast_ratio(black, white);
152
153 assert!(relative_luminance(white) > relative_luminance(black));
154 assert!((ratio - 21.0).abs() < 1.0e-12);
155 }
156
157 #[test]
158 fn classifies_aa_and_aaa_thresholds() {
159 assert_eq!(classify_normal_text(7.1).unwrap(), ContrastLevel::AAA);
160 assert_eq!(classify_normal_text(4.6).unwrap(), ContrastLevel::AA);
161 assert_eq!(classify_normal_text(4.4).unwrap(), ContrastLevel::Fail);
162
163 assert_eq!(classify_large_text(4.6).unwrap(), ContrastLevel::AAA);
164 assert_eq!(classify_large_text(3.2).unwrap(), ContrastLevel::AA);
165 assert_eq!(classify_large_text(2.9).unwrap(), ContrastLevel::Fail);
166 }
167
168 #[test]
169 fn checks_pass_fail_helpers() {
170 assert!(passes_normal_text_aa(4.5));
171 assert!(passes_normal_text_aaa(7.0));
172 assert!(passes_large_text_aa(3.0));
173 assert!(passes_large_text_aaa(4.5));
174 assert!(!passes_normal_text_aa(4.4));
175 }
176
177 #[test]
178 fn rejects_invalid_ratios() {
179 assert_eq!(
180 classify_normal_text(f64::NAN),
181 Err(ContrastError::InvalidRatio)
182 );
183 assert_eq!(classify_large_text(0.9), Err(ContrastError::InvalidRatio));
184 }
185}