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