From 9e7019990bbbf5182ab50c5c968143b81d216dcb Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Tue, 12 Mar 2024 13:09:25 -0600 Subject: [PATCH] feat(color): add color channel values and luminosity, saturation, clip functions (#4366) Decided to move the Color improvements from https://github.com/dequelabs/axe-core/pull/4365/files into their own PR. This also fixes the `clip` bug mentioned in https://github.com/dequelabs/axe-core/pull/4365/files#r1517706612 No QA needed --- .eslintrc.js | 2 +- lib/commons/color/color.js | 240 +++++++++++++++--- test/commons/color/color.js | 113 +++++++++ .../color/get-stroke-colors-from-shadows.js | 15 +- test/commons/color/get-text-shadow-colors.js | 15 +- 5 files changed, 329 insertions(+), 56 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index d5dd272f22..71a39bc1ca 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,7 +2,7 @@ module.exports = { root: true, extends: ['prettier'], parserOptions: { - ecmaVersion: 2021 + ecmaVersion: 2023 }, env: { node: true, diff --git a/lib/commons/color/color.js b/lib/commons/color/color.js index 15a33f1d60..5201628d38 100644 --- a/lib/commons/color/color.js +++ b/lib/commons/color/color.js @@ -1,7 +1,6 @@ import { Colorjs } from '../../core/imports'; const hexRegex = /^#[0-9a-f]{3,8}$/i; -const hslRegex = /hsl\(\s*([\d.]+)(rad|turn)/; /** * @class Color @@ -12,7 +11,26 @@ const hslRegex = /hsl\(\s*([\d.]+)(rad|turn)/; * @param {number} alpha */ export default class Color { + // color channel values typically in the range of 0-1 (can go below or above) + #r; + #g; + #b; + // color component values resolved to the sRGB color space (0-255) + #red; + #green; + #blue; + constructor(red, green, blue, alpha = 1) { + if (red instanceof Color) { + // preserve out of gamut values + const { r, g, b } = red; + this.r = r; + this.g = g; + this.b = b; + this.alpha = red.alpha; + return; + } + /** @type {number} */ this.red = red; @@ -26,6 +44,60 @@ export default class Color { this.alpha = alpha; } + get r() { + return this.#r; + } + + set r(value) { + this.#r = value; + this.#red = Math.round(clamp(value, 0, 1) * 255); + } + + get g() { + return this.#g; + } + + set g(value) { + this.#g = value; + this.#green = Math.round(clamp(value, 0, 1) * 255); + } + + get b() { + return this.#b; + } + + set b(value) { + this.#b = value; + this.#blue = Math.round(clamp(value, 0, 1) * 255); + } + + get red() { + return this.#red; + } + + set red(value) { + this.#r = value / 255; + this.#red = clamp(value, 0, 255); + } + + get green() { + return this.#green; + } + + set green(value) { + this.#g = value / 255; + this.#green = clamp(value, 0, 255); + } + + get blue() { + return this.#blue; + } + + set blue(value) { + this.#b = value / 255; + this.#blue = clamp(value, 0, 255); + } + /** * Provide the hex string value for the color * @method toHexString @@ -34,9 +106,9 @@ export default class Color { * @return {string} */ toHexString() { - var redString = Math.round(this.red).toString(16); - var greenString = Math.round(this.green).toString(16); - var blueString = Math.round(this.blue).toString(16); + const redString = Math.round(this.red).toString(16); + const greenString = Math.round(this.green).toString(16); + const blueString = Math.round(this.blue).toString(16); return ( '#' + (this.red > 15.5 ? redString : '0' + redString) + @@ -57,28 +129,12 @@ export default class Color { * @instance */ parseString(colorString) { - // Colorjs currently does not support rad or turn angle values - // @see https://github.com/LeaVerou/color.js/issues/311 - colorString = colorString.replace(hslRegex, (match, angle, unit) => { - const value = angle + unit; - - switch (unit) { - case 'rad': - return match.replace(value, radToDeg(angle)); - case 'turn': - return match.replace(value, turnToDeg(angle)); - } - }); - try { // srgb values are between 0 and 1 const color = new Colorjs(colorString).to('srgb'); - // when converting from one color space to srgb - // the values of rgb may be above 1 so we need to clamp them - // we also need to round the final value as rgb values don't have decimals - this.red = Math.round(clamp(color.r, 0, 1) * 255); - this.green = Math.round(clamp(color.g, 0, 1) * 255); - this.blue = Math.round(clamp(color.b, 0, 1) * 255); + this.r = color.r; + this.g = color.g; + this.b = color.b; // color.alpha is a Number object so convert it to a number this.alpha = +color.alpha; } catch (err) { @@ -137,32 +193,138 @@ export default class Color { * @return {number} The luminance value, ranges from 0 to 1 */ getRelativeLuminance() { - var rSRGB = this.red / 255; - var gSRGB = this.green / 255; - var bSRGB = this.blue / 255; + const { r: rSRGB, g: gSRGB, b: bSRGB } = this; - var r = + const r = rSRGB <= 0.03928 ? rSRGB / 12.92 : Math.pow((rSRGB + 0.055) / 1.055, 2.4); - var g = + const g = gSRGB <= 0.03928 ? gSRGB / 12.92 : Math.pow((gSRGB + 0.055) / 1.055, 2.4); - var b = + const b = bSRGB <= 0.03928 ? bSRGB / 12.92 : Math.pow((bSRGB + 0.055) / 1.055, 2.4); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } + + /** + * Add a value to the color channels + * @private + * @param {number} value The value to add + * @return {Color} A new color instance + */ + #add(value) { + const C = new Color(this); + C.r += value; + C.g += value; + C.b += value; + return C; + } + + /** + * Get the luminosity of a color + * using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable + * @method getLuminosity + * @memberof axe.commons.color.Color + * @instance + * @return {number} The luminosity of the color + */ + getLuminosity() { + return 0.3 * this.r + 0.59 * this.g + 0.11 * this.b; + } + + /** + * Set the luminosity of a color + * using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable + * @method setLuminosity + * @memberof axe.commons.color.Color + * @instance + * @param {number} L The luminosity + * @return {Color} A new color instance + */ + setLuminosity(L) { + const d = L - this.getLuminosity(); + return this.#add(d).clip(); + } + + /** + * Get the saturation of a color + * using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable + * @method getSaturation + * @memberof axe.commons.color.Color + * @instance + * @return {number} The saturation of the color + */ + getSaturation() { + return Math.max(this.r, this.g, this.b) - Math.min(this.r, this.g, this.b); + } + + /** + * Set the saturation of a color + * using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable + * @method setSaturation + * @memberof axe.commons.color.Color + * @instance + * @param {number} s The saturation + * @return {Color} A new color instance + */ + setSaturation(s) { + const C = new Color(this); + const colorEntires = [ + { name: 'r', value: C.r }, + { name: 'g', value: C.g }, + { name: 'b', value: C.b } + ]; + + // find the min, mid, and max values of the color components + const [Cmin, Cmid, Cmax] = colorEntires.sort((a, b) => { + return a.value - b.value; + }); + + if (Cmax.value > Cmin.value) { + Cmid.value = ((Cmid.value - Cmin.value) * s) / (Cmax.value - Cmin.value); + Cmax.value = s; + } else { + Cmid.value = Cmax.value = 0; + } + + Cmin.value = 0; + + C[Cmax.name] = Cmax.value; + C[Cmin.name] = Cmin.value; + C[Cmid.name] = Cmid.value; + return C; + } + + /** + * Clip the color between RGB 0-1 accounting for the luminosity of the color. Color must be normalized before calling. + * using algorithm from https://www.w3.org/TR/compositing-1/#blendingnonseparable + * @method clip + * @memberof axe.commons.color.Color + * @instance + * @return {Color} A new color instance clipped between 0-1 + */ + clip() { + const C = new Color(this); + const L = C.getLuminosity(); + const n = Math.min(C.r, C.g, C.b); + const x = Math.max(C.r, C.g, C.b); + + if (n < 0) { + C.r = L + ((C.r - L) * L) / (L - n); + C.g = L + ((C.g - L) * L) / (L - n); + C.b = L + ((C.b - L) * L) / (L - n); + } + + if (x > 1) { + C.r = L + ((C.r - L) * (1 - L)) / (x - L); + C.g = L + ((C.g - L) * (1 - L)) / (x - L); + C.b = L + ((C.b - L) * (1 - L)) / (x - L); + } + + return C; + } } // clamp a value between two numbers (inclusive) function clamp(value, min, max) { return Math.min(Math.max(min, value), max); } - -// convert radians to degrees -function radToDeg(rad) { - return (rad * 180) / Math.PI; -} - -// convert turn to degrees -function turnToDeg(turn) { - return turn * 360; -} diff --git a/test/commons/color/color.js b/test/commons/color/color.js index be349aa27f..1c1230e7f8 100644 --- a/test/commons/color/color.js +++ b/test/commons/color/color.js @@ -10,6 +10,40 @@ describe('color.Color', () => { assert.equal(c1.alpha, 1); }); + it('can be constructed from a Color', () => { + const c1 = new Color(4, 3, 2, 0.5); + const c2 = new Color(c1); + assert.equal(c2.red, 4); + assert.equal(c2.green, 3); + assert.equal(c2.blue, 2); + assert.equal(c2.alpha, 0.5); + }); + + it('clamps out of gamut values for red, green, blue', () => { + const c1 = new Color(-255, 0, 510, 0.5); + assert.equal(c1.red, 0); + assert.equal(c1.green, 0); + assert.equal(c1.blue, 255); + assert.equal(c1.alpha, 0.5); + }); + + it('retains out of gamut values for r, g, b', () => { + const c1 = new Color(-255, 0, 510, 0.5); + assert.equal(c1.r, -1); + assert.equal(c1.g, 0); + assert.equal(c1.b, 2); + assert.equal(c1.alpha, 0.5); + }); + + it('can be constructed from a Color preserving out of gamut values', () => { + const c1 = new Color(-255, 0, 510, 0.5); + const c2 = new Color(c1); + assert.equal(c2.r, -1); + assert.equal(c2.g, 0); + assert.equal(c2.b, 2); + assert.equal(c2.alpha, 0.5); + }); + it('has a toJSON method', () => { const c1 = new Color(255, 128, 0); assert.deepEqual(c1.toJSON(), { @@ -413,4 +447,83 @@ describe('color.Color', () => { assert.isTrue(lBlue > lBlack); }); }); + + describe('getLuminosity', () => { + it('returns luminosity of the Color', () => { + const L = new Color(128, 128, 0, 1).getLuminosity(); + assert.equal(L, 0.44674509803921564); + }); + }); + + describe('setLuminosity', () => { + it('sets the luminosity of the Color', () => { + const color = new Color(0, 0, 0, 1).setLuminosity(0.5); + assert.deepEqual(color.toJSON(), { + red: 128, + green: 128, + blue: 128, + alpha: 1 + }); + }); + + it('returns a new Color', () => { + const black = new Color(0, 0, 0, 1); + const nBlack = black.setLuminosity(0.5); + assert.notEqual(black, nBlack); + }); + }); + + describe('getSaturation', () => { + it('returns the saturation of the Color', () => { + const s = new Color(255, 128, 200, 1).getSaturation(); + assert.equal(s, 0.4980392156862745); + }); + }); + + describe('setSaturation', () => { + it('sets the saturation of the Color', () => { + const color = new Color(128, 100, 0, 1).setSaturation(0.8); + assert.deepEqual(color.toJSON(), { + red: 204, + green: 159, + blue: 0, + alpha: 1 + }); + }); + + it('returns a new Color', () => { + const black = new Color(0, 0, 0, 1); + const nBlack = black.setSaturation(0.5); + assert.notEqual(black, nBlack); + }); + }); + + describe('clip', () => { + it('clips to the lower bound', () => { + const color = new Color(255, 0, -1, 1).clip(); + assert.equal(color.r, 0.9909493297254295); + assert.equal(color.g, 0.003870895819239939); + assert.equal(color.b, 0); + }); + + it('clips to the upper bound', () => { + const color = new Color(255, 0, 256, 1).clip(); + assert.equal(color.r, 0.9961043436801178); + assert.equal(color.g, 0.002711982110142841); + assert.equal(color.b, 1); + }); + + it('clips both the lower and upper bounds', () => { + const color = new Color(-1, 0, 256, 1).clip(); + assert.equal(color.r, 0.00047889410870861904); + assert.equal(color.g, 0.004247986549875488); + assert.equal(color.b, 0.9691356514885925); + }); + + it('returns a new Color', () => { + const black = new Color(0, 0, 0, 1); + const nBlack = black.clip(); + assert.notEqual(black, nBlack); + }); + }); }); diff --git a/test/commons/color/get-stroke-colors-from-shadows.js b/test/commons/color/get-stroke-colors-from-shadows.js index ec22a85f2d..ef7e51abdb 100644 --- a/test/commons/color/get-stroke-colors-from-shadows.js +++ b/test/commons/color/get-stroke-colors-from-shadows.js @@ -14,14 +14,13 @@ describe('axe.commons.color.getStrokeColorsFromShadow', () => { -2px 0 #F00 `); const shadowColors = getStrokeColorsFromShadows(shadows); - assert.deepEqual(shadowColors, [ - { - red: 255, - green: 0, - blue: 0, - alpha: 1 - } - ]); + assert.lengthOf(shadowColors, 1); + assert.deepEqual(shadowColors[0].toJSON(), { + red: 255, + green: 0, + blue: 0, + alpha: 1 + }); }); it('returns empty when only one side is covered by the shadow', () => { diff --git a/test/commons/color/get-text-shadow-colors.js b/test/commons/color/get-text-shadow-colors.js index eb46db25ff..a9a7900162 100644 --- a/test/commons/color/get-text-shadow-colors.js +++ b/test/commons/color/get-text-shadow-colors.js @@ -202,14 +202,13 @@ describe('axe.commons.color.getTextShadowColors', function () { ">Hello world `; const shadowColors = getTextShadowColors(fixture.firstElementChild, opt); - assert.deepEqual(shadowColors, [ - { - red: 255, - green: 0, - blue: 0, - alpha: 1 - } - ]); + assert.lengthOf(shadowColors, 1); + assert.deepEqual(shadowColors[0].toJSON(), { + red: 255, + green: 0, + blue: 0, + alpha: 1 + }); }); it('only combines shadows thinner than minRatio', () => {