1
+ using static System . Math ;
2
+
3
+ namespace MaterialColorUtilities ;
4
+
5
+ /// <summary>
6
+ /// Color science for contrast utilities.
7
+ ///
8
+ /// Utility methods for calculating contrast given two colors, or calculating a color given one
9
+ /// color and a contrast ratio.
10
+ ///
11
+ /// Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y
12
+ /// becomes HCT's tone and L*a*b*'s' L*.
13
+ /// </summary>
14
+ public static class Contrast
15
+ {
16
+ // The minimum contrast ratio of two colors.
17
+ // Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1.
18
+ public const double RatioMin = 1.0 ;
19
+
20
+ // The maximum contrast ratio of two colors.
21
+ // Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100.
22
+ // If lighter == 100, darker = 0, ratio == 21.
23
+ public const double RatioMax = 21.0 ;
24
+
25
+ public const double Ratio30 = 3.0 ;
26
+ public const double Ratio45 = 4.5 ;
27
+ public const double Ratio70 = 7.0 ;
28
+
29
+ // Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio
30
+ // with the color can be calculated. However, that luminance may not contrast as desired, i.e. the
31
+ // contrast ratio of the input color and the returned luminance may not reach the contrast ratio
32
+ // asked for.
33
+ //
34
+ // When the desired contrast ratio and the result contrast ratio differ by more than this amount,
35
+ // an error value should be returned, or the method should be documented as 'unsafe', meaning,
36
+ // it will return a valid luminance but that luminance may not meet the requested contrast ratio.
37
+ //
38
+ // 0.04 selected because it ensures the resulting ratio rounds to the same tenth.
39
+ private const double ContrastRatioEpsilon = 0.04 ;
40
+
41
+ // Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as
42
+ // perceptually accurate color spaces.
43
+ //
44
+ // To be displayed, they must gamut map to a "display space", one that has a defined limit on the
45
+ // number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB.
46
+ // Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must
47
+ // choose how to sacrifice accuracy in hue, saturation, and/or lightness.
48
+ //
49
+ // A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue,
50
+ // thus maintaining aesthetic intent, and reduce chroma until the color is in gamut.
51
+ //
52
+ // HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness,
53
+ // if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB
54
+ // color with, for example, 47.892 lightness, but not 47.891.
55
+ //
56
+ // To allow for this inherent incompatibility between perceptually accurate color spaces and
57
+ // display color spaces, methods that take a contrast ratio and luminance, and return a luminance
58
+ // that reaches that contrast ratio for the input luminance, purposefully darken/lighten their
59
+ // result such that the desired contrast ratio will be reached even if inaccuracy is introduced.
60
+ //
61
+ // 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough
62
+ // guarantee that as long as a perceptual color space gamut maps lightness such that the resulting
63
+ // lightness rounds to the same as the requested, the desired contrast ratio will be reached.
64
+ private const double LuminanceGamutMapTolerance = 0.4 ;
65
+
66
+ /// <summary>
67
+ /// Contrast ratio is a measure of legibility, its used to compare the lightness of two colors.
68
+ /// This method is used commonly in industry due to its use by WCAG.
69
+ ///
70
+ /// To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness,
71
+ /// also known as relative luminance.
72
+ ///
73
+ /// The equation is ratio = lighter Y + 5 / darker Y + 5.
74
+ /// </summary>
75
+ public static double RatioOfYs ( double y1 , double y2 )
76
+ {
77
+ var lighter = y1 > y2 ? y1 : y2 ;
78
+ var darker = ( lighter == y2 ) ? y1 : y2 ;
79
+ return ( lighter + 5.0 ) / ( darker + 5.0 ) ;
80
+ }
81
+
82
+ /// <summary>
83
+ /// Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perceptual
84
+ /// luminance.
85
+ ///
86
+ /// Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is
87
+ /// linear to number of photons, not to perception of lightness. Perceptual luminance, L* in
88
+ /// L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're
89
+ /// accurate to the eye.
90
+ ///
91
+ /// Y and L* are pure functions of each other, so it possible to use perceptually accurate color
92
+ /// spaces, and measure contrast, and measure contrast in a much more understandable way: instead
93
+ /// of a ratio, a linear difference. This allows a designer to determine what they need to adjust a
94
+ /// color's lightness to in order to reach their desired contrast, instead of guessing & checking
95
+ /// with hex codes.
96
+ /// </summary>
97
+ public static double RatioOfTones ( double t1 , double t2 )
98
+ {
99
+ return RatioOfYs ( ColorUtils . YFromLstar ( t1 ) , ColorUtils . YFromLstar ( t2 ) ) ;
100
+ }
101
+
102
+ /// <summary>
103
+ /// Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*.
104
+ /// Returns -1 if ratio cannot be achieved.
105
+ /// </summary>
106
+ /// <param name="tone">Tone return value must contrast with.</param>
107
+ /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
108
+ public static double Lighter ( double tone , double ratio )
109
+ {
110
+ if ( tone is < 0.0 or > 100.0 )
111
+ {
112
+ return - 1.0 ;
113
+ }
114
+ var darkY = ColorUtils . YFromLstar ( tone ) ;
115
+ var lightY = ratio * ( darkY + 5.0 ) - 5.0 ;
116
+ if ( lightY is < 0.0 or > 100.0 )
117
+ {
118
+ return - 1.0 ;
119
+ }
120
+ var realContrast = RatioOfYs ( lightY , darkY ) ;
121
+ var delta = Abs ( realContrast - ratio ) ;
122
+ if ( realContrast < ratio && delta > ContrastRatioEpsilon )
123
+ {
124
+ return - 1.0 ;
125
+ }
126
+
127
+ var returnValue = ColorUtils . LstarFromY ( lightY ) + LuminanceGamutMapTolerance ;
128
+ if ( returnValue is < 0.0 or > 100.0 )
129
+ {
130
+ return - 1.0 ;
131
+ }
132
+ return returnValue ;
133
+ }
134
+
135
+ /// <summary>
136
+ /// Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved.
137
+ ///
138
+ /// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
139
+ /// bounds return value may not reach the desired ratio.
140
+ /// </summary>
141
+ /// <param name="tone">Tone return value must contrast with.</param>
142
+ /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
143
+ public static double LighterUnsafe ( double tone , double ratio )
144
+ {
145
+ var lighterSafe = Lighter ( tone , ratio ) ;
146
+ return lighterSafe < 0.0 ? 100.0 : lighterSafe ;
147
+ }
148
+
149
+ /// <summary>
150
+ /// Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*.
151
+ /// Returns -1 if ratio cannot be achieved.
152
+ /// </summary>
153
+ /// <param name="tone">Tone return value must contrast with.</param>
154
+ /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
155
+ public static double Darker ( double tone , double ratio )
156
+ {
157
+ if ( tone is < 0.0 or > 100.0 )
158
+ {
159
+ return - 1.0 ;
160
+ }
161
+ var lightY = ColorUtils . YFromLstar ( tone ) ;
162
+ var darkY = ( ( lightY + 5.0 ) / ratio ) - 5.0 ;
163
+ if ( darkY is < 0.0 or > 100.0 )
164
+ {
165
+ return - 1.0 ;
166
+ }
167
+ var realContrast = RatioOfYs ( lightY , darkY ) ;
168
+ var delta = Abs ( realContrast - ratio ) ;
169
+ if ( realContrast < ratio && delta > ContrastRatioEpsilon )
170
+ {
171
+ return - 1.0 ;
172
+ }
173
+ var returnValue = ColorUtils . LstarFromY ( darkY ) - LuminanceGamutMapTolerance ;
174
+ if ( returnValue is < 0.0 or > 100.0 )
175
+ {
176
+ return - 1.0 ;
177
+ }
178
+ return returnValue ;
179
+ }
180
+
181
+ /// <summary>
182
+ /// Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved.
183
+ ///
184
+ /// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
185
+ /// bounds return value may not reach the desired ratio.
186
+ /// </summary>
187
+ /// <param name="tone">Tone return value must contrast with.</param>
188
+ /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param>
189
+ public static double DarkerUnsafe ( double tone , double ratio )
190
+ {
191
+ var darkerSafe = Darker ( tone , ratio ) ;
192
+ return Max ( 0.0 , darkerSafe ) ;
193
+ }
194
+ }
0 commit comments