pub fn parse_rgb(hex: &str) -> Option<(u8, u8, u8)> {
let hex = hex.strip_prefix('#')?;
if hex.len() != 6 && hex.len() != 8 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((r, g, b))
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Cmyk {
pub c: f32,
pub m: f32,
pub y: f32,
pub k: f32,
}
pub fn parse_cmyk(s: &str) -> Option<Cmyk> {
let inner = s.strip_prefix("cmyk(")?.strip_suffix(')')?;
let mut parts = inner.split(',');
let c = parse_pct(parts.next()?)?;
let m = parse_pct(parts.next()?)?;
let y = parse_pct(parts.next()?)?;
let k = parse_pct(parts.next()?)?;
if parts.next().is_some() {
return None;
}
Some(Cmyk { c, m, y, k })
}
fn parse_pct(tok: &str) -> Option<f32> {
let v: f32 = tok.trim().parse().ok()?;
if v.is_finite() && (0.0..=100.0).contains(&v) {
Some(v)
} else {
None
}
}
pub fn cmyk_to_srgb(cmyk: Cmyk) -> (u8, u8, u8) {
let chan = |v: f32, kk: f32| -> u8 {
let f = 255.0_f32 * (1.0 - v / 100.0) * (1.0 - kk / 100.0);
f.round().clamp(0.0, 255.0) as u8
};
(
chan(cmyk.c, cmyk.k),
chan(cmyk.m, cmyk.k),
chan(cmyk.y, cmyk.k),
)
}
pub fn cmyk_to_hex(cmyk: Cmyk) -> String {
let (r, g, b) = cmyk_to_srgb(cmyk);
format!("#{r:02x}{g:02x}{b:02x}")
}
pub fn rgb_to_hex(rgb: (u8, u8, u8)) -> String {
format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2)
}
pub fn relative_luminance(rgb: (u8, u8, u8)) -> f64 {
let linearize = |channel: u8| -> f64 {
let c = channel as f64 / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
let r = linearize(rgb.0);
let g = linearize(rgb.1);
let b = linearize(rgb.2);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
pub fn contrast_ratio(a: (u8, u8, u8), b: (u8, u8, u8)) -> f64 {
let la = relative_luminance(a);
let lb = relative_luminance(b);
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
pub fn apca_lc(text: (u8, u8, u8), bg: (u8, u8, u8)) -> f64 {
const TRC: f64 = 2.4;
const RCO: f64 = 0.212_672_9;
const GCO: f64 = 0.715_152_2;
const BCO: f64 = 0.072_175_0;
const BLK_THRS: f64 = 0.022;
const BLK_CLMP: f64 = 1.414;
const DELTA_Y_MIN: f64 = 0.0005;
const LO_CLIP: f64 = 0.1;
const NORM_BG: f64 = 0.56;
const NORM_TXT: f64 = 0.57;
const REV_TXT: f64 = 0.62;
const REV_BG: f64 = 0.65;
const SCALE: f64 = 1.14;
const LO_OFFSET: f64 = 0.027;
let screen_y = |rgb: (u8, u8, u8)| -> f64 {
let ch = |c: u8| (c as f64 / 255.0).powf(TRC);
let mut y = RCO * ch(rgb.0) + GCO * ch(rgb.1) + BCO * ch(rgb.2);
if y < BLK_THRS {
y += (BLK_THRS - y).powf(BLK_CLMP);
}
y
};
let ytxt = screen_y(text);
let ybg = screen_y(bg);
if (ybg - ytxt).abs() < DELTA_Y_MIN {
return 0.0;
}
let output = if ybg > ytxt {
let sapc = (ybg.powf(NORM_BG) - ytxt.powf(NORM_TXT)) * SCALE;
if sapc < LO_CLIP {
0.0
} else {
sapc - LO_OFFSET
}
} else {
let sapc = (ybg.powf(REV_BG) - ytxt.powf(REV_TXT)) * SCALE;
if sapc > -LO_CLIP {
0.0
} else {
sapc + LO_OFFSET
}
};
output * 100.0
}
pub fn best_text_color(bg: (u8, u8, u8), a: (u8, u8, u8), b: (u8, u8, u8)) -> (u8, u8, u8) {
if apca_lc(a, bg).abs() >= apca_lc(b, bg).abs() {
a
} else {
b
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apca_black_on_white_approx_106() {
let lc = apca_lc((0, 0, 0), (255, 255, 255));
assert!(
(lc - 106.04).abs() < 0.5,
"black-on-white Lc should be ~106, got {lc}"
);
}
#[test]
fn apca_white_on_black_is_negative_approx_108() {
let lc = apca_lc((255, 255, 255), (0, 0, 0));
assert!(
(lc + 107.88).abs() < 0.5,
"white-on-black Lc should be ~-108, got {lc}"
);
}
#[test]
fn apca_same_color_is_zero() {
assert_eq!(apca_lc((120, 120, 120), (120, 120, 120)), 0.0);
}
#[test]
fn apca_polarity_sign() {
assert!(apca_lc((20, 20, 20), (240, 240, 240)) > 0.0);
assert!(apca_lc((240, 240, 240), (20, 20, 20)) < 0.0);
}
#[test]
fn best_text_color_picks_higher_contrast() {
let dark = (24, 26, 30);
let light = (247, 248, 250);
assert_eq!(best_text_color((250, 250, 250), dark, light), dark);
assert_eq!(best_text_color((10, 12, 16), dark, light), light);
let g = (34, 197, 94);
let chosen = best_text_color(g, dark, light);
assert!(
apca_lc(chosen, g).abs() >= apca_lc(dark, g).abs().max(apca_lc(light, g).abs()) - 1e-9
);
}
#[test]
fn parse_rgb_6_digits() {
assert_eq!(parse_rgb("#ffffff"), Some((255, 255, 255)));
assert_eq!(parse_rgb("#000000"), Some((0, 0, 0)));
assert_eq!(parse_rgb("#aabbcc"), Some((0xaa, 0xbb, 0xcc)));
}
#[test]
fn parse_rgb_8_digits_ignores_alpha() {
assert_eq!(parse_rgb("#aabbccff"), Some((0xaa, 0xbb, 0xcc)));
assert_eq!(parse_rgb("#ffffff80"), Some((255, 255, 255)));
}
#[test]
fn parse_rgb_rejects_bad_input() {
assert_eq!(parse_rgb("ffffff"), None); assert_eq!(parse_rgb("#fff"), None); assert_eq!(parse_rgb("#fffffg"), None); assert_eq!(parse_rgb(""), None);
assert_eq!(parse_rgb("#"), None);
}
#[test]
fn cmyk_zero_is_white() {
let c = parse_cmyk("cmyk(0,0,0,0)").expect("must parse");
assert_eq!(cmyk_to_srgb(c), (255, 255, 255));
assert_eq!(cmyk_to_hex(c), "#ffffff");
}
#[test]
fn cmyk_full_k_is_black() {
let c = parse_cmyk("cmyk(0,0,0,100)").expect("must parse");
assert_eq!(cmyk_to_srgb(c), (0, 0, 0));
assert_eq!(cmyk_to_hex(c), "#000000");
}
#[test]
fn cmyk_violet_converts_to_expected_purple() {
let c = parse_cmyk("cmyk(59,85,0,7)").expect("must parse");
assert_eq!(cmyk_to_srgb(c), (97, 36, 237));
assert_eq!(cmyk_to_hex(c), "#6124ed");
}
#[test]
fn cmyk_accepts_spaces_and_decimals() {
let c = parse_cmyk("cmyk( 12.5 , 0 , 0 , 0 )").expect("must parse");
assert_eq!(c.c, 12.5);
assert_eq!(c.m, 0.0);
}
#[test]
fn cmyk_rejects_malformed_and_out_of_range() {
assert!(parse_cmyk("cmyk(0,0,0)").is_none()); assert!(parse_cmyk("cmyk(0,0,0,0,0)").is_none()); assert!(parse_cmyk("cmyk(0,0,0,101)").is_none()); assert!(parse_cmyk("cmyk(-1,0,0,0)").is_none()); assert!(parse_cmyk("cmyk(a,0,0,0)").is_none()); assert!(parse_cmyk("rgb(0,0,0,0)").is_none()); assert!(parse_cmyk("cmyk(0,0,0,0").is_none()); assert!(parse_cmyk("#ffffff").is_none()); }
#[test]
fn luminance_black_is_zero() {
let l = relative_luminance((0, 0, 0));
assert!(l.abs() < 1e-10, "black luminance should be 0, got {l}");
}
#[test]
fn luminance_white_is_one() {
let l = relative_luminance((255, 255, 255));
assert!(
(l - 1.0).abs() < 1e-6,
"white luminance should be ~1, got {l}"
);
}
#[test]
fn contrast_white_vs_black_approx_21() {
let ratio = contrast_ratio((255, 255, 255), (0, 0, 0));
assert!(
(ratio - 21.0).abs() < 0.1,
"white/black ratio should be ~21, got {ratio}"
);
}
#[test]
fn contrast_white_vs_white_is_1() {
let ratio = contrast_ratio((255, 255, 255), (255, 255, 255));
assert!(
(ratio - 1.0).abs() < 1e-6,
"same color ratio should be 1, got {ratio}"
);
}
#[test]
fn contrast_gray_777_on_white_approx_4_48() {
let ratio = contrast_ratio((0x77, 0x77, 0x77), (255, 255, 255));
assert!(
(ratio - 4.48).abs() < 0.1,
"#777777 on white should be ~4.48, got {ratio}"
);
}
#[test]
fn contrast_ratio_is_symmetric() {
let ab = contrast_ratio((0x77, 0x77, 0x77), (255, 255, 255));
let ba = contrast_ratio((255, 255, 255), (0x77, 0x77, 0x77));
assert!(
(ab - ba).abs() < 1e-10,
"contrast_ratio must be symmetric: {ab} vs {ba}"
);
}
}