UNPKG

colorjs.io

Version:

Let’s get serious about color

2,185 lines (1,835 loc) 196 kB
'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