diff --git a/lib/color.js b/lib/color.js new file mode 100644 index 00000000..0cae20d0 --- /dev/null +++ b/lib/color.js @@ -0,0 +1,336 @@ +'use strict'; + +// https://github.com/imathis/hsl-picker/blob/master/assets/javascripts/modules/color.coffee +const rHex3 = /^#[0-9a-f]{3}$/; +const rHex6 = /^#[0-9a-f]{6}$/; +const rRGB = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,?\s*(0?\.?\d+)?\s*\)$/; +const rHSL = /^hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\s*,?\s*(0?\.?\d+)?\s*\)$/; + +// https://www.w3.org/TR/css3-color/#svg-color +const colorNames = { + aliceblue: {r: 240, g: 248, b: 255, a: 1}, + antiquewhite: {r: 250, g: 235, b: 215, a: 1}, + aqua: {r: 0, g: 255, b: 255, a: 1}, + aquamarine: {r: 127, g: 255, b: 212, a: 1}, + azure: {r: 240, g: 255, b: 255, a: 1}, + beige: {r: 245, g: 245, b: 220, a: 1}, + bisque: {r: 255, g: 228, b: 196, a: 1}, + black: {r: 0, g: 0, b: 0, a: 1}, + blanchedalmond: {r: 255, g: 235, b: 205, a: 1}, + blue: {r: 0, g: 0, b: 255, a: 1}, + blueviolet: {r: 138, g: 43, b: 226, a: 1}, + brown: {r: 165, g: 42, b: 42, a: 1}, + burlywood: {r: 222, g: 184, b: 135, a: 1}, + cadetblue: {r: 95, g: 158, b: 160, a: 1}, + chartreuse: {r: 127, g: 255, b: 0, a: 1}, + chocolate: {r: 210, g: 105, b: 30, a: 1}, + coral: {r: 255, g: 127, b: 80, a: 1}, + cornflowerblue: {r: 100, g: 149, b: 237, a: 1}, + cornsilk: {r: 255, g: 248, b: 220, a: 1}, + crimson: {r: 220, g: 20, b: 60, a: 1}, + cyan: {r: 0, g: 255, b: 255, a: 1}, + darkblue: {r: 0, g: 0, b: 139, a: 1}, + darkcyan: {r: 0, g: 139, b: 139, a: 1}, + darkgoldenrod: {r: 184, g: 134, b: 11, a: 1}, + darkgray: {r: 169, g: 169, b: 169, a: 1}, + darkgreen: {r: 0, g: 100, b: 0, a: 1}, + darkgrey: {r: 169, g: 169, b: 169, a: 1}, + darkkhaki: {r: 189, g: 183, b: 107, a: 1}, + darkmagenta: {r: 139, g: 0, b: 139, a: 1}, + darkolivegreen: {r: 85, g: 107, b: 47, a: 1}, + darkorange: {r: 255, g: 140, b: 0, a: 1}, + darkorchid: {r: 153, g: 50, b: 204, a: 1}, + darkred: {r: 139, g: 0, b: 0, a: 1}, + darksalmon: {r: 233, g: 150, b: 122, a: 1}, + darkseagreen: {r: 143, g: 188, b: 143, a: 1}, + darkslateblue: {r: 72, g: 61, b: 139, a: 1}, + darkslategray: {r: 47, g: 79, b: 79, a: 1}, + darkslategrey: {r: 47, g: 79, b: 79, a: 1}, + darkturquoise: {r: 0, g: 206, b: 209, a: 1}, + darkviolet: {r: 148, g: 0, b: 211, a: 1}, + deeppink: {r: 255, g: 20, b: 147, a: 1}, + deepskyblue: {r: 0, g: 191, b: 255, a: 1}, + dimgray: {r: 105, g: 105, b: 105, a: 1}, + dimgrey: {r: 105, g: 105, b: 105, a: 1}, + dodgerblue: {r: 30, g: 144, b: 255, a: 1}, + firebrick: {r: 178, g: 34, b: 34, a: 1}, + floralwhite: {r: 255, g: 250, b: 240, a: 1}, + forestgreen: {r: 34, g: 139, b: 34, a: 1}, + fuchsia: {r: 255, g: 0, b: 255, a: 1}, + gainsboro: {r: 220, g: 220, b: 220, a: 1}, + ghostwhite: {r: 248, g: 248, b: 255, a: 1}, + gold: {r: 255, g: 215, b: 0, a: 1}, + goldenrod: {r: 218, g: 165, b: 32, a: 1}, + gray: {r: 128, g: 128, b: 128, a: 1}, + green: {r: 0, g: 128, b: 0, a: 1}, + greenyellow: {r: 173, g: 255, b: 47, a: 1}, + grey: {r: 128, g: 128, b: 128, a: 1}, + honeydew: {r: 240, g: 255, b: 240, a: 1}, + hotpink: {r: 255, g: 105, b: 180, a: 1}, + indianred: {r: 205, g: 92, b: 92, a: 1}, + indigo: {r: 75, g: 0, b: 130, a: 1}, + ivory: {r: 255, g: 255, b: 240, a: 1}, + khaki: {r: 240, g: 230, b: 140, a: 1}, + lavender: {r: 230, g: 230, b: 250, a: 1}, + lavenderblush: {r: 255, g: 240, b: 245, a: 1}, + lawngreen: {r: 124, g: 252, b: 0, a: 1}, + lemonchiffon: {r: 255, g: 250, b: 205, a: 1}, + lightblue: {r: 173, g: 216, b: 230, a: 1}, + lightcoral: {r: 240, g: 128, b: 128, a: 1}, + lightcyan: {r: 224, g: 255, b: 255, a: 1}, + lightgoldenrodyellow: {r: 250, g: 250, b: 210, a: 1}, + lightgray: {r: 211, g: 211, b: 211, a: 1}, + lightgreen: {r: 144, g: 238, b: 144, a: 1}, + lightgrey: {r: 211, g: 211, b: 211, a: 1}, + lightpink: {r: 255, g: 182, b: 193, a: 1}, + lightsalmon: {r: 255, g: 160, b: 122, a: 1}, + lightseagreen: {r: 32, g: 178, b: 170, a: 1}, + lightskyblue: {r: 135, g: 206, b: 250, a: 1}, + lightslategray: {r: 119, g: 136, b: 153, a: 1}, + lightslategrey: {r: 119, g: 136, b: 153, a: 1}, + lightsteelblue: {r: 176, g: 196, b: 222, a: 1}, + lightyellow: {r: 255, g: 255, b: 224, a: 1}, + lime: {r: 0, g: 255, b: 0, a: 1}, + limegreen: {r: 50, g: 205, b: 50, a: 1}, + linen: {r: 250, g: 240, b: 230, a: 1}, + magenta: {r: 255, g: 0, b: 255, a: 1}, + maroon: {r: 128, g: 0, b: 0, a: 1}, + mediumaquamarine: {r: 102, g: 205, b: 170, a: 1}, + mediumblue: {r: 0, g: 0, b: 205, a: 1}, + mediumorchid: {r: 186, g: 85, b: 211, a: 1}, + mediumpurple: {r: 147, g: 112, b: 219, a: 1}, + mediumseagreen: {r: 60, g: 179, b: 113, a: 1}, + mediumslateblue: {r: 123, g: 104, b: 238, a: 1}, + mediumspringgreen: {r: 0, g: 250, b: 154, a: 1}, + mediumturquoise: {r: 72, g: 209, b: 204, a: 1}, + mediumvioletred: {r: 199, g: 21, b: 133, a: 1}, + midnightblue: {r: 25, g: 25, b: 112, a: 1}, + mintcream: {r: 245, g: 255, b: 250, a: 1}, + mistyrose: {r: 255, g: 228, b: 225, a: 1}, + moccasin: {r: 255, g: 228, b: 181, a: 1}, + navajowhite: {r: 255, g: 222, b: 173, a: 1}, + navy: {r: 0, g: 0, b: 128, a: 1}, + oldlace: {r: 253, g: 245, b: 230, a: 1}, + olive: {r: 128, g: 128, b: 0, a: 1}, + olivedrab: {r: 107, g: 142, b: 35, a: 1}, + orange: {r: 255, g: 165, b: 0, a: 1}, + orangered: {r: 255, g: 69, b: 0, a: 1}, + orchid: {r: 218, g: 112, b: 214, a: 1}, + palegoldenrod: {r: 238, g: 232, b: 170, a: 1}, + palegreen: {r: 152, g: 251, b: 152, a: 1}, + paleturquoise: {r: 175, g: 238, b: 238, a: 1}, + palevioletred: {r: 219, g: 112, b: 147, a: 1}, + papayawhip: {r: 255, g: 239, b: 213, a: 1}, + peachpuff: {r: 255, g: 218, b: 185, a: 1}, + peru: {r: 205, g: 133, b: 63, a: 1}, + pink: {r: 255, g: 192, b: 203, a: 1}, + plum: {r: 221, g: 160, b: 221, a: 1}, + powderblue: {r: 176, g: 224, b: 230, a: 1}, + purple: {r: 128, g: 0, b: 128, a: 1}, + red: {r: 255, g: 0, b: 0, a: 1}, + rosybrown: {r: 188, g: 143, b: 143, a: 1}, + royalblue: {r: 65, g: 105, b: 225, a: 1}, + saddlebrown: {r: 139, g: 69, b: 19, a: 1}, + salmon: {r: 250, g: 128, b: 114, a: 1}, + sandybrown: {r: 244, g: 164, b: 96, a: 1}, + seagreen: {r: 46, g: 139, b: 87, a: 1}, + seashell: {r: 255, g: 245, b: 238, a: 1}, + sienna: {r: 160, g: 82, b: 45, a: 1}, + silver: {r: 192, g: 192, b: 192, a: 1}, + skyblue: {r: 135, g: 206, b: 235, a: 1}, + slateblue: {r: 106, g: 90, b: 205, a: 1}, + slategray: {r: 112, g: 128, b: 144, a: 1}, + slategrey: {r: 112, g: 128, b: 144, a: 1}, + snow: {r: 255, g: 250, b: 250, a: 1}, + springgreen: {r: 0, g: 255, b: 127, a: 1}, + steelblue: {r: 70, g: 130, b: 180, a: 1}, + tan: {r: 210, g: 180, b: 140, a: 1}, + teal: {r: 0, g: 128, b: 128, a: 1}, + thistle: {r: 216, g: 191, b: 216, a: 1}, + tomato: {r: 255, g: 99, b: 71, a: 1}, + turquoise: {r: 64, g: 224, b: 208, a: 1}, + violet: {r: 238, g: 130, b: 238, a: 1}, + wheat: {r: 245, g: 222, b: 179, a: 1}, + white: {r: 255, g: 255, b: 255, a: 1}, + whitesmoke: {r: 245, g: 245, b: 245, a: 1}, + yellow: {r: 255, g: 255, b: 0, a: 1}, + yellowgreen: {r: 154, g: 205, b: 50, a: 1} +}; + +const convertHue = (p, q, h) => { + if (h < 0) h++; + if (h > 1) h--; + + let color; + + if (h * 6 < 1) { + color = p + ((q - p) * h * 6); + } else if (h * 2 < 1) { + color = q; + } else if (h * 3 < 2) { + color = p + ((q - p) * ((2 / 3) - h) * 6); + } else { + color = p; + } + + return Math.round(color * 255); +}; + +const convertRGB = value => { + const str = value.toString(16); + if (value < 16) return `0${str}`; + + return str; +}; + +const mixValue = (a, b, ratio) => a + ((b - a) * ratio); + +class Color { + + /** + * @param {string|{ r: number; g: number; b: number; a: number;}} color + */ + constructor(color) { + if (typeof color === 'string') { + this._parse(color); + } else if (color != null && typeof color === 'object') { + this.r = color.r | 0; + this.g = color.g | 0; + this.b = color.b | 0; + this.a = +color.a; + } else { + throw new TypeError('color is required!'); + } + + if (this.r < 0 || this.r > 255 + || this.g < 0 || this.g > 255 + || this.b < 0 || this.b > 255 + || this.a < 0 || this.a > 1) { + throw new RangeError(`{r: ${this.r}, g: ${this.g}, b: ${this.b}, a: ${this.a}} is invalid.`); + } + } + + /** + * @param {string} color + */ + _parse(color) { + color = color.toLowerCase(); + + if (Object.prototype.hasOwnProperty.call(colorNames, color)) { + const obj = colorNames[color]; + + this.r = obj.r; + this.g = obj.g; + this.b = obj.b; + this.a = obj.a; + + return; + } + + if (rHex3.test(color)) { + const txt = color.substring(1); + const code = parseInt(txt, 16); + + this.r = ((code & 0xF00) >> 8) * 17; + this.g = ((code & 0xF0) >> 4) * 17; + this.b = (code & 0xF) * 17; + this.a = 1; + + return; + } + + if (rHex6.test(color)) { + const txt = color.substring(1); + const code = parseInt(txt, 16); + + this.r = (code & 0xFF0000) >> 16; + this.g = (code & 0xFF00) >> 8; + this.b = code & 0xFF; + this.a = 1; + + return; + } + + let match = color.match(rRGB); + + if (match) { + this.r = match[1] | 0; + this.g = match[2] | 0; + this.b = match[3] | 0; + this.a = match[4] ? +match[4] : 1; + + return; + } + + match = color.match(rHSL); + + if (match) { + const h = +match[1] / 360; + const s = +match[2] / 100; + const l = +match[3] / 100; + + this.a = match[4] ? +match[4] : 1; + + if (!s) { + this.r = this.g = this.b = l * 255; + } + + const q = l < 0.5 ? l * (1 + s) : l + s - (l * s); + const p = (2 * l) - q; + + const rt = h + (1 / 3); + const gt = h; + const bt = h - (1 / 3); + + this.r = convertHue(p, q, rt); + this.g = convertHue(p, q, gt); + this.b = convertHue(p, q, bt); + + return; + } + + throw new Error(`${color} is not a supported color format.`); + } + + toString() { + if (this.a === 1) { + const r = convertRGB(this.r); + const g = convertRGB(this.g); + const b = convertRGB(this.b); + + if (this.r % 17 || this.g % 17 || this.b % 17) { + return `#${r}${g}${b}`; + } + + return `#${r[0]}${g[0]}${b[0]}`; + } + + return `rgba(${this.r}, ${this.g}, ${this.b}, ${parseFloat(this.a.toFixed(2))})`; + } + + /** + * @param {string|{ r: number; g: number; b: number; a: number;}} color + * @param {number} ratio + */ + mix(color, ratio) { + if (ratio > 1 || ratio < 0) { + throw new RangeError('Valid numbers is only between 0 and 1.'); + } + switch (ratio) { + case 0: + return new Color(this); + + case 1: + return new Color(color); + } + + return new Color({ + r: Math.round(mixValue(this.r, color.r, ratio)), + g: Math.round(mixValue(this.g, color.g, ratio)), + b: Math.round(mixValue(this.b, color.b, ratio)), + a: mixValue(this.a, color.a, ratio) + }); + } +} + +module.exports = Color; diff --git a/test/index.js b/test/index.js index 834279c8..117491df 100644 --- a/test/index.js +++ b/test/index.js @@ -3,6 +3,7 @@ describe('util', () => { require('./scripts/cache_stream'); require('./scripts/camel_case_keys'); + require('./scripts/color'); require('./scripts/escape_diacritic'); require('./scripts/escape_html'); require('./scripts/escape_regexp'); diff --git a/test/scripts/color.js b/test/scripts/color.js new file mode 100644 index 00000000..898777f7 --- /dev/null +++ b/test/scripts/color.js @@ -0,0 +1,53 @@ +'use strict'; + +describe('color', () => { + const Color = require('../../lib/color'); + + it('name', () => { + const red = new Color('red'); + const pink = new Color('pink'); + const mid1 = red.mix(pink, 1 / 3); + const mid2 = red.mix(pink, 2 / 3); + + `${red}`.should.eql('#f00'); + `${pink}`.should.eql('#ffc0cb'); + `${mid1}`.should.eql('#ff4044'); + `${mid2}`.should.eql('#ff8087'); + }); + + it('hex', () => { + const red = new Color('#f00'); + const pink = new Color('#ffc0cb'); + const mid1 = red.mix(pink, 1 / 3); + const mid2 = red.mix(pink, 2 / 3); + + `${red}`.should.eql('#f00'); + `${pink}`.should.eql('#ffc0cb'); + `${mid1}`.should.eql('#ff4044'); + `${mid2}`.should.eql('#ff8087'); + }); + + it('RGBA', () => { + const steelblueA = new Color('rgba(70, 130, 180, 0.3)'); + const steelblue = new Color('rgb(70, 130, 180)'); + const mid1 = steelblueA.mix(steelblue, 1 / 3); + const mid2 = steelblueA.mix(steelblue, 2 / 3); + + `${steelblueA}`.should.eql('rgba(70, 130, 180, 0.3)'); + `${steelblue}`.should.eql('#4682b4'); + `${mid1}`.should.eql('rgba(70, 130, 180, 0.53)'); + `${mid2}`.should.eql('rgba(70, 130, 180, 0.77)'); + }); + + it('HSLA', () => { + const steelblueA = new Color('hsla(207, 44%, 49%, 0.3)'); + const steelblue = new Color('hsl(207, 44%, 49%)'); + const mid1 = steelblueA.mix(steelblue, 1 / 3); + const mid2 = steelblueA.mix(steelblue, 2 / 3); + + `${steelblueA}`.should.eql('rgba(70, 130, 180, 0.3)'); + `${steelblue}`.should.eql('#4682b4'); + `${mid1}`.should.eql('rgba(70, 130, 180, 0.53)'); + `${mid2}`.should.eql('rgba(70, 130, 180, 0.77)'); + }); +});