Skip to content

Commit

Permalink
fix: parsing color
Browse files Browse the repository at this point in the history
  • Loading branch information
cdoublev committed May 13, 2021
1 parent 4d25f8f commit ab76b79
Show file tree
Hide file tree
Showing 3 changed files with 276 additions and 101 deletions.
6 changes: 3 additions & 3 deletions lib/CSSStyleDeclaration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ describe('CSSStyleDeclaration', () => {
style.color = 'rgba(0,0,0,0)';
expect(style.color).toEqual('rgba(0, 0, 0, 0)');
style.color = 'rgba(5%, 10%, 20%, 0.4)';
expect(style.color).toEqual('rgba(12, 25, 51, 0.4)');
expect(style.color).toEqual('rgba(13, 26, 51, 0.4)');
style.color = 'rgb(33%, 34%, 33%)';
expect(style.color).toEqual('rgb(84, 86, 84)');
expect(style.color).toEqual('rgb(84, 87, 84)');
style.color = 'rgba(300, 200, 100, 1.5)';
expect(style.color).toEqual('rgb(255, 200, 100)');
style.color = 'hsla(0, 1%, 2%, 0.5)';
Expand All @@ -198,7 +198,7 @@ describe('CSSStyleDeclaration', () => {
style.color = 'currentcolor';
expect(style.color).toEqual('currentcolor');
style.color = '#ffffffff';
expect(style.color).toEqual('rgba(255, 255, 255, 1)');
expect(style.color).toEqual('rgb(255, 255, 255)');
style.color = '#fffa';
expect(style.color).toEqual('rgba(255, 255, 255, 0.667)');
style.color = '#ffffff66';
Expand Down
309 changes: 211 additions & 98 deletions lib/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ const integerPattern = '[-+]?\\d+';
const numberPattern = `((${integerPattern})(\\.\\d+)?|[-+]?(\\.\\d+))(e[-+]?${integerPattern})?`;
const percentPattern = `(${numberPattern})(%)`;
const identRegEx = new RegExp(`^${identPattern}$`, 'i');
const integerRegEx = new RegExp(`^${integerPattern}$`);
const numberRegEx = new RegExp(`^${numberPattern}$`);
const percentRegEx = new RegExp(`^${percentPattern}$`);
const stringRegEx = /^("[^"]*"|'[^']*')$/;
Expand All @@ -64,10 +63,9 @@ const anglePattern = `(${numberPattern})(deg|grad|rad|turn)`;
const lengthPattern = `(${numberPattern})(ch|cm|r?em|ex|in|lh|mm|pc|pt|px|q|vh|vmin|vmax|vw)`;
const angleRegEx = new RegExp(`^${anglePattern}$`, 'i');
const calcRegEx = /^calc\(\s*(.+)\s*\)$/i;
const colorRegEx1 = /^#([0-9a-fA-F]{3,4}){1,2}$/;
const colorRegEx2 = /^rgb\(([^)]*)\)$/;
const colorRegEx3 = /^rgba\(([^)]*)\)$/;
const colorRegEx4 = /^hsla?\(\s*(-?\d+|-?\d*.\d+)\s*,\s*(-?\d+|-?\d*.\d+)%\s*,\s*(-?\d+|-?\d*.\d+)%\s*(,\s*(-?\d+|-?\d*.\d+)\s*)?\)/;
const colorHexRegEx = /^#([0-9a-f]{3,4}){1,2}$/i;
const colorFnSeparators = [',', '/', ' '];
const colorFnRegex = /^(hsl|rgb)a?\(\s*(.+)\s*\)$/i;
const lengthRegEx = new RegExp(`^${lengthPattern}$`, 'i');
const numericRegEx = new RegExp(`^(${numberPattern})(%|${identPattern})?$`, 'i');
const timeRegEx = new RegExp(`^(${numberPattern})(m?s)$`, 'i');
Expand Down Expand Up @@ -220,6 +218,41 @@ exports.parseMeasurement = function parseMeasurement(val) {
return exports.parsePercent(val);
};

/**
* https://drafts.csswg.org/cssom/#ref-for-alphavalue-def
* https://drafts.csswg.org/cssom/#ref-for-alphavalue-def
*
* Browsers store a gradient alpha value as an 8 bit unsigned integer value when
* given as a percentage, while they store a gradient alpha value as a decimal
* value when given as a number, or when given an opacity value as a number or
* percentage.
*/
exports.parseAlpha = function parseAlpha(val, is8Bit = false) {
if (val === '') {
return val;
}
let parsed = exports.parseNumber(val);
if (parsed !== undefined) {
is8Bit = false;
val = Math.min(1, Math.max(0, parsed)) * 100;
} else if ((parsed = exports.parsePercent(val, true))) {
val = Math.min(100, Math.max(0, parsed.slice(0, -1)));
} else {
return undefined;
}

if (!is8Bit) {
return serializeNumber(val / 100);
}

// Fix JS precision (eg. 50 * 2.55 === 127.499... instead of 127.5) with toPrecision(15)
const alpha = Math.round((val * 2.55).toPrecision(15));
const integer = Math.round(alpha / 2.55);
const hasInteger = Math.round((integer * 2.55).toPrecision(15)) === alpha;

return String(hasInteger ? integer / 100 : Math.round(alpha / 0.255) / 1000);
};

/**
* https://drafts.csswg.org/css-values-4/#angles
* https://drafts.csswg.org/cssom/#ref-for-angle-value
Expand Down Expand Up @@ -526,115 +559,195 @@ exports.parseColor = function parseColor(val) {
if (val === '') {
return val;
}
var red,
green,
blue,
hue,
saturation,
lightness,
alpha = 1;
var parts;
var res = colorRegEx1.exec(val);
// is it #aaa, #ababab, #aaaa, #abababaa
if (res) {
var defaultHex = val.substr(1);
var hex = val.substr(1);
if (hex.length === 3 || hex.length === 4) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];

if (defaultHex.length === 4) {
hex = hex + defaultHex[3] + defaultHex[3];
}
}
red = parseInt(hex.substr(0, 2), 16);
green = parseInt(hex.substr(2, 2), 16);
blue = parseInt(hex.substr(4, 2), 16);
if (hex.length === 8) {
var hexAlpha = hex.substr(6, 2);
var hexAlphaToRgbaAlpha = Number((parseInt(hexAlpha, 16) / 255).toFixed(3));

return 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + hexAlphaToRgbaAlpha + ')';
}
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
}
const rgb = [];

res = colorRegEx2.exec(val);
if (res) {
parts = res[1].split(/\s*,\s*/);
if (parts.length !== 3) {
return undefined;
/**
* <hex-color>
* value should be `#` followed by 3, 4, 6, or 8 hexadecimal digits
* value should be resolved to <rgb()> | <rgba()>
* value should be resolved to <rgb()> if <alpha> === 1
*/
const hex = colorHexRegEx.exec(val);

if (hex) {
const [, n1, n2, n3, n4, n5, n6, n7, n8] = val;
let alpha = 1;

switch (val.length) {
case 4:
rgb.push(Number(`0x${n1}${n1}`), Number(`0x${n2}${n2}`), Number(`0x${n3}${n3}`));
break;
case 5:
rgb.push(Number(`0x${n1}${n1}`), Number(`0x${n2}${n2}`), Number(`0x${n3}${n3}`));
alpha = Number(`0x${n4}${n4}` / 255);
break;
case 7:
rgb.push(Number(`0x${n1}${n2}`), Number(`0x${n3}${n4}`), Number(`0x${n5}${n6}`));
break;
case 9:
rgb.push(Number(`0x${n1}${n2}`), Number(`0x${n3}${n4}`), Number(`0x${n5}${n6}`));
alpha = Number(`0x${n7}${n8}` / 255);
break;
default:
return undefined;
}
if (parts.every(percentRegEx.test.bind(percentRegEx))) {
red = Math.floor((parseFloat(parts[0].slice(0, -1)) * 255) / 100);
green = Math.floor((parseFloat(parts[1].slice(0, -1)) * 255) / 100);
blue = Math.floor((parseFloat(parts[2].slice(0, -1)) * 255) / 100);
} else if (parts.every(integerRegEx.test.bind(integerRegEx))) {
red = parseInt(parts[0], 10);
green = parseInt(parts[1], 10);
blue = parseInt(parts[2], 10);
} else {
return undefined;

if (alpha == 1) {
return `rgb(${rgb.join(', ')})`;
}
red = Math.min(255, Math.max(0, red));
green = Math.min(255, Math.max(0, green));
blue = Math.min(255, Math.max(0, blue));
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
return `rgba(${rgb.join(', ')}, ${+alpha.toFixed(3)})`;
}

res = colorRegEx3.exec(val);
if (res) {
parts = res[1].split(/\s*,\s*/);
if (parts.length !== 4) {
return undefined;
}
if (parts.slice(0, 3).every(percentRegEx.test.bind(percentRegEx))) {
red = Math.floor((parseFloat(parts[0].slice(0, -1)) * 255) / 100);
green = Math.floor((parseFloat(parts[1].slice(0, -1)) * 255) / 100);
blue = Math.floor((parseFloat(parts[2].slice(0, -1)) * 255) / 100);
alpha = parseFloat(parts[3]);
} else if (parts.slice(0, 3).every(integerRegEx.test.bind(integerRegEx))) {
red = parseInt(parts[0], 10);
green = parseInt(parts[1], 10);
blue = parseInt(parts[2], 10);
alpha = parseFloat(parts[3]);
} else {
/**
* <rgb()> | <rgba()>
* <hsl()> | <hsla()>
* <arg1>, <arg2>, <arg3>[, <alpha>]? or <arg1> <arg2> <arg3>[ / <alpha>]?
* <alpha> should be <number> or <percentage>
* <alpha> should be resolved to <number> and clamped to 0-1
* value should be resolved to <rgb()> if <alpha> === 1
*/
const fn = colorFnRegex.exec(val);

if (fn) {
let [, name, args] = fn;
const [[arg1, arg2, arg3, arg4 = 1], [sep1, sep2, sep3]] = exports.splitFnArgs(
args,
colorFnSeparators
);
const alpha = exports.parseAlpha(arg4, true);

name = name.toLowerCase();

if (
!alpha ||
sep1 !== sep2 ||
((sep3 && !(sep3 === ',' && sep1 === ',')) || (sep3 === '/' && sep1 === ' '))
) {
return undefined;
}
if (isNaN(alpha)) {
alpha = 1;
}
red = Math.min(255, Math.max(0, red));
green = Math.min(255, Math.max(0, green));
blue = Math.min(255, Math.max(0, blue));
alpha = Math.min(1, Math.max(0, alpha));
if (alpha === 1) {
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';

/**
* <hsl()> | <hsla()>
* <hue> should be <angle> or <number>
* <hue> should be resolved to <number> and clamped to 0-360 (540 -> 180)
* <saturation> and <lightness> should be <percentage> and clamped to 0-100%
* value should be resolved to <rgb()> or <rgba()>
*/
if (name === 'hsl') {
const hsl = [];
let hue;
if ((hue = exports.parseNumber(arg1))) {
hsl.push((hue /= 60));
} else if ((hue = exports.parseAngle(arg1, true))) {
hsl.push(hue.slice(0, -3) / 60);
} else {
return undefined;
}
[arg2, arg3].forEach(val => {
if ((val = exports.parsePercent(val, true))) {
return hsl.push(Math.min(100, Math.max(0, val.slice(0, -1))) / 100);
}
});

if (hsl.length < 3) {
return undefined;
}

rgb.push(...hslToRgb(...hsl));

if (alpha === '1') {
return `rgb(${rgb.join(', ')})`;
}
return `rgba(${rgb.join(', ')}, ${alpha})`;
}

/**
* <rgb()> | <rgba()>
* rgb args should all be <number> or <percentage>
* rgb args should be resolved to <number> and clamped to 0-255
*/
if (name === 'rgb') {
const types = new Set();
[arg1, arg2, arg3].forEach(val => {
const number = exports.parseNumber(val);
if (number) {
types.add('number');
rgb.push(Math.round(Math.min(255, Math.max(0, number))));
return;
}
const percentage = exports.parsePercent(val, true);
if (percentage) {
types.add('percent');
rgb.push(Math.round(Math.min(255, Math.max(0, (percentage.slice(0, -1) / 100) * 255))));
return;
}
});

if (rgb.length < 3) {
return undefined;
}

if (types.size > 1) {
return undefined;
}

if (alpha == 1) {
return `rgb(${rgb.join(', ')})`;
}
return `rgba(${rgb.join(', ')}, ${alpha})`;
}
return 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + alpha + ')';
}

res = colorRegEx4.exec(val);
if (res) {
const [, _hue, _saturation, _lightness, _alphaString = ''] = res;
const _alpha = parseFloat(_alphaString.replace(',', '').trim());
if (!_hue || !_saturation || !_lightness) {
return undefined;
/**
* <named-color> | <system-color> | currentcolor | transparent
*/
return exports.parseKeyword(val, namedColors);
};

/**
* This function is used to split args from a CSS function that can have nested
* functions which are sharing the same separator(s).
*/
exports.splitFnArgs = function splitFnArgs(val, separators = [',']) {
let argIndex = 0;
let depth = 0;

const seps = [];
const args = Array.from(val).reduce((args, char) => {
if (char === '(') {
depth++;
} else if (char === ')') {
depth--;
} else if (depth === 0 && separators.includes(char)) {
// Create empty arg except if separator is a space
if (args[argIndex] === undefined) {
if (char === ' ') {
return args;
}
if (seps[argIndex - 1] === ' ') {
seps[argIndex - 1] = char;
return args;
}
args[argIndex] = '';
}
argIndex++;
seps.push(char);
return args;
}
hue = parseFloat(_hue);
saturation = parseInt(_saturation, 10);
lightness = parseInt(_lightness, 10);
if (_alpha && numberRegEx.test(_alpha)) {
alpha = parseFloat(_alpha);
if (args[argIndex] === undefined) {
args.push(char);
} else {
args[argIndex] += char;
}
return args;
}, []);

const [r, g, b] = hslToRgb(hue, saturation / 100, lightness / 100);
if (!_alphaString || alpha === 1) {
return 'rgb(' + r + ', ' + g + ', ' + b + ')';
}
return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')';
if (args.length === seps.length) {
args.push('');
}

return exports.parseKeyword(val, namedColors);
return [args.map(a => a.trim('')), seps];
};

// utility to translate from border-width to borderWidth
Expand Down
Loading

0 comments on commit ab76b79

Please sign in to comment.