|
| 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 | + double lighter = y1 > y2 ? y1 : y2; |
| 78 | + double 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 toneA, double toneB) |
| 98 | + { |
| 99 | + return RatioOfYs( |
| 100 | + ColorUtils.YFromLstar(MathUtils.Clamp(0.0, 100.0, toneA)), |
| 101 | + ColorUtils.YFromLstar(MathUtils.Clamp(0.0, 100.0, toneB))); |
| 102 | + } |
| 103 | + |
| 104 | + /// <summary> |
| 105 | + /// Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. |
| 106 | + /// Returns -1 if ratio cannot be achieved. |
| 107 | + /// </summary> |
| 108 | + /// <param name="tone">Tone return value must contrast with.</param> |
| 109 | + /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param> |
| 110 | + public static double Lighter(double tone, double ratio) |
| 111 | + { |
| 112 | + if (tone is < 0.0 or > 100.0) |
| 113 | + { |
| 114 | + return -1.0; |
| 115 | + } |
| 116 | + double darkY = ColorUtils.YFromLstar(tone); |
| 117 | + double lightY = ratio * (darkY + 5.0) - 5.0; |
| 118 | + if (lightY is < 0.0 or > 100.0) |
| 119 | + { |
| 120 | + return -1.0; |
| 121 | + } |
| 122 | + double realContrast = RatioOfYs(lightY, darkY); |
| 123 | + double delta = Abs(realContrast - ratio); |
| 124 | + if (realContrast < ratio && delta > ContrastRatioEpsilon) |
| 125 | + { |
| 126 | + return -1.0; |
| 127 | + } |
| 128 | + |
| 129 | + double returnValue = ColorUtils.LstarFromY(lightY) + LuminanceGamutMapTolerance; |
| 130 | + if (returnValue is < 0.0 or > 100.0) |
| 131 | + { |
| 132 | + return -1.0; |
| 133 | + } |
| 134 | + return returnValue; |
| 135 | + } |
| 136 | + |
| 137 | + /// <summary> |
| 138 | + /// Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved. |
| 139 | + /// |
| 140 | + /// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in |
| 141 | + /// bounds return value may not reach the desired ratio. |
| 142 | + /// </summary> |
| 143 | + /// <param name="tone">Tone return value must contrast with.</param> |
| 144 | + /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param> |
| 145 | + public static double LighterUnsafe(double tone, double ratio) |
| 146 | + { |
| 147 | + double lighterSafe = Lighter(tone, ratio); |
| 148 | + return lighterSafe < 0.0 ? 100.0 : lighterSafe; |
| 149 | + } |
| 150 | + |
| 151 | + /// <summary> |
| 152 | + /// Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. |
| 153 | + /// Returns -1 if ratio cannot be achieved. |
| 154 | + /// </summary> |
| 155 | + /// <param name="tone">Tone return value must contrast with.</param> |
| 156 | + /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param> |
| 157 | + public static double Darker(double tone, double ratio) |
| 158 | + { |
| 159 | + if (tone is < 0.0 or > 100.0) |
| 160 | + { |
| 161 | + return -1.0; |
| 162 | + } |
| 163 | + double lightY = ColorUtils.YFromLstar(tone); |
| 164 | + double darkY = ((lightY + 5.0) / ratio) - 5.0; |
| 165 | + if (darkY is < 0.0 or > 100.0) |
| 166 | + { |
| 167 | + return -1.0; |
| 168 | + } |
| 169 | + double realContrast = RatioOfYs(lightY, darkY); |
| 170 | + double delta = Abs(realContrast - ratio); |
| 171 | + if (realContrast < ratio && delta > ContrastRatioEpsilon) |
| 172 | + { |
| 173 | + return -1.0; |
| 174 | + } |
| 175 | + double returnValue = ColorUtils.LstarFromY(darkY) - LuminanceGamutMapTolerance; |
| 176 | + if (returnValue is < 0.0 or > 100.0) |
| 177 | + { |
| 178 | + return -1.0; |
| 179 | + } |
| 180 | + return returnValue; |
| 181 | + } |
| 182 | + |
| 183 | + /// <summary> |
| 184 | + /// Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved. |
| 185 | + /// |
| 186 | + /// This method is unsafe because the returned value is guaranteed to be in bounds, but, the in |
| 187 | + /// bounds return value may not reach the desired ratio. |
| 188 | + /// </summary> |
| 189 | + /// <param name="tone">Tone return value must contrast with.</param> |
| 190 | + /// <param name="ratio">Desired contrast ratio of return value and tone parameter.</param> |
| 191 | + public static double DarkerUnsafe(double tone, double ratio) |
| 192 | + { |
| 193 | + double darkerSafe = Darker(tone, ratio); |
| 194 | + return Max(0.0, darkerSafe); |
| 195 | + } |
| 196 | +} |
0 commit comments