Skip to content

Commit 1b76a07

Browse files
committed
fix: parsing color
1 parent b79a7c0 commit 1b76a07

File tree

3 files changed

+202
-102
lines changed

3 files changed

+202
-102
lines changed

lib/CSSStyleDeclaration.test.js

Lines changed: 3 additions & 3 deletions
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

Lines changed: 158 additions & 99 deletions
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');
@@ -227,6 +225,45 @@ exports.parseLengthOrPercentage = function parseLengthOrPercentage(val, resolve)
227225
return exports.parseLength(val, resolve) || exports.parsePercentage(val, resolve);
228226
};
229227

228+
/**
229+
* https://drafts.csswg.org/cssom/#ref-for-alphavalue-def
230+
* https://drafts.csswg.org/cssom/#ref-for-alphavalue-def
231+
*
232+
* Browsers store a gradient alpha value as an 8 bit unsigned integer value when
233+
* given as a percentage, while they store a gradient alpha value as a decimal
234+
* value when given as a number, or when given an opacity value as a number or
235+
* percentage.
236+
*/
237+
exports.parseAlpha = function parseAlpha(val, is8Bit = false) {
238+
if (val === '') {
239+
return val;
240+
}
241+
const variable = exports.parseCustomVariable(val);
242+
if (variable) {
243+
return variable;
244+
}
245+
let parsed = exports.parseNumber(val);
246+
if (parsed !== undefined) {
247+
is8Bit = false;
248+
val = Math.min(1, Math.max(0, parsed)) * 100;
249+
} else if ((parsed = exports.parsePercentage(val, true))) {
250+
val = Math.min(100, Math.max(0, parsed.slice(0, -1)));
251+
} else {
252+
return undefined;
253+
}
254+
255+
if (!is8Bit) {
256+
return serializeNumber(val / 100);
257+
}
258+
259+
// Fix JS precision (eg. 50 * 2.55 === 127.499... instead of 127.5) with toPrecision(15)
260+
const alpha = Math.round((val * 2.55).toPrecision(15));
261+
const integer = Math.round(alpha / 2.55);
262+
const hasInteger = Math.round((integer * 2.55).toPrecision(15)) === alpha;
263+
264+
return String(hasInteger ? integer / 100 : Math.round(alpha / 0.255) / 1000);
265+
};
266+
230267
/**
231268
* https://drafts.csswg.org/css-values-4/#angles
232269
* https://drafts.csswg.org/cssom/#ref-for-angle-value
@@ -572,115 +609,137 @@ exports.parseColor = function parseColor(val) {
572609
return variable;
573610
}
574611

575-
var red,
576-
green,
577-
blue,
578-
hue,
579-
saturation,
580-
lightness,
581-
alpha = 1;
582-
var parts;
583-
var res = colorRegEx1.exec(val);
584-
// is it #aaa, #ababab, #aaaa, #abababaa
585-
if (res) {
586-
var defaultHex = val.substr(1);
587-
var hex = val.substr(1);
588-
if (hex.length === 3 || hex.length === 4) {
589-
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
612+
const rgb = [];
590613

591-
if (defaultHex.length === 4) {
592-
hex = hex + defaultHex[3] + defaultHex[3];
593-
}
594-
}
595-
red = parseInt(hex.substr(0, 2), 16);
596-
green = parseInt(hex.substr(2, 2), 16);
597-
blue = parseInt(hex.substr(4, 2), 16);
598-
if (hex.length === 8) {
599-
var hexAlpha = hex.substr(6, 2);
600-
var hexAlphaToRgbaAlpha = Number((parseInt(hexAlpha, 16) / 255).toFixed(3));
601-
602-
return 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + hexAlphaToRgbaAlpha + ')';
603-
}
604-
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
605-
}
614+
/**
615+
* <hex-color>
616+
* value should be `#` followed by 3, 4, 6, or 8 hexadecimal digits
617+
* value should be resolved to <rgb()> | <rgba()>
618+
* value should be resolved to <rgb()> if <alpha> === 1
619+
*/
620+
const hex = colorHexRegEx.exec(val);
606621

