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