use crate::{ColorMix, Srgb};
use core::fmt;
pub trait WcagRelativeLuminance: Sized + Copy
where
Srgb: From<Self>,
{
fn relative_luminance(&self) -> f64 {
let Srgb { red, green, blue, .. } = (*self).into();
let r = red as f64 / 255.0;
let g = green as f64 / 255.0;
let b = blue as f64 / 255.0;
let gamma_correct = |c: f64| {
if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
};
let r_linear = gamma_correct(r);
let g_linear = gamma_correct(g);
let b_linear = gamma_correct(b);
0.2126 * r_linear + 0.7152 * g_linear + 0.0722 * b_linear
}
}
impl<C: Copy> WcagRelativeLuminance for C where Srgb: From<C> {}
pub trait WcagColorContrast<T: WcagRelativeLuminance>: WcagRelativeLuminance + Sized
where
Srgb: From<T> + From<Self>,
{
fn wcag_contrast_ratio(&self, other: T) -> f64 {
let lum1 = self.relative_luminance();
let lum2 = other.relative_luminance();
let (lighter, darker) = if lum1 > lum2 { (lum1, lum2) } else { (lum2, lum1) };
(lighter + 0.05) / (darker + 0.05)
}
fn wcag_level(&self, other: T) -> WcagLevel {
WcagLevel::from_ratio(self.wcag_contrast_ratio(other))
}
fn find_minimum_contrast<Space>(&self, other: T, level: WcagLevel) -> Option<Space>
where
Self: WcagColorContrast<Space>,
Space: ColorMix<Self, T> + From<Self> + From<T> + Copy + PartialEq + std::fmt::Debug,
Srgb: From<Space>,
crate::Hex: From<Space>,
{
let current_ratio = self.wcag_contrast_ratio(other);
if current_ratio <= level.min_ratio() {
return None;
}
let mut low = 0.0;
let mut high = 100.0;
let mut best_color: Option<Space> = None;
let tolerance = 0.001; const MAX_ITERATIONS: usize = 30;
for _ in 0..MAX_ITERATIONS {
if high - low < tolerance {
break;
}
let mid = (low + high) / 2.0;
let candidate = Space::mix(*self, other, mid);
let candidate_ratio = (self.wcag_contrast_ratio(candidate) * 100.0).round() / 100.0;
if candidate_ratio > level.min_ratio() {
best_color = Some(candidate);
high = mid;
} else {
low = mid;
}
}
best_color
}
}
impl<C: Copy, T: Copy> WcagColorContrast<T> for C where Srgb: From<C> + From<T> {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WcagLevel {
Fail,
AALarge,
AA,
AAA,
}
impl fmt::Display for WcagLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Fail => write!(f, "Fail"),
Self::AALarge => write!(f, "AA Large"),
Self::AA => write!(f, "AA"),
Self::AAA => write!(f, "AAA"),
}
}
}
impl WcagLevel {
pub const fn min_ratio(self) -> f64 {
match self {
WcagLevel::Fail => 0.0,
WcagLevel::AALarge => 3.0,
WcagLevel::AA => 4.5,
WcagLevel::AAA => 7.0,
}
}
pub const fn from_ratio(ratio: f64) -> Self {
if ratio >= 7.0 {
WcagLevel::AAA
} else if ratio >= 4.5 {
WcagLevel::AA
} else if ratio >= 3.0 {
WcagLevel::AALarge
} else {
WcagLevel::Fail
}
}
pub const fn description(self) -> &'static str {
match self {
WcagLevel::Fail => "Fails WCAG requirements",
WcagLevel::AALarge => "WCAG AA Large (3:1)",
WcagLevel::AA => "WCAG AA (4.5:1)",
WcagLevel::AAA => "WCAG AAA (7:1)",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::*;
#[test]
fn test_relative_luminance() {
assert_eq!(Named::White.relative_luminance(), 1.0);
assert_eq!(Named::Black.relative_luminance(), 0.0);
assert_eq!(Srgb::new(128, 128, 128, 100.0).relative_luminance(), 0.21586050011389923);
}
#[test]
fn test_contrast_ratio() {
assert_eq!(Named::White.wcag_contrast_ratio(Named::Black), 21.0);
assert_eq!(Named::White.wcag_contrast_ratio(Named::White), 1.0);
let ratio1 = Named::White.wcag_contrast_ratio(Named::Black);
let ratio2 = Named::Black.wcag_contrast_ratio(Named::White);
assert_eq!(ratio1, ratio2);
}
#[test]
fn test_wcag_levels() {
assert_eq!(Named::White.wcag_level(Named::Black), WcagLevel::AAA);
assert_eq!(Named::White.wcag_level(Named::White), WcagLevel::Fail);
assert_eq!(Named::Rebeccapurple.wcag_level(Named::Gold), WcagLevel::AA);
}
#[test]
fn test_find_minimum_contrast_aa() {
let min = Named::Rebeccapurple.find_minimum_contrast::<Srgb>(Named::White, WcagLevel::AA);
assert!(min.is_some());
let ratio = Named::Rebeccapurple.wcag_contrast_ratio(min.unwrap());
assert_eq!((ratio * 10.0).round() / 10.0, 4.5, "Should hit the actual contrast");
}
#[test]
fn test_find_minimum_contrast_aaa() {
let min = Named::Rebeccapurple.find_minimum_contrast::<Srgb>(Named::White, WcagLevel::AAA);
assert!(min.is_some());
let ratio = Named::Rebeccapurple.wcag_contrast_ratio(min.unwrap());
assert_eq!((ratio * 10.0).round() / 10.0, 7.0, "Should hit the actual contrast");
}
}