colorjs.io
Version:
Let’s get serious about color
2,185 lines (1,835 loc) • 196 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
/** @import { Matrix3x3, Vector3 } from "./types.js" */
/**
* A is m x n. B is n x p. product is m x p.
*
* Array arguments are treated like vectors:
* - A becomes 1 x n
* - B becomes n x 1
*
* Returns Matrix m x p or equivalent array or number
*
* @overload
* @param {number[]} A Vector 1 x n
* @param {number[]} B Vector n x 1
* @returns {number} Scalar number
*
* @overload
* @param {number[][]} A Matrix m x n
* @param {number[]} B Vector n x 1
* @returns {number[]} Array with length m
*
* @overload
* @param {number[]} A Vector 1 x n
* @param {number[][]} B Matrix n x p
* @returns {number[]} Array with length p
*
* @overload
* @param {number[][]} A Matrix m x n
* @param {number[][]} B Matrix n x p
* @returns {number[][]} Matrix m x p
*
* @param {number[] | number[][]} A Matrix m x n or a vector
* @param {number[] | number[][]} B Matrix n x p or a vector
* @returns {number | number[] | number[][]} Matrix m x p or equivalent array or number
*/
function multiplyMatrices (A, B) {
let m = A.length;
/** @type {number[][]} */
let AM;
/** @type {number[][]} */
let BM;
let aVec = false;
let bVec = false;
if (!Array.isArray(A[0])) {
// A is vector, convert to [[a, b, c, ...]]
AM = [/** @type {number[]} */ (A)];
m = AM.length;
aVec = true;
}
else {
AM = /** @type {number[][]} */ (A);
}
if (!Array.isArray(B[0])) {
// B is vector, convert to [[a], [b], [c], ...]]
BM = B.length > 0 ? B.map(x => [x]) : [[]]; // Avoid mapping empty array
bVec = true;
}
else {
BM = /** @type {number[][]} */ (B);
}
let p = BM[0].length;
let BM_cols = BM[0].map((_, i) => BM.map(x => x[i])); // transpose B
/** @type {number[] | number[][]} */
let product = AM.map(row =>
BM_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 && aVec) {
product = product[0]; // Avoid [[a, b, c, ...]]
}
if (p === 1 && bVec) {
if (m === 1 && aVec) {
return product[0]; // Avoid [[a]], return a number
}
else {
return product.map(x => x[0]); // Avoid [[a], [b], [c], ...]]
}
}
return product;
}
// dot3 and transform functions adapted from https://github.com/texel-org/color/blob/9793c7d4d02b51f068e0f3fd37131129a4270396/src/core.js
//
// The MIT License (MIT)
// Copyright (c) 2024 Matt DesLauriers
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
// OR OTHER DEALINGS IN THE SOFTWARE.
/**
* Returns the dot product of two vectors each with a length of 3.
*
* @param {Vector3} a
* @param {Vector3} b
* @returns {number}
*/
function dot3 (a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
/**
* Transforms a vector of length 3 by a 3x3 matrix. Specify the same input and output
* vector to transform in place.
*
* @param {Vector3} input
* @param {Matrix3x3} matrix
* @param {Vector3} [out]
* @returns {Vector3}
*/
function multiply_v3_m3x3 (input, matrix, out = [0, 0, 0]) {
const x = dot3(input, matrix[0]);
const y = dot3(input, matrix[1]);
const z = dot3(input, matrix[2]);
out[0] = x;
out[1] = y;
out[2] = z;
return out;
}
/**
* Various utility functions
*/
/**
* Check if a value is a string (including a String object)
* @param {any} str - Value to check
* @returns {str is string}
*/
function isString (str) {
return type(str) === "string";
}
/**
* Determine the internal JavaScript [[Class]] of an object.
* @param {any} o - Value to check
* @returns {string}
*/
function type (o) {
let str = Object.prototype.toString.call(o);
return (str.match(/^\[object\s+(.*?)\]$/)[1] || "").toLowerCase();
}
/**
* @param {number} n
* @param {{ precision?: number | undefined, unit?: string | undefined }} options
* @returns {string}
*/
function serializeNumber (n, { precision = 16, unit }) {
if (isNone(n)) {
return "none";
}
n = +toPrecision(n, precision);
return n + (unit ?? "");
}
/**
* Check if a value corresponds to a none argument
* @param {any} n - Value to check
* @returns {n is null}
*/
function isNone (n) {
return n === null;
}
/**
* Replace none values with 0
* @param {number | null} n
* @returns {number}
*/
function skipNone (n) {
return isNone(n) ? 0 : n;
}
/**
* 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) {
if (n === 0) {
return 0;
}
let integer = ~~n;
let digits = 0;
if (integer && precision) {
digits = ~~Math.log10(Math.abs(integer)) + 1;
}
const multiplier = 10.0 ** (precision - digits);
return Math.floor(n * multiplier + 0.5) / multiplier;
}
/**
* @param {number} start
* @param {number} end
* @param {number} p
*/
function interpolate (start, end, p) {
if (isNaN(start)) {
return end;
}
if (isNaN(end)) {
return start;
}
return start + (end - start) * p;
}
/**
* @param {number} start
* @param {number} end
* @param {number} value
*/
function interpolateInv (start, end, value) {
return (value - start) / (end - start);
}
/**
* @param {[number, number]} from
* @param {[number, number]} to
* @param {number} value
*/
function mapRange (from, to, value) {
if (
!from ||
!to ||
from === to ||
(from[0] === to[0] && from[1] === to[1]) ||
isNaN(value) ||
value === null
) {
// Ranges missing or the same
return value;
}
return interpolate(to[0], to[1], interpolateInv(from[0], from[1], value));
}
/**
* Clamp value between the minimum and maximum
* @param {number} min minimum value to return
* @param {number} val the value to return if it is between min and max
* @param {number} max maximum value to return
*/
function clamp (min, val, max) {
return Math.max(Math.min(max, val), min);
}
/**
* Copy sign of one value to another.
* @param {number} to - Number to copy sign to
* @param {number} from - Number to copy sign from
*/
function copySign (to, from) {
return Math.sign(to) === Math.sign(from) ? to : -to;
}
/**
* Perform pow on a signed number and copy sign to result
* @param {number} base The base number
* @param {number} exp The exponent
*/
function spow (base, exp) {
return copySign(Math.abs(base) ** exp, base);
}
/**
* Perform a divide, but return zero if the denominator is zero
* @param {number} n The numerator
* @param {number} d The denominator
*/
function zdiv (n, d) {
return d === 0 ? 0 : n / d;
}
/**
* Perform a bisect on a sorted list and locate the insertion point for
* a value in arr to maintain sorted order.
* @param {number[]} arr - array of sorted numbers
* @param {number} value - value to find insertion point for
* @param {number} lo - used to specify a the low end of a subset of the list
* @param {number} hi - used to specify a the high end of a subset of the list
*/
function bisectLeft (arr, value, lo = 0, hi = arr.length) {
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < value) {
lo = mid + 1;
}
else {
hi = mid;
}
}
return lo;
}
/**
* Determines whether an argument is an instance of a constructor, including subclasses.
* This is done by first just checking `instanceof`,
* and then comparing the string names of the constructors if that fails.
* @param {any} arg
* @param {C} constructor
* @template {new (...args: any) => any} C
* @returns {arg is InstanceType<C>}
*/
function isInstance (arg, constructor) {
if (arg instanceof constructor) {
return true;
}
const targetName = constructor.name;
while (arg) {
const proto = Object.getPrototypeOf(arg);
const constructorName = proto?.constructor?.name;
if (constructorName === targetName) {
return true;
}
if (!constructorName || constructorName === "Object") {
return false;
}
arg = proto;
}
return false;
}
var util = /*#__PURE__*/Object.freeze({
__proto__: null,
bisectLeft: bisectLeft,
clamp: clamp,
copySign: copySign,
interpolate: interpolate,
interpolateInv: interpolateInv,
isInstance: isInstance,
isNone: isNone,
isString: isString,
mapRange: mapRange,
multiplyMatrices: multiplyMatrices,
multiply_v3_m3x3: multiply_v3_m3x3,
serializeNumber: serializeNumber,
skipNone: skipNone,
spow: spow,
toPrecision: toPrecision,
type: type,
zdiv: zdiv
});
/**
* 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: "css",
precision: 5,
deltaE: "76", // Default deltaE method
verbose: globalThis?.process?.env?.NODE_ENV?.toLowerCase() !== "test",
warn: function warn (msg) {
if (this.verbose) {
globalThis?.console?.warn?.(msg);
}
},
};
class Type {
// Class properties - declared here so that type inference works
type;
coordMeta;
coordRange;
/** @type {[number, number]} */
range;
/**
* @param {any} type
* @param {import("./types.js").CoordMeta} coordMeta
*/
constructor (type, coordMeta) {
if (typeof type === "object") {
this.coordMeta = type;
}
if (coordMeta) {
this.coordMeta = coordMeta;
this.coordRange = coordMeta.range ?? coordMeta.refRange;
}
if (typeof type === "string") {
let params = type
.trim()
.match(/^(?<type><[a-z]+>)(\[(?<min>-?[.\d]+),\s*(?<max>-?[.\d]+)\])?$/);
if (!params) {
throw new TypeError(`Cannot parse ${type} as a type definition.`);
}
this.type = params.groups.type;
let { min, max } = params.groups;
if (min || max) {
this.range = [+min, +max];
}
}
}
/** @returns {[number, number]} */
get computedRange () {
if (this.range) {
return this.range;
}
if (this.type === "<percentage>") {
return this.percentageRange();
}
else if (this.type === "<angle>") {
return [0, 360];
}
return null;
}
get unit () {
if (this.type === "<percentage>") {
return "%";
}
else if (this.type === "<angle>") {
return "deg";
}
return "";
}
/**
* Map a number to the internal representation
* @param {number} number
*/
resolve (number) {
if (this.type === "<angle>") {
return number;
}
let fromRange = this.computedRange;
let toRange = this.coordRange;
if (this.type === "<percentage>") {
toRange ??= this.percentageRange();
}
return mapRange(fromRange, toRange, number);
}
/**
* Serialize a number from the internal representation to a string
* @param {number} number
* @param {number} [precision]
*/
serialize (number, precision) {
let toRange = this.type === "<percentage>" ? this.percentageRange(100) : this.computedRange;
let unit = this.unit;
number = mapRange(this.coordRange, toRange, number);
return serializeNumber(number, { unit, precision });
}
toString () {
let ret = this.type;
if (this.range) {
let [min = "", max = ""] = this.range;
ret += `[${min},${max}]`;
}
return ret;
}
/**
* Returns a percentage range for values of this type
* @param {number} scale
* @returns {[number, number]}
*/
percentageRange (scale = 1) {
let range;
if (
(this.coordMeta && this.coordMeta.range) ||
(this.coordRange && this.coordRange[0] >= 0)
) {
range = [0, 1];
}
else {
range = [-1, 1];
}
return [range[0] * scale, range[1] * scale];
}
static get (type, coordMeta) {
if (isInstance(type, this)) {
return type;
}
return new this(type, coordMeta);
}
}
/** @import { ColorSpace, Coords } from "./types.js" */
// Type re-exports
/** @typedef {import("./types.js").Format} FormatInterface */
/**
* @internal
* Used to index {@link FormatInterface Format} objects and store an instance.
* Not meant for external use
*/
const instance = Symbol("instance");
/**
* Remove the first element of an array type
* @template {any[]} T
* @typedef {T extends [any, ...infer R] ? R : T[number][]} RemoveFirstElement
*/
/**
* @class Format
* @implements {Omit<FormatInterface, "coords" | "serializeCoords">}
* Class to hold a color serialization format
*/
class Format {
// Class properties - declared here so that type inference works
type;
name;
spaceCoords;
/** @type {Type[][]} */
coords;
/** @type {string | undefined} */
id;
/** @type {boolean | undefined} */
alpha;
/**
* @param {FormatInterface} format
* @param {ColorSpace} space
*/
constructor (format, space = format.space) {
format[instance] = this;
this.type = "function";
this.name = "color";
Object.assign(this, format);
this.space = space;
if (this.type === "custom") {
// Nothing else to do here
return;
}
this.spaceCoords = Object.values(space.coords);
if (!this.coords) {
// @ts-expect-error Strings are converted to the correct type later
this.coords = this.spaceCoords.map(coordMeta => {
let ret = ["<number>", "<percentage>"];
if (coordMeta.type === "angle") {
ret.push("<angle>");
}
return ret;
});
}
this.coords = this.coords.map(
/** @param {string | string[] | Type[]} types */ (types, i) => {
let coordMeta = this.spaceCoords[i];
if (typeof types === "string") {
types = types.trim().split(/\s*\|\s*/);
}
return types.map(type => Type.get(type, coordMeta));
},
);
}
/**
* @param {Coords} coords
* @param {number} precision
* @param {Type[]} types
*/
serializeCoords (coords, precision, types) {
types = coords.map((_, i) =>
Type.get(types?.[i] ?? this.coords[i][0], this.spaceCoords[i]));
return coords.map((c, i) => types[i].serialize(c, precision));
}
/**
* Validates the coordinates of a color against a format's coord grammar and
* maps the coordinates to the range or refRange of the coordinates.
* @param {Coords} coords
* @param {[string, string, string]} types
*/
coerceCoords (coords, types) {
return Object.entries(this.space.coords).map(([id, coordMeta], i) => {
let arg = coords[i];
if (isNone(arg) || isNaN(arg)) {
// Nothing to do here
return arg;
}
// Find grammar alternative that matches the provided type
// Non-strict equals is intentional because we are comparing w/ string objects
let providedType = types[i];
let type = this.coords[i].find(c => c.type == providedType);
// Check that each coord conforms to its grammar
if (!type) {
// Type does not exist in the grammar, throw
let coordName = coordMeta.name || id;
throw new TypeError(
`${providedType ?? /** @type {any} */ (arg)?.raw ?? arg} not allowed for ${coordName} in ${this.name}()`,
);
}
arg = type.resolve(arg);
if (type.range) {
// Adjust type to include range
types[i] = type.toString();
}
return arg;
});
}
/**
* @returns {boolean | Required<FormatInterface>["serialize"]}
*/
canSerialize () {
return this.type === "function" || /** @type {any} */ (this).serialize;
}
/**
* @param {string} str
* @returns {(import("./types.js").ColorConstructor) | undefined | null}
*/
parse (str) {
return null;
}
/**
* @param {Format | FormatInterface} format
* @param {RemoveFirstElement<ConstructorParameters<typeof Format>>} args
* @returns {Format}
*/
static get (format, ...args) {
if (!format || isInstance(format, this)) {
return /** @type {Format} */ (format);
}
if (format[instance]) {
return format[instance];
}
return new Format(format, ...args);
}
}
// Type re-exports
/** @typedef {import("./types.js").White} White */
/** @type {Record<string, White>} */
// prettier-ignore
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],
};
/**
*
* @param {string | White} name
* @returns {White}
*/
function getWhite (name) {
if (Array.isArray(name)) {
return name;
}
return WHITES[name];
}
/**
* Adapt XYZ from white point W1 to W2
* @param {White | string} W1
* @param {White | string} W2
* @param {[number, number, number]} XYZ
* @param {{ method?: string | undefined }} options
* @returns {[number, number, number]}
*/
function adapt$2 (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) {
// prettier-ignore
env.M = [
[ 1.0479297925449969, 0.022946870601609652, -0.05019226628920524 ],
[ 0.02962780877005599, 0.9904344267538799, -0.017073799063418826 ],
[ -0.009243040646204504, 0.015055191490298152, 0.7518742814281371 ],
];
}
else if (env.W1 === WHITES.D50 && env.W2 === WHITES.D65) {
// prettier-ignore
env.M = [
[ 0.955473421488075, -0.02309845494876471, 0.06325924320057072 ],
[ -0.0283697093338637, 1.0099953980813041, 0.021041441191917323 ],
[ 0.012314014864481998, -0.020507649298898964, 1.330365926242124 ],
];
}
}
hooks.run("chromatic-adaptation-end", env);
if (env.M) {
return multiply_v3_m3x3(env.XYZ, env.M);
}
else {
throw new TypeError("Only Bradford CAT with white points D50 and D65 supported for now.");
}
}
/** @import { ColorConstructor } from "./types.js" */
// Type re-exports
/** @typedef {import("./types.js").ArgumentMeta} ArgumentMeta */
/** @typedef {import("./types.js").ParseFunctionReturn} ParseFunctionReturn */
/** @typedef {import("./types.js").ParseOptions} ParseOptions */
/**
* Convert a CSS Color string to a color object
* @param {string} str
* @param {ParseOptions} [options]
* @returns {ColorConstructor}
*/
function parse (str, options) {
let env = {
str: String(str)?.trim(),
options,
};
hooks.run("parse-start", env);
if (env.color) {
return env.color;
}
env.parsed = parseFunction(env.str);
let ret;
let meta = env.options ? (env.options.parseMeta ?? env.options.meta) : null;
if (env.parsed) {
// Is a functional syntax
let name = env.parsed.name;
let format;
let space;
let coords = env.parsed.args;
let types = coords.map((c, i) => env.parsed.argMeta[i]?.type);
if (name === "color") {
// color() function
let id = coords.shift();
types.shift();
// Check against both <dashed-ident> and <ident> versions
let alternateId = id.startsWith("--") ? id.substring(2) : `--${id}`;
let ids = [id, alternateId];
format = ColorSpace.findFormat({ name, id: ids, type: "function" });
if (!format) {
// Not found
let didYouMean;
let registryId = id in ColorSpace.registry ? id : alternateId;
if (registryId in ColorSpace.registry) {
// Used color space id instead of color() id, these are often different
let cssId = ColorSpace.registry[registryId].formats?.color?.id;
if (cssId) {
let altColor = str.replace("color(" + id, "color(" + cssId);
didYouMean = `Did you mean ${altColor}?`;
}
}
throw new TypeError(
`Cannot parse ${env.str}. ` + (didYouMean ?? "Missing a plugin?"),
);
}
space = format.space;
if (format.id.startsWith("--") && !id.startsWith("--")) {
defaults.warn(
`${space.name} is a non-standard space and not currently supported in the CSS spec. ` +
`Use prefixed color(${format.id}) instead of color(${id}).`,
);
}
if (id.startsWith("--") && !format.id.startsWith("--")) {
defaults.warn(
`${space.name} is a standard space and supported in the CSS spec. ` +
`Use color(${format.id}) instead of prefixed color(${id}).`,
);
}
}
else {
format = ColorSpace.findFormat({ name, type: "function" });
space = format.space;
}
if (meta) {
Object.assign(meta, {
format,
formatId: format.name,
types,
commas: env.parsed.commas,
});
}
let alpha = 1;
if (env.parsed.lastAlpha) {
alpha = env.parsed.args.pop();
if (meta) {
meta.alphaType = types.pop();
}
}
let coordCount = format.coords.length;
if (coords.length !== coordCount) {
throw new TypeError(
`Expected ${coordCount} coordinates for ${space.id} in ${env.str}), got ${coords.length}`,
);
}
coords = format.coerceCoords(coords, types);
ret = { spaceId: space.id, coords, alpha };
}
else {
// Custom, colorspace-specific format
spaceloop: 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;
}
// Convert to Format object
let formatObject = space.getFormat(format);
let color = formatObject.parse(env.str);
if (color) {
if (meta) {
Object.assign(meta, { format: formatObject, formatId });
}
ret = color;
break spaceloop;
}
}
}
}
if (!ret) {
// If we're here, we couldn't parse
throw new TypeError(`Could not parse ${str} as a color. Missing a plugin?`);
}
// Clamp alpha to [0, 1]
ret.alpha = isNone(ret.alpha)
? ret.alpha
: ret.alpha === undefined
? 1
: clamp(0, ret.alpha, 1);
return ret;
}
/**
* Units and multiplication factors for the internally stored numbers
*/
const units = {
"%": 0.01,
deg: 1,
grad: 0.9,
rad: 180 / Math.PI,
turn: 360,
};
const regex = {
// Need to list calc(NaN) explicitly as otherwise its ending paren would terminate the function call
function: /^([a-z]+)\(((?:calc\(NaN\)|.)+?)\)$/i,
number: /^([-+]?(?:[0-9]*\.)?[0-9]+(e[-+]?[0-9]+)?)$/i,
unitValue: RegExp(`(${Object.keys(units).join("|")})$`),
// NOTE The -+ are not just for prefix, but also for idents, and e+N notation!
singleArgument: /\/?\s*(none|NaN|calc\(NaN\)|[-+\w.]+(?:%|deg|g?rad|turn)?)/g,
};
/**
* Parse a single function argument
* @param {string} rawArg
* @returns {{value: number, meta: ArgumentMeta}}
*/
function parseArgument (rawArg) {
/** @type {Partial<ArgumentMeta>} */
let meta = {};
let unit = rawArg.match(regex.unitValue)?.[0];
/** @type {string | number} */
let value = (meta.raw = rawArg);
if (unit) {
// It’s a dimension token
meta.type = unit === "%" ? "<percentage>" : "<angle>";
meta.unit = unit;
meta.unitless = Number(value.slice(0, -unit.length)); // unitless number
value = meta.unitless * units[unit];
}
else if (regex.number.test(value)) {
// It's a number
// Convert numerical args to numbers
value = Number(value);
meta.type = "<number>";
}
else if (value === "none") {
value = null;
}
else if (value === "NaN" || value === "calc(NaN)") {
value = NaN;
meta.type = "<number>";
}
else {
meta.type = "<ident>";
}
return { value: /** @type {number} */ (value), meta: /** @type {ArgumentMeta} */ (meta) };
}
/**
* Parse a CSS function, regardless of its name and arguments
* @param {string} str String to parse
* @return {ParseFunctionReturn | void}
*/
function parseFunction (str) {
if (!str) {
return;
}
str = str.trim();
let parts = str.match(regex.function);
if (parts) {
// It is a function, parse args
let args = [];
let argMeta = [];
let lastAlpha = false;
let name = parts[1].toLowerCase();
let separators = parts[2].replace(regex.singleArgument, ($0, rawArg) => {
let { value, meta } = parseArgument(rawArg);
if (
// If there's a slash here, it's modern syntax
$0.startsWith("/") ||
// If there's still elements to process after there's already 3 in `args` (and the we're not dealing with "color()"), it's likely to be a legacy color like "hsl(0, 0%, 0%, 0.5)"
(name !== "color" && args.length === 3)
) {
// It's alpha
lastAlpha = true;
}
args.push(value);
argMeta.push(meta);
return "";
});
return {
name,
args,
argMeta,
lastAlpha,
commas: separators.includes(","),
rawName: parts[1],
rawArgs: parts[2],
};
}
}
/** @import { ColorTypes, ParseOptions as GetColorOptions, PlainColorObject } from "./types.js" */
/**
* Resolves a color reference (object or string) to a plain color object
* @overload
* @param {ColorTypes} color
* @param {GetColorOptions} [options]
* @returns {PlainColorObject}
*/
/**
* @overload
* @param {ColorTypes[]} color
* @param {GetColorOptions} [options]
* @returns {PlainColorObject[]}
*/
function getColor (color, options) {
if (Array.isArray(color)) {
return color.map(c => getColor(c, options));
}
if (!color) {
throw new TypeError("Empty color reference");
}
if (isString(color)) {
color = parse(color, options);
}
// Object fixup
let space = color.space || color.spaceId;
if (typeof space === "string") {
// Convert string id to color space object
color.space = ColorSpace.get(space);
}
if (color.alpha === undefined) {
color.alpha = 1;
}
return color;
}
/**
* @packageDocumentation
* Defines the class and other types related to creating color spaces.
* For the builtin color spaces, see the `spaces` module.
*/
const ε$7 = 0.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;
for (let name in coords) {
if (!("name" in coords[name])) {
coords[name].name = name;
}
}
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 (!this.formats.color?.id) {
this.formats.color = {
...(this.formats.color ?? {}),
id: options.cssId || this.id,
};
}
// Gamut space
if (options.gamutSpace) {
// Gamut space explicitly specified
this.gamutSpace =
options.gamutSpace === "self" ? this : ColorSpace.get(options.gamutSpace);
}
else {
// No gamut space specified, calculate a sensible default
if (this.isPolar) {
// Do not check gamut through polar coordinates
this.gamutSpace = this.base;
}
else {
this.gamutSpace = this;
}
}
// Optimize inGamut for unbounded spaces
if (this.gamutSpace.isUnbounded) {
this.inGamut = (coords, options) => {
return true;
};
}
// Other stuff
this.referred = options.referred;
// Compute ancestors and store them, since they will never change
Object.defineProperty(this, "path", {
value: getPath(this).reverse(),
writable: false,
enumerable: true,
configurable: true,
});
hooks.run("colorspace-init-end", this);
}
inGamut (coords, { epsilon = ε$7 } = {}) {
if (!this.equals(this.gamutSpace)) {
coords = this.to(this.gamutSpace, coords);
return this.gamutSpace.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 (isNone(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 isUnbounded () {
return Object.values(this.coords).every(coord => !("range" in coord));
}
get cssId () {
return this.formats?.color?.id || this.id;
}
get isPolar () {
for (let id in this.coords) {
if (this.coords[id].type === "angle") {
return true;
}
}
return false;
}
/**
* Lookup a format in this color space
* @param {string | object | Format} format - Format id if string. If object, it's converted to a `Format` object and returned.
* @returns {Format}
*/
getFormat (format) {
if (!format) {
return null;
}
if (format === "default") {
format = Object.values(this.formats)[0];
}
else if (typeof format === "string") {
format = this.formats[format];
}
let ret = Format.get(format, this);
if (ret !== format && format.name in this.formats) {
// Update the format we have on file so we can find it more quickly next time
this.formats[format.name] = ret;
}
return ret;
}
/**
* Check if this color space is the same as another color space reference.
* Allows proxying color space objects and comparing color spaces with ids.
* @param {string | ColorSpace} space ColorSpace object or id to compare to
* @returns {boolean}
*/
equals (space) {
if (!space) {
return false;
}
return this === space || this.id === space || this.id === space.id;
}
to (space, coords) {
if (arguments.length === 1) {
const color = getColor(space);
[space, coords] = [color.space, color.coords];
}
space = ColorSpace.get(space);
if (this.equals(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 => (isNone(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].equals(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) {
const color = getColor(space);
[space, coords] = [color.space, color.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 || isInstance(space, this)) {
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`);
}
/**
* Look up all color spaces for a format that matches certain criteria
* @param {object | string} filters
* @param {Array<ColorSpace>} [spaces=ColorSpace.all]
* @returns {Format | null}
*/
static findFormat (filters, spaces = ColorSpace.all) {
if (!filters) {
return null;
}
if (typeof filters === "string") {
filters = { name: filters };
}
for (let space of spaces) {
for (let [name, format] of Object.entries(space.formats)) {
format.name ??= name;
format.type ??= "function";
let matches =
(!filters.name || format.name === filters.name) &&
(!filters.type || format.type === filters.type);
if (filters.id) {
let ids = format.ids || [format.id];
let filterIds = Array.isArray(filters.id) ? filters.id : [filters.id];
matches &&= filterIds.some(id => ids.includes(id));
}
if (matches) {
let ret = Format.get(format, space);
if (ret !== format) {
space.formats[format.name] = ret;
}
return ret;
}
}
}
return null;
}
/**
* 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",
};
}
function getPath (space) {
let ret = [space];
for (let s = space; (s = s.base); ) {
ret.push(s);
}
return ret;
}
var xyz_d65 = new ColorSpace({
id: "xyz-d65",
name: "XYZ D65",
coords: {
x: {
refRange: [0, 1],
name: "X",
},
y: {
refRange: [0, 1],
name: "Y",
},
z: {
refRange: [0, 1],
name: "Z",
},
},
white: "D65",
formats: {
color: {
ids: ["xyz-d65", "xyz"],
},
},
aliases: ["xyz"],
});
// Type re-exports
/** @typedef {import("./types.js").RGBOptions} RGBOptions */
/** Convenience class for RGB color spaces */
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 {RGBOptions} options
*/
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 = multiply_v3_m3x3(rgb, options.toXYZ_M);
if (this.white !== this.base.white) {
// Perform chromatic adaptation
xyz = adapt$2(this.white, this.base.white, xyz);
}
return xyz;
};
options.fromBase ??= xyz => {
xyz = adapt$2(this.base.white, this.white, xyz);
return multiply_v3_m3x3(xyz, options.fromXYZ_M);
};
}
options.referred ??= "display";
super(options);
}
}
/** @import { ColorTypes, PlainColorObject } from "./types.js" */
// Type re-exports
/** @typedef {import("./types.js").TryColorOptions} TryColorOptions */
/**
* Resolves a color reference (object or string) to a plain color object, or `null` if resolution fails.
* Can resolve more complex CSS colors (e.g. relative colors, `calc()`, CSS variables, `color-mix()`, etc.) through the DOM.
*
* @overload
* @param {ColorTypes} color
* @param {TryColorOptions} [options]
* @returns {PlainColorObject | null}
*/
/**
* @overload
* @param {ColorTypes[]} color
* @param {TryColorOptions} [options]
* @returns {(PlainColorObject | null)[]}
*/
function tryColor (color, options = {}) {
if (Array.isArray(color)) {
return color.map(c => tryColor(c, options));
}
let { cssProperty = "background-color", element, ...getColorOptions } = options;
let error = null;
try {
return getColor(color, getColorOptions);
}
catch (e) {
error = e;
}
let { CSS, getComputedStyle } = globalThis;
if (isString(color) && element && CSS && getComputedStyle) {
// Try resolving the color using the DOM, if supported in CSS
if (CSS.supports(cssProperty, color)) {
let previousValue = element.style[cssProperty];
if (color !== previousValue) {
element.style[cssProperty] = color;
}
let computedColor = getComputedStyle(element).getPropertyValue(cssProperty);
if (color !== previousValue) {
element.style[cssProperty] = previousValue;
}
if (computedColor !== color) {
// getComputedStyle() changed the color, try again
try {
return getColor(computedColor, getColorOptions);
}
catch (e) {
error = e;
}
}
else {
// Still not resolved
error = {
message: "Color value is a valid CSS color, but it could not be resolved :(",
};
}
}
}
// If we're here, we failed to resolve the color
if (options.errorMeta) {
options.errorMeta.error = error;
}
return null;
}
/** @import { ColorTypes, Coords } from "./types.js" */
/**
* Options for {@link getAll}
* @typedef GetAllOptions
* @property {string | ColorSpace | undefined} [space]
* The color space to convert to. Defaults to the color's current space
* @property {number | undefined} [precision]
* The number of significant digits to round the coordinates to
*/
/**
* Get the coordinates of a color in any color space
* @overload
* @param {ColorTypes} color
* @param {string | ColorSpace} [options=color.space] The color space to convert to. Defaults to the color's current space
* @returns {Coords} The color coordinates in the given color space
*/
/**
* @overload
* @param {ColorTypes} color
* @param {GetAllOptions} [options]
* @returns {Coords} The color coordinates in the given color space
*/
function getAll (color, options) {
color = getColor(color);
let space = ColorSpace.get(options, options?.space);
let precision = options?.precision;
let coords;
if (!space || color.space.equals(space)) {
// No conversion needed
coords = color.coords.slice();
}
else {
coords = space.from(color);
}
return precision === undefined ? coords : coords.map(coord => toPrecision(coord, precision));
}
/** @import { ColorTypes, Ref } from "./types.js" */
/**
* @param {ColorTypes} color
* @param {Ref} prop
* @returns {number}
*/
function get (color, prop) {
color = getColor(color);
if (prop === "alpha") {
return color.alpha ?? 1;
}
let { space, index } = ColorSpace.resolveCoord(prop, color.space);
let coords = getAll(color, space);
return coords[index];
}
/** @import { ColorTypes, Coords, PlainColorObject } from "./types.js" */
/**
* Set all coordinates of a color at once, in its own color space or another.
* Modifies the color in place.
* @overload
* @param {ColorTypes} color
* @param {Coords} coords Array of coordinates
* @param {number} [alpha]
* @returns {PlainColorObject}
*/
/**
* @overload
* @param {ColorTypes} color
* @param {string | ColorSpace} space The color space of the provided coordinates.
* @param {Coords} coords Array of coordinates
* @param {number} [alpha]
* @returns {PlainColorObject}
*/
function setAll (color, space, coords, alpha) {
color = getColor(color);
if (Array.isArray(space)) {
// Space is omitted
[space, coords, alpha] = [color.space, space, coords];
}
space = ColorSpace.get(space); // Make sure we have a ColorSpace object
color.coords = space === color.space ? coords.slice() : space.to(color.space, coords);
if (alpha !== undefined) {
color.alpha = alpha;
}
return color;
}
/** @type {"color"} */
setAll.returns = "color";
/** @import { ColorTypes, PlainColorObject, Ref } from "./types.js" */
/**
* Set properties and return current instance
* @overload
* @param {ColorTypes} color
* @param {Ref} prop
* @param {number | ((coord: number) => number)} value
* @returns {PlainColorObject}
*/
/**
* @overload
* @param {ColorTypes} color
* @param {Record<string, number | ((coord: number) => number)>} props
* @returns {PlainColorObject}
*/
function set (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(color, p, object[p]);
}
}
else {
if (typeof value === "function") {
value = value(get(color, prop));
}
if (prop === "alpha") {
color.alpha = value;
}
else {
let { space, index } = ColorSpace.resolveCoord(prop, color.space);
let coords = getAll(color, space);
coords[index] = value;
setAll(color, space, coords);
}
}
return color;
}
/** @type {"color"} */
set.returns = "color";
var XYZ_D50 = new ColorSpace({
id: "xyz-d50",
name: "XYZ D50",
white: "D50",
base: xyz_d65,
fromBase: coords => adapt$2(xyz_d65.white, "D50", coords),
toBase: coords => adapt$2("D50", xyz_d65.white, coords),
});
// κ * ε = 2^3 = 8
const ε$6 = 216 / 24389; // 6^3/29^3 == (24/116)^3
const ε3$1 = 24 / 116;
const κ$4 = 24389 / 27; // 29^3/3^3
let white$4 = WHITES.D50;
var lab = new ColorSpace({
id: "lab",
name: "Lab",
coords: {
l: {
refRange: [0, 100],
name: "Lightness",
},
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$4,
base: XYZ_D50,
// Convert D50-adapted XYX to Lab
// CIE 15.3:2004 section 8.2.1.1
fromBase (XYZ) {
// XYZ scaled relative to reference white
let xyz = XYZ.map((value, i) => value / white$4[i]);
let f = xyz.map(value => (value > ε$6 ? Math.cbrt(value) : (κ$4 * value + 16) / 116));
let L = 116 * f[1] - 16;
let a = 500 * (f[0] - f[1]);
let b = 200 * (f[1] - f[2]);
return [L, a, 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 [L, a, b] = Lab;
let f = [];
f[1] = (L + 16) / 116;
f[0] = a / 500 + f[1];
f[2] = f[1] - b / 200;
// compute xyz
// prettier-ignore
let xyz = [
f[0] > ε3$1 ? Math.pow(f[0], 3) : (116 * f[0] - 16) / κ$4,
Lab[0] > 8 ? Math.pow((Lab[0] + 16) / 116, 3) : Lab[0] / κ$4,
f[2] > ε3$1 ? Math.pow(f[2], 3) : (116 * f[2] - 16) / κ$4,
];
// Compute XYZ by scaling xyz by reference white
return xyz.map((value, i) => value * white$4[i]);
},
formats: {
lab: {
coords: [
"<percentage> | <number>",
"<number> | <percentage>",
"<number> | <percentage>",
],
},
},
});
/**
* Constrain an angle to 360 degrees
* @param {number} angle
* @returns {number}
*/
function constrain (angle) {
if (typeof angle !== "number") {
return angle;
}
return ((angle % 360) + 360) % 360;
}
/**
* @param {"raw" | "increasing" | "decreasing" | "longer" | "shorter"} arc
* @param {[number, number]} angles
* @returns {[number, number]}
*/
function adjust (arc, angles) {
let [a1, a2] = angles;
let none1 = isNone(a1);
let none2 = isNone(a2);
if (none1 && none2) {
return [a1, a2];
}
else if (none1) {
a1 = a2;
}
else if (none2) {
a2 = a1;
}
if (arc === "raw") {
return angles;
}
a1 = constrain(a1);
a2 = constrain(a2);
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) {
a1 += 360;
}
else {
a2 += 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) {
// These methods are used for other polar forms as well, so we can't hardcode the ε
if (this.ε === undefined) {
// @ts-expect-error Property 'coords' does not exist on type 'string | ColorSpace'
let range = Object.values(this.base.coords)[1].refRange;
let extent = range[1] - range[0];
this.ε = extent / 100000;
}
// Convert to polar form
let [L, a, b] = Lab;
let isAchromatic = Math.abs(a) < this.ε && Math.abs(b) < this.ε;
let h = isAchromatic ? null : constrain((Math.atan2(b, a) * 180) / Math.PI);
let C = isAchromatic ? 0 : Math.sqrt(a ** 2 + b ** 2);
return [L, C, h];
},
toBase (lch) {
// Convert from polar form
let [L, C, h] = lch;
let a = null,
b = null;
if (!isNone(h)) {
C = C < 0 ? 0 : C; // Clamp negative Chroma
a = C * Math.cos((h * Math.PI) / 180);
b = C * Math.sin((h * Math.PI) / 180);
}
return [L, a, b];
},
formats: {
lch: {
coords: ["<percentage> | <number>", "<number> | <percentage>", "<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 pow7 (x) {
// Faster than x ** 7 or Math.pow(x, 7)
const x2 = x * x;
const x7 = x2 * x2 * x2 * x;
return x7;
}
/**
* @param {import("../types.js").ColorTypes} color
* @param {import("../types.js").ColorTypes} sample
* @param {{ kL?: number | undefined; kC?: number | undefined; kH?: number | undefined }} options
* @returns {number}
*/
function deltaE2000 (color, sample, { kL = 1, kC = 1, kH = 1 } = {}) {
[color, sample] = getColor([color, sample]);
// 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
// calcula