better-color-tools
Advanced tools
Comparing version 0.0.2 to 0.1.0
# better-color-tools | ||
## 0.1.0 | ||
### Minor Changes | ||
- Add P3 support | ||
## 0.0.2 | ||
@@ -4,0 +10,0 @@ |
export declare type RGB = [number, number, number]; | ||
export declare type RGBA = [number, number, number, number]; | ||
export declare type HSL = [number, number, number, number]; | ||
export declare type P3 = [number, number, number, number]; | ||
export declare type Color = string | number | RGB | RGBA; | ||
@@ -14,3 +15,8 @@ export declare type ColorOutput = { | ||
hslVal: RGBA; | ||
p3: string; | ||
}; | ||
export declare const HEX_RE: RegExp; | ||
export declare const RGB_RE: RegExp; | ||
export declare const HSL_RE: RegExp; | ||
export declare const P3_RE: RegExp; | ||
/** | ||
@@ -25,2 +31,3 @@ * Parse any valid CSS color color string and convert to: | ||
* - hslVal: [0, 1, 1, 1] // 0% = 0, 100% = 1 | ||
* - p3: 'color(display-p3 1 0 0)' | ||
*/ | ||
@@ -27,0 +34,0 @@ export declare function from(rawColor: Color): ColorOutput; |
import NP from 'number-precision'; | ||
import cssNames from './css-names.js'; | ||
import { leftPad } from './utils.js'; | ||
import { clamp, leftPad } from './utils.js'; | ||
NP.enableBoundaryChecking(false); // don’t throw error on inaccurate calculation | ||
const P = 5; // standard precision: 16-bit | ||
const COMMA = '(\\s*,\\s*|\\s+)'; | ||
const FLOAT = '-?[0-9]+(\\.[0-9]+)?'; | ||
export const HEX_RE = /^#?[0-9a-f]{3,8}$/i; | ||
export const RGB_RE = new RegExp(`^rgba?\\(\\s*(?<R>${FLOAT})${COMMA}(?<G>${FLOAT})${COMMA}(?<B>${FLOAT})(${COMMA}(?<A>${FLOAT}))?\\s*\\)$`, 'i'); | ||
export const HSL_RE = new RegExp(`^hsla?\\(\\s*(?<H>${FLOAT})${COMMA}(?<S>${FLOAT})%${COMMA}(?<L>${FLOAT})%(${COMMA}(?<A>${FLOAT}))?\\s*\\)$`, 'i'); | ||
export const P3_RE = new RegExp(`^color\\(\\s*display-p3\\s+(?<R>${FLOAT})\\s+(?<G>${FLOAT})\\s+(?<B>${FLOAT})(\\s*\\/\\s*(?<A>${FLOAT}))?\\s*\\)$`, 'i'); | ||
const { plus, minus, times, divide, round, strip } = NP; | ||
/** | ||
@@ -14,2 +22,3 @@ * Parse any valid CSS color color string and convert to: | ||
* - hslVal: [0, 1, 1, 1] // 0% = 0, 100% = 1 | ||
* - p3: 'color(display-p3 1 0 0)' | ||
*/ | ||
@@ -23,4 +32,4 @@ export function from(rawColor) { | ||
if (n < 3) | ||
return leftPad(v.toString(16), 2); | ||
return v < 1 ? NP.round(255 * v, 0).toString(16) : ''; | ||
return leftPad(round(v, 0).toString(16), 2); | ||
return v < 1 ? round(times(255, v), 0).toString(16) : ''; | ||
}) | ||
@@ -32,4 +41,4 @@ .join('')}`; | ||
if (n < 3) | ||
return leftPad(v.toString(16), 2); | ||
return v < 1 ? leftPad((256 * v).toString(16), 2) : ''; | ||
return leftPad(round(v, 0).toString(16), 2); | ||
return v < 1 ? leftPad(times(256, v).toString(16), 2) : ''; | ||
}); | ||
@@ -40,6 +49,6 @@ return parseInt(`0x${hex.join('')}`, 16); | ||
if (color[3] == 1) { | ||
return `rgb(${color[0]}, ${color[1]}, ${color[2]})`; | ||
return `rgb(${round(color[0], 0)}, ${round(color[1], 0)}, ${round(color[2], 0)})`; | ||
} | ||
else { | ||
return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3]})`; | ||
return `rgba(${round(color[0], 0)}, ${round(color[1], 0)}, ${round(color[2], 0)}, ${round(color[3], P)})`; | ||
} | ||
@@ -54,3 +63,3 @@ }, | ||
const [h, s, l, a] = rgbToHSL(color); | ||
return `hsl(${h}, ${NP.strip(s * 100)}%, ${NP.strip(l * 100)}%, ${a})`; | ||
return `hsl(${h}, ${strip(times(s, 100))}%, ${strip(times(l, 100))}%, ${round(a, P)})`; | ||
}, | ||
@@ -60,2 +69,6 @@ get hslVal() { | ||
}, | ||
get p3() { | ||
const [r, g, b, a] = color; | ||
return `color(display-p3 ${round(divide(r, 255), P)} ${round(divide(g, 255), P)} ${round(divide(b, 255), P)}${a < 1 ? `/${round(a, P)}` : ''})`; | ||
}, | ||
}; | ||
@@ -71,9 +84,8 @@ } | ||
export function mix(color1, color2, weight = 0.5) { | ||
if (!(weight >= 0 && weight <= 1)) | ||
throw new Error(`Weight must be between 0 and 1, received ${weight}`); | ||
const w1 = 1 - weight; | ||
const w2 = weight; | ||
const w = clamp(weight, 0, 1); | ||
const w1 = minus(1, w); | ||
const w2 = w; | ||
const [r1, g1, b1, a1] = from(color1).rgbVal; | ||
const [r2, g2, b2, a2] = from(color2).rgbVal; | ||
return [Math.round(r1 ** 2 * w1 + r2 ** 2 * w2), Math.round(g1 ** 2 * w1 + g2 ** 2 * w2), Math.round(b1 ** 2 * w1 + b2 ** 2 * w2), Math.round(a1 * w1 + a2 * w2)]; | ||
return [round(plus(times(r1 ** 2, w1), times(r2 ** 2, w2)), 0), round(plus(times(g1 ** 2, w1), times(g2 ** 2, w2)), 0), round(plus(times(b1 ** 2, w1), times(b2 ** 2, w2)), 0), round(plus(times(a1, w1), times(a2, w2)), 0)]; | ||
} | ||
@@ -84,5 +96,3 @@ /** Convert any number of user inputs into RGBA array */ | ||
function parseHexVal(hexVal) { | ||
if (hexVal < 0 || hexVal > 0xffffffff) | ||
throw new Error(`Expected color in hex range (0xff0000), received ${hexVal.toString(16) || hexVal}`); | ||
const hexStr = leftPad(hexVal.toString(16), 6); // note: 0x000001 will convert to '1' | ||
const hexStr = leftPad(clamp(hexVal, 0, 0xffffffff).toString(16), 6); // note: 0x000001 will convert to '1' | ||
return [ | ||
@@ -92,17 +102,5 @@ parseInt(hexStr.substring(0, 2), 16), | ||
parseInt(hexStr.substring(4, 6), 16), | ||
parseInt(hexStr.substring(6, 8) || 'ff', 16) / 255, // a | ||
round(divide(parseInt(hexStr.substring(6, 8) || 'ff', 16), 255), P), // a | ||
]; | ||
} | ||
/** Validate RGBA array */ | ||
function validate(c) { | ||
if (!(c[0] >= 0 && c[0] <= 255)) | ||
throw new Error(`${rawColor} Expected r to be between 0 and 255, received ${c[0]}`); | ||
if (!(c[1] >= 0 && c[1] <= 255)) | ||
throw new Error(`${rawColor} Expected g to be between 0 and 255, received ${c[1]}`); | ||
if (!(c[2] >= 0 && c[2] <= 255)) | ||
throw new Error(`${rawColor} Expected b to be between 0 and 255, received ${c[2]}`); | ||
if (!(c[3] >= 0 && c[3] <= 1)) | ||
throw new Error(`${rawColor} Expected alpha to be between 0 and 1, received ${c[3]}`); | ||
return c; | ||
} | ||
// [R, G, B] or [R, G, B, A] | ||
@@ -118,9 +116,5 @@ if (Array.isArray(rawColor)) { | ||
const v = rawColor[n]; | ||
if (n == 3 && (v < 0 || v > 1)) | ||
throw new Error(`Alpha must be between 0 and 1, received ${v}`); | ||
else if (v < 0 || v > 255) | ||
throw new Error(`Color channel must be between 0 and 255, received ${v}`); | ||
color[n] = rawColor[n]; | ||
color[n] = clamp(v, 0, n == 3 ? 1 : 255); | ||
} | ||
return validate(color); | ||
return color; | ||
} | ||
@@ -130,3 +124,3 @@ // 0xff0000 (number) | ||
const color = parseHexVal(rawColor); | ||
return validate(color); | ||
return color; | ||
} | ||
@@ -141,52 +135,45 @@ // '#ff0000' / 'red' / 'rgb(255, 0, 0)' / 'hsl(0, 1, 1)' | ||
const color = parseHexVal(cssNames[strVal]); | ||
return validate(color); | ||
return color; | ||
} | ||
// hex | ||
const maybeHex = parseInt(strVal.replace(/^#/, ''), 16); | ||
if (!Number.isNaN(maybeHex)) { | ||
const color = parseHexVal(maybeHex); | ||
return validate(color); | ||
if (HEX_RE.test(strVal)) { | ||
const hex = strVal.replace('#', ''); | ||
const hexNum = parseInt(hex.length < 6 | ||
? hex | ||
.split('') | ||
.map((d) => `${d}${d}`) | ||
.join('') // handle shortcut (#fff) | ||
: hex, 16); | ||
const color = parseHexVal(hexNum); | ||
return color; | ||
} | ||
// rgb | ||
if (RGB_RE.test(strVal)) { | ||
const v = RGB_RE.exec(strVal).groups || {}; | ||
const color = [clamp(parseFloat(v.R), 0, 255), clamp(parseFloat(v.G), 0, 255), clamp(parseFloat(v.B), 0, 255), v.A ? clamp(parseFloat(v.A), 0, 1) : 1]; | ||
return color; | ||
} | ||
// hsl | ||
if (strVal.toLocaleLowerCase().startsWith('hsl')) { | ||
if (HSL_RE.test(strVal)) { | ||
const hsl = [0, 0, 0, 1]; | ||
let [h, s, l, a] = strVal | ||
.replace(/hsla?\s*\(/i, '') | ||
.replace(/\)\s*$/, '') | ||
.split(','); | ||
hsl[0] = parseFloat(h); | ||
if ((s.includes('%') && !l.includes('%')) || (!s.includes('%') && l.includes('%'))) | ||
throw new Error(`Mix of % and normalized (0–1) values in "${strVal}", prefer one or the other.`); | ||
if (s.includes('%')) { | ||
const sVal = parseFloat(s) / 100; | ||
const lVal = parseFloat(l) / 100; | ||
if (sVal < 0 || sVal > 100) | ||
throw new Error(`Saturation out of bounds for "${strVal}", must be between 0% and 100%`); | ||
if (lVal < 0 || lVal > 100) | ||
throw new Error(`Lightness out of bounds for "${strVal}", must be between 0% and 100%`); | ||
hsl[1] = sVal; | ||
hsl[2] = lVal; | ||
} | ||
else { | ||
const sVal = parseFloat(s); | ||
const lVal = parseFloat(l); | ||
if (sVal < 0 || sVal > 1) | ||
throw new Error(`Saturation out of bounds for "${strVal}", must be between 0 and 1 (or be a %)`); | ||
if (lVal < 0 || lVal > 1) | ||
throw new Error(`Lightness out of bounds for "${strVal}", must be between 0 and 1 (or be a %)`); | ||
hsl[1] = sVal; | ||
hsl[2] = lVal; | ||
} | ||
hsl[3] = parseFloat(a || '1'); | ||
const v = HSL_RE.exec(strVal).groups || {}; | ||
hsl[0] = parseFloat(v.H); | ||
const isPerc = strVal.includes('%'); | ||
let sVal = parseFloat(v.S); | ||
let lVal = parseFloat(v.L); | ||
if (isPerc) | ||
sVal /= 100; | ||
if (isPerc) | ||
lVal /= 100; | ||
hsl[1] = clamp(sVal, 0, 1); | ||
hsl[2] = clamp(lVal, 0, 1); | ||
hsl[3] = v.A ? parseFloat(v.A) : 1; | ||
const color = hslToRGB(hsl); | ||
return validate(color); | ||
return color; | ||
} | ||
// rgb (and fallbacks) | ||
const rawStr = strVal.replace(/rgba?\s*\(/i, '').replace(/\)\s*$/, ''); | ||
const values = rawStr.includes(',') ? rawStr.split(',').filter((v) => !!v.trim()) : rawStr.split(' ').filter((v) => !!v.trim()); | ||
if (values.length != 3 && values.length != 4) | ||
throw new Error(`Unable to parse color "${rawColor}"`); | ||
const [r, g, b, a] = values; | ||
const color = [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10), parseFloat((a || '1').trim())]; | ||
return validate(color); | ||
// p3 | ||
if (P3_RE.test(strVal)) { | ||
const v = P3_RE.exec(strVal).groups || {}; | ||
return [times(clamp(parseFloat(v.R), 0, 1), 255), times(clamp(parseFloat(v.G), 0, 1), 255), times(clamp(parseFloat(v.B), 0, 1), 255), v.A ? clamp(parseFloat(v.A), 0, 1) : 1]; | ||
} | ||
} | ||
@@ -248,4 +235,4 @@ throw new Error(`Unable to parse color "${rawColor}"`); | ||
H = Math.abs(H % 360); // allow < 0 and > 360 | ||
const C = (1 - Math.abs(2 * L - 1)) * S; | ||
const X = C * (1 - Math.abs(((H / 60) % 2) - 1)); | ||
const C = times(S, minus(1, Math.abs(minus(times(2, L), 1)))); | ||
const X = times(C, minus(1, Math.abs(minus(divide(H, 60) % 2, 1)))); | ||
let R = 0; | ||
@@ -278,4 +265,4 @@ let G = 0; | ||
} | ||
const m = L - C / 2; | ||
return [Math.round((R + m) * 255), Math.round((G + m) * 255), Math.round((B + m) * 255), A]; | ||
const m = minus(L, divide(C, 2)); | ||
return [round(times(plus(R, m), 255), P), round(times(plus(G, m), 255), P), round(times(plus(B, m), 255), P), round(A, P)]; | ||
} | ||
@@ -293,8 +280,8 @@ /** | ||
let S = 0; | ||
let L = (M + m) / 2; // default: standard HSL (“fill”) calculation | ||
let L = divide(plus(M, m), 2); // default: standard HSL (“fill”) calculation | ||
// if white/black/gray, exit early | ||
if (M == m) | ||
return [H, S, NP.round(L / 255, 4), A]; | ||
return [H, S, NP.round(divide(L, 255), 4), A]; | ||
// if any other color, calculate hue & saturation | ||
const C = M - m; | ||
const C = minus(M, m); | ||
// Hue | ||
@@ -304,9 +291,9 @@ if (C != 0) { | ||
case R: | ||
H = (60 * (G - B)) / C; | ||
H = divide(times(60, minus(G, B)), C); | ||
break; | ||
case G: | ||
H = 60 * (2 + (B - R) / C); | ||
H = times(60, plus(2, divide(minus(B, R), C))); | ||
break; | ||
case B: | ||
H = 60 * (4 + (R - G) / C); | ||
H = times(60, plus(4, divide(minus(R, G), C))); | ||
break; | ||
@@ -320,5 +307,5 @@ } | ||
if (L != 0 && L != 1) { | ||
S = (M - L) / Math.min(L, 255 - L); | ||
S = divide(minus(M, L), Math.min(L, minus(255, L))); | ||
} | ||
return [NP.round(H, 2), NP.round(S, 4), NP.round(L / 255, 4), A]; | ||
return [round(H, P - 2), round(S, P), round(divide(L, 255), P), A]; | ||
} | ||
@@ -325,0 +312,0 @@ export default { |
@@ -5,1 +5,2 @@ /** you know it, you love it */ | ||
export declare function radToDeg(radians: number): number; | ||
export declare function clamp(input: number, min: number, max: number): number; |
@@ -16,1 +16,4 @@ import NP from 'number-precision'; | ||
} | ||
export function clamp(input, min, max) { | ||
return Math.min(Math.max(input, min), max); | ||
} |
{ | ||
"name": "better-color-tools", | ||
"description": "Better color manipulation for Sass and JavaScript / TypeScript.", | ||
"version": "0.0.2", | ||
"version": "0.1.0", | ||
"author": { | ||
@@ -28,2 +28,11 @@ "name": "Drew Powers", | ||
"types": "./dist/index.d.ts", | ||
"scripts": { | ||
"build": "rm -rf dist && tsc", | ||
"changeset": "changeset", | ||
"dev": "tsc -w", | ||
"lint": "eslint \"**/*.{js,ts}\"", | ||
"prepublishOnly": "npm run build", | ||
"pretest": "npm run build", | ||
"test": "mocha --parallel" | ||
}, | ||
"dependencies": { | ||
@@ -33,8 +42,8 @@ "number-precision": "^1.5.1" | ||
"devDependencies": { | ||
"@changesets/cli": "^2.18.1", | ||
"@types/node": "^16.11.11", | ||
"@typescript-eslint/eslint-plugin": "^5.5.0", | ||
"@typescript-eslint/parser": "^5.5.0", | ||
"@changesets/cli": "^2.19.0", | ||
"@types/node": "^16.11.19", | ||
"@typescript-eslint/eslint-plugin": "^5.9.0", | ||
"@typescript-eslint/parser": "^5.9.0", | ||
"chai": "^4.3.4", | ||
"eslint": "^8.4.0", | ||
"eslint": "^8.6.0", | ||
"eslint-config-prettier": "^8.3.0", | ||
@@ -45,12 +54,4 @@ "eslint-plugin-prettier": "^4.0.0", | ||
"prettier": "^2.5.1", | ||
"typescript": "^4.5.2" | ||
}, | ||
"scripts": { | ||
"build": "rm -rf dist && tsc", | ||
"changeset": "changeset", | ||
"dev": "tsc -w", | ||
"lint": "eslint \"**/*.{js,ts}\"", | ||
"pretest": "npm run build", | ||
"test": "mocha --parallel" | ||
"typescript": "^4.5.4" | ||
} | ||
} | ||
} |
@@ -5,2 +5,8 @@ # better-color-tools | ||
Supports: | ||
- ✅ RGB / Hex | ||
- ✅ HSL | ||
- ✅ [P3] | ||
## Installing | ||
@@ -122,4 +128,4 @@ | ||
It’s in HSL handling where approaches differ. Because HSL is a smaller color space than RGB, in order to use it, it **requires at least 1 decimal place.** So any library that rounds out-of-the-box will produce inaccurate results (compare this library to | ||
[color-convert] converting from RGB -> HSL and back again): | ||
It’s in HSL handling where approaches differ. Few realize that when using whole numbers in HSL, [it only has 14.5% the colors of RGB][hsl-rgb]. In order to recreate the full RGB spectrum you need **at least 1 decimal place in all H, S, and L values.** For | ||
this reason, any library that uses whole numbers in HSL out-of-the-box will result in distorted colors and quality loss (compare this library to [color-convert] converting from RGB -> HSL and back again): | ||
@@ -142,2 +148,4 @@ ```ts | ||
`color.from()` takes any valid CSS string, hex number, or RGBA array as an input, and can generate any desired output as a result: | ||
```ts | ||
@@ -151,2 +159,8 @@ import color from 'better-color-tools'; | ||
// convert hex to p3 | ||
color.from('rgb(196, 67, 43, 0.8)').p3; // 'color(display-p3 0.76863 0.26275 0.16863/0.8)' | ||
// convert from p3 to hex | ||
color.from('color(display-p3 0.23 0.872 0.918)').hex; // #3bdeea | ||
// convert color to rgb | ||
@@ -157,6 +171,6 @@ color.from('#C4432B').rgb; // 'rgb(196, 67, 43)' | ||
// convert color to rgba | ||
// convert hex to rgba | ||
color.from('#C4432B').rgba; // 'rgba(196, 67, 43, 1)' | ||
color.from(0xc4432b).rgba; // 'rgba(196, 67, 43, 1)' | ||
color.from('#C4432B').rgbaVal; // [196, 67, 43, 1] | ||
color.from('#C4432B80').rgbaVal; // [196, 67, 43, 0.5] | ||
@@ -168,2 +182,5 @@ // convert color to hsl | ||
// convert hsl to rgb | ||
color.from('hsl(328, 100%, 54%)').rgb; // 'rgb(255, 20, 146)' | ||
// convert color names to hex | ||
@@ -173,6 +190,17 @@ color.from('rebeccapurple').hex; // '#663399' | ||
#### A note on P3 | ||
The [P3 colorspace][p3] is larger than RGB. As a result, many tools apply [gamut matrices](http://endavid.com/index.php?entry=79) to convert RGB to P3 and vice-versa. While that is needed when dealing with image editing software and white-balancing, it’s | ||
unnecessary for the web. Since browsers are better at this conversion (and may improve it over time), this library takes an intentional “hands-off” approach where P3 is equated with **Ideal RGB**, e.g.: | ||
| P3 Color | Ideal RGB | colorjs.io | | ||
| :------: | :----------: | :----------: | | ||
| `1 0 0` | ✅ `255 0 0` | ❌ `250 0 0` | | ||
This approach produces better color conversion across-the-board by letting the browser make conversions rather than the library, which is also the approach | ||
[recommended by Apple for CSS](https://webkit.org/blog/10042/wide-gamut-color-in-css-with-display-p3/). TL;DR this library’s P3 conversion is optimized for web. | ||
## TODO / Roadmap | ||
- Adding color spaces like [Adobe](https://en.wikipedia.org/wiki/Adobe_RGB_color_space) and [Rec 709](https://en.wikipedia.org/wiki/Rec._709) to allow color mixing and lightening/darkening to use different perceptual color algorithms | ||
- This library currently only supports 8-bit RGB (web & apps); is 16-bit useful? (create an issue!) | ||
- **Planned**: Adding color spaces like [Adobe](https://en.wikipedia.org/wiki/Adobe_RGB_color_space) and [Rec 709](https://en.wikipedia.org/wiki/Rec._709) to allow color mixing and lightening/darkening to use different perceptual color algorithms | ||
@@ -182,4 +210,6 @@ [color-convert]: https://github.com/Qix-/color-convert | ||
[hsl]: https://en.wikipedia.org/wiki/HSL_and_HSV#Disadvantages | ||
[hsl-rgb]: https://pow.rs/blog/dont-use-hsl-for-anything/ | ||
[number-precision]: https://github.com/nefe/number-precision | ||
[p3]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color() | ||
[sass-color]: https://sass-lang.com/documentation/modules/color | ||
[sass-color-scale]: https://sass-lang.com/documentation/modules/color#scale |
170
src/index.ts
import NP from 'number-precision'; | ||
import cssNames from './css-names.js'; | ||
import { leftPad } from './utils.js'; | ||
import { clamp, leftPad } from './utils.js'; | ||
@@ -8,7 +8,27 @@ export type RGB = [number, number, number]; | ||
export type HSL = [number, number, number, number]; | ||
export type P3 = [number, number, number, number]; | ||
export type Color = string | number | RGB | RGBA; | ||
export type ColorOutput = { hex: string; rgb: string; rgba: string; hexVal: number; rgbVal: RGBA; rgbaVal: RGBA; hsl: string; hslVal: RGBA }; | ||
export type ColorOutput = { | ||
hex: string; | ||
rgb: string; | ||
rgba: string; | ||
hexVal: number; | ||
rgbVal: RGBA; | ||
rgbaVal: RGBA; | ||
hsl: string; | ||
hslVal: RGBA; | ||
p3: string; | ||
}; | ||
NP.enableBoundaryChecking(false); // don’t throw error on inaccurate calculation | ||
const P = 5; // standard precision: 16-bit | ||
const COMMA = '(\\s*,\\s*|\\s+)'; | ||
const FLOAT = '-?[0-9]+(\\.[0-9]+)?'; | ||
export const HEX_RE = /^#?[0-9a-f]{3,8}$/i; | ||
export const RGB_RE = new RegExp(`^rgba?\\(\\s*(?<R>${FLOAT})${COMMA}(?<G>${FLOAT})${COMMA}(?<B>${FLOAT})(${COMMA}(?<A>${FLOAT}))?\\s*\\)$`, 'i'); | ||
export const HSL_RE = new RegExp(`^hsla?\\(\\s*(?<H>${FLOAT})${COMMA}(?<S>${FLOAT})%${COMMA}(?<L>${FLOAT})%(${COMMA}(?<A>${FLOAT}))?\\s*\\)$`, 'i'); | ||
export const P3_RE = new RegExp(`^color\\(\\s*display-p3\\s+(?<R>${FLOAT})\\s+(?<G>${FLOAT})\\s+(?<B>${FLOAT})(\\s*\\/\\s*(?<A>${FLOAT}))?\\s*\\)$`, 'i'); | ||
const { plus, minus, times, divide, round, strip } = NP; | ||
/** | ||
@@ -23,2 +43,3 @@ * Parse any valid CSS color color string and convert to: | ||
* - hslVal: [0, 1, 1, 1] // 0% = 0, 100% = 1 | ||
* - p3: 'color(display-p3 1 0 0)' | ||
*/ | ||
@@ -32,4 +53,4 @@ export function from(rawColor: Color): ColorOutput { | ||
.map((v, n) => { | ||
if (n < 3) return leftPad(v.toString(16), 2); | ||
return v < 1 ? NP.round(255 * v, 0).toString(16) : ''; | ||
if (n < 3) return leftPad(round(v, 0).toString(16), 2); | ||
return v < 1 ? round(times(255, v), 0).toString(16) : ''; | ||
}) | ||
@@ -40,4 +61,4 @@ .join('')}`; | ||
const hex = color.map((v, n) => { | ||
if (n < 3) return leftPad(v.toString(16), 2); | ||
return v < 1 ? leftPad((256 * v).toString(16), 2) : ''; | ||
if (n < 3) return leftPad(round(v, 0).toString(16), 2); | ||
return v < 1 ? leftPad(times(256, v).toString(16), 2) : ''; | ||
}); | ||
@@ -48,5 +69,5 @@ return parseInt(`0x${hex.join('')}`, 16); | ||
if (color[3] == 1) { | ||
return `rgb(${color[0]}, ${color[1]}, ${color[2]})`; | ||
return `rgb(${round(color[0], 0)}, ${round(color[1], 0)}, ${round(color[2], 0)})`; | ||
} else { | ||
return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3]})`; | ||
return `rgba(${round(color[0], 0)}, ${round(color[1], 0)}, ${round(color[2], 0)}, ${round(color[3], P)})`; | ||
} | ||
@@ -61,3 +82,3 @@ }, | ||
const [h, s, l, a] = rgbToHSL(color); | ||
return `hsl(${h}, ${NP.strip(s * 100)}%, ${NP.strip(l * 100)}%, ${a})`; | ||
return `hsl(${h}, ${strip(times(s, 100))}%, ${strip(times(l, 100))}%, ${round(a, P)})`; | ||
}, | ||
@@ -67,2 +88,6 @@ get hslVal(): RGBA { | ||
}, | ||
get p3(): string { | ||
const [r, g, b, a] = color; | ||
return `color(display-p3 ${round(divide(r, 255), P)} ${round(divide(g, 255), P)} ${round(divide(b, 255), P)}${a < 1 ? `/${round(a, P)}` : ''})`; | ||
}, | ||
}; | ||
@@ -79,8 +104,8 @@ } | ||
export function mix(color1: Color, color2: Color, weight = 0.5): RGBA { | ||
if (!(weight >= 0 && weight <= 1)) throw new Error(`Weight must be between 0 and 1, received ${weight}`); | ||
const w1 = 1 - weight; | ||
const w2 = weight; | ||
const w = clamp(weight, 0, 1); | ||
const w1 = minus(1, w); | ||
const w2 = w; | ||
const [r1, g1, b1, a1] = from(color1).rgbVal; | ||
const [r2, g2, b2, a2] = from(color2).rgbVal; | ||
return [Math.round(r1 ** 2 * w1 + r2 ** 2 * w2), Math.round(g1 ** 2 * w1 + g2 ** 2 * w2), Math.round(b1 ** 2 * w1 + b2 ** 2 * w2), Math.round(a1 * w1 + a2 * w2)]; | ||
return [round(plus(times(r1 ** 2, w1), times(r2 ** 2, w2)), 0), round(plus(times(g1 ** 2, w1), times(g2 ** 2, w2)), 0), round(plus(times(b1 ** 2, w1), times(b2 ** 2, w2)), 0), round(plus(times(a1, w1), times(a2, w2)), 0)]; | ||
} | ||
@@ -92,4 +117,3 @@ | ||
function parseHexVal(hexVal: number): RGBA { | ||
if (hexVal < 0 || hexVal > 0xffffffff) throw new Error(`Expected color in hex range (0xff0000), received ${hexVal.toString(16) || hexVal}`); | ||
const hexStr = leftPad(hexVal.toString(16), 6); // note: 0x000001 will convert to '1' | ||
const hexStr = leftPad(clamp(hexVal, 0, 0xffffffff).toString(16), 6); // note: 0x000001 will convert to '1' | ||
return [ | ||
@@ -99,15 +123,6 @@ parseInt(hexStr.substring(0, 2), 16), // r | ||
parseInt(hexStr.substring(4, 6), 16), // b | ||
parseInt(hexStr.substring(6, 8) || 'ff', 16) / 255, // a | ||
round(divide(parseInt(hexStr.substring(6, 8) || 'ff', 16), 255), P), // a | ||
]; | ||
} | ||
/** Validate RGBA array */ | ||
function validate(c: RGBA): RGBA { | ||
if (!(c[0] >= 0 && c[0] <= 255)) throw new Error(`${rawColor} Expected r to be between 0 and 255, received ${c[0]}`); | ||
if (!(c[1] >= 0 && c[1] <= 255)) throw new Error(`${rawColor} Expected g to be between 0 and 255, received ${c[1]}`); | ||
if (!(c[2] >= 0 && c[2] <= 255)) throw new Error(`${rawColor} Expected b to be between 0 and 255, received ${c[2]}`); | ||
if (!(c[3] >= 0 && c[3] <= 1)) throw new Error(`${rawColor} Expected alpha to be between 0 and 1, received ${c[3]}`); | ||
return c; | ||
} | ||
// [R, G, B] or [R, G, B, A] | ||
@@ -121,7 +136,5 @@ if (Array.isArray(rawColor)) { | ||
const v = rawColor[n]; | ||
if (n == 3 && (v < 0 || v > 1)) throw new Error(`Alpha must be between 0 and 1, received ${v}`); | ||
else if (v < 0 || v > 255) throw new Error(`Color channel must be between 0 and 255, received ${v}`); | ||
color[n] = rawColor[n]; | ||
color[n] = clamp(v, 0, n == 3 ? 1 : 255); | ||
} | ||
return validate(color); | ||
return color; | ||
} | ||
@@ -132,3 +145,3 @@ | ||
const color = parseHexVal(rawColor); | ||
return validate(color); | ||
return color; | ||
} | ||
@@ -144,45 +157,46 @@ | ||
const color = parseHexVal(cssNames[strVal as keyof typeof cssNames] as number); | ||
return validate(color); | ||
return color; | ||
} | ||
// hex | ||
const maybeHex = parseInt(strVal.replace(/^#/, ''), 16); | ||
if (!Number.isNaN(maybeHex)) { | ||
const color = parseHexVal(maybeHex); | ||
return validate(color); | ||
if (HEX_RE.test(strVal)) { | ||
const hex = strVal.replace('#', ''); | ||
const hexNum = parseInt( | ||
hex.length < 6 | ||
? hex | ||
.split('') | ||
.map((d) => `${d}${d}`) | ||
.join('') // handle shortcut (#fff) | ||
: hex, | ||
16 | ||
); | ||
const color = parseHexVal(hexNum); | ||
return color; | ||
} | ||
// rgb | ||
if (RGB_RE.test(strVal)) { | ||
const v: Record<string, string> = (RGB_RE.exec(strVal) as any).groups || {}; | ||
const color: RGBA = [clamp(parseFloat(v.R), 0, 255), clamp(parseFloat(v.G), 0, 255), clamp(parseFloat(v.B), 0, 255), v.A ? clamp(parseFloat(v.A), 0, 1) : 1]; | ||
return color; | ||
} | ||
// hsl | ||
if (strVal.toLocaleLowerCase().startsWith('hsl')) { | ||
if (HSL_RE.test(strVal)) { | ||
const hsl: HSL = [0, 0, 0, 1]; | ||
let [h, s, l, a] = strVal | ||
.replace(/hsla?\s*\(/i, '') | ||
.replace(/\)\s*$/, '') | ||
.split(','); | ||
hsl[0] = parseFloat(h); | ||
if ((s.includes('%') && !l.includes('%')) || (!s.includes('%') && l.includes('%'))) throw new Error(`Mix of % and normalized (0–1) values in "${strVal}", prefer one or the other.`); | ||
if (s.includes('%')) { | ||
const sVal = parseFloat(s) / 100; | ||
const lVal = parseFloat(l) / 100; | ||
if (sVal < 0 || sVal > 100) throw new Error(`Saturation out of bounds for "${strVal}", must be between 0% and 100%`); | ||
if (lVal < 0 || lVal > 100) throw new Error(`Lightness out of bounds for "${strVal}", must be between 0% and 100%`); | ||
hsl[1] = sVal; | ||
hsl[2] = lVal; | ||
} else { | ||
const sVal = parseFloat(s); | ||
const lVal = parseFloat(l); | ||
if (sVal < 0 || sVal > 1) throw new Error(`Saturation out of bounds for "${strVal}", must be between 0 and 1 (or be a %)`); | ||
if (lVal < 0 || lVal > 1) throw new Error(`Lightness out of bounds for "${strVal}", must be between 0 and 1 (or be a %)`); | ||
hsl[1] = sVal; | ||
hsl[2] = lVal; | ||
} | ||
hsl[3] = parseFloat(a || '1'); | ||
const v: Record<string, string> = (HSL_RE.exec(strVal) as any).groups || {}; | ||
hsl[0] = parseFloat(v.H); | ||
const isPerc = strVal.includes('%'); | ||
let sVal = parseFloat(v.S); | ||
let lVal = parseFloat(v.L); | ||
if (isPerc) sVal /= 100; | ||
if (isPerc) lVal /= 100; | ||
hsl[1] = clamp(sVal, 0, 1); | ||
hsl[2] = clamp(lVal, 0, 1); | ||
hsl[3] = v.A ? parseFloat(v.A) : 1; | ||
const color = hslToRGB(hsl); | ||
return validate(color); | ||
return color; | ||
} | ||
// rgb (and fallbacks) | ||
const rawStr = strVal.replace(/rgba?\s*\(/i, '').replace(/\)\s*$/, ''); | ||
const values = rawStr.includes(',') ? rawStr.split(',').filter((v) => !!v.trim()) : rawStr.split(' ').filter((v) => !!v.trim()); | ||
if (values.length != 3 && values.length != 4) throw new Error(`Unable to parse color "${rawColor}"`); | ||
const [r, g, b, a] = values; | ||
const color: RGBA = [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10), parseFloat((a || '1').trim())]; | ||
return validate(color); | ||
// p3 | ||
if (P3_RE.test(strVal)) { | ||
const v: Record<string, string> = (P3_RE.exec(strVal) as any).groups || {}; | ||
return [times(clamp(parseFloat(v.R), 0, 1), 255), times(clamp(parseFloat(v.G), 0, 1), 255), times(clamp(parseFloat(v.B), 0, 1), 255), v.A ? clamp(parseFloat(v.A), 0, 1) : 1]; | ||
} | ||
} | ||
@@ -245,4 +259,4 @@ | ||
const C = (1 - Math.abs(2 * L - 1)) * S; | ||
const X = C * (1 - Math.abs(((H / 60) % 2) - 1)); | ||
const C = times(S, minus(1, Math.abs(minus(times(2, L), 1)))); | ||
const X = times(C, minus(1, Math.abs(minus(divide(H, 60) % 2, 1)))); | ||
@@ -272,5 +286,5 @@ let R = 0; | ||
} | ||
const m = L - C / 2; | ||
const m = minus(L, divide(C, 2)); | ||
return [Math.round((R + m) * 255), Math.round((G + m) * 255), Math.round((B + m) * 255), A]; | ||
return [round(times(plus(R, m), 255), P), round(times(plus(G, m), 255), P), round(times(plus(B, m), 255), P), round(A, P)]; | ||
} | ||
@@ -291,9 +305,9 @@ | ||
let S = 0; | ||
let L = (M + m) / 2; // default: standard HSL (“fill”) calculation | ||
let L = divide(plus(M, m), 2); // default: standard HSL (“fill”) calculation | ||
// if white/black/gray, exit early | ||
if (M == m) return [H, S, NP.round(L / 255, 4), A]; | ||
if (M == m) return [H, S, NP.round(divide(L, 255), 4), A]; | ||
// if any other color, calculate hue & saturation | ||
const C = M - m; | ||
const C = minus(M, m); | ||
@@ -304,9 +318,9 @@ // Hue | ||
case R: | ||
H = (60 * (G - B)) / C; | ||
H = divide(times(60, minus(G, B)), C); | ||
break; | ||
case G: | ||
H = 60 * (2 + (B - R) / C); | ||
H = times(60, plus(2, divide(minus(B, R), C))); | ||
break; | ||
case B: | ||
H = 60 * (4 + (R - G) / C); | ||
H = times(60, plus(4, divide(minus(R, G), C))); | ||
break; | ||
@@ -321,6 +335,6 @@ } | ||
if (L != 0 && L != 1) { | ||
S = (M - L) / Math.min(L, 255 - L); | ||
S = divide(minus(M, L), Math.min(L, minus(255, L))); | ||
} | ||
return [NP.round(H, 2), NP.round(S, 4), NP.round(L / 255, 4), A]; | ||
return [round(H, P - 2), round(S, P), round(divide(L, 255), P), A]; | ||
} | ||
@@ -327,0 +341,0 @@ |
@@ -19,1 +19,5 @@ import NP from 'number-precision'; | ||
} | ||
export function clamp(input: number, min: number, max: number): number { | ||
return Math.min(Math.max(input, min), max); | ||
} |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
148698
1206
208