pub(crate) const MIN_RATIO: f32 = 4.5;
pub(crate) const HIGH_RATIO: f32 = 7.0;
pub(crate) type Rgb = [f32; 3];
fn to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
fn luminance_linear(l: Rgb) -> f32 {
0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2]
}
pub(crate) fn luminance(c: Rgb) -> f32 {
luminance_linear(c.map(to_linear))
}
pub(crate) fn ratio(a: Rgb, b: Rgb) -> f32 {
let (la, lb) = (luminance(a), luminance(b));
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
}
pub(crate) fn ensure(fg: Rgb, bg: Rgb, min: f32) -> Rgb {
if ratio(fg, bg) >= min {
return fg;
}
let lin = fg.map(to_linear);
let l = luminance_linear(lin);
let lb = luminance(bg);
let min = min * 1.01;
let toward_white = 1.05 / (lb + 0.05) > (lb + 0.05) / 0.05;
let out = if toward_white {
let want = (min * (lb + 0.05) - 0.05).min(1.0);
if l >= want {
return fg;
}
let t = ((want - l) / (1.0 - l)).clamp(0.0, 1.0);
lin.map(|c| c + (1.0 - c) * t)
} else {
let want = ((lb + 0.05) / min - 0.05).max(0.0);
if l <= want {
return fg;
}
let k = want / l;
lin.map(|c| c * k)
};
out.map(to_srgb)
}
#[cfg(test)]
mod tests {
use super::*;
const WHITE: Rgb = [1.0, 1.0, 1.0];
const BLACK: Rgb = [0.0, 0.0, 0.0];
fn hex(v: u32) -> Rgb {
[(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff].map(|c| c as f32 / 255.0)
}
#[test]
fn ratios_match_wcag() {
assert!((ratio(BLACK, WHITE) - 21.0).abs() < 0.01);
assert!((ratio(WHITE, WHITE) - 1.0).abs() < 1e-6);
assert!((ratio(hex(0x777777), WHITE) - 4.48).abs() < 0.01);
assert_eq!(ratio(hex(0x1c71d8), WHITE), ratio(WHITE, hex(0x1c71d8)));
}
#[test]
fn a_legible_color_is_left_alone() {
let blue = hex(0x1c71d8);
assert_eq!(ensure(blue, WHITE, MIN_RATIO), blue);
assert_eq!(ensure(WHITE, BLACK, HIGH_RATIO), WHITE);
}
#[test]
fn an_illegible_color_is_pushed_just_far_enough() {
let light = WHITE;
let dark = hex(0x1e1e1e);
for (fg, bg) in [
(hex(0xffffff), light),
(hex(0xffff00), light),
(hex(0xe5a50a), light),
(hex(0x2ec27e), light),
(hex(0x000000), dark),
(hex(0xe5a50a), dark),
(hex(0x2ec27e), dark),
(hex(0xc01c28), dark),
(hex(0x1c71d8), dark),
(hex(0x808080), light),
(hex(0x808080), dark),
] {
for min in [MIN_RATIO, HIGH_RATIO] {
let out = ensure(fg, bg, min);
let r = ratio(out, bg);
assert!(r >= min, "{fg:?} on {bg:?} reached only {r} of {min}");
if ratio(fg, bg) < min {
assert!(
r < min * 1.05,
"{fg:?} on {bg:?} moved further than needed: {r}"
);
} else {
assert_eq!(out, fg, "{fg:?} on {bg:?} was legible already");
}
}
}
}
#[test]
fn darkening_keeps_the_hue() {
let out = ensure(hex(0xe5a50a), WHITE, MIN_RATIO);
let (a, b) = (hex(0xe5a50a).map(to_linear), out.map(to_linear));
assert!((a[1] / a[0] - b[1] / b[0]).abs() < 0.01);
assert!(out[0] < 0.9, "pushed darker");
}
#[test]
fn the_extremes_stay_put_when_nothing_further_reaches() {
let gray = hex(0x6a6a6a);
assert_eq!(ensure(WHITE, gray, HIGH_RATIO), WHITE);
let light = hex(0x9a9a9a);
assert_eq!(ensure(BLACK, light, HIGH_RATIO), BLACK);
}
#[test]
fn a_background_no_color_can_reach_gets_the_best_there_is() {
let mid = hex(0x777777);
let out = ensure(hex(0x7a7a7a), mid, HIGH_RATIO);
assert!(ratio(out, mid) >= ratio(WHITE, mid).max(ratio(BLACK, mid)) - 0.01);
}
}