use crate::contrast::contrast;
use crate::schema::{Color, ColorRole, ColorScheme};
use crate::tokens::DesignTokenTable;
const WHITE: [f64; 3] = [95.047, 100.0, 108.883];
fn linearized(channel: u32) -> f64 {
let value = f64::from(channel) / 255.0;
let linear = if value <= 0.040_449_936 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
};
linear * 100.0
}
fn delinearized(linear: f64) -> u32 {
let value = linear / 100.0;
let encoded = if value <= 0.003_130_8 {
value * 12.92
} else {
1.055 * value.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0 + 0.5).floor().clamp(0.0, 255.0) as u32
}
fn lab_f(t: f64) -> f64 {
let e = 216.0 / 24389.0;
let kappa = 24389.0 / 27.0;
if t > e {
t.powf(1.0 / 3.0)
} else {
(kappa * t + 16.0) / 116.0
}
}
fn lab_invf(ft: f64) -> f64 {
let e = 216.0 / 24389.0;
let kappa = 24389.0 / 27.0;
let ft3 = ft * ft * ft;
if ft3 > e {
ft3
} else {
(116.0 * ft - 16.0) / kappa
}
}
fn y_from_lstar(lstar: f64) -> f64 {
100.0 * lab_invf((lstar + 16.0) / 116.0)
}
fn lstar_from_y(y: f64) -> f64 {
lab_f(y / 100.0) * 116.0 - 16.0
}
fn signum(value: f64) -> f64 {
if value > 0.0 {
1.0
} else if value < 0.0 {
-1.0
} else {
0.0
}
}
struct Viewing {
rgb_d: [f64; 3],
aw: f64,
n: f64,
c: f64,
z: f64,
nbb: f64,
ncb: f64,
nc: f64,
fl: f64,
}
impl Viewing {
fn standard() -> Self {
let [wx, wy, wz] = WHITE;
let adapting = (200.0 / std::f64::consts::PI) * y_from_lstar(50.0) / 100.0;
let background = 50.0;
let surround = 2.0;
let rw = wx * 0.401_288 + wy * 0.650_173 + wz * -0.051_461;
let gw = wx * -0.250_268 + wy * 1.204_414 + wz * 0.045_854;
let bw = wx * -0.002_079 + wy * 0.048_952 + wz * 0.953_127;
let f = 0.8 + surround / 10.0;
let c = if f >= 0.9 {
0.59 + (0.69 - 0.59) * ((f - 0.9) * 10.0)
} else {
0.525 + (0.59 - 0.525) * ((f - 0.8) * 10.0)
};
let d = (f * (1.0 - (1.0 / 3.6) * ((-adapting - 42.0) / 92.0).exp())).clamp(0.0, 1.0);
let rgb_d = [
d * (100.0 / rw) + 1.0 - d,
d * (100.0 / gw) + 1.0 - d,
d * (100.0 / bw) + 1.0 - d,
];
let k = 1.0 / (5.0 * adapting + 1.0);
let k4 = k * k * k * k;
let k4f = 1.0 - k4;
let fl = k4 * adapting + 0.1 * k4f * k4f * (5.0 * adapting).powf(1.0 / 3.0);
let n = y_from_lstar(background) / wy;
let z = 1.48 + n.sqrt();
let nbb = 0.725 / n.powf(0.2);
let factor = |rgb_d: f64, white: f64| (fl * rgb_d * white / 100.0).powf(0.42);
let adapted = |factor: f64| 400.0 * factor / (factor + 27.13);
let aw = (2.0 * adapted(factor(rgb_d[0], rw))
+ adapted(factor(rgb_d[1], gw))
+ 0.05 * adapted(factor(rgb_d[2], bw)))
* nbb;
Self {
rgb_d,
aw,
n,
c,
z,
nbb,
ncb: nbb,
nc: f,
fl,
}
}
}
pub(crate) fn hct(color: Color) -> (f64, f64, f64) {
let viewing = Viewing::standard();
let argb = color.to_argb();
let r = linearized((argb >> 16) & 0xff);
let g = linearized((argb >> 8) & 0xff);
let b = linearized(argb & 0xff);
let x = 0.412_338_95 * r + 0.357_620_64 * g + 0.180_510_42 * b;
let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let z = 0.019_321_41 * r + 0.119_163_82 * g + 0.950_344_78 * b;
let rc = 0.401_288 * x + 0.650_173 * y - 0.051_461 * z;
let gc = -0.250_268 * x + 1.204_414 * y + 0.045_854 * z;
let bc = -0.002_079 * x + 0.048_952 * y + 0.953_127 * z;
let adapt = |value: f64| {
if value == 0.0 {
return 0.0;
}
let af = (viewing.fl * value.abs() / 100.0).powf(0.42);
signum(value) * 400.0 * af / (af + 27.13)
};
let ra = adapt(viewing.rgb_d[0] * rc);
let ga = adapt(viewing.rgb_d[1] * gc);
let ba = adapt(viewing.rgb_d[2] * bc);
let a = (11.0 * ra + -12.0 * ga + ba) / 11.0;
let bb = (ra + ga - 2.0 * ba) / 9.0;
let u = (20.0 * ra + 20.0 * ga + 21.0 * ba) / 20.0;
let p2 = (40.0 * ra + 20.0 * ga + ba) / 20.0;
let mut hue = bb.atan2(a).to_degrees();
if hue < 0.0 {
hue += 360.0;
} else if hue >= 360.0 {
hue -= 360.0;
}
let ac = p2 * viewing.nbb;
let j = 100.0 * (ac / viewing.aw).powf(viewing.c * viewing.z);
let hue_prime = if hue < 20.14 { hue + 360.0 } else { hue };
let e_hue = 0.25 * ((hue_prime.to_radians() + 2.0).cos() + 3.8);
let p1 = 50000.0 / 13.0 * e_hue * viewing.nc * viewing.ncb;
let t = p1 * a.hypot(bb) / (u + 0.305);
let alpha = t.powf(0.9) * (1.64 - 0.29_f64.powf(viewing.n)).powf(0.73);
let chroma = alpha * (j / 100.0).sqrt();
(hue, chroma, lstar_from_y(y))
}
fn linear_from_jch(viewing: &Viewing, j: f64, chroma: f64, hue: f64) -> [f64; 4] {
let alpha = if chroma == 0.0 || j == 0.0 {
0.0
} else {
chroma / (j / 100.0).sqrt()
};
let t = (alpha / (1.64 - 0.29_f64.powf(viewing.n)).powf(0.73)).powf(1.0 / 0.9);
let radians = hue.to_radians();
let e_hue = 0.25 * ((radians + 2.0).cos() + 3.8);
let ac = viewing.aw * (j / 100.0).powf(1.0 / viewing.c / viewing.z);
let p1 = e_hue * (50000.0 / 13.0) * viewing.nc * viewing.ncb;
let p2 = ac / viewing.nbb;
let (sin, cos) = (radians.sin(), radians.cos());
let gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * cos + 108.0 * t * sin);
let a = gamma * cos;
let b = gamma * sin;
let ra = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
let ga = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
let ba = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
let unadapt = |value: f64| {
if value == 0.0 {
return 0.0;
}
let base = (27.13 * value.abs() / (400.0 - value.abs())).max(0.0);
signum(value) * (100.0 / viewing.fl) * base.powf(1.0 / 0.42)
};
let rf = unadapt(ra) / viewing.rgb_d[0];
let gf = unadapt(ga) / viewing.rgb_d[1];
let bf = unadapt(ba) / viewing.rgb_d[2];
let x = 1.862_067_86 * rf - 1.011_254_63 * gf + 0.149_186_77 * bf;
let y = 0.387_526_54 * rf + 0.621_447_44 * gf - 0.008_973_98 * bf;
let z = -0.015_841_50 * rf - 0.034_122_94 * gf + 1.049_964_44 * bf;
[
3.241_377_479_238_868_5 * x - 1.537_665_240_285_185_1 * y - 0.498_853_668_462_680_53 * z,
-0.969_145_251_300_532_1 * x + 1.875_885_345_106_787_2 * y + 0.041_565_856_169_120_61 * z,
0.055_620_936_896_913_05 * x - 0.203_955_245_647_421_23 * y + 1.057_179_911_122_033_5 * z,
y,
]
}
fn at_tone(viewing: &Viewing, hue: f64, chroma: f64, tone: f64) -> Option<[f64; 3]> {
let target = y_from_lstar(tone);
let (mut low, mut high) = (0.0, 100.0);
for _ in 0..64 {
let middle = (low + high) / 2.0;
if linear_from_jch(viewing, middle, chroma, hue)[3] < target {
low = middle;
} else {
high = middle;
}
}
let [r, g, b, _] = linear_from_jch(viewing, (low + high) / 2.0, chroma, hue);
let epsilon = 1e-7;
if r.min(g).min(b) < -epsilon || r.max(g).max(b) > 100.0 + epsilon {
return None;
}
Some([r, g, b])
}
pub(crate) fn from_hct(hue: f64, chroma: f64, tone: f64) -> Color {
if tone <= 0.0 {
return Color::rgb(0x000000);
}
if tone >= 100.0 {
return Color::rgb(0xffffff);
}
let viewing = Viewing::standard();
let linear = at_tone(&viewing, hue, chroma, tone).unwrap_or_else(|| {
let (mut low, mut high) = (0.0, chroma);
let mut best = at_tone(&viewing, hue, 0.0, tone).unwrap_or([0.0; 3]);
for _ in 0..48 {
let middle = (low + high) / 2.0;
match at_tone(&viewing, hue, middle, tone) {
Some(found) => {
low = middle;
best = found;
}
None => high = middle,
}
}
best
});
let [r, g, b] = linear.map(|channel| delinearized(channel.clamp(0.0, 100.0)));
Color::rgb((r << 16) | (g << 8) | b)
}
pub(crate) fn over(ink: Color, under: Color, alpha: f64) -> Color {
let mut out = 0;
for shift in [16, 8, 0] {
let fg = f64::from((ink.to_argb() >> shift) & 0xff);
let bg = f64::from((under.to_argb() >> shift) & 0xff);
out |= ((fg * alpha + bg * (1.0 - alpha) + 0.5).floor() as u32) << shift;
}
Color::rgb(out)
}
pub(crate) fn shift_until(color: Color, dark: bool, passes: impl Fn(Color) -> bool) -> Color {
if passes(color) {
return color;
}
let (hue, chroma, tone) = hct(color);
let step = if dark { 1.0 } else { -1.0 };
(1..=100)
.map(|k| from_hct(hue, chroma, tone + step * f64::from(k)))
.find(|candidate| passes(*candidate))
.unwrap_or(color)
}
pub(crate) const DERIVED_FROM: [(ColorRole, ColorRole); 15] = [
(ColorRole::SyntaxKeyword, ColorRole::Primary),
(ColorRole::SyntaxString, ColorRole::Tertiary),
(ColorRole::SyntaxComment, ColorRole::OnSurfaceVariant),
(ColorRole::SyntaxNumber, ColorRole::OnTertiaryContainer),
(ColorRole::SyntaxConstant, ColorRole::OnTertiaryContainer),
(ColorRole::SyntaxType, ColorRole::Secondary),
(ColorRole::SyntaxFunction, ColorRole::OnPrimaryContainer),
(ColorRole::SyntaxVariable, ColorRole::OnSurface),
(ColorRole::SyntaxProperty, ColorRole::OnSecondaryContainer),
(ColorRole::SyntaxOperator, ColorRole::OnSurfaceVariant),
(ColorRole::SyntaxPunctuation, ColorRole::OnSurfaceVariant),
(ColorRole::SyntaxTag, ColorRole::Primary),
(ColorRole::SyntaxAttribute, ColorRole::Secondary),
(ColorRole::SyntaxEscape, ColorRole::Error),
(ColorRole::SyntaxMacro, ColorRole::Secondary),
];
pub(crate) const ADDED_HUE: f64 = 145.0;
pub(crate) fn derive(table: &DesignTokenTable, scheme: ColorScheme) -> Vec<(ColorRole, Color)> {
let dark = scheme == ColorScheme::Dark;
let panel = table.color(ColorRole::SurfaceContainer, scheme);
let reads = |ink: Color| contrast(ink, panel) >= 4.5;
let mut out: Vec<(ColorRole, Color)> = DERIVED_FROM
.iter()
.map(|(role, from)| (*role, shift_until(table.color(*from, scheme), dark, reads)))
.collect();
let (line, word) = if dark { (0.20, 0.40) } else { (0.12, 0.30) };
let error = table.color(ColorRole::Error, scheme);
let (_, chroma, tone) = hct(error);
let added = from_hct(ADDED_HUE, chroma, tone);
let settle = |ink: Color| {
shift_until(ink, dark, |candidate| {
contrast(candidate, over(candidate, panel, line)) >= 4.5
})
};
let added = settle(added);
let removed = settle(error);
out.push((ColorRole::DiffAdded, added));
out.push((ColorRole::DiffRemoved, removed));
out.push((
ColorRole::DiffModified,
table.color(ColorRole::Tertiary, scheme),
));
out.push((ColorRole::DiffAddedContainer, over(added, panel, line)));
out.push((ColorRole::DiffRemovedContainer, over(removed, panel, line)));
out.push((ColorRole::DiffAddedEmphasis, over(added, panel, word)));
out.push((ColorRole::DiffRemovedEmphasis, over(removed, panel, word)));
out.sort_by_key(|(role, _)| *role as u16);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fr13_1_3_hct_round_trips_an_opaque_colour() {
for argb in [
0xb3261e, 0x6750a4, 0x0081ff, 0x123456, 0xf2b8b5, 0x00ff00, 0x777777,
] {
let color = Color::rgb(argb);
let (hue, chroma, tone) = hct(color);
assert_eq!(
from_hct(hue, chroma, tone),
color,
"#{argb:06x} came back as something else"
);
}
}
#[test]
fn fr13_1_3_hct_agrees_with_the_published_error_tone() {
let (hue, chroma, tone) = hct(Color::rgb(0xb3261e));
assert!((hue - 25.98).abs() < 0.05, "hue {hue}");
assert!((chroma - 76.33).abs() < 0.05, "chroma {chroma}");
assert!((tone - 39.69).abs() < 0.05, "tone {tone}");
}
}