colorjs.io
Version:
Let’s get serious about color
1,981 lines (1,657 loc) • 105 kB
JavaScript
// A is m x n. B is n x p. product is m x p.
function multiplyMatrices(A, B) {
let m = A.length;
if (!Array.isArray(A[0])) {
// A is vector, convert to [[a, b, c, ...]]
A = [A];
}
if (!Array.isArray(B[0])) {
// B is vector, convert to [[a], [b], [c], ...]]
B = B.map(x => [x]);
}
let p = B[0].length;
let B_cols = B[0].map((_, i) => B.map(x => x[i])); // transpose B
let product = A.map(row => B_cols.map(col => {
let ret = 0;
if (!Array.isArray(row)) {
for (let c of col) {
ret += row * c;
}
return ret;
}
for (let i=0; i < row.length; i++) {
ret += row[i] * (col[i] || 0);
}
return ret;
}));
if (m === 1) {
product = product[0]; // Avoid [[a, b, c, ...]]
}
if (p === 1) {
return product.map(x => x[0]); // Avoid [[a], [b], [c], ...]]
}
return product;
}
/**
* Various utility functions
*/
/**
* Check if a value is a string (including a String object)
* @param {*} str - Value to check
* @returns {boolean}
*/
function isString (str) {
return type(str) === "string";
}
/**
* Determine the internal JavaScript [[Class]] of an object.
* @param {*} o - Value to check
* @returns {string}
*/
function type (o) {
let str = Object.prototype.toString.call(o);
return (str.match(/^\[object\s+(.*?)\]$/)[1] || "").toLowerCase();
}
/**
* Round a number to a certain number of significant digits
* @param {number} n - The number to round
* @param {number} precision - Number of significant digits
*/
function toPrecision(n, precision) {
n = +n;
precision = +precision;
let integerLength = (Math.floor(n) + "").length;
if (precision > integerLength) {
return +n.toFixed(precision - integerLength);
}
else {
let p10 = 10 ** (integerLength - precision);
return Math.round(n / p10) * p10;
}
}
/**
* Parse a CSS function, regardless of its name and arguments
* @param String str String to parse
* @return {{name, args, rawArgs}}
*/
function parseFunction (str) {
if (!str) {
return;
}
str = str.trim();
const isFunctionRegex = /^([a-z]+)\((.+?)\)$/i;
const isNumberRegex = /^-?[\d.]+$/;
let parts = str.match(isFunctionRegex);
if (parts) {
// It is a function, parse args
let args = [];
parts[2].replace(/\/?\s*([-\w.]+(?:%|deg)?)/g, ($0, arg) => {
if (/%$/.test(arg)) {
// Convert percentages to 0-1 numbers
arg = new Number(arg.slice(0, -1) / 100);
arg.type = "<percentage>";
}
else if (/deg$/.test(arg)) {
// Drop deg from degrees and convert to number
// TODO handle other units too
arg = new Number(+arg.slice(0, -3));
arg.type = "<angle>";
arg.unit = "deg";
}
else if (isNumberRegex.test(arg)) {
// Convert numerical args to numbers
arg = new Number(arg);
arg.type = "<number>";
}
if ($0.startsWith("/")) {
// It's alpha
arg = arg instanceof Number? arg : new Number(arg);
arg.alpha = true;
}
args.push(arg);
});
return {
name: parts[1].toLowerCase(),
rawName: parts[1],
rawArgs: parts[2],
// An argument could be (as of css-color-4):
// a number, percentage, degrees (hue), ident (in color())
args
};
}
}
function last(arr) {
return arr[arr.length - 1];
}
function interpolate (start, end, p) {
if (isNaN(start)) {
return end;
}
if (isNaN(end)) {
return start;
}
return start + (end - start) * p;
}
function interpolateInv (start, end, value) {
return (value - start) / (end - start);
}
function mapRange(from, to, value) {
return interpolate(to[0], to[1], interpolateInv(from[0], from[1], value));
}
function parseCoordGrammar (coordGrammars) {
return coordGrammars.map(coordGrammar => {
return coordGrammar.split("|").map(type => {
type = type.trim();
let range = type.match(/^(<[a-z]+>)\[(-?[.\d]+),\s*(-?[.\d]+)\]?$/);
if (range) {
let ret = new String(range[1]);
ret.range = [+range[2], +range[3]];
return ret;
}
return type;
});
});
}
var util = /*#__PURE__*/Object.freeze({
__proto__: null,
isString: isString,
type: type,
toPrecision: toPrecision,
parseFunction: parseFunction,
last: last,
interpolate: interpolate,
interpolateInv: interpolateInv,
mapRange: mapRange,
parseCoordGrammar: parseCoordGrammar,
multiplyMatrices: multiplyMatrices
});
/**
* A class for adding deep extensibility to any piece of JS code
*/
class Hooks {
add (name, callback, first) {
if (typeof arguments[0] != "string") {
// Multiple hooks
for (var name in arguments[0]) {
this.add(name, arguments[0][name], arguments[1]);
}
return;
}
(Array.isArray(name)? name : [name]).forEach(function(name) {
this[name] = this[name] || [];
if (callback) {
this[name][first? "unshift" : "push"](callback);
}
}, this);
}
run (name, env) {
this[name] = this[name] || [];
this[name].forEach(function(callback) {
callback.call(env && env.context? env.context : env, env);
});
}
}
/**
* The instance of {@link Hooks} used throughout Color.js
*/
const hooks = new Hooks();
// Global defaults one may want to configure
var defaults = {
gamut_mapping: "lch.c",
precision: 5,
deltaE: "76", // Default deltaE method
};
const WHITES = {
// for compatibility, the four-digit chromaticity-derived ones everyone else uses
D50: [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585],
D65: [0.3127 / 0.3290, 1.00000, (1.0 - 0.3127 - 0.3290) / 0.3290],
};
function getWhite(name) {
if (Array.isArray(name)) {
return name;
}
return WHITES[name];
}
// Adapt XYZ from white point W1 to W2
function adapt$1 (W1, W2, XYZ, options = {}) {
W1 = getWhite(W1);
W2 = getWhite(W2);
if (!W1 || !W2) {
throw new TypeError(`Missing white point to convert ${!W1? "from" : ""}${!W1&&!W2? "/" : ""}${!W2? "to" : ""}`);
}
if (W1 === W2) {
// Same whitepoints, no conversion needed
return XYZ;
}
let env = {W1, W2, XYZ, options};
hooks.run("chromatic-adaptation-start", env);
if (!env.M) {
if (env.W1 === WHITES.D65 && env.W2 === WHITES.D50) {
env.M = [
[ 1.0479298208405488, 0.022946793341019088, -0.05019222954313557 ],
[ 0.029627815688159344, 0.990434484573249, -0.01707382502938514 ],
[ -0.009243058152591178, 0.015055144896577895, 0.7518742899580008 ]
];
}
else if (env.W1 === WHITES.D50 && env.W2 === WHITES.D65) {
env.M = [
[ 0.9554734527042182, -0.023098536874261423, 0.0632593086610217 ],
[ -0.028369706963208136, 1.0099954580058226, 0.021041398966943008 ],
[ 0.012314001688319899, -0.020507696433477912, 1.3303659366080753 ]
];
}
}
hooks.run("chromatic-adaptation-end", env);
if (env.M) {
return multiplyMatrices(env.M, env.XYZ);
}
else {
throw new TypeError("Only Bradford CAT with white points D50 and D65 supported for now.");
}
}
const ε$4 = .000075;
/**
* Class to represent a color space
*/
class ColorSpace {
constructor (options) {
this.id = options.id;
this.name = options.name;
this.base = options.base ? ColorSpace.get(options.base) : null;
this.aliases = options.aliases;
if (this.base) {
this.fromBase = options.fromBase;
this.toBase = options.toBase;
}
// Coordinate metadata
let coords = options.coords ?? this.base.coords;
this.coords = coords;
// White point
let white = options.white ?? this.base.white ?? "D65";
this.white = getWhite(white);
// Sort out formats
this.formats = options.formats ?? {};
for (let name in this.formats) {
let format = this.formats[name];
format.type ||= "function";
format.name ||= name;
}
if (options.cssId && !this.formats.functions?.color) {
this.formats.color = { id: options.cssId };
Object.defineProperty(this, "cssId", {value: options.cssId});
}
else if (this.formats?.color && !this.formats?.color.id) {
this.formats.color.id = this.id;
}
// Other stuff
this.referred = options.referred;
// Compute ancestors and store them, since they will never change
this.#path = this.#getPath().reverse();
hooks.run("colorspace-init-end", this);
}
inGamut (coords, {epsilon = ε$4} = {}) {
if (this.isPolar) {
// Do not check gamut through polar coordinates
coords = this.toBase(coords);
return this.base.inGamut(coords, {epsilon});
}
let coordMeta = Object.values(this.coords);
return coords.every((c, i) => {
let meta = coordMeta[i];
if (meta.type !== "angle" && meta.range) {
if (Number.isNaN(c)) {
// NaN is always in gamut
return true;
}
let [min, max] = meta.range;
return (min === undefined || c >= min - epsilon)
&& (max === undefined || c <= max + epsilon);
}
return true;
});
}
get cssId () {
return this.formats.functions?.color?.id || this.id;
}
get isPolar() {
for (let id in this.coords) {
if (this.coords[id].type === "angle") {
return true;
}
}
return false;
}
#processFormat(format) {
if (format.coords && !format.coordGrammar) {
format.type ||= "function";
format.name ||= "color";
// Format has not been processed
format.coordGrammar = parseCoordGrammar(format.coords);
let coordFormats = Object.entries(this.coords).map(([id, coordMeta], i) => {
// Preferred format for each coord is the first one
let outputType = format.coordGrammar[i][0];
let fromRange = coordMeta.range || coordMeta.refRange;
let toRange = outputType.range, suffix = "";
// Non-strict equals intentional since outputType could be a string object
if (outputType == "<percentage>") {
toRange = [0, 100];
suffix = "%";
}
else if (outputType == "<angle>") {
suffix = "deg";
}
return {fromRange, toRange, suffix};
});
format.serializeCoords = (coords, precision) => {
return coords.map((c, i) => {
let {fromRange, toRange, suffix} = coordFormats[i];
if (fromRange && toRange) {
c = mapRange(fromRange, toRange, c);
}
c = toPrecision(c, precision);
if (suffix) {
c += suffix;
}
return c;
})
};
}
return format;
}
getFormat (format) {
if (typeof format === "object") {
format = this.#processFormat(format);
return format;
}
let ret;
if (format === "default") {
// Get first format
ret = Object.values(this.formats)[0];
}
else {
ret = this.formats[format];
}
if (ret) {
ret = this.#processFormat(ret);
return ret;
}
return null;
}
#path
#getPath () {
let ret = [this];
for (let space = this; space = space.base;) {
ret.push(space);
}
return ret;
}
to (space, coords) {
if (arguments.length === 1) {
[space, coords] = [space.space, space.coords];
}
space = ColorSpace.get(space);
if (this === space) {
// Same space, no change needed
return coords;
}
// Convert NaN to 0, which seems to be valid in every coordinate of every color space
coords = coords.map(c => Number.isNaN(c)? 0 : c);
// Find connection space = lowest common ancestor in the base tree
let myPath = this.#path;
let otherPath = space.#path;
let connectionSpace, connectionSpaceIndex;
for (let i=0; i < myPath.length; i++) {
if (myPath[i] === otherPath[i]) {
connectionSpace = myPath[i];
connectionSpaceIndex = i;
}
else {
break;
}
}
if (!connectionSpace) {
// This should never happen
throw new Error(`Cannot convert between color spaces ${this} and ${space}: no connection space was found`);
}
// Go up from current space to connection space
for (let i = myPath.length - 1; i > connectionSpaceIndex; i--) {
coords = myPath[i].toBase(coords);
}
// Go down from connection space to target space
for (let i = connectionSpaceIndex + 1; i < otherPath.length; i++) {
coords = otherPath[i].fromBase(coords);
}
return coords;
}
from (space, coords) {
if (arguments.length === 1) {
[space, coords] = [space.space, space.coords];
}
space = ColorSpace.get(space);
return space.to(this, coords);
}
toString () {
return `${this.name} (${this.id})`;
}
getMinCoords () {
let ret = [];
for (let id in this.coords) {
let meta = this.coords[id];
let range = meta.range || meta.refRange;
ret.push(range?.min ?? 0);
}
return ret;
}
static registry = {}
// Returns array of unique color spaces
static get all () {
return [...new Set(Object.values(ColorSpace.registry))];
}
static register (id, space) {
if (arguments.length === 1) {
space = arguments[0];
id = space.id;
}
space = this.get(space);
if (this.registry[id] && this.registry[id] !== space) {
throw new Error(`Duplicate color space registration: '${id}'`);
}
this.registry[id] = space;
// Register aliases when called without an explicit ID.
if (arguments.length === 1 && space.aliases) {
for (let alias of space.aliases) {
this.register(alias, space);
}
}
return space;
}
/**
* Lookup ColorSpace object by name
* @param {ColorSpace | string} name
*/
static get (space, ...alternatives) {
if (!space || space instanceof ColorSpace) {
return space;
}
let argType = type(space);
if (argType === "string") {
// It's a color space id
let ret = ColorSpace.registry[space.toLowerCase()];
if (!ret) {
throw new TypeError(`No color space found with id = "${space}"`);
}
return ret;
}
if (alternatives.length) {
return ColorSpace.get(...alternatives);
}
throw new TypeError(`${space} is not a valid color space`);
}
/**
* Get metadata about a coordinate of a color space
*
* @static
* @param {Array | string} ref
* @param {ColorSpace | string} [workingSpace]
* @return {Object}
*/
static resolveCoord (ref, workingSpace) {
let coordType = type(ref);
let space, coord;
if (coordType === "string") {
if (ref.includes(".")) {
// Absolute coordinate
[space, coord] = ref.split(".");
}
else {
// Relative coordinate
[space, coord] = [, ref];
}
}
else if (Array.isArray(ref)) {
[space, coord] = ref;
}
else {
// Object
space = ref.space;
coord = ref.coordId;
}
space = ColorSpace.get(space);
if (!space) {
space = workingSpace;
}
if (!space) {
throw new TypeError(`Cannot resolve coordinate reference ${ref}: No color space specified and relative references are not allowed here`);
}
coordType = type(coord);
if (coordType === "number" || coordType === "string" && coord >= 0) {
// Resolve numerical coord
let meta = Object.entries(space.coords)[coord];
if (meta) {
return {space, id: meta[0], index: coord, ...meta[1]}
}
}
space = ColorSpace.get(space);
let normalizedCoord = coord.toLowerCase();
let i = 0;
for (let id in space.coords) {
let meta = space.coords[id];
if (id.toLowerCase() === normalizedCoord || meta.name?.toLowerCase() === normalizedCoord) {
return {space, id, index: i, ...meta};
}
i++;
}
throw new TypeError(`No "${coord}" coordinate found in ${space.name}. Its coordinates are: ${Object.keys(space.coords).join(", ")}`);
}
static DEFAULT_FORMAT = {
type: "functions",
name: "color",
}
}
var XYZ_D65 = new ColorSpace({
id: "xyz-d65",
name: "XYZ D65",
coords: {
x: {name: "X"},
y: {name: "Y"},
z: {name: "Z"},
},
white: "D65",
formats: {
color: {
ids: ['xyz-d65', 'xyz'],
}
},
aliases: ['xyz'],
});
/**
* Convenience class for RGB color spaces
* @extends {ColorSpace}
*/
class RGBColorSpace extends ColorSpace {
/**
* Creates a new RGB ColorSpace.
* If coords are not specified, they will use the default RGB coords.
* Instead of `fromBase()` and `toBase()` functions,
* you can specify to/from XYZ matrices and have `toBase()` and `fromBase()` automatically generated.
* @param {*} options - Same options as {@link ColorSpace} plus:
* @param {number[][]} options.toXYZ_M - Matrix to convert to XYZ
* @param {number[][]} options.fromXYZ_M - Matrix to convert from XYZ
*/
constructor (options) {
if (!options.coords) {
options.coords = {
r: {
range: [0, 1],
name: "Red"
},
g: {
range: [0, 1],
name: "Green"
},
b: {
range: [0, 1],
name: "Blue"
}
};
}
if (!options.base) {
options.base = XYZ_D65;
}
if (options.toXYZ_M && options.fromXYZ_M) {
options.toBase ??= rgb => {
let xyz = multiplyMatrices(options.toXYZ_M, rgb);
if (this.white !== this.base.white) {
// Perform chromatic adaptation
xyz = adapt$1(this.white, this.base.white, xyz);
}
return xyz;
};
options.fromBase ??= xyz => {
xyz = adapt$1(this.base.white, this.white, xyz);
return multiplyMatrices(options.fromXYZ_M, xyz);
};
}
options.referred ??= "display";
super(options);
}
}
// CSS color to Color object
function parse (str) {
let env = {"str": String(str)?.trim()};
hooks.run("parse-start", env);
if (env.color) {
return env.color;
}
env.parsed = parseFunction(env.str);
if (env.parsed) {
// Is a functional syntax
let name = env.parsed.name;
if (name === "color") {
// color() function
let id = env.parsed.args.shift();
let alpha = env.parsed.rawArgs.indexOf("/") > 0? env.parsed.args.pop() : 1;
for (let space of ColorSpace.all) {
let colorSpec = space.getFormat("color");
if (colorSpec) {
if (id === colorSpec.id || colorSpec.ids?.includes(id)) {
// From https://drafts.csswg.org/css-color-4/#color-function
// If more <number>s or <percentage>s are provided than parameters that the colorspace takes, the excess <number>s at the end are ignored.
// If less <number>s or <percentage>s are provided than parameters that the colorspace takes, the missing parameters default to 0. (This is particularly convenient for multichannel printers where the additional inks are spot colors or varnishes that most colors on the page won’t use.)
let argCount = Object.keys(space.coords).length;
let coords = Array(argCount).fill(0);
coords.forEach((_, i) => coords[i] = env.parsed.args[i] || 0);
return {spaceId: space.id, coords, alpha};
}
}
}
// Not found
let didYouMean = "";
if (id in ColorSpace.registry) {
// Used color space id instead of color() id, these are often different
let cssId = ColorSpace.registry[id].formats?.functions?.color?.id;
if (cssId) {
didYouMean = `Did you mean color(${cssId})?`;
}
}
throw new TypeError(`Cannot parse color(${id}). ` + (didYouMean || "Missing a plugin?"));
}
else {
for (let space of ColorSpace.all) {
// color space specific function
let format = space.getFormat(name);
if (format && format.type === "function") {
let alpha = 1;
if (format.lastAlpha || last(env.parsed.args).alpha) {
alpha = env.parsed.args.pop();
}
let coords = env.parsed.args;
if (format.coordGrammar) {
Object.entries(space.coords).forEach(([id, coordMeta], i) => {
let coordGrammar = format.coordGrammar[i];
let providedType = coords[i]?.type;
// Find grammar alternative that matches the provided type
// Non-strict equals is intentional because we are comparing w/ string objects
coordGrammar = coordGrammar.find(c => c == providedType);
// Check that each coord conforms to its grammar
if (!coordGrammar) {
// Type does not exist in the grammar, throw
let coordName = coordMeta.name || id;
throw new TypeError(`${providedType} not allowed for ${coordName} in ${name}()`);
}
let fromRange = coordGrammar.range;
if (providedType === "<percentage>") {
fromRange ||= [0, 1];
}
let toRange = coordMeta.range || coordMeta.refRange;
if (fromRange && toRange) {
coords[i] = mapRange(fromRange, toRange, coords[i]);
}
});
}
return {
spaceId: space.id,
coords, alpha
};
}
}
}
}
else {
// Custom, colorspace-specific format
for (let space of ColorSpace.all) {
for (let formatId in space.formats) {
let format = space.formats[formatId];
if (format.type !== "custom") {
continue;
}
if (format.test && !format.test(env.str)) {
continue;
}
let color = format.parse(env.str);
if (color) {
color.alpha ??= 1;
return color;
}
}
}
}
// If we're here, we couldn't parse
throw new TypeError(`Could not parse ${str} as a color. Missing a plugin?`);
}
/**
* Resolves a color reference (object or string) to a plain color object
* @param {Color | {space, coords, alpha} | string} color
* @returns {{space, coords, alpha}}
*/
function getColor (color) {
if (!color) {
throw new TypeError("Empty color reference");
}
if (isString(color)) {
color = parse(color);
}
// Object fixup
let space = color.space || color.spaceId;
if (!(space instanceof ColorSpace)) {
// Convert string id to color space object
color.space = ColorSpace.get(space);
}
if (color.alpha === undefined) {
color.alpha = 1;
}
return color;
}
/**
* Get the coordinates of a color in another color space
*
* @param {string | ColorSpace} space
* @returns {number[]}
*/
function getAll (color, space) {
space = ColorSpace.get(space);
return space.from(color);
}
function get (color, prop) {
let {space, index} = ColorSpace.resolveCoord(prop, color.space);
let coords = getAll(color, space);
return coords[index];
}
function setAll (color, space, coords) {
space = ColorSpace.get(space);
color.coords = space.to(color.space, coords);
return color;
}
// Set properties and return current instance
function set$1 (color, prop, value) {
color = getColor(color);
if (arguments.length === 2 && type(arguments[1]) === "object") {
// Argument is an object literal
let object = arguments[1];
for (let p in object) {
set$1(color, p, object[p]);
}
}
else {
if (typeof value === "function") {
value = value(get(color, prop));
}
let {space, index} = ColorSpace.resolveCoord(prop, color.space);
let coords = getAll(color, space);
coords[index] = value;
setAll(color, space, coords);
}
return color;
}
var XYZ_D50 = new ColorSpace({
id: "xyz-d50",
name: "XYZ D50",
white: "D50",
base: XYZ_D65,
fromBase: coords => adapt$1(XYZ_D65.white, "D50", coords),
toBase: coords => adapt$1("D50", XYZ_D65.white, coords),
formats: {
color: {}
},
});
// κ * ε = 2^3 = 8
const ε$3 = 216/24389; // 6^3/29^3 == (24/116)^3
const ε3$1 = 24/116;
const κ$1 = 24389/27; // 29^3/3^3
let white$1 = WHITES.D50;
var lab = new ColorSpace({
id: "lab",
name: "Lab",
coords: {
l: {
refRange: [0, 100],
name: "L"
},
a: {
refRange: [-125, 125]
},
b: {
refRange: [-125, 125]
}
},
// Assuming XYZ is relative to D50, convert to CIE Lab
// from CIE standard, which now defines these as a rational fraction
white: white$1,
base: XYZ_D50,
// Convert D50-adapted XYX to Lab
// CIE 15.3:2004 section 8.2.1.1
fromBase (XYZ) {
// compute xyz, which is XYZ scaled relative to reference white
let xyz = XYZ.map((value, i) => value / white$1[i]);
// now compute f
let f = xyz.map(value => value > ε$3 ? Math.cbrt(value) : (κ$1 * value + 16)/116);
return [
(116 * f[1]) - 16, // L
500 * (f[0] - f[1]), // a
200 * (f[1] - f[2]) // b
];
},
// Convert Lab to D50-adapted XYZ
// Same result as CIE 15.3:2004 Appendix D although the derivation is different
// http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
toBase (Lab) {
// compute f, starting with the luminance-related term
let f = [];
f[1] = (Lab[0] + 16)/116;
f[0] = Lab[1]/500 + f[1];
f[2] = f[1] - Lab[2]/200;
// compute xyz
let xyz = [
f[0] > ε3$1 ? Math.pow(f[0], 3) : (116*f[0]-16)/κ$1,
Lab[0] > 8 ? Math.pow((Lab[0]+16)/116, 3) : Lab[0]/κ$1,
f[2] > ε3$1 ? Math.pow(f[2], 3) : (116*f[2]-16)/κ$1
];
// Compute XYZ by scaling xyz by reference white
return xyz.map((value, i) => value * white$1[i]);
},
formats: {
"lab": {
coords: ["<number> | <percentage>", "<number>", "<number>"],
}
}
});
function constrain (angle) {
return ((angle % 360) + 360) % 360;
}
function adjust (arc, angles) {
if (arc === "raw") {
return angles;
}
let [a1, a2] = angles.map(constrain);
let angleDiff = a2 - a1;
if (arc === "increasing") {
if (angleDiff < 0) {
a2 += 360;
}
}
else if (arc === "decreasing") {
if (angleDiff > 0) {
a1 += 360;
}
}
else if (arc === "longer") {
if (-180 < angleDiff && angleDiff < 180) {
if (angleDiff > 0) {
a2 += 360;
}
else {
a1 += 360;
}
}
}
else if (arc === "shorter") {
if (angleDiff > 180) {
a1 += 360;
}
else if (angleDiff < -180) {
a2 += 360;
}
}
return [a1, a2];
}
var lch = new ColorSpace({
id: "lch",
name: "LCH",
coords: {
l: {
refRange: [0, 100],
name: "Lightness"
},
c: {
refRange: [0, 150],
name: "Chroma"
},
h: {
refRange: [0, 360],
type: "angle",
name: "Hue"
}
},
base: lab,
fromBase (Lab) {
// Convert to polar form
let [L, a, b] = Lab;
let hue;
const ε = 0.02;
if (Math.abs(a) < ε && Math.abs(b) < ε) {
hue = NaN;
}
else {
hue = Math.atan2(b, a) * 180 / Math.PI;
}
return [
L, // L is still L
Math.sqrt(a ** 2 + b ** 2), // Chroma
constrain(hue) // Hue, in degrees [0 to 360)
];
},
toBase (LCH) {
// Convert from polar form
let [Lightness, Chroma, Hue] = LCH;
// Clamp any negative Chroma
if (Chroma < 0) {
Chroma = 0;
} // Deal with NaN Hue
if (isNaN(Hue)) {
Hue = 0;
}
return [
Lightness, // L is still L
Chroma * Math.cos(Hue * Math.PI / 180), // a
Chroma * Math.sin(Hue * Math.PI / 180) // b
];
},
formats: {
"lch": {
coords: ["<number> | <percentage>", "<number>", "<number> | <angle>"],
}
}
});
// deltaE2000 is a statistically significant improvement
// and is recommended by the CIE and Idealliance
// especially for color differences less than 10 deltaE76
// but is wicked complicated
// and many implementations have small errors!
// DeltaE2000 is also discontinuous; in case this
// matters to you, use deltaECMC instead.
const Gfactor = 25 ** 7;
const π$1 = Math.PI;
const r2d = 180 / π$1;
const d2r$1 = π$1 / 180;
function deltaE2000 (color, sample, {kL = 1, kC = 1, kH = 1} = {}) {
// Given this color as the reference
// and the function parameter as the sample,
// calculate deltaE 2000.
// This implementation assumes the parametric
// weighting factors kL, kC and kH
// for the influence of viewing conditions
// are all 1, as sadly seems typical.
// kL should be increased for lightness texture or noise
// and kC increased for chroma noise
let [L1, a1, b1] = lab.from(color);
let C1 = lch.from(lab, [L1, a1, b1])[1];
let [L2, a2, b2] = lab.from(sample);
let C2 = lch.from(lab, [L2, a2, b2])[1];
// Check for negative Chroma,
// which might happen through
// direct user input of LCH values
if (C1 < 0) {
C1 = 0;
}
if (C2 < 0) {
C2 = 0;
}
let Cbar = (C1 + C2)/2; // mean Chroma
// calculate a-axis asymmetry factor from mean Chroma
// this turns JND ellipses for near-neutral colors back into circles
let C7 = Cbar ** 7;
let G = 0.5 * (1 - Math.sqrt(C7/(C7 + Gfactor)));
// scale a axes by asymmetry factor
// this by the way is why there is no Lab2000 colorspace
let adash1 = (1 + G) * a1;
let adash2 = (1 + G) * a2;
// calculate new Chroma from scaled a and original b axes
let Cdash1 = Math.sqrt(adash1 ** 2 + b1 ** 2);
let Cdash2 = Math.sqrt(adash2 ** 2 + b2 ** 2);
// calculate new hues, with zero hue for true neutrals
// and in degrees, not radians
let h1 = (adash1 === 0 && b1 === 0)? 0: Math.atan2(b1, adash1);
let h2 = (adash2 === 0 && b2 === 0)? 0: Math.atan2(b2, adash2);
if (h1 < 0) {
h1 += 2 * π$1;
}
if (h2 < 0) {
h2 += 2 * π$1;
}
h1 *= r2d;
h2 *= r2d;
// Lightness and Chroma differences; sign matters
let ΔL = L2 - L1;
let ΔC = Cdash2 - Cdash1;
// Hue difference, getting the sign correct
let hdiff = h2 - h1;
let hsum = h1 + h2;
let habs = Math.abs(hdiff);
let Δh;
if (Cdash1 * Cdash2 === 0) {
Δh = 0;
}
else if (habs <= 180) {
Δh = hdiff;
}
else if (hdiff > 180) {
Δh = hdiff - 360;
}
else if (hdiff < -180) {
Δh = hdiff + 360;
}
else {
console.log("the unthinkable has happened");
}
// weighted Hue difference, more for larger Chroma
let ΔH = 2 * Math.sqrt(Cdash2 * Cdash1) * Math.sin(Δh * d2r$1 / 2);
// calculate mean Lightness and Chroma
let Ldash = (L1 + L2)/2;
let Cdash = (Cdash1 + Cdash2)/2;
let Cdash7 = Math.pow(Cdash, 7);
// Compensate for non-linearity in the blue region of Lab.
// Four possibilities for hue weighting factor,
// depending on the angles, to get the correct sign
let hdash;
if (Cdash1 * Cdash2 === 0) {
hdash = hsum; // which should be zero
}
else if (habs <= 180) {
hdash = hsum / 2;
}
else if (hsum < 360) {
hdash = (hsum + 360) / 2;
}
else {
hdash = (hsum - 360) / 2;
}
// positional corrections to the lack of uniformity of CIELAB
// These are all trying to make JND ellipsoids more like spheres
// SL Lightness crispening factor
// a background with L=50 is assumed
let lsq = (Ldash - 50) ** 2;
let SL = 1 + ((0.015 * lsq) / Math.sqrt(20 + lsq));
// SC Chroma factor, similar to those in CMC and deltaE 94 formulae
let SC = 1 + 0.045 * Cdash;
// Cross term T for blue non-linearity
let T = 1;
T -= (0.17 * Math.cos(( hdash - 30) * d2r$1));
T += (0.24 * Math.cos( 2 * hdash * d2r$1));
T += (0.32 * Math.cos(((3 * hdash) + 6) * d2r$1));
T -= (0.20 * Math.cos(((4 * hdash) - 63) * d2r$1));
// SH Hue factor depends on Chroma,
// as well as adjusted hue angle like deltaE94.
let SH = 1 + 0.015 * Cdash * T;
// RT Hue rotation term compensates for rotation of JND ellipses
// and Munsell constant hue lines
// in the medium-high Chroma blue region
// (Hue 225 to 315)
let Δθ = 30 * Math.exp(-1 * (((hdash - 275)/25) ** 2));
let RC = 2 * Math.sqrt(Cdash7/(Cdash7 + Gfactor));
let RT = -1 * Math.sin(2 * Δθ * d2r$1) * RC;
// Finally calculate the deltaE, term by term as root sume of squares
let dE = (ΔL / (kL * SL)) ** 2;
dE += (ΔC / (kC * SC)) ** 2;
dE += (ΔH / (kH * SH)) ** 2;
dE += RT * (ΔC / (kC * SC)) * (ΔH / (kH * SH));
return Math.sqrt(dE);
// Yay!!!
}
const ε$2 = .000075;
/**
* Check if a color is in gamut of either its own or another color space
* @return {Boolean} Is the color in gamut?
*/
function inGamut (color, space = color.space, {epsilon = ε$2} = {}) {
color = getColor(color);
space = ColorSpace.get(space);
let coords = color.coords;
if (space !== color.space) {
coords = space.from(color);
}
return space.inGamut(coords, {epsilon});
}
function clone(color) {
return {
space: color.space,
coords: color.coords.slice(),
alpha: color.alpha
};
}
/**
* Force coordinates to be in gamut of a certain color space.
* Mutates the color it is passed.
* @param {Object} options
* @param {string} options.method - How to force into gamut.
* If "clip", coordinates are just clipped to their reference range.
* If in the form [colorSpaceId].[coordName], that coordinate is reduced
* until the color is in gamut. Please note that this may produce nonsensical
* results for certain coordinates (e.g. hue) or infinite loops if reducing the coordinate never brings the color in gamut.
* @param {ColorSpace|string} options.space - The space whose gamut we want to map to
*/
function toGamut (color, {method = defaults.gamut_mapping, space = color.space} = {}) {
if (isString(arguments[1])) {
space = arguments[1];
}
space = ColorSpace.get(space);
if (inGamut(color, space, {epsilon: 0})) {
return color;
}
// 3 spaces:
// color.space: current color space
// space: space whose gamut we are mapping to
// mapSpace: space with the coord we're reducing
let spaceColor = to(color, space);
if (method !== "clip" && !inGamut(color, space)) {
let clipped = toGamut(clone(spaceColor), {method: "clip", space});
if (deltaE2000(color, clipped) > 2) {
// Reduce a coordinate of a certain color space until the color is in gamut
let coordMeta = ColorSpace.resolveCoord(method);
let mapSpace = coordMeta.space;
let coordId = coordMeta.id;
let mappedColor = to(spaceColor, mapSpace);
let bounds = coordMeta.range || coordMeta.refRange;
let min = bounds[0];
let ε = .01; // for deltaE
let low = min;
let high = get(mappedColor, coordId);
while (high - low > ε) {
let clipped = clone(mappedColor);
clipped = toGamut(clipped, {space, method: "clip"});
let deltaE = deltaE2000(mappedColor, clipped);
if (deltaE - 2 < ε) {
low = get(mappedColor, coordId);
}
else {
high = get(mappedColor, coordId);
}
set$1(mappedColor, coordId, (low + high) / 2);
}
spaceColor = to(mappedColor, space);
}
else {
spaceColor = clipped;
}
}
if (method === "clip" // Dumb coord clipping
// finish off smarter gamut mapping with clip to get rid of ε, see #17
|| !inGamut(spaceColor, space, {epsilon: 0})
) {
let bounds = Object.values(space.coords).map(c => c.range || []);
spaceColor.coords = spaceColor.coords.map((c, i) => {
let [min, max] = bounds[i];
if (min !== undefined) {
c = Math.max(min, c);
}
if (max !== undefined) {
c = Math.min(c, max);
}
return c;
});
}
if (space !== color.space) {
spaceColor = to(spaceColor, color.space);
}
color.coords = spaceColor.coords;
return color;
}
toGamut.returns = "color";
/**
* Convert to color space and return a new color
* @param {Object|string} space - Color space object or id
* @param {Object} options
* @param {boolean} options.inGamut - Whether to force resulting color in gamut
* @returns {Color}
*/
function to (color, space, {inGamut} = {}) {
color = getColor(color);
space = ColorSpace.get(space);
let coords = space.from(color);
let ret = {space, coords, alpha: color.alpha};
if (inGamut) {
ret = toGamut(ret);
}
return ret;
}
to.returns = "color";
/**
* Generic toString() method, outputs a color(spaceId ...coords) function, a functional syntax, or custom formats defined by the color space
* @param {Object} options
* @param {number} options.precision - Significant digits
* @param {boolean} options.inGamut - Adjust coordinates to fit in gamut first? [default: false]
*/
function serialize (color, {
precision = defaults.precision,
format = "default",
inGamut: inGamut$1 = true,
...customOptions
} = {}) {
let ret;
color = getColor(color);
let formatId = format;
format = color.space.getFormat(format)
?? color.space.getFormat("default")
?? ColorSpace.DEFAULT_FORMAT;
inGamut$1 ||= format.toGamut;
let coords = color.coords;
// Convert NaN to zeros to have a chance at a valid CSS color
// Also convert -0 to 0
// This also clones it so we can manipulate it
coords = coords.map(c => c? c : 0);
if (inGamut$1 && !inGamut(color)) {
coords = toGamut(clone(color), inGamut$1 === true? undefined : inGamut$1).coords;
}
if (format.type === "custom") {
customOptions.precision = precision;
if (format.serialize) {
ret = format.serialize(coords, color.alpha, customOptions);
}
else {
throw new TypeError(`format ${formatId} can only be used to parse colors, not for serialization`);
}
}
else {
// Functional syntax
let name = format.name || "color";
if (format.serializeCoords) {
coords = format.serializeCoords(coords, precision);
}
else {
if (precision !== null) {
coords = coords.map(c => toPrecision(c, precision));
}
}
let args = [...coords];
if (name === "color") {
// If output is a color() function, add colorspace id as first argument
let cssId = format.id || format.ids?.[0] || color.space.id;
args.unshift(cssId);
}
let alpha = color.alpha;
if (precision !== null) {
alpha = toPrecision(alpha, precision);
}
let strAlpha = color.alpha < 1? ` ${format.commas? "," : "/"} ${alpha}` : "";
ret = `${name}(${args.join(format.commas? ", " : " ")}${strAlpha})`;
}
return ret;
}
// convert an array of linear-light rec2020 values to CIE XYZ
// using D65 (no chromatic adaptation)
// http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
// 0 is actually calculated as 4.994106574466076e-17
const toXYZ_M$5 = [
[ 0.6369580483012914, 0.14461690358620832, 0.1688809751641721 ],
[ 0.2627002120112671, 0.6779980715188708, 0.05930171646986196 ],
[ 0.000000000000000, 0.028072693049087428, 1.060985057710791 ]
];
// from ITU-R BT.2124-0 Annex 2 p.3
const fromXYZ_M$5 = [
[ 1.716651187971268, -0.355670783776392, -0.253366281373660 ],
[ -0.666684351832489, 1.616481236634939, 0.0157685458139111 ],
[ 0.017639857445311, -0.042770613257809, 0.942103121235474 ]
];
var REC2020Linear = new RGBColorSpace({
id: "rec2020-linear",
name: "Linear REC.2020",
white: "D65",
toXYZ_M: toXYZ_M$5,
fromXYZ_M: fromXYZ_M$5
});
// import sRGB from "./srgb.js";
const α = 1.09929682680944;
const β = 0.018053968510807;
var REC2020 = new RGBColorSpace({
id: "rec2020",
name: "REC.2020",
base: REC2020Linear,
// Non-linear transfer function from Rec. ITU-R BT.2020-2 table 4
toBase (RGB) {
return RGB.map(function (val) {
if (val < β * 4.5 ) {
return val / 4.5;
}
return Math.pow((val + α -1 ) / α, 1/0.45);
});
},
fromBase (RGB) {
return RGB.map(function (val) {
if (val >= β ) {
return α * Math.pow(val, 0.45) - (α - 1);
}
return 4.5 * val;
});
},
formats: {
color: {},
}
});
const toXYZ_M$4 = [
[0.4865709486482162, 0.26566769316909306, 0.1982172852343625],
[0.2289745640697488, 0.6917385218365064, 0.079286914093745],
[0.0000000000000000, 0.04511338185890264, 1.043944368900976]
];
const fromXYZ_M$4 = [
[ 2.493496911941425, -0.9313836179191239, -0.40271078445071684],
[-0.8294889695615747, 1.7626640603183463, 0.023624685841943577],
[ 0.03584583024378447, -0.07617238926804182, 0.9568845240076872]
];
var P3Linear = new RGBColorSpace({
id: "p3-linear",
name: "Linear P3",
white: "D65",
toXYZ_M: toXYZ_M$4,
fromXYZ_M: fromXYZ_M$4
});
// This is the linear-light version of sRGB
// as used for example in SVG filters
// or in Canvas
// This matrix was calculated directly from the RGB and white chromaticities
// when rounded to 8 decimal places, it agrees completely with the official matrix
// see https://github.com/w3c/csswg-drafts/issues/5922
const toXYZ_M$3 = [
[ 0.41239079926595934, 0.357584339383878, 0.1804807884018343 ],
[ 0.21263900587151027, 0.715168678767756, 0.07219231536073371 ],
[ 0.01933081871559182, 0.11919477979462598, 0.9505321522496607 ]
];
// This matrix is the inverse of the above;
// again it agrees with the official definition when rounded to 8 decimal places
const fromXYZ_M$3 = [
[ 3.2409699419045226, -1.537383177570094, -0.4986107602930034 ],
[ -0.9692436362808796, 1.8759675015077202, 0.04155505740717559 ],
[ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786 ]
];
var sRGBLinear = new RGBColorSpace({
id: "srgb-linear",
name: "Linear sRGB",
white: "D65",
toXYZ_M: toXYZ_M$3,
fromXYZ_M: fromXYZ_M$3,
formats: {
color: {}
},
});
/* List of CSS color keywords
* Note that this does not include currentColor, transparent,
* or system colors
*/
// To produce: Visit https://www.w3.org/TR/css-color-4/#named-colors
// and run in the console:
// copy($$("tr", $(".named-color-table tbody")).map(tr => `"${tr.cells[2].textContent.trim()}": [${tr.cells[4].textContent.trim().split(/\s+/).map(c => c === "0"? "0" : c === "255"? "1" : c + " / 255").join(", ")}]`).join(",\n"))
var KEYWORDS = {
"aliceblue": [240 / 255, 248 / 255, 1],
"antiquewhite": [250 / 255, 235 / 255, 215 / 255],
"aqua": [0, 1, 1],
"aquamarine": [127 / 255, 1, 212 / 255],
"azure": [240 / 255, 1, 1],
"beige": [245 / 255, 245 / 255, 220 / 255],
"bisque": [1, 228 / 255, 196 / 255],
"black": [0, 0, 0],
"blanchedalmond": [1, 235 / 255, 205 / 255],
"blue": [0, 0, 1],
"blueviolet": [138 / 255, 43 / 255, 226 / 255],
"brown": [165 / 255, 42 / 255, 42 / 255],
"burlywood": [222 / 255, 184 / 255, 135 / 255],
"cadetblue": [95 / 255, 158 / 255, 160 / 255],
"chartreuse": [127 / 255, 1, 0],
"chocolate": [210 / 255, 105 / 255, 30 / 255],
"coral": [1, 127 / 255, 80 / 255],
"cornflowerblue": [100 / 255, 149 / 255, 237 / 255],
"cornsilk": [1, 248 / 255, 220 / 255],
"crimson": [220 / 255, 20 / 255, 60 / 255],
"cyan": [0, 1, 1],
"darkblue": [0, 0, 139 / 255],
"darkcyan": [0, 139 / 255, 139 / 255],
"darkgoldenrod": [184 / 255, 134 / 255, 11 / 255],
"darkgray": [169 / 255, 169 / 255, 169 / 255],
"darkgreen": [0, 100 / 255, 0],
"darkgrey": [169 / 255, 169 / 255, 169 / 255],
"darkkhaki": [189 / 255, 183 / 255, 107 / 255],
"darkmagenta": [139 / 255, 0, 139 / 255],
"darkolivegreen": [85 / 255, 107 / 255, 47 / 255],
"darkorange": [1, 140 / 255, 0],
"darkorchid": [153 / 255, 50 / 255, 204 / 255],
"darkred": [139 / 255, 0, 0],
"darksalmon": [233 / 255, 150 / 255, 122 / 255],
"darkseagreen": [143 / 255, 188 / 255, 143 / 255],
"darkslateblue": [72 / 255, 61 / 255, 139 / 255],
"darkslategray": [47 / 255, 79 / 255, 79 / 255],
"darkslategrey": [47 / 255, 79 / 255, 79 / 255],
"darkturquoise": [0, 206 / 255, 209 / 255],
"darkviolet": [148 / 255, 0, 211 / 255],
"deeppink": [1, 20 / 255, 147 / 255],
"deepskyblue": [0, 191 / 255, 1],
"dimgray": [105 / 255, 105 / 255, 105 / 255],
"dimgrey": [105 / 255, 105 / 255, 105 / 255],
"dodgerblue": [30 / 255, 144 / 255, 1],
"firebrick": [178 / 255, 34 / 255, 34 / 255],
"floralwhite": [1, 250 / 255, 240 / 255],
"forestgreen": [34 / 255, 139 / 255, 34 / 255],
"fuchsia": [1, 0, 1],
"gainsboro": [220 / 255, 220 / 255, 220 / 255],
"ghostwhite": [248 / 255, 248 / 255, 1],
"gold": [1, 215 / 255, 0],
"goldenrod": [218 / 255, 165 / 255, 32 / 255],
"gray": [128 / 255, 128 / 255, 128 / 255],
"green": [0, 128 / 255, 0],
"greenyellow": [173 / 255, 1, 47 / 255],
"grey": [128 / 255, 128 / 255, 128 / 255],
"honeydew": [240 / 255, 1, 240 / 255],
"hotpink": [1, 105 / 255, 180 / 255],
"indianred": [205 / 255, 92 / 255, 92 / 255],
"indigo": [75 / 255, 0, 130 / 255],
"ivory": [1, 1, 240 / 255],
"khaki": [240 / 255, 230 / 255, 140 / 255],
"lavender": [230 / 255, 230 / 255, 250 / 255],
"lavenderblush": [1, 240 / 255, 245 / 255],
"lawngreen": [124 / 255, 252 / 255, 0],
"lemonchiffon": [1, 250 / 255, 205 / 255],
"lightblue": [173 / 255, 216 / 255, 230 / 255],
"lightcoral": [240 / 255, 128 / 255, 128 / 255],
"lightcyan": [224 / 255, 1, 1],
"lightgoldenrodyellow": [250 / 255, 250 / 255, 210 / 255],
"lightgray": [211 / 255, 211 / 255, 211 / 255],
"lightgreen": [144 / 255, 238 / 255, 144 / 255],
"lightgrey": [211 / 255, 211 / 255, 211 / 255],
"lightpink": [1, 182 / 255, 193 / 255],
"lightsalmon": [1, 160 / 255, 122 / 255],
"lightseagreen": [32 / 255, 178 / 255, 170 / 255],
"lightskyblue": [135 / 255, 206 / 255, 250 / 255],
"lightslategray": [119 / 255, 136 / 255, 153 / 255],
"lightslategrey": [119 / 255, 136 / 255, 153 / 255],
"lightsteelblue": [176 / 255, 196 / 255, 222 / 255],
"lightyellow": [1, 1, 224 / 255],
"lime": [0, 1, 0],
"limegreen": [50 / 255, 205 / 255, 50 / 255],
"linen": [250 / 255, 240 / 255, 230 / 255],
"magenta": [1, 0, 1],
"maroon": [128 / 255, 0, 0],
"mediumaquamarine": [102 / 255, 205 / 255, 170 / 255],
"mediumblue": [0, 0, 205 / 255],
"mediumorchid": [186 / 255, 85 / 255, 211 / 255],
"mediumpurple": [147 / 255, 112 / 255, 219 / 255],
"mediumseagreen": [60 / 255, 179 / 255, 113 / 255],
"mediumslateblue": [123 / 255, 104 / 255, 238 / 255],
"mediumspringgreen": [0, 250 / 255, 154 / 255],
"mediumturquoise": [72 / 255, 209 / 255, 204 / 255],
"mediumvioletred": [199 / 255, 21 / 255, 133 / 255],
"midnightblue": [25 / 255, 25 / 255, 112 / 255],
"mintcream": [245 / 255, 1, 250 / 255],
"mistyrose": [1, 228 / 255, 225 / 255],
"moccasin": [1, 228 / 255, 181 / 255],
"navajowhite": [1, 222 / 255, 173 / 255],
"navy": [0, 0, 128 / 255],
"oldlace": [253 / 255, 245 / 255, 230 / 255],
"olive": [128 / 255, 128 / 255, 0],
"olivedrab": [107 / 255, 142 / 255, 35 / 255],
"orange": [1, 165 / 255, 0],
"orangered": [1, 69 / 255, 0],
"orchid": [218 / 255, 112 / 255, 214 / 255],
"palegoldenrod": [238 / 255, 232 / 255, 170 / 255],
"palegreen": [152 / 255, 251 / 255, 152 / 255],
"paleturquoise": [175 / 255, 238 / 255, 238 / 255],
"palevioletred": [219 / 255, 112 / 255, 147 / 255],
"papayawhip": [1, 239 / 255, 213 / 255],
"peachpuff": [1, 218 / 255, 185 / 255],
"peru": [205 / 255, 133 / 255, 63 / 255],
"pink": [1, 192 / 255, 203 / 255],
"plum": [221 / 255, 160 / 255, 221 / 255],
"powderblue": [176 / 255, 224 / 255, 230 / 255],
"purple": [128 / 255, 0, 128 / 255],
"rebeccapurple": [102 / 255, 51 / 255, 153 / 255],
"red": [1, 0, 0],
"rosybrown": [188 / 255, 143 / 255, 143 / 255],
"royalblue": [65 / 255, 105 / 255, 225 / 255],
"saddlebrown": [139 / 255, 69 / 255, 19 / 255],
"salmon": [250 / 255, 128 / 255, 114 / 255],
"sandybrown": [244 / 255, 164 / 255, 96 / 255],
"seagreen": [46 / 255, 139 / 255, 87 / 255],
"seashell": [1, 245 / 255, 238 / 255],
"sienna": [160 / 255, 82 / 255, 45 / 255],
"silver": [192 / 255, 192 / 255, 192 / 255],
"skyblue": [135 / 255, 206 / 255, 235 / 255],
"slateblue": [106 / 255, 90 / 255, 205 / 255],
"slategray": [112 / 255, 128 / 255, 144 / 255],
"slategrey": [112 / 255, 128 / 255, 144 / 255],
"snow": [1, 250 / 255, 250 / 255],
"springgreen": [0, 1, 127 / 255],
"steelblue": [70 / 255, 130 / 255, 180 / 255],
"tan": [210 / 255, 180 / 255, 140 / 255],
"teal": [0, 128 / 255, 128 / 255],
"thistle": [216 / 255, 191 / 255, 216 / 255],
"tomato": [1, 99 / 255, 71 / 255],
"turquoise": [64 / 255, 224 / 255, 208 / 255],
"violet": [238 / 255, 130 / 255, 238 / 255],
"wheat": [245 / 255, 222 / 255, 179 / 255],
"white": [1, 1, 1],
"whitesmoke": [245 / 255, 245 / 255, 245 / 255],
"yellow": [1, 1, 0],
"yellowgreen": [154 / 255, 205 / 255, 50 / 255]
};
let coordGrammar = Array(3).fill("<percentage> | <number>[0, 255]");
var sRGB = new RGBColorSpace({
id: "srgb",
name: "sRGB",
base: sRGBLinear,
fromBase: rgb => {
// convert an array of linear-light sRGB values in the range 0.0-1.0
// to gamma corrected form
// https://en.wikipedia.org/wiki/SRGB
return rgb.map(val => {
let sign = val < 0? -1 : 1;
let abs = val * sign;
if (abs > 0.0031308) {
return sign * (1.055 * (abs ** (1/2.4)) - 0.055);
}
return 12.92 * val;
});
},
toBase: rgb => {
// convert an array of sRGB values in the range 0.0 - 1.0
// to linear light (un-companded) form.
// https://en.wikipedia.org/wiki/SRGB
return rgb.map(val => {
let sign = val < 0? -1 : 1;
let abs = val * sign;
if (abs < 0.04045) {
return val / 12.92;
}
return sign * (((abs + 0.055) / 1.055) ** 2.4);
});
},
formats: {
"rgb": {
coords: coordGrammar,
},
"color": { /* use defaults */ },
"rgba": {
coords: coordGrammar,
commas: true,
lastAlpha: true,
},
"hex": {
type: "custom",
toGamut: true,
test: str => /^#([a-f0-9]{3,4}){1,2}$/i.test(str),
parse (str) {
if (str.length <= 5) {
// #rgb or #rgba, duplicate digits
str = str.replace(/[a-f0-9]/gi, "$&$&");
}
let rgba = [];
str.replace(/[a-f0-9]{2}/gi, component => {
rgba.push(parseInt(component, 16) / 255);
});
return {
spaceId: "srgb",
coords: rgba.slice(0, 3),
alpha: rgba.slice(3)[0]
};
},
serialize: (coords, alpha, {
collapse = true // collapse to 3-4 digit hex when possible?
} = {}) => {
if (alpha < 1) {
coords.push(alpha);
}
coords = coords.map(c => Math.round(c * 255));
let collapsible = collapse && coords.every(c => c % 17 === 0);
let hex = coords.map(c => {
if (collapsible) {
return (c/17).toString(16);
}
return c.toString(16).padStart(2, "0");
}).join("");
return "#" + hex;
}
},
"keyword": {
type: "custom",
test: str => /^[a-z]+$/i.test(str),
parse (str) {
str = str.toLowerCase();
let ret = {spaceId: "srgb", coords: null, alpha: 1};
if (str === "transparent") {
ret.coords = KEYWORDS.black;
ret.alpha = 0;
}
else {
ret.coords = KEYWORDS[str];
}
if (ret.coords) {
return ret;
}
}
},
}
});
var P3 = new RGBColorSpace({
id: "p3",
name: "P3",
base: P3Linear,
// Gamma encoding/decoding is the same as sRGB
fromBase: sRGB.fromBase,
toBase: sRGB.toBase,
formats: {
color: {
id: "display-p3",
}
},
});
// Default space for CSS output. Code in Color.js makes this wider if there's a DOM available
defaults.display_space = sRGB;
if (typeof CSS !== "undefined" && CSS.supports) {
// Find widest supported color space for CSS
for (let space of [lab, REC2020, P3]) {
let coords = space.getMinCoords();
let color = {space, coords, alpha: 1};
let str = serialize(color);
if (CSS.supports("color", str)) {
defaults.display_space = space;
break;
}
}
}
/**
* Returns a serialization of the color that can actually be displayed in the browser.
* If the default serialization can be displayed, it is returned.
* Otherwise, the color is converted to Lab, REC2020, or P3, whichever is the widest supported.
* In Node.js, this is basically equivalent to `serialize()` but returns a `String` object instead.
*
* @export
* @param {{space, coords} | Color | string} color
* @param {*} [options={}] Options to be passed to serialize()
* @param {ColorSpace | string} [options.space = defaults.display_space] Color space to use for serialization if default is not supported
* @returns {String} String object containing the serialized color with a color property containing the converted color (or the original, if no conversion was necessary)
*/
function display (color, {space = defaults.display_sp