Skip to content

Commit

Permalink
fix: parsing color
Browse files Browse the repository at this point in the history
  • Loading branch information
cdoublev committed Jun 14, 2021
1 parent 17e192b commit 258bc65
Show file tree
Hide file tree
Showing 3 changed files with 205 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 @@ -266,9 +266,9 @@ describe('CSSStyleDeclaration', () => {
style.color = 'rgba(0,0,0,0)';
expect(style.color).toBe('rgba(0, 0, 0, 0)');
style.color = 'rgba(5%, 10%, 20%, 0.4)';
expect(style.color).toBe('rgba(12, 25, 51, 0.4)');
expect(style.color).toBe('rgba(13, 26, 51, 0.4)');
style.color = 'rgb(33%, 34%, 33%)';
expect(style.color).toBe('rgb(84, 86, 84)');
expect(style.color).toBe('rgb(84, 87, 84)');
style.color = 'rgba(300, 200, 100, 1.5)';
expect(style.color).toBe('rgb(255, 200, 100)');
style.color = 'hsla(0, 1%, 2%, 0.5)';
Expand All @@ -282,7 +282,7 @@ describe('CSSStyleDeclaration', () => {
style.color = 'currentcolor';
expect(style.color).toBe('currentcolor');
style.color = '#ffffffff';
expect(style.color).toBe('rgba(255, 255, 255, 1)');
expect(style.color).toBe('rgb(255, 255, 255)');
style.color = '#fffa';
expect(style.color).toBe('rgba(255, 255, 255, 0.667)');
style.color = '#ffffff66';
Expand Down
258 changes: 160 additions & 98 deletions lib/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ const integer = '[-+]?\\d+';
const number = `((${integer})(\\.\\d+)?|[-+]?(\\.\\d+))(e[-+]?${integer})?`;
const percentage = `(${number})(%)`;
const identRegEx = new RegExp(`^${ident}$`, 'i');
const integerRegEx = new RegExp(`^${integer}$`);
const numberRegEx = new RegExp(`^${number}$`);
const percentageRegEx = new RegExp(`^${percentage}$`);
const stringRegEx = /^("[^"]*"|'[^']*')$/;
Expand All @@ -85,10 +84,9 @@ const calcOperatorRegEx = new RegExp(
`^${whitespace}[-+]${whitespace}|${whitespace}?[*/]${whitespace}?$`
);
const calcRegEx = new RegExp(`^calc\\(${ws}(.+)${ws}\\)$`, '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 = new RegExp(`^#(${hexDigit}{3,4}){1,2}$`, 'i');
const colorFnSeparatorsRegEx = new RegExp(`,|/|${whitespace}`);
const colorFnRegEx = new RegExp(`^(hsl|rgb)a?\\(${ws}(.+)${ws}\\)$`, 'i');
const lengthRegEx = new RegExp(`^${length}$`, 'i');
const numericRegEx = new RegExp(`^(${number})(%|${ident})?$`, 'i');
const timeRegEx = new RegExp(`^(${number})(m?s)$`, 'i');
Expand Down Expand Up @@ -224,6 +222,41 @@ exports.parseLengthOrPercentage = function parseLengthOrPercentage(val, resolve)
return exports.parseLength(val, resolve) || exports.parsePercentage(val, resolve);
};

/**
* 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) {
const variable = exports.parseCustomVariable(val);
if (variable) {
return variable;
}
let parsed = exports.parseNumber(val);
if (parsed !== null) {
is8Bit = false;
val = Math.min(1, Math.max(0, parsed)) * 100;
} else if ((parsed = exports.parsePercentage(val, true))) {
val = Math.min(100, Math.max(0, parsed.slice(0, -1)));
} else {
return null;
}

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 @@ -542,122 +575,151 @@ exports.parseString = function parseString(val) {
/**
* https://drafts.csswg.org/css-color/#color-type
* https://drafts.csswg.org/cssom/#ref-for-valuea-def-color
*
* TODO: <hwb()>, <lab()>, <lch()>, <color()>, <device-cmyk()>.
*/
exports.parseColor = function parseColor(val) {
const variable = exports.parseCustomVariable(val);
if (variable) {
return variable;
}

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];
const rgb = [];

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));
/**
* <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);

return 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + hexAlphaToRgbaAlpha + ')';
}
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
}
if (hex) {
const [, n1, n2, n3, n4, n5, n6, n7, n8] = val;
let alpha = 1;

res = colorRegEx2.exec(val);
if (res) {
parts = res[1].split(/\s*,\s*/);
if (parts.length !== 3) {
return null;
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 null;
}
if (parts.every(percentageRegEx.test.bind(percentageRegEx))) {
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 null;

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 null;
}
if (parts.slice(0, 3).every(percentageRegEx.test.bind(percentageRegEx))) {
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], separators] = exports.splitTokens(
args,
colorFnSeparatorsRegEx
);
const [sep1, sep2, sep3] = separators.map(s => (whitespaceRegEx.test(s) ? s : s.trim()));
const alpha = exports.parseAlpha(arg4, true);

