feat(color): added utilities and improved color parsing performance (#9110)

This commit is contained in:
farfromrefuge
2021-08-11 19:54:57 +02:00
committed by Nathan Walker
parent 65f9598a0d
commit c4db847ded
4 changed files with 152 additions and 154 deletions

View File

@ -1,7 +1,6 @@
import * as definition from '.';
import * as definition from '.';
import * as types from '../utils/types';
import * as knownColors from './known-colors';
import { convertHSLToRGBColor } from '../css/parser';
const SHARP = '#';
const HEX_REGEX = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)|(^#[0-9A-F]{8}$)/i;
@ -12,26 +11,28 @@ export class Color implements definition.Color {
constructor(color: number);
constructor(color: string);
constructor(a: number, r: number, g: number, b: number);
constructor(a: number, r: number, g: number, b: number, type?: 'rbg' | 'hsl' | 'hsv');
constructor(...args) {
if (args.length === 1) {
const arg = args[0];
if (types.isString(arg)) {
if (isRgbOrRgba(arg)) {
this._argb = argbFromRgbOrRgba(arg);
} else if (isHslOrHsla(arg)) {
this._argb = argbFromHslOrHsla(arg);
} else if (knownColors.isKnownName(arg)) {
const lowered = arg.toLowerCase();
if (isRgbOrRgba(lowered)) {
this._argb = argbFromRgbOrRgba(lowered);
} else if (isHslOrHsla(lowered)) {
this._argb = argbFromHslOrHsla(lowered);
} else if (isHsvOrHsva(lowered)) {
this._argb = argbFromHsvOrHsva(lowered);
} else if (knownColors.isKnownName(lowered)) {
// The parameter is a known color name
const argb = knownColors.getKnownColor(arg);
const argb = knownColors.getKnownColor(lowered);
this._name = arg;
this._argb = argb;
} else if (arg[0].charAt(0) === SHARP && (arg.length === 4 || arg.length === 7 || arg.length === 9)) {
// we dont use the regexp as it is quite slow. Instead we expect it to be a valid hex format
// strange that it would not be. And if it is not a thrown error seems best
// The parameter is a "#RRGGBBAA" formatted string
const hex = this._normalizeHex(arg);
this._argb = this._argbFromString(hex);
this._argb = this._argbFromString(arg);
} else {
throw new Error('Invalid color: ' + arg);
}
@ -47,8 +48,23 @@ export class Color implements definition.Color {
} else {
throw new Error('Expected 1 or 4 constructor parameters.');
}
} else if (args.length === 4) {
this._argb = (args[0] & 0xff) * 0x01000000 + (args[1] & 0xff) * 0x00010000 + (args[2] & 0xff) * 0x00000100 + (args[3] & 0xff) * 0x00000001;
} else if (args.length >= 4) {
const a = args[0];
switch (args[4]) {
case 'hsl': {
const { r, g, b } = hslToRgb(args[1], args[2], args[3]);
this._argb = (a & 0xff) * 0x01000000 + (r & 0xff) * 0x00010000 + (g & 0xff) * 0x00000100 + (b & 0xff) * 0x00000001;
break;
}
case 'hsv': {
const { r, g, b } = hsvToRgb(args[1], args[2], args[3]);
this._argb = (a & 0xff) * 0x01000000 + (r & 0xff) * 0x00010000 + (g & 0xff) * 0x00000100 + (b & 0xff) * 0x00000001;
break;
}
default:
this._argb = (a & 0xff) * 0x01000000 + (args[1] & 0xff) * 0x00010000 + (args[2] & 0xff) * 0x00000100 + (args[3] & 0xff) * 0x00000001;
break;
}
} else {
throw new Error('Expected 1 or 4 constructor parameters.');
}
@ -72,11 +88,11 @@ export class Color implements definition.Color {
}
get hex(): string {
if (this.a === 0xff) {
return ('#' + this._componentToHex(this.r) + this._componentToHex(this.g) + this._componentToHex(this.b)).toUpperCase();
} else {
return ('#' + this._componentToHex(this.r) + this._componentToHex(this.g) + this._componentToHex(this.b) + this._componentToHex(this.a)).toUpperCase();
let result = SHARP + ('000000' + (this._argb & 0xffffff).toString(16)).toUpperCase().slice(-6);
if (this.a !== 0xff) {
return (result += ('00' + this.a.toString(16).toUpperCase()).slice(-2));
}
return result;
}
get name(): string {
@ -92,13 +108,13 @@ export class Color implements definition.Color {
}
public _argbFromString(hex: string): number {
if (hex.charAt(0) === '#') {
hex = hex.substr(1);
}
if (hex.length === 3) {
// always called as SHARP as first char
hex = hex.substr(1);
const length = hex.length;
// first we normalize
if (length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
} else if (hex.length === 4) {
} else if (length === 4) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
}
@ -142,35 +158,19 @@ export class Color implements definition.Color {
if (!types.isString(value)) {
return false;
}
const lowered = value.toLowerCase();
if (knownColors.isKnownName(value)) {
if (knownColors.isKnownName(lowered)) {
return true;
}
return HEX_REGEX.test(value) || isRgbOrRgba(value) || isHslOrHsla(value);
return HEX_REGEX.test(value) || isRgbOrRgba(lowered) || isHslOrHsla(lowered);
}
public static fromHSL(a, h, s, l) {
const rgb = hslToRgb(h, s, l);
return new Color(a, rgb.r, rgb.g, rgb.b);
return new Color(a, h, s, l, 'hsl');
}
private _componentToHex(component: number): string {
let hex = component.toString(16);
if (hex.length === 1) {
hex = '0' + hex;
}
return hex;
}
private _normalizeHex(hexStr: string): string {
// we expect this to already has a # as first char as it is supposed to be tested before
if (hexStr.length === 4) {
// Duplicate each char after the #, so "#123" becomes "#112233"
hexStr = hexStr.charAt(0) + hexStr.charAt(1) + hexStr.charAt(1) + hexStr.charAt(2) + hexStr.charAt(2) + hexStr.charAt(3) + hexStr.charAt(3);
}
return hexStr;
public static fromHSV(a, h, s, l) {
return new Color(a, h, s, l, 'hsv');
}
public toString(): string {
@ -247,7 +247,7 @@ export class Color implements definition.Color {
*
*/
public toHsl() {
const hsl = rgbToHsl(this.r, this.g, this.b);
const hsl = rgbToHsl(this.r / 255, this.g / 255, this.b / 255);
return { h: hsl.h * 360, s: hsl.s, l: hsl.l, a: this.a };
}
@ -256,7 +256,7 @@ export class Color implements definition.Color {
*
*/
public toHslString() {
const hsl = rgbToHsl(this.r, this.g, this.b);
const hsl = rgbToHsl(this.r / 255, this.g / 255, this.b / 255);
const h = Math.round(hsl.h * 360),
s = Math.round(hsl.s * 100),
l = Math.round(hsl.l * 100);
@ -269,7 +269,7 @@ export class Color implements definition.Color {
*
*/
public toHsv() {
const hsv = rgbToHsv(this.r, this.g, this.b);
const hsv = rgbToHsv(this.r / 255, this.g / 255, this.b / 255);
return { h: hsv.h * 360, s: hsv.s, v: hsv.v, a: this.a };
}
@ -278,7 +278,7 @@ export class Color implements definition.Color {
*
*/
public toHsvString() {
const hsv = rgbToHsv(this.r, this.g, this.b);
const hsv = rgbToHsv(this.r / 255, this.g / 255, this.b / 255);
const h = Math.round(hsv.h * 360),
s = Math.round(hsv.s * 100),
v = Math.round(hsv.v * 100);
@ -302,10 +302,10 @@ export class Color implements definition.Color {
*/
public desaturate(amount: number) {
amount = amount === 0 ? 0 : amount || 10;
const hsl = this.toHsl();
const hsl = rgbToHsl(this.r / 255, this.g / 255, this.b / 255);
hsl.s -= amount / 100;
hsl.s = Math.min(1, Math.max(0, hsl.s));
return Color.fromHSL(this.a, hsl.h, hsl.s, hsl.l);
return Color.fromHSL(this.a, hsl.h * 360, hsl.s * 100, hsl.l * 100);
}
/**
@ -315,10 +315,10 @@ export class Color implements definition.Color {
*/
public saturate(amount: number) {
amount = amount === 0 ? 0 : amount || 10;
const hsl = this.toHsl();
const hsl = rgbToHsl(this.r / 255, this.g / 255, this.b / 255);
hsl.s += amount / 100;
hsl.s = Math.min(1, Math.max(0, hsl.s));
return Color.fromHSL(this.a, hsl.h, hsl.s, hsl.l);
return Color.fromHSL(this.a, hsl.h * 360, hsl.s * 100, hsl.l * 100);
}
/**
@ -336,10 +336,10 @@ export class Color implements definition.Color {
*/
public lighten(amount: number) {
amount = amount === 0 ? 0 : amount || 10;
const hsl = this.toHsl();
const hsl = rgbToHsl(this.r / 255, this.g / 255, this.b / 255);
hsl.l += amount / 100;
hsl.l = Math.min(1, Math.max(0, hsl.l));
return Color.fromHSL(this.a, hsl.h, hsl.s, hsl.l);
return Color.fromHSL(this.a, hsl.h * 360, hsl.s * 100, hsl.l * 100);
}
/**
@ -362,16 +362,14 @@ export class Color implements definition.Color {
*/
public darken(amount: number) {
amount = amount === 0 ? 0 : amount || 10;
const hsl = this.toHsl();
hsl.l -= amount / 100;
hsl.l = Math.min(1, Math.max(0, hsl.l));
return Color.fromHSL(this.a, hsl.h, hsl.s, hsl.l);
const hsl = rgbToHsl(this.r / 255, this.g / 255, this.b / 255);
return Color.fromHSL(this.a, hsl.h * 360, hsl.s * 100, Math.min(100, Math.max(0, hsl.l - amount)));
}
/**
* Spin the hue a given amount, from -360 to 360. Calling with 0, 360, or -360 will do nothing (since it sets the hue back to what it was before).
*
* @param amount (between 0 and 100)
* @param amount (between -360 and 360)
*/
public spin(amount: number) {
const hsl = this.toHsl();
@ -389,25 +387,37 @@ export class Color implements definition.Color {
hsl.h = (hsl.h + 180) % 360;
return Color.fromHSL(this.a, hsl.h, hsl.s, hsl.l);
}
static mix(color1: Color, color2: Color, amount = 50) {
const p = amount / 100;
const rgba = {
r: (color2.r - color1.r) * p + color1.r,
g: (color2.g - color1.g) * p + color1.g,
b: (color2.b - color1.b) * p + color1.b,
a: (color2.a - color1.a) * p + color1.a,
};
return new Color(rgba.a, rgba.r, rgba.g, rgba.b);
}
}
function isRgbOrRgba(value: string): boolean {
const toLower = value.toLowerCase();
return (toLower.indexOf('rgb(') === 0 || toLower.indexOf('rgba(') === 0) && toLower.indexOf(')') === toLower.length - 1;
return (value.startsWith('rgb(') || value.startsWith('rgba(')) && value.endsWith(')');
}
function isHslOrHsla(value: string): boolean {
const toLower = value.toLowerCase();
return (toLower.indexOf('hsl(') === 0 || toLower.indexOf('hsla(') === 0) && toLower.indexOf(')') === toLower.length - 1;
return (value.startsWith('hsl') || value.startsWith('hsla(')) && value.endsWith(')');
}
function isHsvOrHsva(value: string): boolean {
return (value.startsWith('hsv') || value.startsWith('hsva(')) && value.endsWith(')');
}
function parseColorWithAlpha(value: string): any {
const toLower = value.toLowerCase();
const parts = toLower
.replace(/(rgb|hsl)a?\(/, '')
const parts = value
.replace(/(rgb|hsl|hsv)a?\(/, '')
.replace(')', '')
.replace(/%/g, '')
.trim()
.split(',');
@ -444,14 +454,22 @@ function argbFromRgbOrRgba(value: string): number {
function argbFromHslOrHsla(value: string): number {
const { f: h, s: s, t: l, a } = parseColorWithAlpha(value);
const { r, g, b } = convertHSLToRGBColor(h, s, l);
const { r, g, b } = hslToRgb(h, s, l);
return (a & 0xff) * 0x01000000 + (r & 0xff) * 0x00010000 + (g & 0xff) * 0x00000100 + (b & 0xff);
}
function argbFromHsvOrHsva(value: string): number {
const { f: h, s: s, t: v, a } = parseColorWithAlpha(value);
const { r, g, b } = hsvToRgb(h, s, v);
return (a & 0xff) * 0x01000000 + (r & 0xff) * 0x00010000 + (g & 0xff) * 0x00000100 + (b & 0xff);
}
// `rgbToHsl`
// Converts an RGB color value to HSL.
// *Assumes:* r, g, and b are contained in [0, 255] or [0, 1]
// *Assumes:* r, g, and b are contained in [0, 1]
// *Returns:* { h, s, l } in [0,1]
function rgbToHsl(r, g, b) {
const max = Math.max(r, g, b),
@ -493,9 +511,12 @@ function hue2rgb(p, q, t) {
// `hslToRgb`
// Converts an HSL color value to RGB.
// *Assumes:* h is contained in [0, 1] or [0, 360] and s and l are contained [0, 1] or [0, 100]
// *Assumes:* h is contained in [0, 360] and s and l are contained [0, 100]
// *Returns:* { r, g, b } in the set [0, 255]
function hslToRgb(h, s, l) {
function hslToRgb(h1, s1, l1) {
const h = (h1 % 360) / 360;
const s = s1 / 100;
const l = l1 / 100;
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
@ -512,7 +533,7 @@ function hslToRgb(h, s, l) {
// `rgbToHsv`
// Converts an RGB color value to HSV
// *Assumes:* r, g, and b are contained in the set [0, 255] or [0, 1]
// *Assumes:* r, g, and b are contained in the set [0, 1]
// *Returns:* { h, s, v } in [0,1]
function rgbToHsv(r, g, b) {
const max = Math.max(r, g, b),
@ -541,3 +562,25 @@ function rgbToHsv(r, g, b) {
}
return { h: h, s: s, v: v };
}
// `hsvToRgb`
// Converts an HSV color value to RGB.
// *Assumes:* h is contained in [0, 360] and s and v are contained [0, 100]
// *Returns:* { r, g, b } in the set [0, 255]
function hsvToRgb(h1, s1, v1) {
const h = ((h1 % 360) / 360) * 6;
const s = s1 / 100;
const v = v1 / 100;
var i = Math.floor(h),
f = h - i,
p = v * (1 - s),
q = v * (1 - f * s),
t = v * (1 - (1 - f) * s),
mod = i % 6,
r = [v, q, p, p, t, v][mod],
g = [t, v, v, q, p, p][mod],
b = [p, p, t, v, v, q][mod];
return { r: r * 255, g: g * 255, b: b * 255 };
}

View File

@ -1,11 +1,11 @@
/**
/**
* Represents a color object. Stores all color components (alpha (opacity), red, green, blue) in a [0..255] range.
*/
export class Color {
constructor(knownColor: string);
constructor(hex: string);
constructor(argb: number);
constructor(alpha: number, red: number, green: number, blue: number);
constructor(alpha: number, red: number, green: number, blue: number, type?: 'rgb' | 'hsl' | 'hsv');
/**
* Gets the Alpha component (in the [0, 255] range) of this color. This is a read-only property.
@ -190,4 +190,17 @@ export class Color {
*
*/
public complement(): Color;
/**
* returns the color complement
*
*/
public static mix(color1: Color, color2: Color, amount: number): Color;
/**
* returns a new Color from HSL
*
*/
public static fromHSL(a, h, s, l): Color;
public static fromHSV(a, h, s, l): Color;
}

View File

@ -1,9 +1,9 @@
import { Color } from '../color';
import { getKnownColor } from '../color/known-colors';
export type Parsed<V> = { start: number; end: number; value: V };
// Values
export type ARGB = number;
export type URL = string;
export type Angle = number;
export interface Unit<T> {
@ -15,7 +15,7 @@ export type Percentage = Unit<'%'>;
export type LengthPercentage = Length | Percentage;
export type Keyword = string;
export interface ColorStop {
argb: ARGB;
color: Color;
offset?: LengthPercentage;
}
export interface LinearGradient {
@ -67,59 +67,31 @@ export function parseURL(text: string, start = 0): Parsed<URL> {
return { start, end, value };
}
const hexColorRegEx = /\s*#((?:[0-9A-F]{8})|(?:[0-9A-F]{6})|(?:[0-9A-F]{3}))\s*/giy;
export function parseHexColor(text: string, start = 0): Parsed<ARGB> {
const hexColorRegEx = /\s*#((?:[0-9A-F]{8})|(?:[0-9A-F]{6})|(?:[0-9A-F]{4})|(?:[0-9A-F]{3}))\s*/giy;
export function parseHexColor(text: string, start = 0): Parsed<Color> {
hexColorRegEx.lastIndex = start;
const result = hexColorRegEx.exec(text);
if (!result) {
return null;
}
const end = hexColorRegEx.lastIndex;
const hex = result[1];
let argb;
if (hex.length === 8) {
argb = parseInt('0x' + hex);
} else if (hex.length === 6) {
argb = parseInt('0xFF' + hex);
} else if (hex.length === 3) {
argb = parseInt('0xFF' + hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]);
}
return { start, end, value: argb };
return { start, end, value: new Color('#'+ result[1]) };
}
function rgbaToArgbNumber(r: number, g: number, b: number, a = 1): number | undefined {
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255 && a >= 0 && a <= 1) {
return Math.round(a * 0xff) * 0x01000000 + r * 0x010000 + g * 0x000100 + b;
} else {
return null;
}
}
const rgbColorRegEx = /\s*(rgb\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*\))/gy;
export function parseRGBColor(text: string, start = 0): Parsed<ARGB> {
rgbColorRegEx.lastIndex = start;
const result = rgbColorRegEx.exec(text);
const cssColorRegEx = /\s*((?:rgb|rgba|hsl|hsla|hsv|hsva)\([^\(\)]\))/gy;
export function parseCssColor(text: string, start = 0): Parsed<Color> {
cssColorRegEx.lastIndex = start;
const result = cssColorRegEx.exec(text);
if (!result) {
return null;
}
const end = rgbColorRegEx.lastIndex;
const value = result[1] && rgbaToArgbNumber(parseInt(result[2]), parseInt(result[3]), parseInt(result[4]));
return { start, end, value };
}
const rgbaColorRegEx = /\s*(rgba\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*,\s*([01]?\.?\d*)\s*\))/gy;
export function parseRGBAColor(text: string, start = 0): Parsed<ARGB> {
rgbaColorRegEx.lastIndex = start;
const result = rgbaColorRegEx.exec(text);
if (!result) {
const end = cssColorRegEx.lastIndex;
try {
return { start, end, value: new Color(text) };
} catch {
return null;
}
const end = rgbaColorRegEx.lastIndex;
const value = rgbaToArgbNumber(parseInt(result[2]), parseInt(result[3]), parseInt(result[4]), parseFloat(result[5]));
return { start, end, value };
}
export function convertHSLToRGBColor(hue: number, saturation: number, lightness: number): { r: number; g: number; b: number } {
@ -160,49 +132,19 @@ export function convertHSLToRGBColor(hue: number, saturation: number, lightness:
};
}
function hslaToArgbNumber(h: number, s: number, l: number, a = 1): number | undefined {
const { r, g, b } = convertHSLToRGBColor(h, s, l);
return rgbaToArgbNumber(r, g, b, a);
}
const hslColorRegEx = /\s*(hsl\(\s*([\d.]*)\s*,\s*([\d.]*)%\s*,\s*([\d.]*)%\s*\))/gy;
export function parseHSLColor(text: string, start = 0): Parsed<ARGB> {
hslColorRegEx.lastIndex = start;
const result = hslColorRegEx.exec(text);
if (!result) {
return null;
}
const end = hslColorRegEx.lastIndex;
const value = result[1] && hslaToArgbNumber(parseFloat(result[2]), parseFloat(result[3]), parseFloat(result[4]));
return { start, end, value };
}
const hslaColorRegEx = /\s*(hsla\(\s*([\d.]*)\s*,\s*([\d.]*)%\s*,\s*([\d.]*)%\s*,\s*([01]?\.?\d*)\s*\))/gy;
export function parseHSLAColor(text: string, start = 0): Parsed<ARGB> {
hslaColorRegEx.lastIndex = start;
const result = hslaColorRegEx.exec(text);
if (!result) {
return null;
}
const end = hslaColorRegEx.lastIndex;
const value = hslaToArgbNumber(parseFloat(result[2]), parseFloat(result[3]), parseFloat(result[4]), parseFloat(result[5]));
return { start, end, value };
}
export function parseColorKeyword(value, start: number, keyword = parseKeyword(value, start)): Parsed<ARGB> {
export function parseColorKeyword(value, start: number, keyword = parseKeyword(value, start)): Parsed<Color> {
const parseColor = keyword && getKnownColor(keyword.value);
if (parseColor != null) {
const end = keyword.end;
const value = parseColor;
return { start, end, value };
return { start, end, value: new Color(parseColor) };
}
return null;
}
export function parseColor(value: string, start = 0, keyword = parseKeyword(value, start)): Parsed<ARGB> {
return parseHexColor(value, start) || parseColorKeyword(value, start, keyword) || parseRGBColor(value, start) || parseRGBAColor(value, start) || parseHSLColor(value, start) || parseHSLAColor(value, start);
export function parseColor(value: string, start = 0, keyword = parseKeyword(value, start)): Parsed<Color> {
return parseHexColor(value, start) || parseColorKeyword(value, start, keyword) || parseCssColor(value, start);
}
const keywordRegEx = /\s*([a-z][\w\-]*)\s*/giy;
@ -558,11 +500,11 @@ export function parseColorStop(text: string, start = 0): Parsed<ColorStop> {
return {
start,
end,
value: { argb: color.value, offset: offset.value },
value: { color: color.value, offset: offset.value },
};
}
return { start, end, value: { argb: color.value } };
return { start, end, value: { color: color.value } };
}
const linearGradientStartRegEx = /\s*linear-gradient\s*/gy;

View File

@ -22,7 +22,7 @@ export class LinearGradient {
}
return {
color: new Color(color.argb),
color: color.color,
offset: offsetUnit,
};
});