xref: /aosp_15_r20/external/libmonet/contrast/Contrast.java (revision 970e10460f970939fd510dd6ad3e0d65908272e3)
1*970e1046SAndroid Build Coastguard Worker /*
2*970e1046SAndroid Build Coastguard Worker  * Copyright 2022 Google LLC
3*970e1046SAndroid Build Coastguard Worker  *
4*970e1046SAndroid Build Coastguard Worker  * Licensed under the Apache License, Version 2.0 (the "License");
5*970e1046SAndroid Build Coastguard Worker  * you may not use this file except in compliance with the License.
6*970e1046SAndroid Build Coastguard Worker  * You may obtain a copy of the License at
7*970e1046SAndroid Build Coastguard Worker  *
8*970e1046SAndroid Build Coastguard Worker  *      http://www.apache.org/licenses/LICENSE-2.0
9*970e1046SAndroid Build Coastguard Worker  *
10*970e1046SAndroid Build Coastguard Worker  * Unless required by applicable law or agreed to in writing, software
11*970e1046SAndroid Build Coastguard Worker  * distributed under the License is distributed on an "AS IS" BASIS,
12*970e1046SAndroid Build Coastguard Worker  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13*970e1046SAndroid Build Coastguard Worker  * See the License for the specific language governing permissions and
14*970e1046SAndroid Build Coastguard Worker  * limitations under the License.
15*970e1046SAndroid Build Coastguard Worker  */
16*970e1046SAndroid Build Coastguard Worker 
17*970e1046SAndroid Build Coastguard Worker package com.google.ux.material.libmonet.contrast;
18*970e1046SAndroid Build Coastguard Worker 
19*970e1046SAndroid Build Coastguard Worker import static java.lang.Math.max;
20*970e1046SAndroid Build Coastguard Worker 
21*970e1046SAndroid Build Coastguard Worker import com.google.ux.material.libmonet.utils.ColorUtils;
22*970e1046SAndroid Build Coastguard Worker 
23*970e1046SAndroid Build Coastguard Worker /**
24*970e1046SAndroid Build Coastguard Worker  * Color science for contrast utilities.
25*970e1046SAndroid Build Coastguard Worker  *
26*970e1046SAndroid Build Coastguard Worker  * <p>Utility methods for calculating contrast given two colors, or calculating a color given one
27*970e1046SAndroid Build Coastguard Worker  * color and a contrast ratio.
28*970e1046SAndroid Build Coastguard Worker  *
29*970e1046SAndroid Build Coastguard Worker  * <p>Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y
30*970e1046SAndroid Build Coastguard Worker  * becomes HCT's tone and L*a*b*'s' L*.
31*970e1046SAndroid Build Coastguard Worker  */
32*970e1046SAndroid Build Coastguard Worker public final class Contrast {
33*970e1046SAndroid Build Coastguard Worker   // The minimum contrast ratio of two colors.
34*970e1046SAndroid Build Coastguard Worker   // Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1.
35*970e1046SAndroid Build Coastguard Worker   public static final double RATIO_MIN = 1.0;
36*970e1046SAndroid Build Coastguard Worker 
37*970e1046SAndroid Build Coastguard Worker   // The maximum contrast ratio of two colors.
38*970e1046SAndroid Build Coastguard Worker   // Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100.
39*970e1046SAndroid Build Coastguard Worker   // If lighter == 100, darker = 0, ratio == 21.
40*970e1046SAndroid Build Coastguard Worker   public static final double RATIO_MAX = 21.0;
41*970e1046SAndroid Build Coastguard Worker   public static final double RATIO_30 = 3.0;
42*970e1046SAndroid Build Coastguard Worker   public static final double RATIO_45 = 4.5;
43*970e1046SAndroid Build Coastguard Worker   public static final double RATIO_70 = 7.0;
44*970e1046SAndroid Build Coastguard Worker 
45*970e1046SAndroid Build Coastguard Worker   // Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio
46*970e1046SAndroid Build Coastguard Worker   // with the color can be calculated. However, that luminance may not contrast as desired, i.e. the
47*970e1046SAndroid Build Coastguard Worker   // contrast ratio of the input color and the returned luminance may not reach the contrast ratio
48*970e1046SAndroid Build Coastguard Worker   // asked for.
49*970e1046SAndroid Build Coastguard Worker   //
50*970e1046SAndroid Build Coastguard Worker   // When the desired contrast ratio and the result contrast ratio differ by more than this amount,
51*970e1046SAndroid Build Coastguard Worker   // an error value should be returned, or the method should be documented as 'unsafe', meaning,
52*970e1046SAndroid Build Coastguard Worker   // it will return a valid luminance but that luminance may not meet the requested contrast ratio.
53*970e1046SAndroid Build Coastguard Worker   //
54*970e1046SAndroid Build Coastguard Worker   // 0.04 selected because it ensures the resulting ratio rounds to the same tenth.
55*970e1046SAndroid Build Coastguard Worker   private static final double CONTRAST_RATIO_EPSILON = 0.04;
56*970e1046SAndroid Build Coastguard Worker 
57*970e1046SAndroid Build Coastguard Worker   // Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as
58*970e1046SAndroid Build Coastguard Worker   // perceptually accurate color spaces.
59*970e1046SAndroid Build Coastguard Worker   //
60*970e1046SAndroid Build Coastguard Worker   // To be displayed, they must gamut map to a "display space", one that has a defined limit on the
61*970e1046SAndroid Build Coastguard Worker   // number of colors. Display spaces include sRGB, more commonly understood  as RGB/HSL/HSV/HSB.
62*970e1046SAndroid Build Coastguard Worker   // Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must
63*970e1046SAndroid Build Coastguard Worker   // choose how to sacrifice accuracy in hue, saturation, and/or lightness.
64*970e1046SAndroid Build Coastguard Worker   //
65*970e1046SAndroid Build Coastguard Worker   // A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue,
66*970e1046SAndroid Build Coastguard Worker   // thus maintaining aesthetic intent, and reduce chroma until the color is in gamut.
67*970e1046SAndroid Build Coastguard Worker   //
68*970e1046SAndroid Build Coastguard Worker   // HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness,
69*970e1046SAndroid Build Coastguard Worker   // if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB
70*970e1046SAndroid Build Coastguard Worker   // color with, for example, 47.892 lightness, but not 47.891.
71*970e1046SAndroid Build Coastguard Worker   //
72*970e1046SAndroid Build Coastguard Worker   // To allow for this inherent incompatibility between perceptually accurate color spaces and
73*970e1046SAndroid Build Coastguard Worker   // display color spaces, methods that take a contrast ratio and luminance, and return a luminance
74*970e1046SAndroid Build Coastguard Worker   // that reaches that contrast ratio for the input luminance, purposefully darken/lighten their
75*970e1046SAndroid Build Coastguard Worker   // result such that the desired contrast ratio will be reached even if inaccuracy is introduced.
76*970e1046SAndroid Build Coastguard Worker   //
77*970e1046SAndroid Build Coastguard Worker   // 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough
78*970e1046SAndroid Build Coastguard Worker   // guarantee that as long as a perceptual color space gamut maps lightness such that the resulting
79*970e1046SAndroid Build Coastguard Worker   // lightness rounds to the same as the requested, the desired contrast ratio will be reached.
80*970e1046SAndroid Build Coastguard Worker   private static final double LUMINANCE_GAMUT_MAP_TOLERANCE = 0.4;
81*970e1046SAndroid Build Coastguard Worker 
Contrast()82*970e1046SAndroid Build Coastguard Worker   private Contrast() {}
83*970e1046SAndroid Build Coastguard Worker 
84*970e1046SAndroid Build Coastguard Worker   /**
85*970e1046SAndroid Build Coastguard Worker    * Contrast ratio is a measure of legibility, its used to compare the lightness of two colors.
86*970e1046SAndroid Build Coastguard Worker    * This method is used commonly in industry due to its use by WCAG.
87*970e1046SAndroid Build Coastguard Worker    *
88*970e1046SAndroid Build Coastguard Worker    * <p>To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness,
89*970e1046SAndroid Build Coastguard Worker    * also known as relative luminance.
90*970e1046SAndroid Build Coastguard Worker    *
91*970e1046SAndroid Build Coastguard Worker    * <p>The equation is ratio = lighter Y + 5 / darker Y + 5.
92*970e1046SAndroid Build Coastguard Worker    */
ratioOfYs(double y1, double y2)93*970e1046SAndroid Build Coastguard Worker   public static double ratioOfYs(double y1, double y2) {
94*970e1046SAndroid Build Coastguard Worker     final double lighter = max(y1, y2);
95*970e1046SAndroid Build Coastguard Worker     final double darker = (lighter == y2) ? y1 : y2;
96*970e1046SAndroid Build Coastguard Worker     return (lighter + 5.0) / (darker + 5.0);
97*970e1046SAndroid Build Coastguard Worker   }
98*970e1046SAndroid Build Coastguard Worker 
99*970e1046SAndroid Build Coastguard Worker   /**
100*970e1046SAndroid Build Coastguard Worker    * Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perpectual
101*970e1046SAndroid Build Coastguard Worker    * luminance.
102*970e1046SAndroid Build Coastguard Worker    *
103*970e1046SAndroid Build Coastguard Worker    * <p>Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is
104*970e1046SAndroid Build Coastguard Worker    * linear to number of photons, not to perception of lightness. Perceptual luminance, L* in
105*970e1046SAndroid Build Coastguard Worker    * L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're
106*970e1046SAndroid Build Coastguard Worker    * accurate to the eye.
107*970e1046SAndroid Build Coastguard Worker    *
108*970e1046SAndroid Build Coastguard Worker    * <p>Y and L* are pure functions of each other, so it possible to use perceptually accurate color
109*970e1046SAndroid Build Coastguard Worker    * spaces, and measure contrast, and measure contrast in a much more understandable way: instead
110*970e1046SAndroid Build Coastguard Worker    * of a ratio, a linear difference. This allows a designer to determine what they need to adjust a
111*970e1046SAndroid Build Coastguard Worker    * color's lightness to in order to reach their desired contrast, instead of guessing & checking
112*970e1046SAndroid Build Coastguard Worker    * with hex codes.
113*970e1046SAndroid Build Coastguard Worker    */
ratioOfTones(double t1, double t2)114*970e1046SAndroid Build Coastguard Worker   public static double ratioOfTones(double t1, double t2) {
115*970e1046SAndroid Build Coastguard Worker     return ratioOfYs(ColorUtils.yFromLstar(t1), ColorUtils.yFromLstar(t2));
116*970e1046SAndroid Build Coastguard Worker   }
117*970e1046SAndroid Build Coastguard Worker 
118*970e1046SAndroid Build Coastguard Worker   /**
119*970e1046SAndroid Build Coastguard Worker    * Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns -1
120*970e1046SAndroid Build Coastguard Worker    * if ratio cannot be achieved.
121*970e1046SAndroid Build Coastguard Worker    *
122*970e1046SAndroid Build Coastguard Worker    * @param tone Tone return value must contrast with.
123*970e1046SAndroid Build Coastguard Worker    * @param ratio Desired contrast ratio of return value and tone parameter.
124*970e1046SAndroid Build Coastguard Worker    */
lighter(double tone, double ratio)125*970e1046SAndroid Build Coastguard Worker   public static double lighter(double tone, double ratio) {
126*970e1046SAndroid Build Coastguard Worker     if (tone < 0.0 || tone > 100.0) {
127*970e1046SAndroid Build Coastguard Worker       return -1.0;
128*970e1046SAndroid Build Coastguard Worker     }
129*970e1046SAndroid Build Coastguard Worker     // Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y.
130*970e1046SAndroid Build Coastguard Worker     final double darkY = ColorUtils.yFromLstar(tone);
131*970e1046SAndroid Build Coastguard Worker     final double lightY = ratio * (darkY + 5.0) - 5.0;
132*970e1046SAndroid Build Coastguard Worker     if (lightY < 0.0 || lightY > 100.0) {
133*970e1046SAndroid Build Coastguard Worker       return -1.0;
134*970e1046SAndroid Build Coastguard Worker     }
135*970e1046SAndroid Build Coastguard Worker     final double realContrast = ratioOfYs(lightY, darkY);
136*970e1046SAndroid Build Coastguard Worker     final double delta = Math.abs(realContrast - ratio);
137*970e1046SAndroid Build Coastguard Worker     if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) {
138*970e1046SAndroid Build Coastguard Worker       return -1.0;
139*970e1046SAndroid Build Coastguard Worker     }
140*970e1046SAndroid Build Coastguard Worker 
141*970e1046SAndroid Build Coastguard Worker     final double returnValue = ColorUtils.lstarFromY(lightY) + LUMINANCE_GAMUT_MAP_TOLERANCE;
142*970e1046SAndroid Build Coastguard Worker     // NOMUTANTS--important validation step; functions it is calling may change implementation.
143*970e1046SAndroid Build Coastguard Worker     if (returnValue < 0 || returnValue > 100) {
144*970e1046SAndroid Build Coastguard Worker       return -1.0;
145*970e1046SAndroid Build Coastguard Worker     }
146*970e1046SAndroid Build Coastguard Worker     return returnValue;
147*970e1046SAndroid Build Coastguard Worker   }
148*970e1046SAndroid Build Coastguard Worker 
149*970e1046SAndroid Build Coastguard Worker   /**
150*970e1046SAndroid Build Coastguard Worker    * Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved.
151*970e1046SAndroid Build Coastguard Worker    *
152*970e1046SAndroid Build Coastguard Worker    * <p>This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
153*970e1046SAndroid Build Coastguard Worker    * bounds return value may not reach the desired ratio.
154*970e1046SAndroid Build Coastguard Worker    *
155*970e1046SAndroid Build Coastguard Worker    * @param tone Tone return value must contrast with.
156*970e1046SAndroid Build Coastguard Worker    * @param ratio Desired contrast ratio of return value and tone parameter.
157*970e1046SAndroid Build Coastguard Worker    */
lighterUnsafe(double tone, double ratio)158*970e1046SAndroid Build Coastguard Worker   public static double lighterUnsafe(double tone, double ratio) {
159*970e1046SAndroid Build Coastguard Worker     double lighterSafe = lighter(tone, ratio);
160*970e1046SAndroid Build Coastguard Worker     return lighterSafe < 0.0 ? 100.0 : lighterSafe;
161*970e1046SAndroid Build Coastguard Worker   }
162*970e1046SAndroid Build Coastguard Worker 
163*970e1046SAndroid Build Coastguard Worker   /**
164*970e1046SAndroid Build Coastguard Worker    * Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns -1
165*970e1046SAndroid Build Coastguard Worker    * if ratio cannot be achieved.
166*970e1046SAndroid Build Coastguard Worker    *
167*970e1046SAndroid Build Coastguard Worker    * @param tone Tone return value must contrast with.
168*970e1046SAndroid Build Coastguard Worker    * @param ratio Desired contrast ratio of return value and tone parameter.
169*970e1046SAndroid Build Coastguard Worker    */
darker(double tone, double ratio)170*970e1046SAndroid Build Coastguard Worker   public static double darker(double tone, double ratio) {
171*970e1046SAndroid Build Coastguard Worker     if (tone < 0.0 || tone > 100.0) {
172*970e1046SAndroid Build Coastguard Worker       return -1.0;
173*970e1046SAndroid Build Coastguard Worker     }
174*970e1046SAndroid Build Coastguard Worker     // Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y.
175*970e1046SAndroid Build Coastguard Worker     final double lightY = ColorUtils.yFromLstar(tone);
176*970e1046SAndroid Build Coastguard Worker     final double darkY = ((lightY + 5.0) / ratio) - 5.0;
177*970e1046SAndroid Build Coastguard Worker     if (darkY < 0.0 || darkY > 100.0) {
178*970e1046SAndroid Build Coastguard Worker       return -1.0;
179*970e1046SAndroid Build Coastguard Worker     }
180*970e1046SAndroid Build Coastguard Worker     final double realContrast = ratioOfYs(lightY, darkY);
181*970e1046SAndroid Build Coastguard Worker     final double delta = Math.abs(realContrast - ratio);
182*970e1046SAndroid Build Coastguard Worker     if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) {
183*970e1046SAndroid Build Coastguard Worker       return -1.0;
184*970e1046SAndroid Build Coastguard Worker     }
185*970e1046SAndroid Build Coastguard Worker 
186*970e1046SAndroid Build Coastguard Worker     // For information on 0.4 constant, see comment in lighter(tone, ratio).
187*970e1046SAndroid Build Coastguard Worker     final double returnValue = ColorUtils.lstarFromY(darkY) - LUMINANCE_GAMUT_MAP_TOLERANCE;
188*970e1046SAndroid Build Coastguard Worker     // NOMUTANTS--important validation step; functions it is calling may change implementation.
189*970e1046SAndroid Build Coastguard Worker     if (returnValue < 0 || returnValue > 100) {
190*970e1046SAndroid Build Coastguard Worker       return -1.0;
191*970e1046SAndroid Build Coastguard Worker     }
192*970e1046SAndroid Build Coastguard Worker     return returnValue;
193*970e1046SAndroid Build Coastguard Worker   }
194*970e1046SAndroid Build Coastguard Worker 
195*970e1046SAndroid Build Coastguard Worker   /**
196*970e1046SAndroid Build Coastguard Worker    * Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved.
197*970e1046SAndroid Build Coastguard Worker    *
198*970e1046SAndroid Build Coastguard Worker    * <p>This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
199*970e1046SAndroid Build Coastguard Worker    * bounds return value may not reach the desired ratio.
200*970e1046SAndroid Build Coastguard Worker    *
201*970e1046SAndroid Build Coastguard Worker    * @param tone Tone return value must contrast with.
202*970e1046SAndroid Build Coastguard Worker    * @param ratio Desired contrast ratio of return value and tone parameter.
203*970e1046SAndroid Build Coastguard Worker    */
darkerUnsafe(double tone, double ratio)204*970e1046SAndroid Build Coastguard Worker   public static double darkerUnsafe(double tone, double ratio) {
205*970e1046SAndroid Build Coastguard Worker     double darkerSafe = darker(tone, ratio);
206*970e1046SAndroid Build Coastguard Worker     return max(0.0, darkerSafe);
207*970e1046SAndroid Build Coastguard Worker   }
208*970e1046SAndroid Build Coastguard Worker }
209