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}