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