1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
use crate::utils::color_utils::ColorUtils;
/// Color science for contrast utilities.
///
/// Utility methods for calculating contrast given two colors, or calculating a color given one color
/// and a contrast ratio.
///
/// Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y becomes
/// HCT's tone and L*a*b*'s' L*.
pub struct Contrast;
impl Contrast {
/// The minimum contrast ratio of two colors. Contrast ratio equation = (lighter + 5) / (darker +
/// 5). If lighter == darker, ratio == 1.
pub const RATIO_MIN: f64 = 1.0;
/// The maximum contrast ratio of two colors. Contrast ratio equation = (lighter + 5) / (darker +
/// 5). If lighter == 100 and darker = 0, ratio == 21.
pub const RATIO_MAX: f64 = 21.0;
pub const RATIO_30: f64 = 3.0;
pub const RATIO_45: f64 = 4.5;
pub const RATIO_70: f64 = 7.0;
// Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio
// with the color can be calculated. However, that luminance may not contrast as desired, i.e. the
// contrast ratio of the input color and the returned luminance may not reach the contrast ratio
// asked for.
//
// When the desired contrast ratio and the result contrast ratio differ by more than this amount,
// an error value should be returned, or the method should be documented as 'unsafe', meaning,
// it will return a valid luminance but that luminance may not meet the requested contrast ratio.
//
// 0.04 selected because it ensures the resulting ratio rounds to the same tenth.
const CONTRAST_RATIO_EPSILON: f64 = 0.04;
// Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as
// perceptually accurate color spaces.
//
// To be displayed, they must gamut map to a "display space", one that has a defined limit on the
// number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB.
// Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must
// choose how to sacrifice accuracy in hue, saturation, and/or lightness.
//
// A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue,
// thus maintaining aesthetic intent, and reduce chroma until the color is in gamut.
//
// HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness,
// if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB
// color with, for example, 47.892 lightness, but not 47.891.
//
// To allow for this inherent incompatibility between perceptually accurate color spaces and
// display color spaces, methods that take a contrast ratio and luminance, and return a luminance
// that reaches that contrast ratio for the input luminance, purposefully darken/lighten their
// result such that the desired contrast ratio will be reached even if inaccuracy is introduced.
//
// 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough
// guarantee that as long as a perceptual color space gamut maps lightness such that the resulting
// lightness rounds to the same as the requested, the desired contrast ratio will be reached.
const LUMINANCE_GAMUT_MAP_TOLERANCE: f64 = 0.4;
/// Contrast ratio is a measure of legibility, its used to compare the lightness of two colors.
/// This method is used commonly in industry due to its use by WCAG.
///
/// To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness,
/// also known as relative luminance.
///
/// The equation is ratio = lighter Y + 5 / darker Y + 5.
#[must_use]
pub fn ratio_of_ys(y1: f64, y2: f64) -> f64 {
let (lighter, darker) = if y1 > y2 { (y1, y2) } else { (y2, y1) };
(lighter + 5.0) / (darker + 5.0)
}
/// Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perpectual
/// luminance.
///
/// Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is
/// linear to number of photons, not to perception of lightness. Perceptual luminance, L* in
/// L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're
/// accurate to the eye.
///
/// Y and L* are pure functions of each other, so it possible to use perceptually accurate color
/// spaces, and measure contrast, and measure contrast in a much more understandable way: instead
/// of a ratio, a linear difference. This allows a designer to determine what they need to adjust a
/// color's lightness to in order to reach their desired contrast, instead of guessing & checking
/// with hex codes.
#[must_use]
pub fn ratio_of_tones(t1: f64, t2: f64) -> f64 {
Self::ratio_of_ys(ColorUtils::y_from_lstar(t1), ColorUtils::y_from_lstar(t2))
}
/// Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns
/// None if ratio cannot be achieved.
///
/// * `tone` - Tone return value must contrast with.
/// * `ratio` - Desired contrast ratio of return value and tone parameter.
#[must_use]
pub fn lighter(tone: f64, ratio: f64) -> Option<f64> {
if !(0.0..=100.0).contains(&tone) {
return None;
}
// Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y.
let dark_y = ColorUtils::y_from_lstar(tone);
let light_y = ratio.mul_add(dark_y + 5.0, -5.0);
if !(0.0..=100.0).contains(&light_y) {
return None;
}
let real_contrast = Self::ratio_of_ys(light_y, dark_y);
let delta = (real_contrast - ratio).abs();
if real_contrast < ratio && delta > Self::CONTRAST_RATIO_EPSILON {
return None;
}
let return_value = ColorUtils::lstar_from_y(light_y) + Self::LUMINANCE_GAMUT_MAP_TOLERANCE;
if (0.0..=100.0).contains(&return_value) {
Some(return_value)
} else {
None
}
}
/// Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved.
///
/// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
/// bounds return value may not reach the desired ratio.
///
/// * `tone` - Tone return value must contrast with.
/// * `ratio` - Desired contrast ratio of return value and tone parameter.
#[must_use]
pub fn lighter_unsafe(tone: f64, ratio: f64) -> f64 {
Self::lighter(tone, ratio).unwrap_or(100.0)
}
/// Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns
/// None if ratio cannot be achieved.
///
/// * `tone` - Tone return value must contrast with.
/// * `ratio` - Desired contrast ratio of return value and tone parameter.
#[must_use]
pub fn darker(tone: f64, ratio: f64) -> Option<f64> {
if !(0.0..=100.0).contains(&tone) {
return None;
}
// Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y.
let light_y = ColorUtils::y_from_lstar(tone);
let dark_y = (light_y + 5.0) / ratio - 5.0;
if !(0.0..=100.0).contains(&dark_y) {
return None;
}
let real_contrast = Self::ratio_of_ys(light_y, dark_y);
let delta = (real_contrast - ratio).abs();
if real_contrast < ratio && delta > Self::CONTRAST_RATIO_EPSILON {
return None;
}
// For information on 0.4 constant, see comment in lighter(tone, ratio).
let return_value = ColorUtils::lstar_from_y(dark_y) - Self::LUMINANCE_GAMUT_MAP_TOLERANCE;
if (0.0..=100.0).contains(&return_value) {
Some(return_value)
} else {
None
}
}
/// Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved.
///
/// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
/// bounds return value may not reach the desired ratio.
///
/// * `tone` - Tone return value must contrast with.
/// * `ratio` - Desired contrast ratio of return value and tone parameter.
#[must_use]
pub fn darker_unsafe(tone: f64, ratio: f64) -> f64 {
Self::darker(tone, ratio).unwrap_or(0.0)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::float_cmp)]
use super::*;
use crate::utils::color_utils::Argb;
#[test]
fn test_ratio_of_ys() {
assert!((Contrast::ratio_of_ys(100.0, 0.0) - 21.0).abs() < 1e-10);
assert!((Contrast::ratio_of_ys(0.0, 100.0) - 21.0).abs() < 1e-10);
assert!((Contrast::ratio_of_ys(50.0, 50.0) - 1.0).abs() < 1e-10);
}
#[test]
fn test_ratio_of_tones() {
assert!((Contrast::ratio_of_tones(100.0, 0.0) - 21.0).abs() < 1e-10);
assert!((Contrast::ratio_of_tones(0.0, 100.0) - 21.0).abs() < 1e-10);
assert!((Contrast::ratio_of_tones(50.0, 50.0) - 1.0).abs() < 1e-10);
}
#[test]
fn test_lighter() {
assert!(Contrast::lighter(0.0, 19.0).is_some());
assert!(Contrast::lighter(100.0, 2.0).is_none());
assert!(Contrast::lighter(50.0, 3.0).is_some());
}
#[test]
fn test_darker() {
assert!(Contrast::darker(100.0, 19.0).is_some());
assert!(Contrast::darker(0.0, 2.0).is_none());
assert!(Contrast::darker(50.0, 3.0).is_some());
}
#[test]
fn test_unsafe_methods() {
assert_eq!(Contrast::lighter_unsafe(100.0, 2.0), 100.0);
assert_eq!(Contrast::darker_unsafe(0.0, 2.0), 0.0);
}
#[test]
fn test_black_white_contrast() {
let black = Argb::from_rgb(0, 0, 0);
let white = Argb::from_rgb(255, 255, 255);
// Get the tone (L*) for each color
let tone_black = black.lstar(); // 0.0
let tone_white = white.lstar(); // 100.0
// Calculate the contrast ratio
let ratio = Contrast::ratio_of_tones(tone_black, tone_white);
assert!((ratio - 21.0).abs() < 0.01);
}
}