Skip to content

Commit

Permalink
fix(color-contrast): support CSS 4 color spaces (#4020)
Browse files Browse the repository at this point in the history
* fix(color-contrast): support css 4 color spaces

* 🤖 Automated formatting fixes

* test title

---------

Co-authored-by: straker <straker@users.noreply.github.com>
  • Loading branch information
straker and straker authored May 15, 2023
1 parent 949f4f8 commit 65621c3
Show file tree
Hide file tree
Showing 5 changed files with 300 additions and 261 deletions.
169 changes: 41 additions & 128 deletions lib/commons/color/color.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import standards from '../../standards';
import { Colorjs } from '../../core/imports';

const hexRegex = /^#[0-9a-f]{3,8}$/i;
const colorFnRegex = /^((?:rgb|hsl)a?)\s*\(([^\)]*)\)/i;
const hslRegex = /hsl\(\s*([\d.]+)(rad|turn)/;

/**
* @class Color
Expand Down Expand Up @@ -57,26 +57,35 @@ export default class Color {
* @instance
*/
parseString(colorString) {
// IE occasionally returns named colors instead of RGB(A) values
if (standards.cssColors[colorString] || colorString === 'transparent') {
const [red, green, blue] = standards.cssColors[colorString] || [0, 0, 0];
this.red = red;
this.green = green;
this.blue = blue;
this.alpha = colorString === 'transparent' ? 0 : 1;
return this;
}
// 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));
}
});

if (colorString.match(colorFnRegex)) {
this.parseColorFnString(colorString);
return this;
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);
// color.alpha is a Number object so convert it to a number
this.alpha = +color.alpha;
} catch (err) {
throw new Error(`Unable to parse color "${colorString}"`);
}

if (colorString.match(hexRegex)) {
this.parseHexString(colorString);
return this;
}
throw new Error(`Unable to parse color "${colorString}"`);
return this;
}

/**
Expand All @@ -88,15 +97,7 @@ export default class Color {
* @param {string} rgb The string value
*/
parseRgbString(colorString) {
// IE can pass transparent as value instead of rgba
if (colorString === 'transparent') {
this.red = 0;
this.green = 0;
this.blue = 0;
this.alpha = 0;
return;
}
this.parseColorFnString(colorString);
this.parseString(colorString);
}

/**
Expand All @@ -111,24 +112,8 @@ export default class Color {
if (!colorString.match(hexRegex) || [6, 8].includes(colorString.length)) {
return;
}
colorString = colorString.replace('#', '');
if (colorString.length < 6) {
const [r, g, b, a] = colorString;
colorString = r + r + g + g + b + b;
if (a) {
colorString += a + a;
}
}

var aRgbHex = colorString.match(/.{1,2}/g);
this.red = parseInt(aRgbHex[0], 16);
this.green = parseInt(aRgbHex[1], 16);
this.blue = parseInt(aRgbHex[2], 16);
if (aRgbHex[3]) {
this.alpha = parseInt(aRgbHex[3], 16) / 255;
} else {
this.alpha = 1;
}
this.parseString(colorString);
}

/**
Expand All @@ -140,30 +125,7 @@ export default class Color {
* @param {string} rgb The string value
*/
parseColorFnString(colorString) {
const [, colorFunc, colorValStr] = colorString.match(colorFnRegex) || [];
if (!colorFunc || !colorValStr) {
return;
}

// Get array of color number strings from the string:
const colorVals = colorValStr
.split(/\s*[,\/\s]\s*/)
.map(str => str.replace(',', '').trim())
.filter(str => str !== '');

// Convert to numbers
let colorNums = colorVals.map((val, index) => {
return convertColorVal(colorFunc, val, index);
});

if (colorFunc.substr(0, 3) === 'hsl') {
colorNums = hslToRgb(colorNums);
}

this.red = colorNums[0];
this.green = colorNums[1];
this.blue = colorNums[2];
this.alpha = typeof colorNums[3] === 'number' ? colorNums[3] : 1;
this.parseString(colorString);
}

/**
Expand All @@ -190,66 +152,17 @@ export default class Color {
}
}

/**
* Convert a CSS color value into a number
*/
function convertColorVal(colorFunc, value, index) {
if (/%$/.test(value)) {
//<percentage>
if (index === 3) {
// alpha
return parseFloat(value) / 100;
}
return (parseFloat(value) * 255) / 100;
}
if (colorFunc[index] === 'h') {
// hue
if (/turn$/.test(value)) {
return parseFloat(value) * 360;
}
if (/rad$/.test(value)) {
return parseFloat(value) * 57.3;
}
}
return parseFloat(value);
// clamp a value between two numbers (inclusive)
function clamp(value, min, max) {
return Math.min(Math.max(min, value), max);
}

/**
* Convert HSL to RGB
*/
function hslToRgb([hue, saturation, lightness, alpha]) {
// Must be fractions of 1
saturation /= 255;
lightness /= 255;

const high = (1 - Math.abs(2 * lightness - 1)) * saturation;
const low = high * (1 - Math.abs(((hue / 60) % 2) - 1));
const base = lightness - high / 2;

let colors;
if (hue < 60) {
// red - yellow
colors = [high, low, 0];
} else if (hue < 120) {
// yellow - green
colors = [low, high, 0];
} else if (hue < 180) {
// green - cyan
colors = [0, high, low];
} else if (hue < 240) {
// cyan - blue
colors = [0, low, high];
} else if (hue < 300) {
// blue - purple
colors = [low, 0, high];
} else {
// purple - red
colors = [high, 0, low];
}
// convert radians to degrees
function radToDeg(rad) {
return (rad * 180) / Math.PI;
}

return colors
.map(color => {
return Math.round((color + base) * 255);
})
.concat(alpha);
// convert turn to degrees
function turnToDeg(turn) {
return turn * 360;
}
3 changes: 2 additions & 1 deletion lib/core/imports/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CssSelectorParser } from 'css-selector-parser';
import doT from '@deque/dot';
import emojiRegexText from 'emoji-regex';
import memoize from 'memoizee';
import Color from 'colorjs.io';

import es6promise from 'es6-promise';
import { Uint32Array } from 'typedarray';
Expand Down Expand Up @@ -40,4 +41,4 @@ if (window.Uint32Array) {
* @namespace imports
* @memberof axe
*/
export { CssSelectorParser, doT, emojiRegexText, memoize };
export { CssSelectorParser, doT, emojiRegexText, memoize, Color as Colorjs };
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"chalk": "^4.x",
"chromedriver": "latest",
"clone": "^2.1.2",
"colorjs.io": "^0.4.3",
"conventional-commits-parser": "^3.2.4",
"core-js": "^3.27.1",
"css-selector-parser": "^1.4.1",
Expand Down
Loading

0 comments on commit 65621c3

Please sign in to comment.