use peniko::Color;
const MAIN_TRC: f64 = 2.4; const S_RCO: f64 = 0.212_672_9; const S_GCO: f64 = 0.715_152_2; const S_BCO: f64 = 0.072_175_0; const NORM_BG: f64 = 0.56; const NORM_TXT: f64 = 0.57; const REV_BG: f64 = 0.65; const REV_TXT: f64 = 0.62; const BLK_THRS: f64 = 0.022; const BLK_CLMP: f64 = 1.414; const SCALE_BOW: f64 = 1.14; const SCALE_WOB: f64 = 1.14; const LO_BOW_OFFSET: f64 = 0.027; const LO_WOB_OFFSET: f64 = 0.027; const DELTA_Y_MIN: f64 = 0.000_5; const LO_CLIP: f64 = 0.1; const Y_MAX: f64 = 1.1;
fn srgb_to_y(c: Color) -> f64 {
let [r, g, b, _a] = c.components;
let lin = |ch: f32| f64::from(ch).clamp(0.0, 1.0).powf(MAIN_TRC);
S_RCO * lin(r) + S_GCO * lin(g) + S_BCO * lin(b)
}
#[must_use]
pub fn lc(text: Color, bg: Color) -> f64 {
contrast(srgb_to_y(text), srgb_to_y(bg))
}
#[must_use]
pub fn lc_abs(text: Color, bg: Color) -> f64 {
lc(text, bg).abs()
}
#[must_use]
pub fn meets(text: Color, bg: Color, target_lc: f64) -> bool {
lc_abs(text, bg) >= target_lc
}
fn wcag_luminance(c: Color) -> f64 {
let [r, g, b, _a] = c.components;
let lin = |ch: f32| {
let u = f64::from(ch).clamp(0.0, 1.0);
if u <= 0.039_28 {
u / 12.92
} else {
((u + 0.055) / 1.055).powf(2.4)
}
};
S_RCO * lin(r) + S_GCO * lin(g) + S_BCO * lin(b)
}
#[must_use]
pub fn wcag2_ratio(a: Color, b: Color) -> f64 {
let la = wcag_luminance(a);
let lb = wcag_luminance(b);
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
#[must_use]
pub fn wcag2_passes(text: Color, bg: Color, large: bool) -> bool {
let threshold = if large { 3.0 } else { 4.5 };
wcag2_ratio(text, bg) >= threshold
}
const APCA_REQ: [(f64, f64); 8] = [
(12.0, 100.0),
(14.0, 90.0),
(16.0, 75.0),
(18.0, 70.0),
(24.0, 60.0),
(36.0, 45.0),
(72.0, 30.0),
(300.0, 15.0),
];
const LC_MAX: f64 = 108.0;
const LC_SPOT: f64 = 15.0;
const WEIGHT_REF: f64 = 400.0;
const WEIGHT_EXP: f64 = 0.5;
#[must_use]
pub fn required_lc(size_px: f32, weight: f32) -> f64 {
let px = f64::from(size_px).max(1.0);
let w = f64::from(weight).clamp(1.0, 1000.0);
let eff = px * (w / WEIGHT_REF).powf(WEIGHT_EXP);
let eff = eff.clamp(APCA_REQ[0].0, APCA_REQ[APCA_REQ.len() - 1].0);
interp_decreasing(&APCA_REQ, eff).clamp(LC_SPOT, LC_MAX)
}
fn interp_decreasing(table: &[(f64, f64)], x: f64) -> f64 {
for win in table.windows(2) {
let (x0, y0) = win[0];
let (x1, y1) = win[1];
if x <= x1 {
let t = (x - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}
}
table[table.len() - 1].1
}
fn contrast(mut txt_y: f64, mut bg_y: f64) -> f64 {
if txt_y.is_nan() || bg_y.is_nan() {
return 0.0;
}
if txt_y.min(bg_y) < 0.0 || txt_y.max(bg_y) > Y_MAX {
return 0.0;
}
if txt_y <= BLK_THRS {
txt_y += (BLK_THRS - txt_y).powf(BLK_CLMP);
}
if bg_y <= BLK_THRS {
bg_y += (BLK_THRS - bg_y).powf(BLK_CLMP);
}
if (bg_y - txt_y).abs() < DELTA_Y_MIN {
return 0.0;
}
let output = if bg_y > txt_y {
let sapc = (bg_y.powf(NORM_BG) - txt_y.powf(NORM_TXT)) * SCALE_BOW;
if sapc < LO_CLIP {
0.0
} else {
sapc - LO_BOW_OFFSET
}
} else {
let sapc = (bg_y.powf(REV_BG) - txt_y.powf(REV_TXT)) * SCALE_WOB;
if sapc > -LO_CLIP {
0.0
} else {
sapc + LO_WOB_OFFSET
}
};
output * 100.0
}
#[cfg(test)]
mod tests {
use super::*;
fn rgb(hex: &str) -> Color {
let h = hex.trim_start_matches('#');
let n = u32::from_str_radix(h, 16).expect("hex");
Color::from_rgba8((n >> 16) as u8, (n >> 8) as u8, n as u8, 255)
}
#[test]
fn matches_reference_vectors() {
let cases = [
("#000000", "#ffffff", 106.040_672_479_003),
("#ffffff", "#000000", -107.884_733_110_653),
("#888888", "#ffffff", 63.056_469_930_209),
("#ffffff", "#888888", -68.541_464_366_450),
("#000000", "#aaaaaa", 58.146_262_578_561),
("#aaaaaa", "#000000", -56.241_133_368_397),
("#000000", "#888888", 41.017_552_855_556),
("#888888", "#000000", -38.622_974_760_542),
("#aaaaaa", "#ffffff", 45.834_574_796_143),
("#ffffff", "#aaaaaa", -50.737_685_085_089),
("#112233", "#ddeeff", 91.668_307_772_408),
("#444444", "#ffffff", 92.609_110_548_456),
];
for (text, bg, want) in cases {
let got = lc(rgb(text), rgb(bg));
assert!(
(got - want).abs() < 0.01,
"Lc({text} on {bg}) = {got}, want {want}"
);
}
}
#[test]
fn polarity_is_signed() {
assert!(lc(rgb("#000000"), rgb("#ffffff")) > 0.0, "BoW positive");
assert!(lc(rgb("#ffffff"), rgb("#000000")) < 0.0, "WoB negative");
}
#[test]
fn identical_colors_have_no_contrast() {
assert_eq!(lc(rgb("#777777"), rgb("#777777")), 0.0);
}
#[test]
fn lc_abs_and_meets_use_magnitude() {
assert!(lc_abs(rgb("#ffffff"), rgb("#000000")) > 100.0);
assert!(meets(rgb("#ffffff"), rgb("#000000"), 90.0));
assert!(!meets(rgb("#888888"), rgb("#777777"), 30.0));
}
#[test]
fn required_lc_is_monotonic_in_size_and_weight() {
assert!(required_lc(12.0, 400.0) > required_lc(16.0, 400.0));
assert!(required_lc(16.0, 400.0) > required_lc(24.0, 400.0));
assert!(required_lc(24.0, 400.0) > required_lc(36.0, 400.0));
assert!(required_lc(16.0, 300.0) > required_lc(16.0, 400.0));
assert!(required_lc(16.0, 400.0) > required_lc(16.0, 500.0));
assert!(required_lc(16.0, 500.0) > required_lc(16.0, 600.0));
}
#[test]
fn required_lc_matches_apca_anchors() {
assert!((required_lc(16.0, 400.0) - 75.0).abs() <= 5.0);
assert!((90.0..=108.0).contains(&required_lc(12.0, 400.0)));
assert!((45.0..=60.0).contains(&required_lc(24.0, 700.0)));
for lc in [required_lc(1.0, 900.0), required_lc(1000.0, 100.0)] {
assert!(lc.is_finite() && (15.0..=108.0).contains(&lc), "lc = {lc}");
}
}
}