name = name.toLowerCase();

if (
!alpha ||
sep1 !== sep2 ||
((sep3 && !(sep3 === ',' && sep1 === ',')) || (sep3 === '/' && whitespaceRegEx.test(sep1)))
) {
return null;
}
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 + ')';
}
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 null;
/**
* <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 null;
}
[arg2, arg3].forEach(val => {
if ((val = exports.parsePercentage(val, true))) {
return hsl.push(Math.min(100, Math.max(0, val.slice(0, -1))) / 100);
}
});
if (hsl.length < 3) {
return null;
}
rgb.push(...hslToRgb(...hsl));
}
hue = parseFloat(_hue);
saturation = parseInt(_saturation, 10);
lightness = parseInt(_lightness, 10);
if (_alpha && numberRegEx.test(_alpha)) {
alpha = parseFloat(_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.parsePercentage(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 || types.size > 1) {
return null;
}
}

const [r, g, b] = hslToRgb(hue, saturation / 100, lightness / 100);
if (!_alphaString || alpha === 1) {
return 'rgb(' + r + ', ' + g + ', ' + b + ')';
if (alpha < 1) {
return `rgba(${rgb.join(', ')}, ${alpha})`;
}
return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')';
return `rgb(${rgb.join(', ')})`;
}

return exports.parseKeyword(val, namedColors);
/**
* <named-color> | <system-color> | currentcolor | transparent
*/
const name = exports.parseKeyword(val, namedColors);
if (name) {
return name;
}

return null;
};

/**
Expand Down
42 changes: 42 additions & 0 deletions lib/parsers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,48 @@ describe('parsePercentage', () => {
describe('parseLengthOrPercentage', () => {
it.todo('test');
});
describe('parseAlpha', () => {
it('returns null for invalid values', () => {
const invalid = ['string', '%', '1px', '#1', '1%%', 'calc(1 * 1px)'];
invalid.forEach(input => expect(parsers.parseAlpha(input)).toBeNull());
});
it('parses alpha with exponent', () => {
expect(parsers.parseAlpha('1e0')).toBe('1');
expect(parsers.parseAlpha('1e+0')).toBe('1');
expect(parsers.parseAlpha('1e-1')).toBe('0.1');
});
it('parses alpha with missing leading 0', () => {
expect(parsers.parseAlpha('.1')).toBe('0.1');
});
it('returns alpha without trailing 0 in decimals', () => {
expect(parsers.parseAlpha('0.10')).toBe('0.1');
});
it('resolves percentage to number', () => {
expect(parsers.parseAlpha('50%')).toBe('0.5');
});
it('clamps alpha between 0 and 1', () => {
expect(parsers.parseAlpha('-100%')).toBe('0');
expect(parsers.parseAlpha('150%')).toBe('1');
expect(parsers.parseAlpha('-1')).toBe('0');
expect(parsers.parseAlpha('1.5')).toBe('1');
});
it('rounds alpha depending on the stored type', () => {
expect(parsers.parseAlpha('0.499')).toBe('0.499');
expect(parsers.parseAlpha('49.9%')).toBe('0.499');
expect(parsers.parseAlpha('0.499', true)).toBe('0.499');
expect(parsers.parseAlpha('49.9%', true)).toBe('0.498');
expect(parsers.parseAlpha('0.501')).toBe('0.501');
expect(parsers.parseAlpha('50.1%')).toBe('0.501');
expect(parsers.parseAlpha('0.501', true)).toBe('0.501');
expect(parsers.parseAlpha('50.1%', true)).toBe('0.5');
});
it('works with calc', () => {
expect(parsers.parseAlpha('calc(0.5 + 0.5)')).toBe('1');
});
it('works with custom variable', () => {
expect(parsers.parseAlpha('var(--alpha)')).toBe('var(--alpha)');
});
});
describe('parseAngle', () => {
it('returns null for invalid values', () => {
const invalid = ['string', '1', 'deg', '1px', '#1deg', '1degg', 'calc(1 + 1)', 'calc(1 * 1px)'];
Expand Down

0 comments on commit 258bc65

Please sign in to comment.