#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WcagLevel {
Aa,
Aaa,
}
impl WcagLevel {
#[must_use]
pub const fn min_ratio(self) -> f64 {
match self {
Self::Aa => 4.5,
Self::Aaa => 7.0,
}
}
#[must_use]
pub const fn min_ratio_large_text(self) -> f64 {
match self {
Self::Aa => 3.0,
Self::Aaa => 4.5,
}
}
}
#[derive(Clone, Debug)]
pub struct ContrastResult {
pub ratio: f64,
pub passes: bool,
pub passes_large_text: bool,
pub level: WcagLevel,
pub foreground: (u8, u8, u8),
pub background: (u8, u8, u8),
pub suggested_foreground: Option<(u8, u8, u8)>,
pub suggested_background: Option<(u8, u8, u8)>,
}
pub struct WcagValidator {
max_correction_iterations: u32,
}
impl Default for WcagValidator {
fn default() -> Self {
Self::new()
}
}
impl WcagValidator {
#[must_use]
pub fn new() -> Self {
Self {
max_correction_iterations: 256,
}
}
#[must_use]
pub fn with_max_iterations(mut self, max: u32) -> Self {
self.max_correction_iterations = max;
self
}
#[must_use]
pub fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
let rs = Self::linearize(r);
let gs = Self::linearize(g);
let bs = Self::linearize(b);
0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
fn linearize(value: u8) -> f64 {
let v = f64::from(value) / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
#[must_use]
pub fn contrast_ratio(fg: (u8, u8, u8), bg: (u8, u8, u8)) -> f64 {
let l1 = Self::relative_luminance(fg.0, fg.1, fg.2);
let l2 = Self::relative_luminance(bg.0, bg.1, bg.2);
let lighter = l1.max(l2);
let darker = l1.min(l2);
(lighter + 0.05) / (darker + 0.05)
}
#[must_use]
pub fn check_contrast(
&self,
fg: (u8, u8, u8),
bg: (u8, u8, u8),
level: WcagLevel,
) -> ContrastResult {
let ratio = Self::contrast_ratio(fg, bg);
let min_normal = level.min_ratio();
let min_large = level.min_ratio_large_text();
let passes = ratio >= min_normal;
let passes_large = ratio >= min_large;
let (suggested_fg, suggested_bg) = if passes {
(None, None)
} else {
(
self.suggest_foreground(fg, bg, level),
self.suggest_background(fg, bg, level),
)
};
ContrastResult {
ratio,
passes,
passes_large_text: passes_large,
level,
foreground: fg,
background: bg,
suggested_foreground: suggested_fg,
suggested_background: suggested_bg,
}
}
#[must_use]
pub fn check_batch(
&self,
pairs: &[((u8, u8, u8), (u8, u8, u8))],
level: WcagLevel,
) -> Vec<ContrastResult> {
pairs
.iter()
.map(|&(fg, bg)| self.check_contrast(fg, bg, level))
.collect()
}
fn suggest_foreground(
&self,
fg: (u8, u8, u8),
bg: (u8, u8, u8),
level: WcagLevel,
) -> Option<(u8, u8, u8)> {
let bg_lum = Self::relative_luminance(bg.0, bg.1, bg.2);
let fg_lum = Self::relative_luminance(fg.0, fg.1, fg.2);
let target_ratio = level.min_ratio();
let go_darker = fg_lum > bg_lum;
let mut best = fg;
for i in 0..self.max_correction_iterations {
let t = (i as f64) / (self.max_correction_iterations as f64);
let candidate = if go_darker {
Self::lerp_color(fg, (0, 0, 0), t)
} else {
Self::lerp_color(fg, (255, 255, 255), t)
};
let ratio = Self::contrast_ratio(candidate, bg);
if ratio >= target_ratio {
best = candidate;
break;
}
best = candidate;
}
if Self::contrast_ratio(best, bg) >= target_ratio {
Some(best)
} else {
if Self::contrast_ratio((0, 0, 0), bg) >= target_ratio {
Some((0, 0, 0))
} else if Self::contrast_ratio((255, 255, 255), bg) >= target_ratio {
Some((255, 255, 255))
} else {
None
}
}
}
fn suggest_background(
&self,
fg: (u8, u8, u8),
bg: (u8, u8, u8),
level: WcagLevel,
) -> Option<(u8, u8, u8)> {
let bg_lum = Self::relative_luminance(bg.0, bg.1, bg.2);
let fg_lum = Self::relative_luminance(fg.0, fg.1, fg.2);
let target_ratio = level.min_ratio();
let go_darker = bg_lum > fg_lum;
let mut best = bg;
for i in 0..self.max_correction_iterations {
let t = (i as f64) / (self.max_correction_iterations as f64);
let candidate = if go_darker {
Self::lerp_color(bg, (0, 0, 0), t)
} else {
Self::lerp_color(bg, (255, 255, 255), t)
};
let ratio = Self::contrast_ratio(fg, candidate);
if ratio >= target_ratio {
best = candidate;
break;
}
best = candidate;
}
if Self::contrast_ratio(fg, best) >= target_ratio {
Some(best)
} else {
if Self::contrast_ratio(fg, (0, 0, 0)) >= target_ratio {
Some((0, 0, 0))
} else if Self::contrast_ratio(fg, (255, 255, 255)) >= target_ratio {
Some((255, 255, 255))
} else {
None
}
}
}
fn lerp_color(a: (u8, u8, u8), b: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
let t = t.clamp(0.0, 1.0);
let r = (f64::from(a.0) * (1.0 - t) + f64::from(b.0) * t).round() as u8;
let g = (f64::from(a.1) * (1.0 - t) + f64::from(b.1) * t).round() as u8;
let b_val = (f64::from(a.2) * (1.0 - t) + f64::from(b.2) * t).round() as u8;
(r, g, b_val)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relative_luminance_black() {
let lum = WcagValidator::relative_luminance(0, 0, 0);
assert!((lum - 0.0).abs() < 1e-10);
}
#[test]
fn test_relative_luminance_white() {
let lum = WcagValidator::relative_luminance(255, 255, 255);
assert!((lum - 1.0).abs() < 1e-4);
}
#[test]
fn test_contrast_ratio_black_white() {
let ratio = WcagValidator::contrast_ratio((255, 255, 255), (0, 0, 0));
assert!((ratio - 21.0).abs() < 0.1);
}
#[test]
fn test_contrast_ratio_same_color() {
let ratio = WcagValidator::contrast_ratio((128, 128, 128), (128, 128, 128));
assert!((ratio - 1.0).abs() < 1e-10);
}
#[test]
fn test_aa_pass_white_on_black() {
let validator = WcagValidator::new();
let result = validator.check_contrast((255, 255, 255), (0, 0, 0), WcagLevel::Aa);
assert!(result.passes);
assert!(result.passes_large_text);
assert!(result.ratio >= 4.5);
}
#[test]
fn test_aaa_pass_white_on_black() {
let validator = WcagValidator::new();
let result = validator.check_contrast((255, 255, 255), (0, 0, 0), WcagLevel::Aaa);
assert!(result.passes);
assert!(result.ratio >= 7.0);
}
#[test]
fn test_aa_fail_low_contrast() {
let validator = WcagValidator::new();
let result = validator.check_contrast((200, 200, 200), (255, 255, 255), WcagLevel::Aa);
assert!(!result.passes);
assert!(result.ratio < 4.5);
}
#[test]
fn test_suggested_correction_on_failure() {
let validator = WcagValidator::new();
let result = validator.check_contrast((200, 200, 200), (255, 255, 255), WcagLevel::Aa);
assert!(!result.passes);
assert!(result.suggested_foreground.is_some());
let suggested = result.suggested_foreground.expect("test");
let new_ratio = WcagValidator::contrast_ratio(suggested, (255, 255, 255));
assert!(new_ratio >= 4.5);
}
#[test]
fn test_contrast_ratio_order_independent() {
let r1 = WcagValidator::contrast_ratio((100, 50, 200), (200, 220, 30));
let r2 = WcagValidator::contrast_ratio((200, 220, 30), (100, 50, 200));
assert!((r1 - r2).abs() < 1e-10);
}
#[test]
fn test_batch_check() {
let validator = WcagValidator::new();
let pairs = vec![
((255, 255, 255), (0, 0, 0)),
((200, 200, 200), (255, 255, 255)),
];
let results = validator.check_batch(&pairs, WcagLevel::Aa);
assert_eq!(results.len(), 2);
assert!(results[0].passes);
assert!(!results[1].passes);
}
#[test]
fn test_large_text_threshold() {
let validator = WcagValidator::new();
let result = validator.check_contrast((119, 119, 119), (255, 255, 255), WcagLevel::Aa);
assert!(!result.passes); assert!(result.passes_large_text); }
#[test]
fn test_linearize_low_values() {
let lin = WcagValidator::relative_luminance(10, 10, 10);
assert!(lin > 0.0);
assert!(lin < 0.01);
}
#[test]
fn test_wcag_level_thresholds() {
assert!((WcagLevel::Aa.min_ratio() - 4.5).abs() < f64::EPSILON);
assert!((WcagLevel::Aaa.min_ratio() - 7.0).abs() < f64::EPSILON);
assert!((WcagLevel::Aa.min_ratio_large_text() - 3.0).abs() < f64::EPSILON);
assert!((WcagLevel::Aaa.min_ratio_large_text() - 4.5).abs() < f64::EPSILON);
}
}