Skip to main content

use_contrast/
lib.rs

1#![forbid(unsafe_code)]
2//! Primitive contrast-ratio helpers.
3//!
4//! The helpers here are intentionally small utility calculations. They do not
5//! guarantee full compliance outcomes by themselves.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use use_contrast::{
11//!     ContrastLevel, RgbColor, classify_normal_text, contrast_ratio, passes_normal_text_aaa,
12//!     relative_luminance,
13//! };
14//!
15//! let black = RgbColor {
16//!     red: 0,
17//!     green: 0,
18//!     blue: 0,
19//! };
20//! let white = RgbColor {
21//!     red: 255,
22//!     green: 255,
23//!     blue: 255,
24//! };
25//! let ratio = contrast_ratio(black, white);
26//!
27//! assert!(relative_luminance(white) > relative_luminance(black));
28//! assert!((ratio - 21.0).abs() < 1.0e-12);
29//! assert!(passes_normal_text_aaa(ratio));
30//! assert_eq!(classify_normal_text(ratio).unwrap(), ContrastLevel::AAA);
31//! ```
32
33#[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}