Skip to content

Commit 5e14857

Browse files
committed
fix: parsing color
1 parent 88eb83b commit 5e14857

File tree

3 files changed

+276
-101
lines changed

3 files changed

+276
-101
lines changed

lib/CSSStyleDeclaration.test.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,9 @@ describe('CSSStyleDeclaration', () => {
182182
style.color = 'rgba(0,0,0,0)';
183183
expect(style.color).toEqual('rgba(0, 0, 0, 0)');
184184
style.color = 'rgba(5%, 10%, 20%, 0.4)';
185-
expect(style.color).toEqual('rgba(12, 25, 51, 0.4)');
185+
expect(style.color).toEqual('rgba(13, 26, 51, 0.4)');
186186
style.color = 'rgb(33%, 34%, 33%)';
187-
expect(style.color).toEqual('rgb(84, 86, 84)');
187+
expect(style.color).toEqual('rgb(84, 87, 84)');
188188
style.color = 'rgba(300, 200, 100, 1.5)';
189189
expect(style.color).toEqual('rgb(255, 200, 100)');
190190
style.color = 'hsla(0, 1%, 2%, 0.5)';
@@ -198,7 +198,7 @@ describe('CSSStyleDeclaration', () => {
198198
style.color = 'currentcolor';
199199
expect(style.color).toEqual('currentcolor');
200200
style.color = '#ffffffff';
201-
expect(style.color).toEqual('rgba(255, 255, 255, 1)');
201+
expect(style.color).toEqual('rgb(255, 255, 255)');
202202
style.color = '#fffa';
203203
expect(style.color).toEqual('rgba(255, 255, 255, 0.667)');
204204
style.color = '#ffffff66';

lib/parsers.js

+211-98
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ const integerPattern = '[-+]?\\d+';
4949
const numberPattern = `((${integerPattern})(\\.\\d+)?|[-+]?(\\.\\d+))(e[-+]?${integerPattern})?`;
5050
const percentPattern = `(${numberPattern})(%)`;
5151
const identRegEx = new RegExp(`^${identPattern}$`, 'i');
52-
const integerRegEx = new RegExp(`^${integerPattern}$`);
5352
const numberRegEx = new RegExp(`^${numberPattern}$`);
5453
const percentRegEx = new RegExp(`^${percentPattern}$`);
5554
const stringRegEx = /^("[^"]*"|'[^']*')$/;
@@ -64,10 +63,9 @@ const anglePattern = `(${numberPattern})(deg|grad|rad|turn)`;
6463
const lengthPattern = `(${numberPattern})(ch|cm|r?em|ex|in|lh|mm|pc|pt|px|q|vh|vmin|vmax|vw)`;
6564
const angleRegEx = new RegExp(`^${anglePattern}$`, 'i');
6665
const calcRegEx = /^calc\(\s*(.+)\s*\)$/i;
67-
const colorRegEx1 = /^#([0-9a-fA-F]{3,4}){1,2}$/;
68-
const colorRegEx2 = /^rgb\(([^)]*)\)$/;
69-
const colorRegEx3 = /^rgba\(([^)]*)\)$/;
70-
const colorRegEx4 = /^hsla?\(\s*(-?\d+|-?\d*.\d+)\s*,\s*(-?\d+|-?\d*.\d+)%\s*,\s*(-?\d+|-?\d*.\d+)%\s*(,\s*(-?\d+|-?\d*.\d+)\s*)?\)/;
66+
const colorHexRegEx = /^#([0-9a-f]{3,4}){1,2}$/i;
67+
const colorFnSeparators = [',', '/', ' '];
68+
const colorFnRegex = /^(hsl|rgb)a?\(\s*(.+)\s*\)$/i;
7169
const lengthRegEx = new RegExp(`^${lengthPattern}$`, 'i');
7270
const numericRegEx = new RegExp(`^(${numberPattern})(%|${identPattern})?$`, 'i');
7371
const timeRegEx = new RegExp(`^(${numberPattern})(m?s)$`, 'i');
@@ -220,6 +218,41 @@ exports.parseMeasurement = function parseMeasurement(val) {
220218
return exports.parsePercent(val);
221219
};
222220

221+
/**
222+
* https://drafts.csswg.org/cssom/#ref-for-alphavalue-def
223+
* https://drafts.csswg.org/cssom/#ref-for-alphavalue-def
224+
*
225+
* Browsers store a gradient alpha value as an 8 bit unsigned integer value when
226+
* given as a percentage, while they store a gradient alpha value as a decimal
227+
* value when given as a number, or when given an opacity value as a number or
228+
* percentage.
229+
*/
230+
exports.parseAlpha = function parseAlpha(val, is8Bit = false) {
231+
if (val === '') {
232+
return val;
233+
}
234+
let parsed = exports.parseNumber(val);
235+
if (parsed !== undefined) {
236+
is8Bit = false;
237+
val = Math.min(1, Math.max(0, parsed)) * 100;
238+
} else if ((parsed = exports.parsePercent(val, true))) {
239+
val = Math.min(100, Math.max(0, parsed.slice(0, -1)));
240+
} else {
241+
return undefined;
242+
}
243+
244+
if (!is8Bit) {
245+
return serializeNumber(val / 100);
246+
}
247+
248+
// Fix JS precision (eg. 50 * 2.55 === 127.499... instead of 127.5) with toPrecision(15)
249+
const alpha = Math.round((val * 2.55).toPrecision(15));
250+
const integer = Math.round(alpha / 2.55);
251+
const hasInteger = Math.round((integer * 2.55).toPrecision(15)) === alpha;
252+
253+
return String(hasInteger ? integer / 100 : Math.round(alpha / 0.255) / 1000);
254+
};
255+
223256
/**
224257
* https://drafts.csswg.org/css-values-4/#angles
225258
* https://drafts.csswg.org/cssom/#ref-for-angle-value
@@ -526,115 +559,195 @@ exports.parseColor = function parseColor(val) {
526559
if (val === '') {
527560
return val;
528561
}
529-
var red,
530-
green,
531-
blue,
532-
hue,
533-
saturation,
534-
lightness,
535-
alpha = 1;
536-
var parts;
537-
var res = colorRegEx1.exec(val);
538-
// is it #aaa, #ababab, #aaaa, #abababaa
539-
if (res) {
540-
var defaultHex = val.substr(1);
541-
var hex = val.substr(1);
542-
if (hex.length === 3 || hex.length === 4) {
543-
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
544-
545-
if (defaultHex.length === 4) {
546-
hex = hex + defaultHex[3] + defaultHex[3];
547-
}
548-
}
549-
red = parseInt(hex.substr(0, 2), 16);
550-
green = parseInt(hex.substr(2, 2), 16);
551-
blue = parseInt(hex.substr(4, 2), 16);
552-
if (hex.length === 8) {
553-
var hexAlpha = hex.substr(6, 2);
554-
var hexAlphaToRgbaAlpha = Number((parseInt(hexAlpha, 16) / 255).toFixed(3));
555562

556-
return 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + hexAlphaToRgbaAlpha + ')';
557-
}
558-
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
559-
}
563+
const rgb = [];
560564

561-
res = colorRegEx2.exec(val);
562-
if (res) {
563-
parts = res[1].split(/\s*,\s*/);
564-
if (parts.length !== 3) {
565-
return undefined;
565+
/**
566+
* <hex-color>
567+
* value should be `#` followed by 3, 4, 6, or 8 hexadecimal digits
568+
* value should be resolved to <rgb()> | <rgba()>
569+
* value should be resolved to <rgb()> if <alpha> === 1
570+
*/
571+
const hex = colorHexRegEx.exec(val);
572+
573+
if (hex) {
574+
const [, n1, n2, n3, n4, n5, n6, n7, n8] = val;
575+
let alpha = 1;
576+
577+
switch (val.length) {
578+
case 4:
579+
rgb.push(Number(`0x${n1}${n1}`), Number(`0x${n2}${n2}`), Number(`0x${n3}${n3}`));
580+
break;
581+
case 5:
582+
rgb.push(Number(`0x${n1}${n1}`), Number(`0x${n2}${n2}`), Number(`0x${n3}${n3}`));
583+
alpha = Number(`0x${n4}${n4}` / 255);
584+
break;
585+
case 7:
586+
rgb.push(Number(`0x${n1}${n2}`), Number(`0x${n3}${n4}`), Number(`0x${n5}${n6}`));
587+
break;
588+
case 9:
589+
rgb.push(Number(`0x${n1}${n2}`), Number(`0x${n3}${n4}`), Number(`0x${n5}${n6}`));
590+
alpha = Number(`0x${n7}${n8}` / 255);
591+
break;
592+
default:
593+
return undefined;
566594
}
567-
if (parts.every(percentRegEx.test.bind(percentRegEx))) {
568-
red = Math.floor((parseFloat(parts[0].slice(0, -1)) * 255) / 100);
569-
green = Math.floor((parseFloat(parts[1].slice(0, -1)) * 255) / 100);
570-
blue = Math.floor((parseFloat(parts[2].slice(0, -1)) * 255) / 100);
571-
} else if (parts.every(integerRegEx.test.bind(integerRegEx))) {
572-
red = parseInt(parts[0], 10);
573-
green = parseInt(parts[1], 10);
574-
blue = parseInt(parts[2], 10);
575-
} else {
576-
return undefined;
595+
596+
if (alpha == 1) {
597+
return `rgb(${rgb.join(', ')})`;
577598
}
578-
red = Math.min(255, Math.max(0, red));
579-
green = Math.min(255, Math.max(0, green));
580-
blue = Math.min(255, Math.max(0, blue));
581-
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
599+
return `rgba(${rgb.join(', ')}, ${+alpha.toFixed(3)})`;
582600
}
583601

584-
res = colorRegEx3.exec(val);
585-
if (res) {
586-
parts = res[1].split(/\s*,\s*/);
587-
if (parts.length !== 4) {
588-
return undefined;
589-
}
590-
if (parts.slice(0, 3).every(percentRegEx.test.bind(percentRegEx))) {
591-
red = Math.floor((parseFloat(parts[0].slice(0, -1)) * 255) / 100);
592-
green = Math.floor((parseFloat(parts[1].slice(0, -1)) * 255) / 100);
593-
blue = Math.floor((parseFloat(parts[2].slice(0, -1)) * 255) / 100);
594-
alpha = parseFloat(parts[3]);
595-
} else if (parts.slice(0, 3).every(integerRegEx.test.bind(integerRegEx))) {
596-
red = parseInt(parts[0], 10);
597-
green = parseInt(parts[1], 10);
598-
blue = parseInt(parts[2], 10);
599-
alpha = parseFloat(parts[3]);
600-
} else {
602+
/**
603+
* <rgb()> | <rgba()>
604+
* <hsl()> | <hsla()>
605+
* <arg1>, <arg2>, <arg3>[, <alpha>]? or <arg1> <arg2> <arg3>[ / <alpha>]?
606+
* <alpha> should be <number> or <percentage>
607+
* <alpha> should be resolved to <number> and clamped to 0-1
608+
* value should be resolved to <rgb()> if <alpha> === 1
609+
*/
610+
const fn = colorFnRegex.exec(val);
611+
612+
if (fn) {
613+
let [, name, args] = fn;
614+
const [[arg1, arg2, arg3, arg4 = 1], [sep1, sep2, sep3]] = exports.splitFnArgs(
615+
args,
616+
colorFnSeparators
617+
);
618+
const alpha = exports.parseAlpha(arg4, true);
619+
620+
name = name.toLowerCase();
621+
622+
if (
623+
!alpha ||
624+
sep1 !== sep2 ||
625+
((sep3 && !(sep3 === ',' && sep1 === ',')) || (sep3 === '/' && sep1 === ' '))
626+
) {
601627
return undefined;
602628
}
603-
if (isNaN(alpha)) {
604-
alpha = 1;
605-
}
606-
red = Math.min(255, Math.max(0, red));
607-
green = Math.min(255, Math.max(0, green));
608-
blue = Math.min(255, Math.max(0, blue));
609-
alpha = Math.min(1, Math.max(0, alpha));
610-
if (alpha === 1) {
611-
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
629+
630+
/**
631+
* <hsl()> | <hsla()>
632+
* <hue> should be <angle> or <number>
633+
* <hue> should be resolved to <number> and clamped to 0-360 (540 -> 180)
634+
* <saturation> and <lightness> should be <percentage> and clamped to 0-100%
635+
* value should be resolved to <rgb()> or <rgba()>
636+
*/
637+
if (name === 'hsl') {
638+
const hsl = [];
639+
let hue;
640+
if ((hue = exports.parseNumber(arg1))) {
641+
hsl.push((hue /= 60));
642+
} else if ((hue = exports.parseAngle(arg1, true))) {
643+
hsl.push(hue.slice(0, -3) / 60);
644+
} else {
645+
return undefined;
646+
}
647+
[arg2, arg3].forEach(val => {
648+
if ((val = exports.parsePercent(val, true))) {
649+
return hsl.push(Math.min(100, Math.max(0, val.slice(0, -1))) / 100);
650+
}
651+
});
652+
653+
if (hsl.length < 3) {
654+
return undefined;
655+
}
656+
657+
rgb.push(...hslToRgb(...hsl));
658+
659+
if (alpha === '1') {
660+
return `rgb(${rgb.join(', ')})`;
661+
}
662+
return `rgba(${rgb.join(', ')}, ${alpha})`;
663+
}
664+
665+
/**
666+
* <rgb()> | <rgba()>
667+
* rgb args should all be <number> or <percentage>
668+
* rgb args should be resolved to <number> and clamped to 0-255
669+
*/
670+
if (name === 'rgb') {
671+
const types = new Set();
672+
[arg1, arg2, arg3].forEach(val => {
673+
const number = exports.parseNumber(val);
674+
if (number) {
675+
types.add('number');
676+
rgb.push(Math.round(Math.min(255, Math.max(0, number))));
677+
return;
678+
}
679+
const percentage = exports.parsePercent(val, true);
680+
if (percentage) {
681+
types.add('percent');
682+
rgb.push(Math.round(Math.min(255, Math.max(0, (percentage.slice(0, -1) / 100) * 255))));
683+
return;
684+
}
685+
});
686+
687+
if (rgb.length < 3) {
688+
return undefined;
689+
}
690+
691+
if (types.size > 1) {
692+
return undefined;
693+
}
694+
695+
if (alpha == 1) {
696+
return `rgb(${rgb.join(', ')})`;
697+
}
698+
return `rgba(${rgb.join(', ')}, ${alpha})`;
612699
}
613-
return 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + alpha + ')';
614700
}
615701

616-
res = colorRegEx4.exec(val);
617-
if (res) {
618-
const [, _hue, _saturation, _lightness, _alphaString = ''] = res;
619-
const _alpha = parseFloat(_alphaString.replace(',', '').trim());
620-
if (!_hue || !_saturation || !_lightness) {
621-
return undefined;
702+
/**
703+
* <named-color> | <system-color> | currentcolor | transparent
704+
*/
705+
return exports.parseKeyword(val, namedColors);
706+
};
707+
708+
/**
709+
* This function is used to split args from a CSS function that can have nested
710+
* functions which are sharing the same separator(s).
711+
*/
712+
exports.splitFnArgs = function splitFnArgs(val, separators = [',']) {
713+
let argIndex = 0;
714+
let depth = 0;
715+
716+
const seps = [];
717+
const args = Array.from(val).reduce((args, char) => {
718+
if (char === '(') {
719+
depth++;
720+
} else if (char === ')') {
721+
depth--;
722+
} else if (depth === 0 && separators.includes(char)) {
723+
// Create empty arg except if separator is a space
724+
if (args[argIndex] === undefined) {
725+
if (char === ' ') {
726+
return args;
727+
}
728+
if (seps[argIndex - 1] === ' ') {
729+
seps[argIndex - 1] = char;
730+
return args;
731+
}
732+
args[argIndex] = '';
733+
}
734+
argIndex++;
735+
seps.push(char);
736+
return args;
622737
}
623-
hue = parseFloat(_hue);
624-
saturation = parseInt(_saturation, 10);
625-
lightness = parseInt(_lightness, 10);
626-
if (_alpha && numberRegEx.test(_alpha)) {
627-
alpha = parseFloat(_alpha);
738+
if (args[argIndex] === undefined) {
739+
args.push(char);
740+
} else {
741+
args[argIndex] += char;
628742
}
743+
return args;
744+
}, []);
629745

630-
const [r, g, b] = hslToRgb(hue, saturation / 100, lightness / 100);
631-
if (!_alphaString || alpha === 1) {
632-
return 'rgb(' + r + ', ' + g + ', ' + b + ')';
633-
}
634-
return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')';
746+
if (args.length === seps.length) {
747+
args.push('');
635748
}
636749

637-
return exports.parseKeyword(val, namedColors);
750+
return [args.map(a => a.trim('')), seps];
638751
};
639752

640753
// utility to translate from border-width to borderWidth

0 commit comments

Comments
 (0)