diff --git a/lib/CSSStyleDeclaration.test.js b/lib/CSSStyleDeclaration.test.js index eb3788eb..bb802981 100644 --- a/lib/CSSStyleDeclaration.test.js +++ b/lib/CSSStyleDeclaration.test.js @@ -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)'; @@ -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'; diff --git a/lib/parsers.js b/lib/parsers.js index 5d9f7696..6e44f561 100644 --- a/lib/parsers.js +++ b/lib/parsers.js @@ -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 = /^("[^"]*"|'[^']*')$/; @@ -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'); @@ -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 @@ -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; + /** + * + * 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); + + 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 { + /** + * | + * | + * , , [, ]? 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], [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 + ')'; + + /** + * | + * 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.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 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.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; + /** + * | | 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 diff --git a/lib/parsers.test.js b/lib/parsers.test.js index 7d85814c..399d1665 100644 --- a/lib/parsers.test.js +++ b/lib/parsers.test.js @@ -90,6 +90,40 @@ describe('parsePercent', () => { expect(parsers.parsePercent('calc(1% + 1%)')).toBe('calc(2%)'); }); }); +describe('parseAlpha', () => { + it('returns undefined for invalid values', () => { + const invalid = ['string', '1%%', '1px%', '#1%', 'calc(1 * 1px)']; + invalid.forEach(input => expect(parsers.parseAlpha(input)).toBeUndefined()); + }); + 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'); + }); +}); describe('parseMeasurement', () => { it.todo('test'); }); @@ -318,3 +352,31 @@ describe('subImplicitSetter', () => { describe('camelToDashed', () => { it.todo('test'); }); +describe('splitFnArgs', () => { + it('should parse color function arguments', () => { + const separators = [',', '/', ' ']; + expect(parsers.splitFnArgs('0,0,0', separators)).toEqual([['0', '0', '0'], [',', ',']]); + expect(parsers.splitFnArgs('0,1%,2%', separators)).toEqual([['0', '1%', '2%'], [',', ',']]); + expect(parsers.splitFnArgs(' 0, 0 , 0', separators)).toEqual([['0', '0', '0'], [',', ',']]); + expect(parsers.splitFnArgs('0deg, 1%, 2%', separators)).toEqual([ + ['0deg', '1%', '2%'], + [',', ','], + ]); + expect(parsers.splitFnArgs('0deg 1% / 2%', separators)).toEqual([ + ['0deg', '1%', '2%'], + [' ', '/'], + ]); + }); + it('should create empty args', () => { + expect(parsers.splitFnArgs(' , ,, 1%,')).toEqual([ + ['', '', '', '1%', ''], + [',', ',', ',', ','], + ]); + }); + it('should parse nested function arguments', () => { + expect(parsers.splitFnArgs('calc(45deg * 2) to left, rgb(255, 0, 0), cyan')).toEqual([ + ['calc(45deg * 2) to left', 'rgb(255, 0, 0)', 'cyan'], + [',', ','], + ]); + }); +});