001/* 002 * This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 * 012 * This library is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 015 * Lesser General Public License for more details. 016 * 017 * For further information about Alkacon Software, please see the 018 * company website: https://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: https://www.opencms.org 022 * 023 * You should have received a copy of the GNU Lesser General Public 024 * License along with this library; if not, write to the Free Software 025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 026 */ 027 028package org.opencms.util; 029 030import java.util.List; 031import java.util.Map; 032import java.util.concurrent.ConcurrentHashMap; 033import java.util.stream.Collectors; 034 035/** 036 * Calculator for color contrast ratios according to WCAG 2.2 guidelines.<p> 037 * 038 * This class provides functionality to: 039 * <ul> 040 * <li>Calculate contrast ratios between colors</li> 041 * <li>Check if color combinations meet WCAG AA accessibility standards</li> 042 * <li>Suggest compliant foreground colors for given background colors</li> 043 * <li>Adjust non-compliant colors to meet contrast requirements</li> 044 * </ul> 045 * 046 * Also provides utility methods for CSS colors to: 047 * <ul> 048 * <li>Check if a given String represents a valid CSS color, supporting "#fff", "#ffffff", "#ffffffaa" and CSS color names like "white" or "aliceblue" as input.</li> 049 * <li>Converting a valid CSS color String to hex. e.g. "white" to "#ffffff" or "transparent" to "ffffff00".</li> 050 * <li>Converting a valid CSS color String to an int[] array consisting of the RGB(A) values.</li> 051 * </ul> 052 * 053 * @see <a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html">WCAG 2.2 Contrast Guidelines</a> 054 */ 055public final class CmsColorContrastCalculator { 056 057 /** Color returned for invalid inputs in foreground methods. */ 058 public static final String INVALID_FOREGROUND = "#ff0000"; 059 060 /** Map of CSS named colors to their RGB values. */ 061 private static final Map<String, int[]> NAMED_COLORS = Map.ofEntries( 062 Map.entry("black", new int[] {0, 0, 0}), 063 Map.entry("white", new int[] {255, 255, 255}), 064 Map.entry("red", new int[] {255, 0, 0}), 065 Map.entry("green", new int[] {0, 128, 0}), 066 Map.entry("blue", new int[] {0, 0, 255}), 067 Map.entry("yellow", new int[] {255, 255, 0}), 068 Map.entry("cyan", new int[] {0, 255, 255}), 069 Map.entry("magenta", new int[] {255, 0, 255}), 070 Map.entry("gray", new int[] {128, 128, 128}), 071 Map.entry("grey", new int[] {128, 128, 128}), 072 Map.entry("transparent", new int[] {255, 255, 255, 0}), 073 Map.entry("aliceblue", new int[] {240, 248, 255}), 074 Map.entry("antiquewhite", new int[] {250, 235, 215}), 075 Map.entry("aqua", new int[] {0, 255, 255}), 076 Map.entry("aquamarine", new int[] {127, 255, 212}), 077 Map.entry("azure", new int[] {240, 255, 255}), 078 Map.entry("beige", new int[] {245, 245, 220}), 079 Map.entry("bisque", new int[] {255, 228, 196}), 080 Map.entry("blanchedalmond", new int[] {255, 235, 205}), 081 Map.entry("blueviolet", new int[] {138, 43, 226}), 082 Map.entry("brown", new int[] {165, 42, 42}), 083 Map.entry("burlywood", new int[] {222, 184, 135}), 084 Map.entry("cadetblue", new int[] {95, 158, 160}), 085 Map.entry("chartreuse", new int[] {127, 255, 0}), 086 Map.entry("chocolate", new int[] {210, 105, 30}), 087 Map.entry("coral", new int[] {255, 127, 80}), 088 Map.entry("cornflowerblue", new int[] {100, 149, 237}), 089 Map.entry("cornsilk", new int[] {255, 248, 220}), 090 Map.entry("crimson", new int[] {220, 20, 60}), 091 Map.entry("darkblue", new int[] {0, 0, 139}), 092 Map.entry("darkcyan", new int[] {0, 139, 139}), 093 Map.entry("darkgoldenrod", new int[] {184, 134, 11}), 094 Map.entry("darkgray", new int[] {169, 169, 169}), 095 Map.entry("darkgreen", new int[] {0, 100, 0}), 096 Map.entry("darkgrey", new int[] {169, 169, 169}), 097 Map.entry("darkkhaki", new int[] {189, 183, 107}), 098 Map.entry("darkmagenta", new int[] {139, 0, 139}), 099 Map.entry("darkolivegreen", new int[] {85, 107, 47}), 100 Map.entry("darkorange", new int[] {255, 140, 0}), 101 Map.entry("darkorchid", new int[] {153, 50, 204}), 102 Map.entry("darkred", new int[] {139, 0, 0}), 103 Map.entry("darksalmon", new int[] {233, 150, 122}), 104 Map.entry("darkseagreen", new int[] {143, 188, 143}), 105 Map.entry("darkslateblue", new int[] {72, 61, 139}), 106 Map.entry("darkslategray", new int[] {47, 79, 79}), 107 Map.entry("darkslategrey", new int[] {47, 79, 79}), 108 Map.entry("darkturquoise", new int[] {0, 206, 209}), 109 Map.entry("darkviolet", new int[] {148, 0, 211}), 110 Map.entry("deeppink", new int[] {255, 20, 147}), 111 Map.entry("deepskyblue", new int[] {0, 191, 255}), 112 Map.entry("dimgray", new int[] {105, 105, 105}), 113 Map.entry("dimgrey", new int[] {105, 105, 105}), 114 Map.entry("dodgerblue", new int[] {30, 144, 255}), 115 Map.entry("firebrick", new int[] {178, 34, 34}), 116 Map.entry("floralwhite", new int[] {255, 250, 240}), 117 Map.entry("forestgreen", new int[] {34, 139, 34}), 118 Map.entry("fuchsia", new int[] {255, 0, 255}), 119 Map.entry("gainsboro", new int[] {220, 220, 220}), 120 Map.entry("ghostwhite", new int[] {248, 248, 255}), 121 Map.entry("gold", new int[] {255, 215, 0}), 122 Map.entry("goldenrod", new int[] {218, 165, 32}), 123 Map.entry("greenyellow", new int[] {173, 255, 47}), 124 Map.entry("honeydew", new int[] {240, 255, 240}), 125 Map.entry("hotpink", new int[] {255, 105, 180}), 126 Map.entry("indianred", new int[] {205, 92, 92}), 127 Map.entry("indigo", new int[] {75, 0, 130}), 128 Map.entry("ivory", new int[] {255, 255, 240}), 129 Map.entry("khaki", new int[] {240, 230, 140}), 130 Map.entry("lavender", new int[] {230, 230, 250}), 131 Map.entry("lavenderblush", new int[] {255, 240, 245}), 132 Map.entry("lawngreen", new int[] {124, 252, 0}), 133 Map.entry("lemonchiffon", new int[] {255, 250, 205}), 134 Map.entry("lightblue", new int[] {173, 216, 230}), 135 Map.entry("lightcoral", new int[] {240, 128, 128}), 136 Map.entry("lightcyan", new int[] {224, 255, 255}), 137 Map.entry("lightgoldenrodyellow", new int[] {250, 250, 210}), 138 Map.entry("lightgray", new int[] {211, 211, 211}), 139 Map.entry("lightgreen", new int[] {144, 238, 144}), 140 Map.entry("lightgrey", new int[] {211, 211, 211}), 141 Map.entry("lightpink", new int[] {255, 182, 193}), 142 Map.entry("lightsalmon", new int[] {255, 160, 122}), 143 Map.entry("lightseagreen", new int[] {32, 178, 170}), 144 Map.entry("lightskyblue", new int[] {135, 206, 250}), 145 Map.entry("lightslategray", new int[] {119, 136, 153}), 146 Map.entry("lightslategrey", new int[] {119, 136, 153}), 147 Map.entry("lightsteelblue", new int[] {176, 196, 222}), 148 Map.entry("lightyellow", new int[] {255, 255, 224}), 149 Map.entry("lime", new int[] {0, 255, 0}), 150 Map.entry("limegreen", new int[] {50, 205, 50}), 151 Map.entry("linen", new int[] {250, 240, 230}), 152 Map.entry("maroon", new int[] {128, 0, 0}), 153 Map.entry("mediumaquamarine", new int[] {102, 205, 170}), 154 Map.entry("mediumblue", new int[] {0, 0, 205}), 155 Map.entry("mediumorchid", new int[] {186, 85, 211}), 156 Map.entry("mediumpurple", new int[] {147, 112, 219}), 157 Map.entry("mediumseagreen", new int[] {60, 179, 113}), 158 Map.entry("mediumslateblue", new int[] {123, 104, 238}), 159 Map.entry("mediumspringgreen", new int[] {0, 250, 154}), 160 Map.entry("mediumturquoise", new int[] {72, 209, 204}), 161 Map.entry("mediumvioletred", new int[] {199, 21, 133}), 162 Map.entry("midnightblue", new int[] {25, 25, 112}), 163 Map.entry("mintcream", new int[] {245, 255, 250}), 164 Map.entry("mistyrose", new int[] {255, 228, 225}), 165 Map.entry("moccasin", new int[] {255, 228, 181}), 166 Map.entry("navajowhite", new int[] {255, 222, 173}), 167 Map.entry("navy", new int[] {0, 0, 128}), 168 Map.entry("oldlace", new int[] {253, 245, 230}), 169 Map.entry("olive", new int[] {128, 128, 0}), 170 Map.entry("olivedrab", new int[] {107, 142, 35}), 171 Map.entry("opencms", new int[] {179, 27, 52}), // couldn't resist :) 172 Map.entry("orange", new int[] {255, 165, 0}), 173 Map.entry("orangered", new int[] {255, 69, 0}), 174 Map.entry("orchid", new int[] {218, 112, 214}), 175 Map.entry("palegoldenrod", new int[] {238, 232, 170}), 176 Map.entry("palegreen", new int[] {152, 251, 152}), 177 Map.entry("paleturquoise", new int[] {175, 238, 238}), 178 Map.entry("palevioletred", new int[] {219, 112, 147}), 179 Map.entry("papayawhip", new int[] {255, 239, 213}), 180 Map.entry("peachpuff", new int[] {255, 218, 185}), 181 Map.entry("peru", new int[] {205, 133, 63}), 182 Map.entry("pink", new int[] {255, 192, 203}), 183 Map.entry("plum", new int[] {221, 160, 221}), 184 Map.entry("powderblue", new int[] {176, 224, 230}), 185 Map.entry("purple", new int[] {128, 0, 128}), 186 Map.entry("rebeccapurple", new int[] {102, 51, 153}), 187 Map.entry("rosybrown", new int[] {188, 143, 143}), 188 Map.entry("royalblue", new int[] {65, 105, 225}), 189 Map.entry("saddlebrown", new int[] {139, 69, 19}), 190 Map.entry("salmon", new int[] {250, 128, 114}), 191 Map.entry("sandybrown", new int[] {244, 164, 96}), 192 Map.entry("seagreen", new int[] {46, 139, 87}), 193 Map.entry("seashell", new int[] {255, 245, 238}), 194 Map.entry("sienna", new int[] {160, 82, 45}), 195 Map.entry("silver", new int[] {192, 192, 192}), 196 Map.entry("skyblue", new int[] {135, 206, 235}), 197 Map.entry("slateblue", new int[] {106, 90, 205}), 198 Map.entry("slategray", new int[] {112, 128, 144}), 199 Map.entry("slategrey", new int[] {112, 128, 144}), 200 Map.entry("snow", new int[] {255, 250, 250}), 201 Map.entry("springgreen", new int[] {0, 255, 127}), 202 Map.entry("steelblue", new int[] {70, 130, 180}), 203 Map.entry("tan", new int[] {210, 180, 140}), 204 Map.entry("teal", new int[] {0, 128, 128}), 205 Map.entry("thistle", new int[] {216, 191, 216}), 206 Map.entry("tomato", new int[] {255, 99, 71}), 207 Map.entry("turquoise", new int[] {64, 224, 208}), 208 Map.entry("violet", new int[] {238, 130, 238}), 209 Map.entry("wheat", new int[] {245, 222, 179}), 210 Map.entry("whitesmoke", new int[] {245, 245, 245}), 211 Map.entry("yellowgreen", new int[] {154, 205, 50})); 212 213 /** Cache for precomputed luminance values. */ 214 private Map<String, Double> m_luminanceCache; 215 216 public CmsColorContrastCalculator() { 217 218 m_luminanceCache = new ConcurrentHashMap<>(); 219 } 220 221 /** 222 * Checks if the provided foreground color has sufficient contrast with the background.<p> 223 * 224 * Returns the provided {@code fgColor} if compliant, otherwise black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if any parameter is invalid.<p> 225 * 226 * @param bgColor background color 227 * @param fgColor potential foreground color 228 * 229 * @return the provided {@code fgColor} if compliant, otherwise black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if any parameter is invalid 230 */ 231 public String checkForeground(String bgColor, String fgColor) { 232 233 try { 234 return checkForegroundRgb(toRgbArray(bgColor, true, false), toRgbArray(fgColor, true, false)); 235 } catch (IllegalArgumentException e) { 236 return INVALID_FOREGROUND; 237 } 238 } 239 240 /** 241 * Checks if any of the foreground colors in the provided list has sufficient contrast with the background. 242 * If so, returns the first compliant color from the list. 243 * If not, returns either black ("#000000") or white ("#ffffff"), whichever provides better contrast.<p> 244 * 245 * @param bgColor background color 246 * @param fgColorList list of potential foreground colors 247 * 248 * @return the first compliant color from the list, otherwise black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if any parameter is invalid 249 */ 250 public String checkForegroundList(String bgColor, List<String> fgColorList) { 251 252 return checkForegroundListRgb(toRgbArray(bgColor, true, false), colorListToRgbList(fgColorList)); 253 } 254 255 /** 256 * Calculates the contrast ratio between two colors according to WCAG 2.2 guidelines.<p> 257 * 258 * The contrast ratio formula is (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter 259 * relative luminance and L2 is the darker.<p> 260 * 261 * Returns the contrast ratio between 1:1 and 21:1, or 0 if either parameter is invalid.<p> 262 * 263 * @see <a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html">WCAG 2.2 Contrast Guidelines</a> 264 * 265 * @param bgColor background color 266 * @param fgColor foreground color 267 * 268 * @return the contrast ratio between 1:1 and 21:1, or 0 if either parameter is invalid 269 */ 270 public double getContrast(String bgColor, String fgColor) { 271 272 return getContrastRgb(toRgbArray(bgColor, true, false), toRgbArray(fgColor, true, false)); 273 } 274 275 /** 276 * Treats the given color as background and returns either black or white as foreground color, 277 * choosing whichever provides better contrast according to WCAG guidelines.<p> 278 * 279 * Returns black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if the parameter is invalid.<p> 280 * 281 * @param bgColor background color 282 * 283 * @return black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if the parameter is invalid 284 */ 285 public String getForeground(String bgColor) { 286 287 try { 288 return getForegroundRgb(toRgbArray(bgColor, true, false)); 289 } catch (IllegalArgumentException e) { 290 return INVALID_FOREGROUND; 291 } 292 } 293 294 /** 295 * Checks if the contrast ratio between two colors meets the WCAG AA standard minimum requirement of 4.5:1 for normal text.<p> 296 * 297 * Returns false if either parameter is invalid.<p> 298 * 299 * @see <a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html">WCAG 2.2 Contrast Guidelines</a> 300 * 301 * @param bgColor background color 302 * @param fgColor foreground color 303 * 304 * @return true if contrast ratio is at least 4.5:1 305 */ 306 public boolean hasSufficientContrast(String bgColor, String fgColor) { 307 308 return hasSufficientContrastRgb(toRgbArray(bgColor, true, false), toRgbArray(fgColor, true, false)); 309 } 310 311 /** 312 * Checks if the provided String represents a CSS color.<p> 313 * 314 * Accepts hex colors in the format "#rrggbb", "#rgb" or "#rrggbbaa". 315 * Additionally, this method supports named CSS colors, e.g. "white", "blue", "transparent" etc. 316 * The input will be trimmed, so it can contain leading or trailing white spaces.<p> 317 * 318 * @param color the color to validate 319 * 320 * @return true if the input is a valid CSS color 321 * 322 * @see #normalize(String) 323 * @see #toHex(String) 324 */ 325 public boolean isValid(String color) { 326 327 return null != toRgbArray(color); 328 } 329 330 /** 331 * Normalizes a CSS color by converting it to a 6 (or 8 for RGBA) digit hex representation.<p> 332 * 333 * Accepts a hex colors in the format "#rrggbb", "#rgb" or "#rrggbbaa". 334 * Additionally, this method supports named CSS colors, e.g. "white", "blue", "transparent" etc. 335 * If the input includes an alpha channel, it will also be included in the returned String. 336 * The input will be trimmed, so it can contain leading or trailing white spaces.<p> 337 * 338 * If the input is not a valid CSS color, the method returns red ("#ff0000"). 339 * So it is assured that the output of this methods is always a valid CSS color.<p> 340 * 341 * @param color the color name or hex color code (e.g. "white", "#ffffff" or "#fff") 342 * 343 * @return to colors hex representation, or red ("#ff0000") if the input is invalid 344 * 345 * @see #isValid(String) 346 * @see #toHex(String) 347 */ 348 public String normalize(String color) { 349 350 String result = toHex(color); 351 if (result == null) { 352 result = INVALID_FOREGROUND; 353 } 354 return result; 355 } 356 357 /** 358 * Suggests a WCAG compliant foreground color based on the given background color.<p> 359 * 360 * If the provided foreground color doesn't meet the minimum contrast ratio of 4.5:1, 361 * it will be adjusted to be either darker or lighter until it does.<p> 362 * 363 * Returns red("#ff0000") if either parameter is invalid.<p> 364 * 365 * @param bgColor background color 366 * @param fgColor potential foreground color 367 * 368 * @return either the original color if compliant, or a suggested compliant alternative 369 */ 370 public String suggestForeground(String bgColor, String fgColor) { 371 372 try { 373 return suggestForegroundRgb(toRgbArray(bgColor, true, false), toRgbArray(fgColor, true, false)); 374 } catch (IllegalArgumentException e) { 375 return INVALID_FOREGROUND; 376 } 377 } 378 379 /** 380 * Converts a CSS color to a 'normalized' hex representation.<p> 381 * 382 * Accepts a hex colors in the format "#rrggbb", "#rgb" or "#rrggbbaa". 383 * Additionally, this method supports named CSS colors, e.g. "white", "blue", "transparent" etc. 384 * If the input includes an alpha channel, it will also be included in the returned String. 385 * The input will be trimmed, so it can contain leading or trailing white spaces.<p> 386 * 387 * If the input is not a valid CSS color, the method returns {@code null}.<p> 388 * 389 * @param color the color name or hex color code (e.g. "white", "#ffffff" or "#fff") 390 * 391 * @return to colors hex representation, or {@code null} if the input is invalid 392 * 393 * @see #isValid(String) 394 * @see #toHex(String) 395 */ 396 public String toHex(String color) { 397 398 String result = null; 399 if ((color != null) && (color.length() != 0)) { 400 if (isValid(color)) { 401 return rgbToHex(toRgbArray(color)); 402 } 403 } 404 return result; 405 } 406 407 /** 408 * Converts a CSS color to its corresponding RGB values.<p> 409 * 410 * Accepts a hex colors in the format "#rrggbb", "#rgb" or "#rrggbbaa". 411 * Additionally, this method supports named CSS colors, e.g. "white", "blue", "transparent" etc. 412 * If the input includes an alpha channel, it will also be included in the returned array. 413 * The input will be trimmed, so it can contain leading or trailing white spaces.<p> 414 * 415 * If the input is not a valid CSS color, the method returns {@code null}.<p> 416 * 417 * @param color the color name or hex color code (e.g. "white", "#ffffff" or "#fff") 418 * 419 * @return an array of integers representing the RGB(A) values, or {@code null} if the input is not a valid CSS color 420 * 421 * @see #toRgbArray(String, boolean, boolean) 422 * @see #toRgb(String) 423 */ 424 public int[] toRgbArray(String color) { 425 426 return toRgbArray(color, true, true); 427 } 428 429 /** 430 * Converts a CSS color to its corresponding RGB values.<p> 431 * 432 * If the input is not a valid CSS color, the method returns {@code null}.<p> 433 * 434 * @param color the color name or hex color code (e.g. "white", "#ffffff", or "#fff") 435 * @param supportNames if {@code true}, support named CSS colors in the input 436 * @param supportAlpha if {@code true}, support alpha channel in RGB(A) hex codes in the input 437 * 438 * @return an array of integers representing the RGB(A) values, or {@code null} if the input is invalid 439 * 440 * @see #toRgbArray(String) 441 * @see #toRgb(String) 442 */ 443 public int[] toRgbArray(String color, boolean supportNames, boolean supportAlpha) { 444 445 int[] result = null; 446 if (color != null) { 447 color = color.toLowerCase().trim(); 448 if (color.startsWith("#")) { 449 try { 450 color = normalizeHex(color, supportAlpha); 451 int length = color.length(); 452 if (supportAlpha || (length == 7)) { 453 result = new int[length == 7 ? 3 : 4]; 454 for (int i = 0; i < result.length; i++) { 455 result[i] = Integer.parseInt(color.substring(1 + (i * 2), 3 + (i * 2)), 16); 456 } 457 } 458 } catch (IllegalArgumentException e) { 459 result = null; 460 } 461 } else if (supportNames) { 462 result = NAMED_COLORS.get(color); 463 } 464 } 465 return result; 466 } 467 468 /** 469 * Converts a CSS color to its corresponding RGB representation as a comma separated String.<p> 470 * For example, the color "red" will be converted to "255, 0, 0".<p> 471 * 472 * @param color the color name or hex color code (e.g. "white", "#ffffff" or "#fff") 473 * 474 * @return the RGB representation as a comma separated String, or {@code null} if the input is invalid 475 * 476 * @see #toRgbArray(String) 477 */ 478 public String toRgb(String color) { 479 480 String result = null; 481 int[] rgb = toRgbArray(color, true, true); 482 if (rgb != null) { 483 StringBuilder sb = new StringBuilder(); 484 for (int i = 0; i < rgb.length; i++) { 485 if (i > 0) { 486 sb.append(", "); 487 } 488 sb.append(rgb[i]); 489 } 490 result = sb.toString(); 491 } 492 return result; 493 } 494 495 /** 496 * Calculates the contrast ratio between two colors using WCAG formula.<p> 497 * 498 * @param color1 first color as RGB array 499 * @param color2 second color as RGB array 500 * 501 * @return contrast ratio between 1:1 and 21:1 502 */ 503 private double calculateContrastRatio(int[] color1, int[] color2) { 504 505 double l1 = getCachedLuminance(color1); 506 double l2 = getCachedLuminance(color2); 507 // Ensure lighter luminance is always first in formula 508 double lighter = Math.max(l1, l2); 509 double darker = Math.min(l1, l2); 510 // WCAG contrast ratio formula: (L1 + 0.05) / (L2 + 0.05) 511 return (lighter + 0.05) / (darker + 0.05); 512 } 513 514 /** 515 * Calculates the relative luminance of a color according to WCAG 2.2.<p> 516 * 517 * @param rgb color as RGB array 518 * @return relative luminance value between 0 and 1 519 */ 520 private double calculateRelativeLuminance(int[] rgb) { 521 522 // WCAG relative luminance formula for sRGB 523 double r = normalizeChannel(rgb[0]); 524 double g = normalizeChannel(rgb[1]); 525 double b = normalizeChannel(rgb[2]); 526 // Coefficients from WCAG 2.0 relative luminance formula 527 return (0.2126 * r) + (0.7152 * g) + (0.0722 * b); 528 } 529 530 /** 531 * Checks if any of the foreground colors in the provided list has sufficient contrast with the background. 532 * If so, returns the first compliant color from the list. 533 * If not, returns either black ("#000000") or white ("#ffffff"), whichever provides better contrast.<p> 534 * 535 * @param bgRgb background color as RGB array 536 * @param fgRgbList list of potential foreground colors as RGB arrays 537 * 538 * @return the first compliant color from the list, otherwise black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if any parameter is invalid 539 */ 540 private String checkForegroundListRgb(int[] bgRgb, List<int[]> fgRgbList) { 541 542 if ((fgRgbList == null) || fgRgbList.isEmpty()) { 543 return INVALID_FOREGROUND; 544 } 545 try { 546 validateRgb(bgRgb); 547 } catch (IllegalArgumentException e) { 548 return INVALID_FOREGROUND; 549 } 550 551 boolean hasValidColor = false; 552 for (int[] fgRgb : fgRgbList) { 553 try { 554 validateRgb(fgRgb); 555 if (hasSufficientContrastRgb(bgRgb, fgRgb)) { 556 return rgbToHex(fgRgb); 557 } 558 hasValidColor = true; 559 } catch (IllegalArgumentException e) { 560 continue; 561 } 562 } 563 564 return hasValidColor ? getForegroundRgb(bgRgb) : INVALID_FOREGROUND; 565 } 566 567 /** 568 * Checks if the provided foreground color has sufficient contrast with the background.<p> 569 * 570 * Returns the provided {@code fgRgb} if compliant, otherwise black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if any parameter is invalid.<p> 571 * 572 * @param bgRgb background color as RGB array 573 * @param fgRgb potential foreground color as RGB array 574 * @return the provided {@code fgRgb} if compliant, otherwise black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if any parameter is invalid 575 */ 576 private String checkForegroundRgb(int[] bgRgb, int[] fgRgb) { 577 578 try { 579 validateRgb(bgRgb); 580 validateRgb(fgRgb); 581 } catch (IllegalArgumentException e) { 582 return INVALID_FOREGROUND; 583 } 584 return hasSufficientContrastRgb(bgRgb, fgRgb) ? rgbToHex(fgRgb) : getForegroundRgb(bgRgb); 585 } 586 587 /** 588 * Converts a list of color strings to a list of RGB arrays.<p> 589 * 590 * @param colorList list of color strings 591 * @return list of RGB int arrays 592 */ 593 private List<int[]> colorListToRgbList(List<String> colorList) { 594 595 if (colorList == null) { 596 return null; 597 } 598 return colorList.stream().map(color -> toRgbArray(color, true, false)).collect(Collectors.toList()); 599 } 600 601 /** 602 * Gets the cached luminance value for a color, calculating it if not present.<p> 603 * 604 * @param rgb color as RGB array 605 * @return luminance value between 0 and 1 606 */ 607 private double getCachedLuminance(int[] rgb) { 608 609 String hex = rgbToHex(rgb); 610 return m_luminanceCache.computeIfAbsent(hex, k -> calculateRelativeLuminance(rgb)); 611 } 612 613 /** 614 * Finds the closest color that meets WCAG contrast requirements by adjusting brightness.<p> 615 * 616 * @param bgRgb background color as RGB array 617 * @param fgRgb foreground color as RGB array to adjust 618 * @return hex color code of the compliant color 619 */ 620 private String getClosestCompliantColor(int[] bgRgb, int[] fgRgb) { 621 622 int step = 5; // Smaller steps for more precise adjustments 623 int[] originalFgRgb = fgRgb.clone(); 624 625 // Try both lighter and darker variants 626 for (int i = 0; i <= 255; i += step) { 627 // Try lighter version 628 int[] lighterRgb = new int[] { 629 Math.min(originalFgRgb[0] + i, 255), 630 Math.min(originalFgRgb[1] + i, 255), 631 Math.min(originalFgRgb[2] + i, 255)}; 632 if (calculateContrastRatio(bgRgb, lighterRgb) >= 4.5) { 633 return rgbToHex(lighterRgb); 634 } 635 636 // Try darker version 637 int[] darkerRgb = new int[] { 638 Math.max(originalFgRgb[0] - i, 0), 639 Math.max(originalFgRgb[1] - i, 0), 640 Math.max(originalFgRgb[2] - i, 0)}; 641 if (calculateContrastRatio(bgRgb, darkerRgb) >= 4.5) { 642 return rgbToHex(darkerRgb); 643 } 644 } 645 646 // If no compliant color found, return black or white based on background luminance 647 return getForegroundRgb(bgRgb); 648 } 649 650 /** 651 * Calculates the contrast ratio between two colors using their RGB values.<p> 652 * 653 * @param bgRgb background color as RGB array 654 * @param fgRgb foreground color as RGB array 655 * @return contrast ratio between 1:1 and 21:1 656 */ 657 private double getContrastRgb(int[] bgRgb, int[] fgRgb) { 658 659 try { 660 validateRgb(bgRgb); 661 validateRgb(fgRgb); 662 } catch (IllegalArgumentException e) { 663 return 0.0; // Return zero for invalid input 664 } 665 return calculateContrastRatio(bgRgb, fgRgb); 666 } 667 668 /** 669 * Determines the best foreground color (black or white) for the given background color based on luminance.<p> 670 * 671 * @param bgRgb background color as RGB array 672 * 673 * @return black ("#000000") or white ("#ffffff") depending on contrast, or red ("#ff0000") if the parameter is invalid 674 */ 675 private String getForegroundRgb(int[] bgRgb) { 676 677 try { 678 validateRgb(bgRgb); 679 } catch (IllegalArgumentException e) { 680 return INVALID_FOREGROUND; 681 } 682 // Use luminance threshold of 0.179 (true middle point between black and white luminance) 683 // This is more efficient than calculating contrast ratios with both black and white 684 return getCachedLuminance(bgRgb) > 0.179 ? "#000000" : "#ffffff"; 685 } 686 687 /** 688 * Checks if the contrast ratio between two colors meets the WCAG AA standard minimum requirement of 4.5:1 for normal text.<p> 689 * 690 * @param bgRgb background color as RGB array 691 * @param fgRgb foreground color as RGB array 692 * 693 * @return true if contrast ratio is at least 4.5:1 694 */ 695 private boolean hasSufficientContrastRgb(int[] bgRgb, int[] fgRgb) { 696 697 return getContrastRgb(bgRgb, fgRgb) >= 4.5; 698 } 699 700 /** 701 * Applies gamma correction to normalize RGB channel values.<p> 702 * 703 * @param value RGB channel value (0-255) 704 * @return normalized value according to WCAG formula 705 */ 706 private double normalizeChannel(int value) { 707 708 // Convert to sRGB value 709 double srgb = value / 255.0; 710 // WCAG requires gamma correction 711 if (srgb <= 0.03928) { 712 return srgb / 12.92; 713 } 714 return Math.pow((srgb + 0.055) / 1.055, 2.4); 715 } 716 717 /** 718 * Normalizes hex color codes to standard 6-digit or 8-digit format with # prefix.<p> 719 * 720 * @param hex color code to normalize 721 * @param supportAlpha if true, supports alpha channel in RGB(A) hex codes 722 * 723 * @return normalized hex color (e.g. "#ffffff" or "#ffffff00") 724 * 725 * @throws IllegalArgumentException if hex format is invalid 726 */ 727 private String normalizeHex(String hex, boolean supportAlpha) { 728 729 if ((hex == null) || (hex.length() == 0)) { 730 throw new IllegalArgumentException("Invalid empty hex color value"); 731 } 732 hex = hex.toLowerCase().trim(); 733 if (!hex.startsWith("#")) { 734 throw new IllegalArgumentException("Invalid hex color, value must start with '#'"); 735 } 736 hex = hex.substring(1); 737 if ((hex.length() != 3) && (hex.length() != 6) && (hex.length() != 8)) { 738 throw new IllegalArgumentException("Invalid hex color value: " + hex); 739 } 740 if (hex.length() == 3) { 741 hex = "" + hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2); 742 } else if (!supportAlpha && (hex.length() == 8) && hex.endsWith("ff")) { 743 hex = hex.substring(0, 6); 744 } 745 return "#" + hex; 746 } 747 748 /** 749 * Converts RGB values to hex color code.<p> 750 * 751 * @param rgb color as RGB array 752 * 753 * @return hex color code (e.g. "#ffffff" or "#ffffff00" if alpha is present) 754 */ 755 private String rgbToHex(int[] rgb) { 756 757 char[] hexChars = new char[rgb.length == 4 ? 9 : 7]; 758 hexChars[0] = '#'; 759 for (int i = 0; i < rgb.length; i++) { 760 int value = rgb[i]; 761 hexChars[1 + (i * 2)] = toHexChar((value >> 4) & 0xF); 762 hexChars[2 + (i * 2)] = toHexChar(value & 0xF); 763 } 764 return new String(hexChars); 765 } 766 767 /** 768 * Suggests a WCAG-compliant foreground color based on the given background color. 769 * If the provided foreground color doesn't meet the minimum contrast ratio of 4.5:1, returns an adjusted color that does.<p> 770 * 771 * @param bgRgb background color as RGB array 772 * @param possibleFgRgb proposed foreground color as RGB array 773 * 774 * @return hex code of either the original color if compliant, or a suggested compliant alternative 775 */ 776 private String suggestForegroundRgb(int[] bgRgb, int[] possibleFgRgb) { 777 778 try { 779 validateRgb(bgRgb); 780 validateRgb(possibleFgRgb); 781 } catch (IllegalArgumentException e) { 782 return INVALID_FOREGROUND; 783 } 784 return hasSufficientContrastRgb(bgRgb, possibleFgRgb) 785 ? rgbToHex(possibleFgRgb) 786 : getClosestCompliantColor(bgRgb, possibleFgRgb); 787 } 788 789 /** 790 * Converts a number (0-15) to its hexadecimal character representation.<p> 791 * 792 * @param value number to convert (0-15) 793 * 794 * @return hexadecimal character ('0'-'9' or 'a'-'f') 795 */ 796 private char toHexChar(int value) { 797 798 return (char)(value < 10 ? '0' + value : 'a' + (value - 10)); 799 } 800 801 /** 802 * Validates that an RGB array contains three values between 0 and 255.<p> 803 * 804 * @param rgb array to validate 805 * 806 * @throws IllegalArgumentException if array is null or contains invalid values 807 */ 808 private void validateRgb(int[] rgb) { 809 810 validateRgb(rgb, false); 811 } 812 813 /** 814 * Validates that an RGB array contains three or four values between 0 and 255.<p> 815 * 816 * @param rgb array to validate 817 * @param supportsAlpha if true, the array can optionally have the length 4 instead of 3 818 * 819 * @throws IllegalArgumentException if array is null or contains invalid values 820 */ 821 private void validateRgb(int[] rgb, boolean supportsAlpha) { 822 823 if (rgb == null) { 824 throw new IllegalArgumentException("Invalid RGB value: " + rgb); 825 } 826 if ((rgb.length == 4) && (rgb[3] == 255)) { 827 // Treat as length 3 if alpha channel is 255 828 rgb = new int[] {rgb[0], rgb[1], rgb[2]}; 829 } 830 if (((rgb.length != 3) && (!supportsAlpha || (rgb.length != 4))) 831 || (rgb[0] < 0) 832 || (rgb[0] > 255) 833 || (rgb[1] < 0) 834 || (rgb[1] > 255) 835 || (rgb[2] < 0) 836 || (rgb[2] > 255) 837 || (supportsAlpha && (rgb.length == 4) && ((rgb[3] < 0) || (rgb[3] > 255)))) { 838 throw new IllegalArgumentException( 839 "Invalid RGB value: [" 840 + rgb[0] 841 + ", " 842 + rgb[1] 843 + ", " 844 + rgb[2] 845 + (supportsAlpha && (rgb.length == 4) ? ", " + rgb[3] : "") 846 + "]. " 847 + "Each value must be an integer between 0 and 255."); 848 } 849 } 850}