use std::fmt;
const SRGB_TO_XYZ: [[f64; 3]; 3] = [
[0.41239079926595934, 0.357584339383878, 0.1804807884018343],
[0.21263900587151027, 0.715168678767756, 0.07219231536073371],
[0.01933081871559182, 0.11919477979462599, 0.9505321522496607],
];
const XYZ_TO_SRGB: [[f64; 3]; 3] = [
[3.240969941904521, -1.537383177570093, -0.4986107602930034],
[-0.9692436362808798, 1.8759675015077206, 0.04155505740717561],
[
0.05563007969699361,
-0.20397695888897652,
1.0569715142428786,
],
];
const P3_TO_XYZ: [[f64; 3]; 3] = [
[0.4865709486482162, 0.26566769316909306, 0.1982172852343625],
[0.2289745640697488, 0.6917385218365064, 0.079286914093745],
[0.0, 0.04511338185890264, 1.043944368900976],
];
const XYZ_TO_P3: [[f64; 3]; 3] = [
[2.493496911941425, -0.9313836179191239, -0.40271078445071684],
[
-0.8294889695615747,
1.7626640603183463,
0.023624685841943577,
],
[
0.03584583024378447,
-0.07617238926804182,
0.9568845240076872,
],
];
const APCA_MAIN_TRC: f64 = 2.4;
const APCA_NORM_BG: f64 = 0.56;
const APCA_NORM_TXT: f64 = 0.57;
const APCA_REV_TXT: f64 = 0.62;
const APCA_REV_BG: f64 = 0.65;
const APCA_BLK_THRS: f64 = 0.022;
const APCA_BLK_CLMP: f64 = 1.414;
const APCA_SCALE_BOW: f64 = 1.14;
const APCA_SCALE_WOB: f64 = 1.14;
const APCA_LO_BOW_OFFSET: f64 = 0.027;
const APCA_LO_WOB_OFFSET: f64 = 0.027;
const APCA_DELTA_Y_MIN: f64 = 0.0005;
const APCA_LO_CLIP: f64 = 0.1;
const SRGB_APCA_COEFFS: [f64; 3] = [0.2126729, 0.7151522, 0.0721750];
const P3_APCA_COEFFS: [f64; 3] = [0.228982959480578, 0.691749262585238, 0.0792677779341829];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSpace {
P3,
Srgb,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContrastModel {
Apca,
Wcag,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchDirection {
Auto,
Lighter,
Darker,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CssFormat {
Oklch,
Rgb,
Hex,
P3,
FigmaP3,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Chroma {
Fixed(f64),
Max { cap: f64 },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
Oklch { l: f64, c: f64, h: f64, alpha: f64 },
Srgb { r: f64, g: f64, b: f64, alpha: f64 },
DisplayP3 { r: f64, g: f64, b: f64, alpha: f64 },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContrastConfig {
pub bg_color: Option<Color>,
pub fg_color: Option<Color>,
pub cr: f64,
pub contrast_model: ContrastModel,
pub search_direction: SearchDirection,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ApcachColor {
pub alpha: f64,
pub chroma: f64,
pub color_space: ColorSpace,
pub contrast_config: ContrastConfig,
pub hue: f64,
pub lightness: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContrastInput(pub ContrastConfig);
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Error {
InvalidContrast,
InvalidChroma,
InvalidHue,
InvalidAlpha,
InvalidRgbComponent,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidContrast => f.write_str("invalid contrast value"),
Self::InvalidChroma => f.write_str("invalid chroma value"),
Self::InvalidHue => f.write_str("invalid hue value"),
Self::InvalidAlpha => f.write_str("invalid alpha value"),
Self::InvalidRgbComponent => f.write_str("invalid rgb component value"),
}
}
}
impl std::error::Error for Error {}
impl From<f64> for ContrastInput {
fn from(cr: f64) -> Self {
Self(cr_to_bg(
Color::white(),
cr,
ContrastModel::Apca,
SearchDirection::Auto,
))
}
}
impl From<ContrastConfig> for ContrastInput {
fn from(value: ContrastConfig) -> Self {
Self(value)
}
}
impl Color {
pub const fn oklch(l: f64, c: f64, h: f64) -> Self {
Self::Oklch {
l,
c,
h,
alpha: 1.0,
}
}
pub const fn oklch_alpha(l: f64, c: f64, h: f64, alpha: f64) -> Self {
Self::Oklch { l, c, h, alpha }
}
pub const fn srgb(r: f64, g: f64, b: f64) -> Self {
Self::Srgb {
r,
g,
b,
alpha: 1.0,
}
}
pub const fn srgb_alpha(r: f64, g: f64, b: f64, alpha: f64) -> Self {
Self::Srgb { r, g, b, alpha }
}
pub const fn display_p3(r: f64, g: f64, b: f64) -> Self {
Self::DisplayP3 {
r,
g,
b,
alpha: 1.0,
}
}
pub const fn display_p3_alpha(r: f64, g: f64, b: f64, alpha: f64) -> Self {
Self::DisplayP3 { r, g, b, alpha }
}
pub const fn white() -> Self {
Self::oklch(1.0, 0.0, 0.0)
}
pub const fn black() -> Self {
Self::oklch(0.0, 0.0, 0.0)
}
}
pub const fn max_chroma() -> Chroma {
Chroma::Max { cap: 0.4 }
}
pub const fn max_chroma_capped(cap: f64) -> Chroma {
Chroma::Max { cap }
}
pub fn cr_to_bg(
bg_color: Color,
cr: f64,
contrast_model: ContrastModel,
search_direction: SearchDirection,
) -> ContrastConfig {
ContrastConfig {
bg_color: Some(bg_color),
fg_color: None,
cr,
contrast_model,
search_direction,
}
}
pub fn cr_to_bg_white(
cr: f64,
contrast_model: ContrastModel,
search_direction: SearchDirection,
) -> ContrastConfig {
cr_to_bg(Color::white(), cr, contrast_model, search_direction)
}
pub fn cr_to_bg_black(
cr: f64,
contrast_model: ContrastModel,
search_direction: SearchDirection,
) -> ContrastConfig {
cr_to_bg(Color::black(), cr, contrast_model, search_direction)
}
pub fn cr_to_fg(
fg_color: Color,
cr: f64,
contrast_model: ContrastModel,
search_direction: SearchDirection,
) -> ContrastConfig {
ContrastConfig {
bg_color: None,
fg_color: Some(fg_color),
cr,
contrast_model,
search_direction,
}
}
pub fn cr_to_fg_white(
cr: f64,
contrast_model: ContrastModel,
search_direction: SearchDirection,
) -> ContrastConfig {
cr_to_fg(Color::white(), cr, contrast_model, search_direction)
}
pub fn cr_to_fg_black(
cr: f64,
contrast_model: ContrastModel,
search_direction: SearchDirection,
) -> ContrastConfig {
cr_to_fg(Color::black(), cr, contrast_model, search_direction)
}
pub fn apcach<C: Into<ContrastInput>>(
contrast: C,
chroma: Chroma,
hue: f64,
alpha: f64,
color_space: ColorSpace,
) -> Result<ApcachColor, Error> {
validate_alpha(alpha)?;
validate_hue(hue)?;
let contrast_config = contrast.into().0;
let chroma = match chroma {
Chroma::Fixed(value) => {
validate_chroma(value)?;
value
}
Chroma::Max { cap } => {
return max_chroma_search(contrast_config, cap, hue, alpha, color_space)
}
};
let lightness = if contrast_is_legal(contrast_config.cr, contrast_config.contrast_model) {
calc_lightness(contrast_config, chroma, hue, color_space)?
} else {
lightness_from_antagonist(contrast_config)?
};
Ok(ApcachColor {
alpha,
chroma,
color_space,
contrast_config,
hue,
lightness,
})
}
pub fn set_contrast(color: ApcachColor, cr: f64) -> Result<ApcachColor, Error> {
let mut contrast_config = color.contrast_config;
contrast_config.cr = clip_contrast(cr);
apcach(
contrast_config,
Chroma::Fixed(color.chroma),
color.hue,
color.alpha,
color.color_space,
)
}
pub fn map_contrast<F: FnOnce(f64) -> f64>(color: ApcachColor, f: F) -> Result<ApcachColor, Error> {
set_contrast(color, f(color.contrast_config.cr))
}
pub fn set_chroma(color: ApcachColor, chroma: f64) -> Result<ApcachColor, Error> {
apcach(
color.contrast_config,
Chroma::Fixed(clip_chroma(chroma)),
color.hue,
color.alpha,
color.color_space,
)
}
pub fn map_chroma<F: FnOnce(f64) -> f64>(color: ApcachColor, f: F) -> Result<ApcachColor, Error> {
set_chroma(color, f(color.chroma))
}
pub fn set_hue(color: ApcachColor, hue: f64) -> Result<ApcachColor, Error> {
apcach(
color.contrast_config,
Chroma::Fixed(color.chroma),
clip_hue(hue),
color.alpha,
color.color_space,
)
}
pub fn map_hue<F: FnOnce(f64) -> f64>(color: ApcachColor, f: F) -> Result<ApcachColor, Error> {
set_hue(color, f(color.hue))
}
pub fn calc_contrast(
fg_color: Color,
bg_color: Color,
contrast_model: ContrastModel,
color_space: ColorSpace,
) -> Result<f64, Error> {
let bg = clamp_color_to_space(bg_color, color_space)?;
let mut fg = clamp_color_to_space(fg_color, color_space)?;
fg = blend_colors(fg, bg);
Ok(calc_contrast_from_prepared_colors(fg, bg, contrast_model, color_space).abs())
}
pub fn to_css(color: ApcachColor, format: CssFormat) -> Result<String, Error> {
match format {
CssFormat::Oklch => Ok(format!(
"oklch({}% {} {})",
color.lightness * 100.0,
color.chroma,
color.hue
)),
CssFormat::Rgb => {
let rgb = clamped_encoded_from_oklch(
color.lightness,
color.chroma,
color.hue,
ColorSpace::Srgb,
)?;
Ok(format!(
"rgb({} {} {})",
format_rgb_channel(rgb[0]),
format_rgb_channel(rgb[1]),
format_rgb_channel(rgb[2])
))
}
CssFormat::Hex => {
let rgb = clamped_encoded_from_oklch(
color.lightness,
color.chroma,
color.hue,
ColorSpace::Srgb,
)?;
Ok(format!(
"#{:02x}{:02x}{:02x}",
to_u8(rgb[0]),
to_u8(rgb[1]),
to_u8(rgb[2])
))
}
CssFormat::P3 => {
let p3 = clamped_encoded_from_oklch(
color.lightness,
color.chroma,
color.hue,
ColorSpace::P3,
)?;
Ok(format!("color(display-p3 {} {} {})", p3[0], p3[1], p3[2]))
}
CssFormat::FigmaP3 => {
let p3 = clamped_encoded_from_oklch(
color.lightness,
color.chroma,
color.hue,
ColorSpace::P3,
)?;
Ok(format!(
"{:02x}{:02x}{:02x}",
to_u8(p3[0]),
to_u8(p3[1]),
to_u8(p3[2])
))
}
}
}
fn max_chroma_search(
contrast_config: ContrastConfig,
cap: f64,
hue: f64,
alpha: f64,
color_space: ColorSpace,
) -> Result<ApcachColor, Error> {
validate_chroma(cap)?;
let mut checking_chroma = cap;
let mut search_patch = 0.4;
let mut color = apcach(
contrast_config,
Chroma::Fixed(checking_chroma),
hue,
alpha,
color_space,
)?;
let mut color_is_valid = false;
for iteration in 0..30 {
let old_chroma = checking_chroma;
checking_chroma = (old_chroma + search_patch).clamp(0.0, cap);
color = apcach(
contrast_config,
Chroma::Fixed(checking_chroma),
hue,
alpha,
color_space,
)?;
let new_color_is_valid = in_color_space(color)?;
if iteration == 0 && !new_color_is_valid {
search_patch *= -1.0;
} else if new_color_is_valid != color_is_valid {
search_patch /= -2.0;
}
color_is_valid = new_color_is_valid;
if checking_chroma <= 0.0 && !color_is_valid {
color.chroma = 0.0;
return Ok(color);
}
if (search_patch.abs() <= 0.001 || checking_chroma == cap) && color_is_valid {
if checking_chroma <= 0.0 {
color.chroma = 0.0;
}
return Ok(color);
}
}
Ok(color)
}
fn calc_lightness(
contrast_config: ContrastConfig,
chroma: f64,
hue: f64,
color_space: ColorSpace,
) -> Result<f64, Error> {
let (mut lightness, mut lightness_patch) = lightness_and_patch(contrast_config, color_space)?;
let limits = chroma_limits(contrast_config, color_space)?;
let mut delta_contrast = 0.0;
let mut best_contrast = f64::INFINITY;
let mut best_lightness = 0.0;
let mut search_window = (0.0, 1.0);
for iteration in 0..20 {
let mut new_lightness = lightness;
if iteration > 0 {
new_lightness += lightness_patch;
}
new_lightness = new_lightness.clamp(limits.0, limits.1);
let checking = clamp_color_to_space(
Color::oklch_alpha(new_lightness, chroma, hue, 1.0),
color_space,
)?;
let calculated = contrast_from_config(checking, contrast_config, color_space)?;
let new_delta_contrast = contrast_config.cr - calculated;
if iteration == 0
&& calculated < contrast_config.cr
&& contrast_config.search_direction != SearchDirection::Auto
{
best_lightness = lightness;
break;
}
if calculated >= contrast_config.cr && calculated < best_contrast {
best_contrast = calculated;
best_lightness = new_lightness;
}
if delta_contrast != 0.0 && sign_of(new_delta_contrast) != sign_of(delta_contrast) {
if lightness_patch > 0.0 {
search_window.1 = new_lightness;
} else {
search_window.0 = new_lightness;
}
lightness_patch = -lightness_patch / 2.0;
} else if (new_lightness + lightness_patch - search_window.0).abs() <= f64::EPSILON
|| (new_lightness + lightness_patch - search_window.1).abs() <= f64::EPSILON
{
lightness_patch /= 2.0;
}
if search_window.1 - search_window.0 < 0.001
|| (iteration > 0 && new_lightness == lightness)
{
break;
}
delta_contrast = new_delta_contrast;
lightness = new_lightness;
}
Ok(best_lightness.clamp(0.0, 1.0))
}
fn lightness_and_patch(
contrast_config: ContrastConfig,
color_space: ColorSpace,
) -> Result<(f64, f64), Error> {
let antagonist_lightness = antagonist_color_lightness(contrast_config, color_space)?;
let result = match contrast_config.search_direction {
SearchDirection::Auto => {
if antagonist_lightness < 0.5 {
(1.0, (1.0 - antagonist_lightness) / -2.0)
} else {
(0.0, antagonist_lightness / 2.0)
}
}
SearchDirection::Darker => (0.0, antagonist_lightness / 2.0),
SearchDirection::Lighter => (1.0, (antagonist_lightness - 1.0) / 2.0),
};
Ok(result)
}
fn chroma_limits(
contrast_config: ContrastConfig,
color_space: ColorSpace,
) -> Result<(f64, f64), Error> {
if contrast_config.search_direction == SearchDirection::Auto {
return Ok((0.0, 1.0));
}
let pair_lightness = antagonist_color_lightness(contrast_config, color_space)?;
let bounds = match contrast_config.search_direction {
SearchDirection::Auto => (0.0, 1.0),
SearchDirection::Lighter => (pair_lightness, 1.0),
SearchDirection::Darker => (0.0, pair_lightness),
};
Ok(bounds)
}
fn antagonist_color_lightness(
contrast_config: ContrastConfig,
color_space: ColorSpace,
) -> Result<f64, Error> {
let antagonist = contrast_antagonist(contrast_config)?;
let clamped = clamp_color_to_space(antagonist, color_space)?;
let oklch = color_to_oklch(clamped)?;
Ok(oklch[0])
}
fn lightness_from_antagonist(contrast_config: ContrastConfig) -> Result<f64, Error> {
Ok(color_to_oklch(contrast_antagonist(contrast_config)?)?[0])
}
fn contrast_antagonist(contrast_config: ContrastConfig) -> Result<Color, Error> {
contrast_config
.bg_color
.or(contrast_config.fg_color)
.ok_or(Error::InvalidContrast)
}
fn contrast_from_config(
color: Color,
contrast_config: ContrastConfig,
color_space: ColorSpace,
) -> Result<f64, Error> {
let (fg, bg) = match (contrast_config.bg_color, contrast_config.fg_color) {
(Some(bg), None) => {
let bg = clamp_color_to_space(bg, color_space)?;
(blend_colors(color, bg), bg)
}
(None, Some(fg)) => (clamp_color_to_space(fg, color_space)?, color),
_ => return Err(Error::InvalidContrast),
};
Ok(
calc_contrast_from_prepared_colors(fg, bg, contrast_config.contrast_model, color_space)
.abs(),
)
}
fn calc_contrast_from_prepared_colors(
fg: Color,
bg: Color,
contrast_model: ContrastModel,
color_space: ColorSpace,
) -> f64 {
match contrast_model {
ContrastModel::Apca => match color_space {
ColorSpace::P3 => apca_contrast(
p3_to_y(encoded_channels(fg, ColorSpace::P3)),
p3_to_y(encoded_channels(bg, ColorSpace::P3)),
),
ColorSpace::Srgb => apca_contrast(
srgb_to_y_u8(encoded_channels(fg, ColorSpace::Srgb)),
srgb_to_y_u8(encoded_channels(bg, ColorSpace::Srgb)),
),
},
ContrastModel::Wcag => wcag_contrast(
encoded_channels(fg, ColorSpace::Srgb),
encoded_channels(bg, ColorSpace::Srgb),
),
}
}
fn in_color_space(color: ApcachColor) -> Result<bool, Error> {
let encoded = color_to_space_encoded(color, color.color_space)?;
Ok(encoded
.into_iter()
.all(|component| (0.0..=1.0).contains(&component)))
}
fn blend_colors(fg: Color, bg: Color) -> Color {
let (fr, fgc, fb, fa) = color_to_rgba_encoded(fg, preferred_space(fg));
if fa >= 1.0 {
return fg;
}
let (br, bgc, bb, _) = color_to_rgba_encoded(bg, preferred_space(bg));
let alpha = fa.clamp(0.0, 1.0);
Color::srgb(
br + (fr - br) * alpha,
bgc + (fgc - bgc) * alpha,
bb + (fb - bb) * alpha,
)
}
fn preferred_space(color: Color) -> ColorSpace {
match color {
Color::DisplayP3 { .. } => ColorSpace::P3,
_ => ColorSpace::Srgb,
}
}
fn color_to_rgba_encoded(color: Color, target: ColorSpace) -> (f64, f64, f64, f64) {
match color {
Color::Srgb { r, g, b, alpha } if target == ColorSpace::Srgb => (r, g, b, alpha),
Color::DisplayP3 { r, g, b, alpha } if target == ColorSpace::P3 => (r, g, b, alpha),
_ => {
let channels = encoded_channels(color, target);
let alpha = match color {
Color::Oklch { alpha, .. }
| Color::Srgb { alpha, .. }
| Color::DisplayP3 { alpha, .. } => alpha,
};
(channels[0], channels[1], channels[2], alpha)
}
}
}
fn clamp_color_to_space(color: Color, color_space: ColorSpace) -> Result<Color, Error> {
validate_color(color)?;
let [l, c, h] = color_to_oklch(color)?;
if in_gamut_oklch(l, c, h, color_space) {
return Ok(Color::oklch_alpha(l, c, h, color_alpha(color)));
}
let mut low = 0.0;
let mut high = c;
for _ in 0..30 {
let mid = (low + high) / 2.0;
if in_gamut_oklch(l, mid, h, color_space) {
low = mid;
} else {
high = mid;
}
}
Ok(Color::oklch_alpha(l, low, h, color_alpha(color)))
}
fn in_gamut_oklch(l: f64, c: f64, h: f64, color_space: ColorSpace) -> bool {
let encoded = encoded_from_oklch(l, c, h, color_space);
encoded
.into_iter()
.all(|value| (0.0..=1.0).contains(&value))
}
fn color_to_space_encoded(color: ApcachColor, color_space: ColorSpace) -> Result<[f64; 3], Error> {
Ok(encoded_from_oklch(
color.lightness,
color.chroma,
color.hue,
color_space,
))
}
fn clamped_encoded_from_oklch(
l: f64,
c: f64,
h: f64,
color_space: ColorSpace,
) -> Result<[f64; 3], Error> {
let color = clamp_color_to_space(Color::oklch(l, c, h), color_space)?;
let Color::Oklch { l, c, h, .. } = color else {
unreachable!();
};
Ok(encoded_from_oklch(l, c, h, color_space))
}
fn encoded_channels(color: Color, target: ColorSpace) -> [f64; 3] {
match color {
Color::Oklch { l, c, h, .. } => encoded_from_oklch(l, c, h, target),
Color::Srgb { r, g, b, .. } => match target {
ColorSpace::Srgb => [r, g, b],
ColorSpace::P3 => convert_encoded_rgb([r, g, b], SRGB_TO_XYZ, XYZ_TO_P3),
},
Color::DisplayP3 { r, g, b, .. } => match target {
ColorSpace::P3 => [r, g, b],
ColorSpace::Srgb => convert_encoded_rgb([r, g, b], P3_TO_XYZ, XYZ_TO_SRGB),
},
}
}
fn convert_encoded_rgb(rgb: [f64; 3], to_xyz: [[f64; 3]; 3], from_xyz: [[f64; 3]; 3]) -> [f64; 3] {
let linear = [
srgb_decode(rgb[0]),
srgb_decode(rgb[1]),
srgb_decode(rgb[2]),
];
let xyz = mul3(to_xyz, linear);
let out_linear = mul3(from_xyz, xyz);
[
srgb_encode(out_linear[0]),
srgb_encode(out_linear[1]),
srgb_encode(out_linear[2]),
]
}
fn encoded_from_oklch(l: f64, c: f64, h: f64, color_space: ColorSpace) -> [f64; 3] {
let xyz = oklch_to_xyz(l, c, h);
let linear = match color_space {
ColorSpace::Srgb => mul3(XYZ_TO_SRGB, xyz),
ColorSpace::P3 => mul3(XYZ_TO_P3, xyz),
};
[
srgb_encode(linear[0]),
srgb_encode(linear[1]),
srgb_encode(linear[2]),
]
}
fn color_to_oklch(color: Color) -> Result<[f64; 3], Error> {
validate_color(color)?;
let result = match color {
Color::Oklch { l, c, h, .. } => [l, c, h],
Color::Srgb { r, g, b, .. } => xyz_to_oklch(mul3(
SRGB_TO_XYZ,
[srgb_decode(r), srgb_decode(g), srgb_decode(b)],
)),
Color::DisplayP3 { r, g, b, .. } => xyz_to_oklch(mul3(
P3_TO_XYZ,
[srgb_decode(r), srgb_decode(g), srgb_decode(b)],
)),
};
Ok(result)
}
fn validate_color(color: Color) -> Result<(), Error> {
match color {
Color::Oklch { alpha, .. } => validate_alpha(alpha),
Color::Srgb { r, g, b, alpha } | Color::DisplayP3 { r, g, b, alpha } => {
validate_alpha(alpha)?;
if [r, g, b].into_iter().all(f64::is_finite) {
Ok(())
} else {
Err(Error::InvalidRgbComponent)
}
}
}
}
fn validate_alpha(alpha: f64) -> Result<(), Error> {
if alpha.is_finite() {
Ok(())
} else {
Err(Error::InvalidAlpha)
}
}
fn validate_chroma(chroma: f64) -> Result<(), Error> {
if chroma.is_finite() && chroma >= 0.0 {
Ok(())
} else {
Err(Error::InvalidChroma)
}
}
fn validate_hue(hue: f64) -> Result<(), Error> {
if hue.is_finite() {
Ok(())
} else {
Err(Error::InvalidHue)
}
}
fn color_alpha(color: Color) -> f64 {
match color {
Color::Oklch { alpha, .. } | Color::Srgb { alpha, .. } | Color::DisplayP3 { alpha, .. } => {
alpha
}
}
}
fn contrast_is_legal(cr: f64, contrast_model: ContrastModel) -> bool {
match contrast_model {
ContrastModel::Apca => cr.abs() >= 8.0,
ContrastModel::Wcag => cr.abs() >= 1.0,
}
}
fn clip_contrast(cr: f64) -> f64 {
cr.clamp(0.0, 108.0)
}
fn clip_chroma(chroma: f64) -> f64 {
chroma.clamp(0.0, 0.37)
}
fn clip_hue(hue: f64) -> f64 {
hue.clamp(0.0, 360.0)
}
fn sign_of(number: f64) -> f64 {
number / number.abs()
}
fn oklch_to_xyz(l: f64, c: f64, h: f64) -> [f64; 3] {
let hr = h.to_radians();
let a = c * hr.cos();
let b = c * hr.sin();
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548 * b;
let l3 = l_ * l_ * l_;
let m3 = m_ * m_ * m_;
let s3 = s_ * s_ * s_;
[
1.227_013_851_1 * l3 - 0.557_799_980_7 * m3 + 0.281_256_149 * s3,
-0.040_580_178_4 * l3 + 1.112_256_869_6 * m3 - 0.071_676_678_7 * s3,
-0.076_381_284_5 * l3 - 0.421_481_978_4 * m3 + 1.586_163_220_4 * s3,
]
}
fn xyz_to_oklch(xyz: [f64; 3]) -> [f64; 3] {
let l = 0.818_933_010_1 * xyz[0] + 0.361_866_742_4 * xyz[1] - 0.128_859_713_7 * xyz[2];
let m = 0.032_984_543_6 * xyz[0] + 0.929_311_871_5 * xyz[1] + 0.036_145_638_7 * xyz[2];
let s = 0.048_200_301_8 * xyz[0] + 0.264_366_269_1 * xyz[1] + 0.633_851_707 * xyz[2];
let l_ = l.cbrt();
let m_ = m.cbrt();
let s_ = s.cbrt();
let l_ok = 0.210_454_255_3 * l_ + 0.793_617_785 * m_ - 0.004_072_046_8 * s_;
let a = 1.977_998_495_1 * l_ - 2.428_592_205 * m_ + 0.450_593_709_9 * s_;
let b = 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766 * s_;
let chroma = (a * a + b * b).sqrt();
let hue = b.atan2(a).to_degrees().rem_euclid(360.0);
[l_ok, chroma, hue]
}
fn mul3(matrix: [[f64; 3]; 3], vector: [f64; 3]) -> [f64; 3] {
[
matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2],
]
}
fn srgb_decode(value: f64) -> f64 {
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
fn srgb_encode(value: f64) -> f64 {
if value <= 0.0031308 {
12.92 * value
} else {
1.055 * value.powf(1.0 / 2.4) - 0.055
}
}
fn srgb_to_y_u8(rgb: [f64; 3]) -> f64 {
let rounded = [
(rgb[0].max(0.0) * 255.0).round(),
(rgb[1].max(0.0) * 255.0).round(),
(rgb[2].max(0.0) * 255.0).round(),
];
SRGB_APCA_COEFFS[0] * (rounded[0] / 255.0).powf(APCA_MAIN_TRC)
+ SRGB_APCA_COEFFS[1] * (rounded[1] / 255.0).powf(APCA_MAIN_TRC)
+ SRGB_APCA_COEFFS[2] * (rounded[2] / 255.0).powf(APCA_MAIN_TRC)
}
fn p3_to_y(rgb: [f64; 3]) -> f64 {
P3_APCA_COEFFS[0] * rgb[0].max(0.0).powf(APCA_MAIN_TRC)
+ P3_APCA_COEFFS[1] * rgb[1].max(0.0).powf(APCA_MAIN_TRC)
+ P3_APCA_COEFFS[2] * rgb[2].max(0.0).powf(APCA_MAIN_TRC)
}
fn apca_contrast(txt_y: f64, bg_y: f64) -> f64 {
if txt_y.is_nan() || bg_y.is_nan() || txt_y < 0.0 || bg_y < 0.0 || txt_y > 1.1 || bg_y > 1.1 {
return 0.0;
}
let txt_y = soft_clamp_black(txt_y);
let bg_y = soft_clamp_black(bg_y);
if (bg_y - txt_y).abs() < APCA_DELTA_Y_MIN {
return 0.0;
}
let output = if bg_y > txt_y {
let sapc = (bg_y.powf(APCA_NORM_BG) - txt_y.powf(APCA_NORM_TXT)) * APCA_SCALE_BOW;
if sapc < APCA_LO_CLIP {
0.0
} else {
sapc - APCA_LO_BOW_OFFSET
}
} else {
let sapc = (bg_y.powf(APCA_REV_BG) - txt_y.powf(APCA_REV_TXT)) * APCA_SCALE_WOB;
if sapc > -APCA_LO_CLIP {
0.0
} else {
sapc + APCA_LO_WOB_OFFSET
}
};
output * 100.0
}
fn soft_clamp_black(y: f64) -> f64 {
if y > APCA_BLK_THRS {
y
} else {
y + (APCA_BLK_THRS - y).powf(APCA_BLK_CLMP)
}
}
fn wcag_contrast(fg: [f64; 3], bg: [f64; 3]) -> f64 {
let fg_l = relative_luminance(fg);
let bg_l = relative_luminance(bg);
let (light, dark) = if fg_l >= bg_l {
(fg_l, bg_l)
} else {
(bg_l, fg_l)
};
(light + 0.05) / (dark + 0.05)
}
fn relative_luminance(rgb: [f64; 3]) -> f64 {
let linear = [
srgb_decode(rgb[0]),
srgb_decode(rgb[1]),
srgb_decode(rgb[2]),
];
0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
fn format_rgb_channel(value: f64) -> String {
to_u8(value).to_string()
}
fn to_u8(value: f64) -> u8 {
(value.clamp(0.0, 1.0) * 255.0).round() as u8
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_close(left: f64, right: f64, epsilon: f64) {
assert!(
(left - right).abs() <= epsilon,
"left={left}, right={right}, epsilon={epsilon}"
);
}
#[test]
fn apca_matches_known_black_white_values() {
let white = Color::white();
let black = Color::black();
assert_close(
calc_contrast(white, black, ContrastModel::Apca, ColorSpace::Srgb).unwrap(),
107.88473318309848,
1e-9,
);
assert_close(
calc_contrast(black, white, ContrastModel::Apca, ColorSpace::Srgb).unwrap(),
106.04067321268862,
1e-9,
);
}
#[test]
fn wcag_matches_known_black_white_values() {
assert_close(
calc_contrast(
Color::white(),
Color::black(),
ContrastModel::Wcag,
ColorSpace::Srgb,
)
.unwrap(),
21.0,
1e-6,
);
}
#[test]
fn creates_known_srgb_color() {
let color = apcach(70.0, Chroma::Fixed(0.15), 150.0, 100.0, ColorSpace::Srgb).unwrap();
assert_close(color.lightness * 100.0, 55.566405559494214, 1e-6);
}
#[test]
fn creates_known_p3_color() {
let color = apcach(70.0, Chroma::Fixed(0.2), 150.0, 100.0, ColorSpace::P3).unwrap();
assert_close(color.lightness * 100.0, 55.17578088989781, 1e-6);
}
#[test]
fn max_chroma_finds_valid_srgb_color() {
let color = apcach(
cr_to_bg_white(70.0, ContrastModel::Apca, SearchDirection::Auto),
max_chroma(),
200.0,
100.0,
ColorSpace::Srgb,
)
.unwrap();
assert!(in_color_space(color).unwrap());
assert!(
calc_contrast(
Color::oklch(color.lightness, color.chroma, color.hue),
Color::white(),
ContrastModel::Apca,
ColorSpace::Srgb,
)
.unwrap()
>= 70.0
);
}
#[test]
fn emits_hex_output() {
let color = apcach(70.0, Chroma::Fixed(0.15), 150.0, 100.0, ColorSpace::Srgb).unwrap();
let css = to_css(color, CssFormat::Hex).unwrap();
assert_eq!(css.len(), 7);
assert!(css.starts_with('#'));
}
#[test]
fn relative_adjustments_work() {
let color = apcach(60.0, Chroma::Fixed(0.15), 145.0, 100.0, ColorSpace::Srgb).unwrap();
let adjusted = map_contrast(color, |cr| cr + 10.0).unwrap();
assert_close(adjusted.contrast_config.cr, 70.0, 1e-12);
}
}