607-
res = colorRegEx2.exec(val);
608-
if (res) {
609-
parts = res[1].split(/\s*,\s*/);
610-
if (parts.length !== 3) {
611-
return undefined;
622+
if (hex) {
623+
const [, n1, n2, n3, n4, n5, n6, n7, n8] = val;
624+
let alpha = 1;
625+
626+
switch (val.length) {
627+
case 4:
628+
rgb.push(Number(`0x${n1}${n1}`), Number(`0x${n2}${n2}`), Number(`0x${n3}${n3}`));
629+
break;
630+
case 5:
631+
rgb.push(Number(`0x${n1}${n1}`), Number(`0x${n2}${n2}`), Number(`0x${n3}${n3}`));
632+
alpha = Number(`0x${n4}${n4}` / 255);
633+
break;
634+
case 7:
635+
rgb.push(Number(`0x${n1}${n2}`), Number(`0x${n3}${n4}`), Number(`0x${n5}${n6}`));
636+
break;
637+
case 9:
638+
rgb.push(Number(`0x${n1}${n2}`), Number(`0x${n3}${n4}`), Number(`0x${n5}${n6}`));
639+
alpha = Number(`0x${n7}${n8}` / 255);
640+
break;
641+
default:
642+
return undefined;
612643
}
613-
if (parts.every(percentRegEx.test.bind(percentRegEx))) {
614-
red = Math.floor((parseFloat(parts[0].slice(0, -1)) * 255) / 100);
615-
green = Math.floor((parseFloat(parts[1].slice(0, -1)) * 255) / 100);
616-
blue = Math.floor((parseFloat(parts[2].slice(0, -1)) * 255) / 100);
617-
} else if (parts.every(integerRegEx.test.bind(integerRegEx))) {
618-
red = parseInt(parts[0], 10);
619-
green = parseInt(parts[1], 10);
620-
blue = parseInt(parts[2], 10);
621-
} else {
622-
return undefined;
644+
645+
if (alpha == 1) {
646+
return `rgb(${rgb.join(', ')})`;
623647
}
624-
red = Math.min(255, Math.max(0, red));
625-
green = Math.min(255, Math.max(0, green));
626-
blue = Math.min(255, Math.max(0, blue));
627-
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
648+
return `rgba(${rgb.join(', ')}, ${+alpha.toFixed(3)})`;
628649
}
629650

630-
res = colorRegEx3.exec(val);
631-
if (res) {
632-
parts = res[1].split(/\s*,\s*/);
633-
if (parts.length !== 4) {
651+
/**
652+
* <rgb()> | <rgba()>
653+
* <hsl()> | <hsla()>
654+
* <arg1>, <arg2>, <arg3>[, <alpha>]? or <arg1> <arg2> <arg3>[ / <alpha>]?
655+
* <alpha> should be <number> or <percentage>
656+
* <alpha> should be resolved to <number> and clamped to 0-1
657+
* value should be resolved to <rgb()> if <alpha> === 1
658+
*/
659+
const fn = colorFnRegex.exec(val);
660+
if (fn) {
661+
let [, name, args] = fn;
662+
const [[arg1, arg2, arg3, arg4 = 1], separators] = exports.splitFnArgs(args, colorFnSeparators);
663+
const [sep1, sep2, sep3] = separators.map(s => (s === ' ' ? s : s.trim()));
664+
const alpha = exports.parseAlpha(arg4, true);
665+
666+
name = name.toLowerCase();
667+
668+
if (
669+
!alpha ||
670+
sep1 !== sep2 ||
671+
((sep3 && !(sep3 === ',' && sep1 === ',')) || (sep3 === '/' && sep1 === ' '))
672+
) {
634673
return undefined;
635674
}
636-
if (parts.slice(0, 3).every(percentRegEx.test.bind(percentRegEx))) {
637-
red = Math.floor((parseFloat(parts[0].slice(0, -1)) * 255) / 100);
638-
green = Math.floor((parseFloat(parts[1].slice(0, -1)) * 255) / 100);
639-
blue = Math.floor((parseFloat(parts[2].slice(0, -1)) * 255) / 100);
640-
alpha = parseFloat(parts[3]);
641-
} else if (parts.slice(0, 3).every(integerRegEx.test.bind(integerRegEx))) {
642-
red = parseInt(parts[0], 10);
643-
green = parseInt(parts[1], 10);
644-
blue = parseInt(parts[2], 10);
645-
alpha = parseFloat(parts[3]);
646-
} else {
647-
return undefined;
648-
}
649-
if (isNaN(alpha)) {
650-
alpha = 1;
651-
}
652-
red = Math.min(255, Math.max(0, red));
653-
green = Math.min(255, Math.max(0, green));
654-
blue = Math.min(255, Math.max(0, blue));
655-
alpha = Math.min(1, Math.max(0, alpha));
656-
if (alpha === 1) {
657-
return 'rgb(' + red + ', ' + green + ', ' + blue + ')';
658-
}
659-
return 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + alpha + ')';
660-
}
661675

662-
res = colorRegEx4.exec(val);
663-
if (res) {
664-
const [, _hue, _saturation, _lightness, _alphaString = ''] = res;
665-
const _alpha = parseFloat(_alphaString.replace(',', '').trim());
666-
if (!_hue || !_saturation || !_lightness) {
667-
return undefined;
676+
/**
677+
* <hsl()> | <hsla()>
678+
* <hue> should be <angle> or <number>
679+
* <hue> should be resolved to <number> and clamped to 0-360 (540 -> 180)
680+
* <saturation> and <lightness> should be <percentage> and clamped to 0-100%
681+
* value should be resolved to <rgb()> or <rgba()>
682+
*/
683+
if (name === 'hsl') {
684+
const hsl = [];
685+
let hue;
686+
if ((hue = exports.parseNumber(arg1))) {
687+
hsl.push((hue /= 60));
688+
} else if ((hue = exports.parseAngle(arg1, true))) {
689+
hsl.push(hue.slice(0, -3) / 60);
690+
} else {
691+
return undefined;
692+
}
693+
[arg2, arg3].forEach(val => {
694+
if ((val = exports.parsePercentage(val, true))) {
695+
return hsl.push(Math.min(100, Math.max(0, val.slice(0, -1))) / 100);
696+
}
697+
});
698+
if (hsl.length < 3) {
699+
return undefined;
700+
}
701+
rgb.push(...hslToRgb(...hsl));
668702
}
669-
hue = parseFloat(_hue);
670-
saturation = parseInt(_saturation, 10);
671-
lightness = parseInt(_lightness, 10);
672-
if (_alpha && numberRegEx.test(_alpha)) {
673-
alpha = parseFloat(_alpha);
703+
704+
/**
705+
* <rgb()> | <rgba()>
706+
* rgb args should all be <number> or <percentage>
707+
* rgb args should be resolved to <number> and clamped to 0-255
708+
*/
709+
if (name === 'rgb') {
710+
const types = new Set();
711+
[arg1, arg2, arg3].forEach(val => {
712+
const number = exports.parseNumber(val);
713+
if (number) {
714+
types.add('number');
715+
rgb.push(Math.round(Math.min(255, Math.max(0, number))));
716+
return;
717+
}
718+
const percentage = exports.parsePercentage(val, true);
719+
if (percentage) {
720+
types.add('percent');
721+
rgb.push(Math.round(Math.min(255, Math.max(0, (percentage.slice(0, -1) / 100) * 255))));
722+
return;
723+
}
724+
});
725+
if (rgb.length < 3 || types.size > 1) {
726+
return undefined;
727+
}
674728
}
675729

676-
const [r, g, b] = hslToRgb(hue, saturation / 100, lightness / 100);
677-
if (!_alphaString || alpha === 1) {
678-
return 'rgb(' + r + ', ' + g + ', ' + b + ')';
730+
if (alpha < 1) {
731+
return `rgba(${rgb.join(', ')}, ${alpha})`;
679732
}
680-
return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')';
733+
return `rgb(${rgb.join(', ')})`;
681734
}
682735

683-
return exports.parseKeyword(val, namedColors);
736+
/**
737+
* <named-color> | <system-color> | currentcolor | transparent
738+
*/
739+
const name = exports.parseKeyword(val, namedColors);
740+
if (name) {
741+
return name;
742+
}
684743
};
685744

686745
/**

lib/parsers.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ describe('parseLength', () => {
8080
});
8181
});
8282
describe('parsePercentage', () => {
83+
it('returns undefined for invalid values', () => {
84+
const invalid = ['string', '1%%', '1px%', '#1%', 'calc(1 * 1px)'];
85+
invalid.forEach(input => expect(parsers.parsePercentage(input)).toBeUndefined());
86+
});
8387
it('parses percent with exponent', () => {
8488
expect(parsers.parsePercentage('1e1%')).toBe('10%');
8589
expect(parsers.parsePercentage('1e+1%')).toBe('10%');
@@ -101,6 +105,43 @@ describe('parsePercentage', () => {
101105
describe('parseLengthOrPercentage', () => {
102106
it.todo('test');
103107
});
108+
describe('parseAlpha', () => {
109+
it('returns undefined for invalid values', () => {
110+
const invalid = ['string', '1%%', '1px%', '#1%', 'calc(1 * 1px)'];
111+
invalid.forEach(input => expect(parsers.parseAlpha(input)).toBeUndefined());
112+
});
113+
it('parses alpha with missing leading 0', () => {
114+
expect(parsers.parseAlpha('.1')).toBe('0.1');
115+
});
116+
it('returns alpha without trailing 0 in decimals', () => {
117+
expect(parsers.parseAlpha('0.10')).toBe('0.1');
118+
});
119+
it('resolves percentage to number', () => {
120+
expect(parsers.parseAlpha('50%')).toBe('0.5');
121+
});
122+
it('clamps alpha between 0 and 1', () => {
123+
expect(parsers.parseAlpha('-100%')).toBe('0');
124+
expect(parsers.parseAlpha('150%')).toBe('1');
125+
expect(parsers.parseAlpha('-1')).toBe('0');
126+
expect(parsers.parseAlpha('1.5')).toBe('1');
127+
});
128+
it('rounds alpha depending on the stored type', () => {
129+
expect(parsers.parseAlpha('0.499')).toBe('0.499');
130+
expect(parsers.parseAlpha('49.9%')).toBe('0.499');
131+
expect(parsers.parseAlpha('0.499', true)).toBe('0.499');
132+
expect(parsers.parseAlpha('49.9%', true)).toBe('0.498');
133+
expect(parsers.parseAlpha('0.501')).toBe('0.501');
134+
expect(parsers.parseAlpha('50.1%')).toBe('0.501');
135+
expect(parsers.parseAlpha('0.501', true)).toBe('0.501');
136+
expect(parsers.parseAlpha('50.1%', true)).toBe('0.5');
137+
});
138+
it('works with calc', () => {
139+
expect(parsers.parseAlpha('calc(0.5 + 0.5)')).toBe('1');
140+
});
141+
it('works with custom variable', () => {
142+
expect(parsers.parseAlpha('var(--alpha)')).toBe('var(--alpha)');
143+
});
144+
});
104145
describe('parseAngle', () => {
105146
it('returns undefined for invalid values', () => {
106147
const invalid = ['string', '1', '1degg', 'a1deg', 'deg', 'calc(1 * 1px)'];

0 commit comments

Comments
 (0)