From a73ea2b6a92a521daacc632b0d66d2c2251140a2 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 16 Dec 2024 11:33:19 +0100 Subject: [PATCH] MOBILE-4653 color: Validate colors and handle alpha --- src/core/singletons/colors.ts | 116 ++++++++++++++--------- src/core/singletons/tests/colors.test.ts | 65 +++++++++---- upgrade.txt | 1 + 3 files changed, 119 insertions(+), 63 deletions(-) diff --git a/src/core/singletons/colors.ts b/src/core/singletons/colors.ts index 8fdd18acc..57e3ba532 100644 --- a/src/core/singletons/colors.ts +++ b/src/core/singletons/colors.ts @@ -42,11 +42,25 @@ export enum CoreIonicColorNames { */ export class CoreColors { + protected static readonly BLACK_TRANSPARENT_COLORS = + ['rgba(0, 0, 0, 0)', 'transparent', '#00000000', '#0000', 'hsl(0, 0%, 0%, 0)']; + // Avoid creating singleton instances. private constructor() { // Nothing to do. } + /** + * Check if a color is valid. + * Accepted formats are rgb, rgba, hsl, hsla, hex and named colors. + * + * @param color Color in any format. + * @returns Whether color is valid. + */ + static isValid(color: string): boolean { + return CoreColors.getColorRGBA(color).length >= 3; + } + /** * Returns better contrast color. * @@ -58,23 +72,30 @@ export class CoreColors { } /** - * Returns the same color % darker. + * Returns the same color % darker. Returned color is always hex, unless the color isn't valid. * * @param color Color to get darker. * @returns Darker Hex RGB color. */ static darker(color: string, percent: number = 48): string { const inversePercent = 1 - (percent / 100); - const components = CoreColors.hexToRGB(color); - components.red = Math.floor(components.red * inversePercent); - components.green = Math.floor(components.green * inversePercent); - components.blue = Math.floor(components.blue * inversePercent); - return CoreColors.RGBToHex(components); + const rgba = CoreColors.getColorRGBA(color); + if (rgba.length < 3) { + return color; // Color not valid, return original value. + } + + const red = Math.floor(rgba[0] * inversePercent); + const green = Math.floor(rgba[1] * inversePercent); + const blue = Math.floor(rgba[2] * inversePercent); + + return rgba[3] !== undefined ? + CoreColors.getColorHex(`rgba(${red}, ${green}, ${blue}, ${rgba[3]})`) : + CoreColors.getColorHex(`rgb(${red}, ${green}, ${blue})`); } /** - * Returns the same color % lighter. + * Returns the same color % lighter. Returned color is always hex, unless the color isn't valid. * * @param color Color to get lighter. * @returns Lighter Hex RGB color. @@ -83,12 +104,18 @@ export class CoreColors { percent = percent / 100; const inversePercent = 1 - percent; - const components = CoreColors.hexToRGB(color); - components.red = Math.floor(255 * percent + components.red * inversePercent); - components.green = Math.floor(255 * percent + components.green * inversePercent); - components.blue = Math.floor(255 * percent + components.blue * inversePercent); + const rgba = CoreColors.getColorRGBA(color); + if (rgba.length < 3) { + return color; // Color not valid, return original value. + } - return CoreColors.RGBToHex(components); + const red = Math.floor(255 * percent + rgba[0] * inversePercent); + const green = Math.floor(255 * percent + rgba[1] * inversePercent); + const blue = Math.floor(255 * percent + rgba[2] * inversePercent); + + return rgba[3] !== undefined ? + CoreColors.getColorHex(`rgba(${red}, ${green}, ${blue}, ${rgba[3]})`) : + CoreColors.getColorHex(`rgb(${red}, ${green}, ${blue})`); } /** @@ -99,19 +126,24 @@ export class CoreColors { */ static getColorHex(color: string): string { const rgba = CoreColors.getColorRGBA(color); - if (rgba.length === 0) { + if (rgba.length < 3) { return ''; } - const hex = [0,1,2].map( + let hex = [0,1,2].map( (idx) => CoreColors.componentToHex(rgba[idx]), ).join(''); + if (rgba.length > 3) { + hex += CoreColors.componentToHex(Math.round(rgba[3] * 255)); + } + return '#' + hex; } /** * Returns RGBA color from any color format. + * Only works with RGB, RGBA, HSL, HSLA, hex and named colors. * * @param color Color in any format. * @returns Red, green, blue and alpha. @@ -119,15 +151,28 @@ export class CoreColors { static getColorRGBA(color: string): number[] { if (!color.match(/rgba?\(.*\)/)) { // Convert the color to RGB format. + // Use backgroundColor instead of color because it detects invalid colors like rgb(0, 80) or #0F. + const originalColor = color; const d = document.createElement('span'); - d.style.color = color; + d.style.backgroundColor = color; document.body.appendChild(d); - color = getComputedStyle(d).color; + color = getComputedStyle(d).backgroundColor; document.body.removeChild(d); + + // Check that the color is valid. Some invalid colors return rgba(0, 0, 0, 0). + if ( + !color.match(/rgba?\(.*\)/) || + (color === 'rgba(0, 0, 0, 0)' && !CoreColors.BLACK_TRANSPARENT_COLORS.includes(originalColor)) + ) { + return []; + } } - const matches = color.match(/\d+[^.]|\d*\.\d*/g) || []; + const matches = color.match(/\d+(\.\d+)?|\.\d+/g) || []; + if (matches.length < 3) { + return []; + } return matches.map((a, index) => index < 3 ? parseInt(a, 10) : parseFloat(a)); } @@ -139,9 +184,12 @@ export class CoreColors { * @returns Luma number based on SMPTE C, Rec. 709 weightings. */ protected static luma(color: string): number { - const rgb = CoreColors.hexToRGB(color); + const rgba = CoreColors.getColorRGBA(color); + if (rgba.length < 3) { + return 0; // Color not valid. + } - return (rgb.red * 0.2126) + (rgb.green * 0.7152) + (rgb.blue * 0.0722); + return (rgba[0] * 0.2126) + (rgba[1] * 0.7152) + (rgba[2] * 0.0722); } /** @@ -149,39 +197,19 @@ export class CoreColors { * * @param color Hexadec RGB Color. * @returns RGB color components. + * @deprecated since 5.0. Use getColorRGBA instead. */ static hexToRGB(color: string): ColorComponents { - if (color.charAt(0) == '#') { - color = color.substring(1); - } - - if (color.length === 3) { - color = color.charAt(0) + color.charAt(0) + color.charAt(1) + color.charAt(1) + color.charAt(2) + color.charAt(2); - } else if (color.length !== 6) { - throw('Invalid hex color: ' + color); - } + const rgba = CoreColors.getColorRGBA(color); return { - red: parseInt(color.substring(0, 2), 16), - green: parseInt(color.substring(2, 4), 16), - blue: parseInt(color.substring(4, 6), 16), + red: rgba[0] ?? 0, + green: rgba[1] ?? 0, + blue: rgba[2] ?? 0, }; } - /** - * Converts RGB components to Hex string. - * - * @param color Color components. - * @returns RGB color in string. - */ - protected static RGBToHex(color: ColorComponents): string { - return '#' + CoreColors.componentToHex(color.red) + - CoreColors.componentToHex(color.green) + - CoreColors.componentToHex(color.blue); - - } - /** * Converts a color component from decimal to hexadec. * diff --git a/src/core/singletons/tests/colors.test.ts b/src/core/singletons/tests/colors.test.ts index 5befd468b..de9e7a294 100644 --- a/src/core/singletons/tests/colors.test.ts +++ b/src/core/singletons/tests/colors.test.ts @@ -16,6 +16,25 @@ import { CoreColors } from '@singletons/colors'; describe('CoreColors singleton', () => { + it('checks if a color is valid', () => { + expect(CoreColors.isValid('')).toBe(false); + expect(CoreColors.isValid('#000000')).toBe(true); + expect(CoreColors.isValid('#00000000')).toBe(true); + expect(CoreColors.isValid('#000')).toBe(true); + expect(CoreColors.isValid('#0000')).toBe(true); + expect(CoreColors.isValid('#00')).toBe(false); + expect(CoreColors.isValid('#00000')).toBe(false); + expect(CoreColors.isValid('rgb(0,0,0)')).toBe(true); + expect(CoreColors.isValid('rgba(0,0,0,0)')).toBe(true); + expect(CoreColors.isValid('rgb(0,0)')).toBe(false); + expect(CoreColors.isValid('hsl(0, 100%, 0%)')).toBe(true); + expect(CoreColors.isValid('hsla(0, 100%, 0%, 0)')).toBe(true); + expect(CoreColors.isValid('hsl(0, 100%)')).toBe(false); + + // @todo There are problems when testing color names (e.g. violet). + // They work fine in real browsers but not in unit tests. + }); + it('determines if white contrast is better', () => { expect(CoreColors.isWhiteContrastingBetter('#000000')).toBe(true); expect(CoreColors.isWhiteContrastingBetter('#999999')).toBe(true); @@ -26,6 +45,12 @@ describe('CoreColors singleton', () => { expect(CoreColors.isWhiteContrastingBetter('#0000ff')).toBe(true); expect(CoreColors.isWhiteContrastingBetter('#ff00ff')).toBe(true); expect(CoreColors.isWhiteContrastingBetter('#ffff00')).toBe(false); + expect(CoreColors.isWhiteContrastingBetter('rgb(0, 0, 0)')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('rgb(153, 153, 153)')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('rgb(255, 255, 255)')).toBe(false); + expect(CoreColors.isWhiteContrastingBetter('hsl(0, 100%, 0%)')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('hsl(0, 0%, 60%)')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('hsl(0, 0%, 100%)')).toBe(false); }); it('makes color darker', () => { @@ -33,6 +58,13 @@ describe('CoreColors singleton', () => { expect(CoreColors.darker('#ffffff', 20)).toEqual('#cccccc'); expect(CoreColors.darker('#ffffff', 80)).toEqual('#323232'); expect(CoreColors.darker('#aabbcc', 40)).toEqual('#66707a'); + expect(CoreColors.darker('rgb(255, 255, 255)', 50)).toEqual('#7f7f7f'); + expect(CoreColors.darker('rgba(255, 255, 255, 0.5)', 50)).toEqual('#7f7f7f80'); + expect(CoreColors.darker('hsl(0, 0%, 100%)', 50)).toEqual('#7f7f7f'); + expect(CoreColors.darker('hsla(0, 0%, 100%, 0.8)', 50)).toEqual('#7f7f7fcc'); + + // Invalid colors return original value. + expect(CoreColors.darker('#fffff', 50)).toEqual('#fffff'); }); it('makes color lighter', () => { @@ -40,12 +72,22 @@ describe('CoreColors singleton', () => { expect(CoreColors.lighter('#000000', 20)).toEqual('#333333'); expect(CoreColors.lighter('#000000', 80)).toEqual('#cccccc'); expect(CoreColors.lighter('#223344', 40)).toEqual('#7a848e'); + expect(CoreColors.lighter('rgb(0, 0, 0)', 50)).toEqual('#7f7f7f'); + expect(CoreColors.lighter('rgba(0, 0, 0, 0.5)', 50)).toEqual('#7f7f7f80'); + expect(CoreColors.lighter('hsl(0, 100%, 0%)', 50)).toEqual('#7f7f7f'); + expect(CoreColors.lighter('hsla(0, 100%, 0%, 0.4)', 50)).toEqual('#7f7f7f66'); + + // Invalid colors return original value. + expect(CoreColors.darker('#00000', 50)).toEqual('#00000'); }); it('gets color hex value', () => { expect(CoreColors.getColorHex('#123456')).toEqual('#123456'); + expect(CoreColors.getColorHex('#12345680')).toEqual('#12345680'); expect(CoreColors.getColorHex('rgb(255, 100, 70)')).toEqual('#ff6446'); - expect(CoreColors.getColorHex('rgba(255, 100, 70, 0.5)')).toEqual('#ff6446'); + expect(CoreColors.getColorHex('rgba(255, 100, 70, 0.5)')).toEqual('#ff644680'); + expect(CoreColors.getColorHex('hsl(0, 0%, 60%)')).toEqual('#999999'); + expect(CoreColors.getColorHex('hsla(0, 0%, 60%, 0.8)')).toEqual('#999999cc'); // @todo There are problems when testing color names (e.g. violet) or hsf colors. // They work fine in real browsers but not in unit tests. @@ -53,31 +95,16 @@ describe('CoreColors singleton', () => { it('gets color RGBA value', () => { expect(CoreColors.getColorRGBA('#123456')).toEqual([18, 52, 86]); + expect(CoreColors.getColorRGBA('#123456cc')).toEqual([18, 52, 86, 0.8]); expect(CoreColors.getColorRGBA('rgb(255, 100, 70)')).toEqual([255, 100, 70]); expect(CoreColors.getColorRGBA('rgba(255, 100, 70, 0.5)')).toEqual([255, 100, 70, 0.5]); + expect(CoreColors.getColorRGBA('hsl(0, 0%, 60%)')).toEqual([153, 153, 153]); + expect(CoreColors.getColorRGBA('hsl(0, 0%, 60%, 0.3)')).toEqual([153, 153, 153, 0.3]); // @todo There are problems when testing color names (e.g. violet) or hsf colors. // They work fine in real browsers but not in unit tests. }); - it('converts hex to rgb', () => { - expect(CoreColors.hexToRGB('#000000')).toEqual({ - red: 0, - green: 0, - blue: 0, - }); - expect(CoreColors.hexToRGB('#ffffff')).toEqual({ - red: 255, - green: 255, - blue: 255, - }); - expect(CoreColors.hexToRGB('#aabbcc')).toEqual({ - red: 170, - green: 187, - blue: 204, - }); - }); - it('gets toolbar background color', () => { document.body.style.setProperty('--core-header-toolbar-background', '#aabbcc'); expect(CoreColors.getToolbarBackgroundColor()).toEqual('#aabbcc'); diff --git a/upgrade.txt b/upgrade.txt index d171afb51..9d24bc429 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -6,6 +6,7 @@ For more information about upgrading, read the official documentation: https://m - The logout process has been refactored, now it uses a logout page to trigger Angular guards. CoreSites.logout now uses this process, and CoreSites.logoutForRedirect is deprecated and shouldn't be used anymore. - The parameters of treatDownloadedFile of plugin file handlers have changed. Now the third parameter is an object with all the optional parameters. + - Some CoreColors functions have been refactored to handle alpha and to validate colors. === 4.5.0 ===