From 6284eaa4ec4bfce31255dda44d09ef6c65a30e50 Mon Sep 17 00:00:00 2001 From: Guillaume Date: Tue, 8 Jun 2021 13:40:52 +0200 Subject: [PATCH] fix: parsing color Fixes https://github.com/jsdom/cssstyle/issues/134, https://github.com/jsdom/cssstyle/issues/135, https://github.com/jsdom/cssstyle/issues/136 --- lib/CSSStyleDeclaration.test.js | 6 +- lib/parsers.js | 256 ++++++++++++++++++++------------ lib/parsers.test.js | 42 ++++++ 3 files changed, 203 insertions(+), 101 deletions(-) diff --git a/lib/CSSStyleDeclaration.test.js b/lib/CSSStyleDeclaration.test.js index c4b47745..966f1f49 100644 --- a/lib/CSSStyleDeclaration.test.js +++ b/lib/CSSStyleDeclaration.test.js @@ -253,9 +253,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)'; @@ -269,7 +269,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'; diff --git a/lib/parsers.js b/lib/parsers.js index 9ad0ff02..ff533d73 100644 --- a/lib/parsers.js +++ b/lib/parsers.js @@ -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 = /^("[^"]*"|'[^']*')$/; @@ -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'); @@ -247,6 +245,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 !== undefined) { + 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 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 @@ -565,6 +598,8 @@ exports.parseString = function parseString(val) { /** * https://drafts.csswg.org/css-color/#color-type * https://drafts.csswg.org/cssom/#ref-for-valuea-def-color + * + * TODO: , , , , . */ exports.parseColor = function parseColor(val) { const variable = exports.parseCustomVariable(val); @@ -572,115 +607,140 @@ exports.parseColor = function parseColor(val) { 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)); + /** + * + * value should be `#` followed by 3, 4, 6, or 8 hexadecimal digits + * value should be resolved to | + * value should be resolved to if === 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 undefined; + 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(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 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(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 { + /** + * | + * | + * , , [, ]? or [ / ]? + * should be or + * should be resolved to and clamped to 0-1 + * value should be resolved to if === 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 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 + ')'; - } - 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; + /** + * | + * should be or + * should be resolved to and clamped to 0-360 (540 -> 180) + * and should be and clamped to 0-100% + * value should be resolved to or + */ + 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.parsePercentage(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)); } - hue = parseFloat(_hue); - saturation = parseInt(_saturation, 10); - lightness = parseInt(_lightness, 10); - if (_alpha && numberRegEx.test(_alpha)) { - alpha = parseFloat(_alpha); + + /** + * | + * rgb args should all be or + * rgb args should be resolved to 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 undefined; + } } - 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); + /** + * | | currentcolor | transparent + */ + const name = exports.parseKeyword(val, namedColors); + if (name) { + return name; + } }; /** diff --git a/lib/parsers.test.js b/lib/parsers.test.js index 7bb11e53..9cb784b3 100644 --- a/lib/parsers.test.js +++ b/lib/parsers.test.js @@ -105,6 +105,48 @@ describe('parsePercentage', () => { describe('parseLengthOrPercentage', () => { it.todo('test'); }); +describe('parseAlpha', () => { + it('returns undefined for invalid values', () => { + const invalid = ['string', '%', '1px', '#1', '1%%', 'calc(1 * 1px)']; + invalid.forEach(input => expect(parsers.parseAlpha(input)).toBeUndefined()); + }); + 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 undefined for invalid values', () => { const invalid = ['string', '1', 'deg', '1px', '#1deg', '1degg', 'calc(1 + 1)', 'calc(1 * 1px)'];