UNPKG

colorjs.io

Version:

Color space agnostic color manipulation library

2,032 lines (1,714 loc) 75.8 kB
// A is m x n. B is n x p. product is m x p. function multiplyMatrices(A, B) { let m = A.length; if (!Array.isArray(A[0])) { // A is vector, convert to [[a, b, c, ...]] A = [A]; } if (!Array.isArray(B[0])) { // B is vector, convert to [[a], [b], [c], ...]] B = B.map(x => [x]); } let p = B[0].length; let B_cols = B[0].map((_, i) => B.map(x => x[i])); // transpose B let product = A.map(row => B_cols.map(col => { if (!Array.isArray(row)) { return col.reduce((a, c) => a + c * row, 0); } return row.reduce((a, c, i) => a + c * (col[i] || 0), 0); })); if (m === 1) { product = product[0]; // Avoid [[a, b, c, ...]] } if (p === 1) { return product.map(x => x[0]); // Avoid [[a], [b], [c], ...]] } return product; } /** * Check if a value is a string (including a String object) * @param {*} str - Value to check * @returns {boolean} */ function isString (str) { return type(str) === "string"; } /** * Determine the internal JavaScript [[Class]] of an object. * @param {*} o - Value to check * @returns {string} */ function type (o) { let str = Object.prototype.toString.call(o); return (str.match(/^\[object\s+(.*?)\]$/)[1] || "").toLowerCase(); } /** * Like Object.assign() but copies property descriptors (including symbols) * @param {Object} target - Object to copy to * @param {...Object} sources - Objects to copy from * @returns {Object} target */ function extend (target, ...sources) { for (let source of sources) { if (source) { let descriptors = Object.getOwnPropertyDescriptors(source); Object.defineProperties(target, descriptors); } } return target; } /** * Copy a descriptor from one object to another * @param {Object} target - Object to copy to * @param {Object} source - Object to copy from * @param {string} prop - Name of property */ function copyDescriptor (target, source, prop) { let descriptor = Object.getOwnPropertyDescriptor(source, prop); Object.defineProperty(target, prop, descriptor); } /** * Uppercase the first letter of a string * @param {string} str - String to capitalize * @returns Capitalized string */ function capitalize(str) { if (!str) { return str; } return str[0].toUpperCase() + str.slice(1); } /** * Round a number to a certain number of significant digits based on a range * @param {number} n - The number to round * @param {number} precision - Number of significant digits * @param {Array[2]} range - Range to base decimals on */ function toPrecision(n, precision, range = [0, 1]) { precision = +precision; let digits = ((range[1] || range[0] || 1) + "").length; let decimals = Math.max(0, precision + 1 - digits); return +n.toFixed(decimals); } function parseCoord(coord) { if (coord.indexOf(".") > 0) { // Reduce a coordinate of a certain color space until the color is in gamut let [spaceId, coordName] = coord.split("."); let space = Color.space(spaceId); if (!(coordName in space.coords)) { throw new ReferenceError(`Color space "${space.name}" has no "${coordName}" coordinate.`); } return [space, coordName]; } } function value(obj, prop, value) { let props = prop.split("."); let lastProp = props.pop(); obj = props.reduceRight((acc, cur) => { return acc && acc[cur]; }, obj); if (obj) { if (value === undefined) { // Get return obj[lastProp]; } else { // Set return obj[lastProp] = value; } } } var util = /*#__PURE__*/Object.freeze({ __proto__: null, isString: isString, type: type, extend: extend, copyDescriptor: copyDescriptor, capitalize: capitalize, toPrecision: toPrecision, parseCoord: parseCoord, value: value, multiplyMatrices: multiplyMatrices }); /** * Module version of Bliss.Hooks. * @author Lea Verou */ 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); }); } } const ε = .000075; const hasDOM = typeof document !== "undefined"; class Color$1 { // Signatures: // new Color(stringToParse) // new Color(otherColor) // new Color(coords, alpha) // defaults to sRGB // new Color(CSS variable [, root]) constructor (...args) { let str, color; // new Color(color) // new Color({spaceId, coords}) // new Color({space, coords}) if (args[0] && typeof args[0] === "object" && (args[0].space || args[0].spaceId) && args[0].coords) { color = args[0]; } else if (isString(args[0])) { // new Color("--foo" [, root]) if (hasDOM && args[0].indexOf("--") === 0) { // CSS variable let root = arguments[1] && arguments[1].nodeType === 1? arguments[1] : document.documentElement; str = getComputedStyle(root).getPropertyValue(arguments[0]); } // new Color(string) else if (args.length === 1) { str = args[0]; } if (str) { color = Color$1.parse(str); } } if (color) { if ("spaceId" in color) { this.spaceId = color.spaceId; } else { this.space = color.space; } this.coords = color.coords.slice(); this.alpha = color.alpha; } else { // default signature new Color([ColorSpace,] array [, alpha]) let spaceId, coords, alpha; if (Array.isArray(args[0])) { // No color space provided, default to sRGB [spaceId, coords, alpha] = ["sRGB", ...args]; } else { [spaceId, coords, alpha] = args; } this.spaceId = spaceId || "sRGB"; this.coords = coords? coords.slice() : [0, 0, 0]; this.alpha = alpha; } this.alpha = this.alpha < 1? this.alpha : 1; // this also deals with NaN etc // Convert "NaN" to NaN for (let i = 0; i < this.coords.length; i++) { if (this.coords[i] === "NaN") { this.coords[i] = NaN; } } } get space () { return Color$1.spaces[this.spaceId]; } set space (value) { // Setting spaceId works with color space objects too return this.spaceId = value; } get spaceId () { return this._spaceId; } // Handle dynamic changes of color space set spaceId (id) { let newSpace = Color$1.space(id); id = newSpace.id; if (this.space && newSpace && this.space !== newSpace) { // We’re not setting this for the first time, need to: // a) Convert coords this.coords = this[id]; // b) Remove instance properties from previous color space for (let prop in this.space.instance) { if (this.hasOwnProperty(prop)) { delete this[prop]; } } } this._spaceId = id; // Add new instance properties from new color space extend(this, this.space.instance); } get white () { return this.space.white || Color$1.whites.D50; } // Set properties and return current instance set (prop, value$1) { if (arguments.length === 1 && type(arguments[0]) === "object") { // Argument is an object literal let object = arguments[0]; for (let p in object) { this.set(p, object[p]); } } else { if (typeof value$1 === "function") { let current = value(this, prop); value(this, prop, value$1.call(this, current)); } else { value(this, prop, value$1); } } return this; } lighten (amount = .25) { let ret = new Color$1(this); let lightness = ret.lightness; ret.lightness = lightness * (1 + amount); return ret; } darken (amount = .25) { let ret = new Color$1(this); let lightness = ret.lightness; ret.lightness = lightness * (1 - amount); return ret; } // Euclidean distance of colors in an arbitrary color space distance (color, space = "lab") { color = Color$1.get(color); space = Color$1.space(space); let coords1 = this[space.id]; let coords2 = color[space.id]; return Math.sqrt(coords1.reduce((a, c, i) => { if (isNaN(c) || isNaN(coords2[i])) { return a; } return a + (coords2[i] - c) ** 2; }, 0)); } deltaE (color, o = {}) { if (isString(o)) { o = {method: o}; } let {method = Color$1.defaults.deltaE, ...rest} = o; color = Color$1.get(color); if (this["deltaE" + method]) { return this["deltaE" + method](color, rest); } return this.deltaE76(color); } // 1976 DeltaE. 2.3 is the JND deltaE76 (color) { return this.distance(color, "lab"); } // Relative luminance get luminance () { return this.xyz.Y; } set luminance (value) { this.xyz.Y = value; } // WCAG 2.0 contrast https://www.w3.org/TR/WCAG20-TECHS/G18.html contrast (color) { color = Color$1.get(color); let L1 = this.luminance; let L2 = color.luminance; if (L2 > L1) { [L1, L2] = [L2, L1]; } return (L1 + .05) / (L2 + .05); } // Chromaticity coordinates get uv () { let [X, Y, Z] = this.xyz; let denom = X + 15 * Y + 3 * Z; return [4 * X / denom, 9 * Y / denom]; } get xy () { let [X, Y, Z] = this.xyz; let sum = X + Y + Z; return [X / sum, Y / sum]; } // no setters, as lightness information is lost // when converting color to chromaticity // Get formatted coords getCoords ({inGamut, precision = Color$1.defaults.precision} = {}) { let coords = this.coords; if (inGamut && !this.inGamut()) { coords = this.toGamut(inGamut === true? undefined : inGamut).coords; } if (precision !== undefined && precision !== null) { let bounds = this.space.coords? Object.values(this.space.coords) : []; coords = coords.map((n, i) => toPrecision(n, precision, bounds[i])); } return coords; } /** * @return {Boolean} Is the color in gamut? */ inGamut (space = this.space, options) { space = Color$1.space(space); return Color$1.inGamut(space, this[space.id], options); } static inGamut (space, coords, {epsilon = ε} = {}) { space = Color$1.space(space); if (space.inGamut) { return space.inGamut(coords); } else { if (!space.coords) { return true; } // No color-space specific inGamut() function, just check if coords are within reference range let bounds = Object.values(space.coords); return coords.every((c, i) => { if (Number.isNaN(c)) { return true; } let [min, max] = bounds[i]; return (min === undefined || c >= min - epsilon) && (max === undefined || c <= max + epsilon); }); } } /** * Force coordinates in gamut of a certain color space and return the result * @param {Object} options * @param {string} options.method - How to force into gamut. * If "clip", coordinates are just clipped to their reference range. * If in the form [colorSpaceId].[coordName], that coordinate is reduced * until the color is in gamut. Please note that this may produce nonsensical * results for certain coordinates (e.g. hue) or infinite loops if reducing the coordinate never brings the color in gamut. * @param {ColorSpace|string} options.space - The space whose gamut we want to map to * @param {boolean} options.inPlace - If true, modify the current color, otherwise return a new one. */ toGamut ({method = Color$1.defaults.gamutMapping, space = this.space, inPlace} = {}) { if (isString(arguments[0])) { space = arguments[0]; } space = Color$1.space(space); if (this.inGamut(space, {epsilon: 0})) { return this; } // 3 spaces: // this.space: current color space // space: space whose gamut we are mapping to // mapSpace: space with the coord we're reducing let color = this.to(space); if (method.indexOf(".") > 0 && !this.inGamut(space)) { let clipped = color.toGamut({method: "clip", space}); // distance of original color from gamut boundary let base_error = this.deltaE(clipped, {method: "2000"}); // console.log(base_error); if (this.deltaE(clipped, {method: "2000"}) > 2.3) { // Reduce a coordinate of a certain color space until the color is in gamut let [mapSpace, coordName] = parseCoord(method); let mappedColor = color.to(mapSpace); let bounds = mapSpace.coords[coordName]; let min = bounds[0]; let ε = .001; // for deltaE let low = min; let high = mappedColor[coordName]; // distance of current estimate from original color let error = color.deltaE(mappedColor, {method: "2000"}); // let i = 0; while ((high - low > ε) && (error < base_error)) { let clipped = mappedColor.toGamut({space, method: "clip"}); let deltaE = mappedColor.deltaE(clipped, {method: "2000"}); error = color.deltaE(mappedColor, {method: "2000"}); if (deltaE - 2 < ε) { low = mappedColor[coordName]; // console.log(++i, "in", mappedColor.chroma, mappedColor.srgb, error); } else { // console.log(++i, "out", mappedColor.chroma, mappedColor.srgb, clipped.srgb, deltaE, error); if (Math.abs(deltaE - 2) < ε) { // We've found the boundary break; } high = mappedColor[coordName]; } mappedColor[coordName] = (high + low) / 2; } color = mappedColor.to(space); } else { color = clipped; } } if (method === "clip" // Dumb coord clipping // finish off smarter gamut mapping with clip to get rid of ε, see #17 || !color.inGamut(space, {epsilon: 0}) ) { let bounds = Object.values(space.coords); color.coords = color.coords.map((c, i) => { let [min, max] = bounds[i]; if (min !== undefined) { c = Math.max(min, c); } if (max !== undefined) { c = Math.min(c, max); } return c; }); } if (space.id !== this.spaceId) { color = color.to(this.space); } if (inPlace) { this.coords = color.coords; return this; } else { return color; } } clone () { return new Color$1(this.spaceId, this.coords, this.alpha); } /** * Convert to color space and return a new color * @param {Object|string} space - Color space object or id * @param {Object} options * @param {boolean} options.inGamut - Whether to force resulting color in gamut * @returns {Color} */ to (space, {inGamut} = {}) { space = Color$1.space(space); let id = space.id; let color = new Color$1(id, this[id], this.alpha); if (inGamut) { color.toGamut({inPlace: true}); } return color; } toJSON () { return { spaceId: this.spaceId, coords: this.coords, alpha: this.alpha }; } /** * Generic toString() method, outputs a color(spaceId ...coords) function * @param {Object} options * @param {number} options.precision - Significant digits * @param {boolean} options.commas - Whether to use commas to separate arguments or spaces (and a slash for alpha) [default: false] * @param {Function|String|Array} options.format - If function, maps all coordinates. Keywords tap to colorspace-specific formats (e.g. "hex") * @param {boolean} options.inGamut - Adjust coordinates to fit in gamut first? [default: false] * @param {string} options.name - Function name [default: color] */ toString ({ precision = Color$1.defaults.precision, format, commas, inGamut, name = "color", fallback } = {}) { let strAlpha = this.alpha < 1? ` ${commas? "," : "/"} ${this.alpha}` : ""; let coords = this.getCoords({inGamut, precision}); // Convert NaN to zeros to have a chance at a valid CSS color // Also convert -0 to 0 coords = coords.map(c => c? c : 0); if (isString(format)) { if (format === "%") { let maximumSignificantDigits = precision; if (!Number.isInteger(precision) || precision > 21) { maximumSignificantDigits = 21; } format = c => c.toLocaleString("en-US", { style: "percent", maximumSignificantDigits }); } } if (typeof format === "function") { coords = coords.map(format); } let args = [...coords]; if (name === "color") { // If output is a color() function, add colorspace id as first argument args.unshift(this.space? this.space.cssId || this.space.id : "XYZ"); } let ret = `${name}(${args.join(commas? ", " : " ")}${strAlpha})`; if (fallback) { // Return a CSS string that's actually supported by the current browser // Return as a String object, so we can also hang the color object on it // in case it's different than this. That way third party code can use that // for e.g. computing text color, indicating out of gamut etc if (!hasDOM || !self.CSS || CSS.supports("color", ret)) { ret = new String(ret); ret.color = this; return ret; } let fallbacks = Array.isArray(fallback)? fallback.slice() : Color$1.defaults.fallbackSpaces; for (let i = 0, fallbackSpace; fallbackSpace = fallbacks[i]; i++) { if (Color$1.spaces[fallbackSpace]) { let color = this.to(fallbackSpace); ret = color.toString({precision}); if (CSS.supports("color", ret)) { ret = new String(ret); ret.color = color; return ret; } else if (fallbacks === Color$1.defaults.fallbackSpaces) { // Drop this space from the default fallbacks since it's not supported fallbacks.splice(i, 1); i--; } } } // None of the fallbacks worked, return in the most conservative form possible let color = this.to("srgb"); ret = new String(color.toString({commas: true})); ret.color = color; } return ret; } equals (color) { color = Color$1.get(color); return this.spaceId === color.spaceId && this.alpha === color.alpha && this.coords.every((c, i) => c === color.coords[i]); } // Adapt XYZ from white point W1 to W2 static chromaticAdaptation (W1, W2, XYZ, options = {}) { W1 = W1 || Color$1.whites.D50; W2 = W2 || Color$1.whites.D50; if (W1 === W2) { return XYZ; } let env = {W1, W2, XYZ, options}; Color$1.hooks.run("chromatic-adaptation-start", env); if (!env.M) { if (env.W1 === Color$1.whites.D65 && env.W2 === Color$1.whites.D50) { // Linear Bradford CAT env.M = [ [ 1.0478112, 0.0228866, -0.0501270], [ 0.0295424, 0.9904844, -0.0170491], [-0.0092345, 0.0150436, 0.7521316] ]; } else if (env.W1 === Color$1.whites.D50 && env.W2 === Color$1.whites.D65) { env.M = [ [ 0.9555766, -0.0230393, 0.0631636], [-0.0282895, 1.0099416, 0.0210077], [ 0.0122982, -0.0204830, 1.3299098] ]; } } Color$1.hooks.run("chromatic-adaptation-end", env); if (env.M) { return multiplyMatrices(env.M, env.XYZ); } else { throw new TypeError("Only Bradford CAT with white points D50 and D65 supported for now."); } } // CSS color to Color object static parse (str) { let env = {str}; Color$1.hooks.run("parse-start", env); if (env.color) { return env.color; } env.parsed = Color$1.parseFunction(env.str); Color$1.hooks.run("parse-function-start", env); if (env.color) { return env.color; } // Try colorspace-specific parsing for (let space of Object.values(Color$1.spaces)) { if (space.parse) { let color = space.parse(env.str, env.parsed); if (color) { return color; } } } let name = env.parsed && env.parsed.name; if (!/^color|^rgb/.test(name) && hasDOM && document.head) { // Use browser to parse when a DOM is available // we mainly use this for color names right now if keywords.js is not included // and for future-proofing let previousColor = document.head.style.color; document.head.style.color = ""; document.head.style.color = str; if (document.head.style.color !== previousColor) { let computed = getComputedStyle(document.head).color; document.head.style.color = previousColor; if (computed) { str = computed; env.parsed = Color$1.parseFunction(computed); name = env.parsed.name; } } } if (env.parsed) { // It's a function if (name === "rgb" || name === "rgba") { let args = env.parsed.args.map((c, i) => i < 3 && !c.percentage? c / 255 : +c); return { spaceId: "srgb", coords: args.slice(0, 3), alpha: args[3] }; } else if (name === "color") { let spaceId = env.parsed.args.shift().toLowerCase(); let space = Object.values(Color$1.spaces).find(space => (space.cssId || space.id) === spaceId); if (space) { // From https://drafts.csswg.org/css-color-4/#color-function // If more <number>s or <percentage>s are provided than parameters that the colorspace takes, the excess <number>s at the end are ignored. // If less <number>s or <percentage>s are provided than parameters that the colorspace takes, the missing parameters default to 0. (This is particularly convenient for multichannel printers where the additional inks are spot colors or varnishes that most colors on the page won’t use.) let argCount = Object.keys(space.coords).length; let alpha = env.parsed.rawArgs.indexOf("/") > 0? env.parsed.args.pop() : 1; let coords = Array(argCount).fill(0); coords.forEach((_, i) => coords[i] = env.parsed.args[i] || 0); return {spaceId: space.id, coords, alpha}; } else { throw new TypeError(`Color space ${spaceId} not found. Missing a plugin?`); } } } throw new TypeError(`Could not parse ${str} as a color. Missing a plugin?`); } /** * Parse a CSS function, regardless of its name and arguments * @param String str String to parse * @return Object An object with {name, args, rawArgs} */ static parseFunction (str) { if (!str) { return; } str = str.trim(); const isFunctionRegex = /^([a-z]+)\((.+?)\)$/i; const isNumberRegex = /^-?[\d.]+$/; let parts = str.match(isFunctionRegex); if (parts) { // It is a function, parse args let args = parts[2].match(/([-\w.]+(?:%|deg)?)/g); args = args.map(arg => { if (/%$/.test(arg)) { // Convert percentages to 0-1 numbers let n = new Number(+arg.slice(0, -1) / 100); n.percentage = true; return n; } else if (/deg$/.test(arg)) { // Drop deg from degrees and convert to number let n = new Number(+arg.slice(0, -3)); n.deg = true; return n; } else if (isNumberRegex.test(arg)) { // Convert numerical args to numbers return +arg; } // Return everything else as-is return arg; }); return { name: parts[1].toLowerCase(), rawName: parts[1], rawArgs: parts[2], // An argument could be (as of css-color-4): // a number, percentage, degrees (hue), ident (in color()) args }; } } // One-off convert between color spaces static convert (coords, fromSpace, toSpace) { fromSpace = Color$1.space(fromSpace); toSpace = Color$1.space(toSpace); if (fromSpace === toSpace) { // Same space, no change needed return coords; } // Convert NaN to 0, which seems to be valid in every coordinate of every color space coords = coords.map(c => Number.isNaN(c)? 0 : c); let fromId = fromSpace.id; let toId = toSpace.id; // Do we have a more specific conversion function? // Avoids round-tripping to & from XYZ if (toSpace.from && toSpace.from[fromId]) { // No white point adaptation, we assume the custom function takes care of it return toSpace.from[fromId](coords); } if (fromSpace.to && fromSpace.to[toId]) { // No white point adaptation, we assume the custom function takes care of it return fromSpace.to[toId](coords); } let XYZ = fromSpace.toXYZ(coords); if (toSpace.white !== fromSpace.white) { // Different white point, perform white point adaptation XYZ = Color$1.chromaticAdaptation(fromSpace.white, toSpace.white, XYZ); } return toSpace.fromXYZ(XYZ); } /** * Get a color from the argument passed * Basically gets us the same result as new Color(color) but doesn't clone an existing color object */ static get (color, ...args) { if (color instanceof Color$1) { return color; } return new Color$1(color, ...args); } /** * Return a color space object from an id or color space object * Mainly used internally, so that functions can easily accept either */ static space (space) { let type$1 = type(space); if (type$1 === "string") { // It's a color space id let ret = Color$1.spaces[space.toLowerCase()]; if (!ret) { throw new TypeError(`No color space found with id = "${space}"`); } return ret; } else if (space && type$1 === "object") { return space; } throw new TypeError(`${space} is not a valid color space`); } // Define a new color space static defineSpace ({id, inherits}) { let space = Color$1.spaces[id] = arguments[0]; if (inherits) { const except = ["id", "parse", "instance", "properties"]; let parent = Color$1.spaces[inherits]; for (let prop in parent) { if (!except.includes(prop) && !(prop in space)) { copyDescriptor(space, parent, prop); } } } let coords = space.coords; if (space.properties) { extend(Color$1.prototype, space.properties); } if (!space.fromXYZ && !space.toXYZ) { // Using a different connection space, define from/to XYZ functions based on that let connectionSpace; // What are we using as a connection space? if (space.from && space.to) { let from = new Set(Object.keys(space.from)); let to = new Set(Object.keys(space.to)); // Find spaces we can both convert to and from let candidates = [...from].filter(id => { if (to.has(id)) { // Of those, only keep those that have fromXYZ and toXYZ let space = Color$1.spaces[id]; return space && space.fromXYZ && space.toXYZ; } }); if (candidates.length > 0) { // Great, we found connection spaces! Pick the first one connectionSpace = Color$1.spaces[candidates[0]]; } } if (connectionSpace) { // Define from/to XYZ functions based on the connection space Object.assign(space, { // ISSUE do we need white point adaptation here? fromXYZ(XYZ) { let newCoords = connectionSpace.fromXYZ(XYZ); return this.from[connectionSpace.id](newCoords); }, toXYZ(coords) { let newCoords = this.to[connectionSpace.id](coords); return connectionSpace.toXYZ(newCoords); } }); } else { throw new ReferenceError(`No connection space found for ${space.name}.`); } } let coordNames = Object.keys(coords); // Define getters and setters for color[spaceId] // e.g. color.lch on *any* color gives us the lch coords Object.defineProperty(Color$1.prototype, id, { // Convert coords to coords in another colorspace and return them // Source colorspace: this.spaceId // Target colorspace: id get () { let ret = Color$1.convert(this.coords, this.spaceId, id); if (!self.Proxy) { return ret; } // Enable color.spaceId.coordName syntax return new Proxy(ret, { has: (obj, property) => { return coordNames.includes(property) || Reflect.has(obj, property); }, get: (obj, property, receiver) => { let i = coordNames.indexOf(property); if (i > -1) { return obj[i]; } return Reflect.get(obj, property, receiver); }, set: (obj, property, value, receiver) => { let i = coordNames.indexOf(property); if (property > -1) { // Is property a numerical index? i = property; // next if will take care of modifying the color } if (i > -1) { obj[i] = value; // Update color.coords this.coords = Color$1.convert(obj, id, this.spaceId); return true; } return Reflect.set(obj, property, value, receiver); }, }); }, // Convert coords in another colorspace to internal coords and set them // Target colorspace: this.spaceId // Source colorspace: id set (coords) { this.coords = Color$1.convert(coords, id, this.spaceId); }, configurable: true, enumerable: true }); return space; } // Define a shortcut property, e.g. color.lightness instead of color.lch.lightness // Shorcut is looked up on Color.shortcuts at calling time // If `long` is provided, it's added to Color.shortcuts as well, otherwise it's assumed to be already there static defineShortcut(prop, obj = Color$1.prototype, long) { if (long) { Color$1.shortcuts[prop] = long; } Object.defineProperty(obj, prop, { get () { return value(this, Color$1.shortcuts[prop]); }, set (value$1) { return value(this, Color$1.shortcuts[prop], value$1); }, configurable: true, enumerable: true }); } // Define static versions of all instance methods static statify(names = []) { names = names || Object.getOwnPropertyNames(Color$1.prototype); for (let prop of Object.getOwnPropertyNames(Color$1.prototype)) { let descriptor = Object.getOwnPropertyDescriptor(Color$1.prototype, prop); if (descriptor.get || descriptor.set) { continue; // avoid accessors } let method = descriptor.value; if (typeof method === "function" && !(prop in Color$1)) { // We have a function, and no static version already Color$1[prop] = function(color, ...args) { color = Color$1.get(color); return color[prop](...args); }; } } } } Object.assign(Color$1, { util, hooks: new Hooks(), whites: { D50: [0.96422, 1.00000, 0.82521], D65: [0.95047, 1.00000, 1.08883], }, spaces: {}, // These will be available as getters and setters on EVERY color instance. // They refer to LCH by default, but can be set to anything // and you can add more by calling Color.defineShortcut() shortcuts: { "lightness": "lch.lightness", "chroma": "lch.chroma", "hue": "lch.hue", }, // Global defaults one may want to configure defaults: { gamutMapping: "lch.chroma", precision: 5, deltaE: "76", // Default deltaE method fallbackSpaces: ["p3", "srgb"] } }); Color$1.defineSpace({ id: "xyz", name: "XYZ", coords: { X: [], Y: [], Z: [] }, white: Color$1.whites.D50, inGamut: coords => true, toXYZ: coords => coords, fromXYZ: coords => coords }); for (let prop in Color$1.shortcuts) { Color$1.defineShortcut(prop); } // Make static methods for all instance methods Color$1.statify(); // Color.DEBUGGING = true; Color$1.defineSpace({ id: "lab", name: "Lab", coords: { L: [0, 100], a: [-100, 100], b: [-100, 100] }, inGamut: coords => true, // Assuming XYZ is relative to D50, convert to CIE Lab // from CIE standard, which now defines these as a rational fraction white: Color$1.whites.D50, ε: 216/24389, // 6^3/29^3 κ: 24389/27, // 29^3/3^3 fromXYZ(XYZ) { const {κ, ε, white} = this; // compute xyz, which is XYZ scaled relative to reference white let xyz = XYZ.map((value, i) => value / white[i]); // now compute f let f = xyz.map(value => value > ε ? Math.cbrt(value) : (κ * value + 16)/116); return [ (116 * f[1]) - 16, // L 500 * (f[0] - f[1]), // a 200 * (f[1] - f[2]) // b ]; }, toXYZ(Lab) { // Convert Lab to D50-adapted XYZ // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html const {κ, ε, white} = this; // compute f, starting with the luminance-related term let f = []; f[1] = (Lab[0] + 16)/116; f[0] = Lab[1]/500 + f[1]; f[2] = f[1] - Lab[2]/200; // compute xyz var xyz = [ Math.pow(f[0], 3) > ε ? Math.pow(f[0], 3) : (116*f[0]-16)/κ, Lab[0] > κ * ε ? Math.pow((Lab[0]+16)/116, 3) : Lab[0]/κ, Math.pow(f[2], 3) > ε ? Math.pow(f[2], 3) : (116*f[2]-16)/κ ]; // Compute XYZ by scaling xyz by reference white return xyz.map((value, i) => value * white[i]); }, parse (str, parsed = Color$1.parseFunction(str)) { if (parsed && parsed.name === "lab") { let L = parsed.args[0]; // Percentages in lab() don't translate to a 0-1 range, but a 0-100 range if (L.percentage) { parsed.args[0] = L * 100; } return { spaceId: "lab", coords: parsed.args.slice(0, 3), alpha: parsed.args.slice(3)[0] }; } }, instance: { toString ({format, ...rest} = {}) { if (!format) { format = (c, i) => i === 0? c + "%" : c; } return Color$1.prototype.toString.call(this, {name: "lab", format, ...rest}); } } }); const range = [0, 360]; range.isAngle = true; function constrain (angle) { return ((angle % 360) + 360) % 360; } function adjust (arc, angles) { if (arc === "raw") { return angles; } let [a1, a2] = angles.map(constrain); let angleDiff = a2 - a1; if (arc === "increasing") { if (angleDiff < 0) { a2 += 360; } } else if (arc === "decreasing") { if (angleDiff > 0) { a1 += 360; } } else if (arc === "longer") { if (-180 < angleDiff && angleDiff < 180) { if (angleDiff > 0) { a2 += 360; } else { a1 += 360; } } } else if (arc === "shorter") { if (angleDiff > 180) { a1 += 360; } else if (angleDiff < -180) { a2 += 360; } } return [a1, a2]; } Color$1.defineSpace({ id: "lch", name: "LCH", coords: { lightness: [0, 100], chroma: [0, 150], hue: range, }, inGamut: coords => true, white: Color$1.whites.D50, from: { lab (Lab) { // Convert to polar form let [L, a, b] = Lab; let hue; const ε = 0.0005; if (Math.abs(a) < ε && Math.abs(b) < ε) { hue = NaN; } else { hue = Math.atan2(b, a) * 180 / Math.PI; } return [ L, // L is still L Math.sqrt(a ** 2 + b ** 2), // Chroma constrain(hue) // Hue, in degrees [0 to 360) ]; } }, to: { lab (LCH) { // Convert from polar form let [Lightness, Chroma, Hue] = LCH; // Clamp any negative Chroma if (Chroma < 0) { Chroma = 0; } // Deal with NaN Hue if (isNaN(Hue)) { Hue = 0; } return [ Lightness, // L is still L Chroma * Math.cos(Hue * Math.PI / 180), // a Chroma * Math.sin(Hue * Math.PI / 180) // b ]; } }, parse (str, parsed = Color$1.parseFunction(str)) { if (parsed && parsed.name === "lch") { let L = parsed.args[0]; // Percentages in lch() don't translate to a 0-1 range, but a 0-100 range if (L.percentage) { parsed.args[0] = L * 100; } return { spaceId: "lch", coords: parsed.args.slice(0, 3), alpha: parsed.args.slice(3)[0] }; } }, instance: { toString ({format, ...rest} = {}) { if (!format) { format = (c, i) => i === 0? c + "%" : c; } return Color$1.prototype.toString.call(this, {name: "lch", format, ...rest}); } } }); Color$1.defineSpace({ id: "srgb", name: "sRGB", coords: { red: [0, 1], green: [0, 1], blue: [0, 1] }, white: Color$1.whites.D65, // convert an array of sRGB values in the range 0.0 - 1.0 // to linear light (un-companded) form. // https://en.wikipedia.org/wiki/SRGB toLinear(RGB) { return RGB.map(function (val) { if (val < 0.04045) { return val / 12.92; } return Math.pow((val + 0.055) / 1.055, 2.4); }); }, // convert an array of linear-light sRGB values in the range 0.0-1.0 // to gamma corrected form // https://en.wikipedia.org/wiki/SRGB toGamma(RGB) { return RGB.map(function (val) { if (val > 0.0031308) { return 1.055 * Math.pow(val, 1/2.4) - 0.055; } return 12.92 * val; }); }, toXYZ_M: [ [0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041] ], fromXYZ_M: [ [ 3.2404542, -1.5371385, -0.4985314], [-0.9692660, 1.8760108, 0.0415560], [ 0.0556434, -0.2040259, 1.0572252] ], // convert an array of sRGB values to CIE XYZ // using sRGB's own white, D65 (no chromatic adaptation) // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html // also // https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz toXYZ(rgb) { rgb = this.toLinear(rgb); return multiplyMatrices(this.toXYZ_M, rgb); }, fromXYZ(XYZ) { return this.toGamma(multiplyMatrices(this.fromXYZ_M, XYZ)); }, // Properties added to Color.prototype properties: { toHex({ alpha = true, // include alpha in hex? collapse = true // collapse to 3-4 digit hex when possible? } = {}) { let coords = this.to("srgb", {inGamut: true}).coords; if (this.alpha < 1 && alpha) { coords.push(this.alpha); } coords = coords.map(c => Math.round(c * 255)); let collapsible = collapse && coords.every(c => c % 17 === 0); let hex = coords.map(c => { if (collapsible) { return (c/17).toString(16); } return c.toString(16).padStart(2, "0"); }).join(""); return "#" + hex; }, get hex() { return this.toHex(); } }, // Properties present only on sRGB colors instance: { toString ({inGamut = true, commas, format = "%", ...rest} = {}) { if (format === 255) { format = c => c * 255; } else if (format === "hex") { return this.toHex(arguments[0]); } return Color$1.prototype.toString.call(this, { inGamut, commas, format, name: "rgb" + (commas && this.alpha < 1? "a" : ""), ...rest }); } }, parseHex (str) { if (str.length <= 5) { // #rgb or #rgba, duplicate digits str = str.replace(/[a-f0-9]/gi, "$&$&"); } let rgba = []; str.replace(/[a-f0-9]{2}/gi, component => { rgba.push(parseInt(component, 16) / 255); }); return { spaceId: "srgb", coords: rgba.slice(0, 3), alpha: rgba.slice(3)[0] }; } }); Color$1.hooks.add("parse-start", env => { let str = env.str; if (/^#([a-f0-9]{3,4}){1,2}$/i.test(str)) { env.color = Color$1.spaces.srgb.parseHex(str); } }); Color$1.defineSpace({ id: "hsl", name: "HSL", coords: { hue: range, saturation: [0, 100], lightness: [0, 100] }, inGamut (coords) { let rgb = this.to.srgb(coords); return Color$1.inGamut("srgb", rgb); }, white: Color$1.whites.D65, // Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB from: { srgb (rgb) { rgb = rgb.map(c => c * 100); let max = Math.max.apply(Math, rgb); let min = Math.min.apply(Math, rgb); let [r, g, b] = rgb; let [h, s, l] = [NaN, 0, (min + max)/2]; let d = max - min; if (d !== 0) { s = d * 100 / (100 - Math.abs(2 * l - 100)); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; } h = h * 60; } return [h, s, l]; } }, // Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative to: { srgb (hsl) { let [h, s, l] = hsl; h = h % 360; if (h < 0) { h += 360; } s /= 100; l /= 100; function f(n) { let k = (n + h/30) % 12; let a = s * Math.min(l, 1 - l); return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); } return [f(0), f(8), f(4)]; } }, parse (str, parsed = Color$1.parseFunction(str)) { if (parsed && /^hsla?$/.test(parsed.name)) { let hsl = parsed.args; // percentages are converted to [0, 1] by parseFunction hsl[1] *= 100; hsl[2] *= 100; return { spaceId: "hsl", coords: hsl.slice(0, 3), alpha: hsl[3] }; } }, instance: { toString ({precision, commas, format, inGamut, ...rest} = {}) { if (!format) { format = (c, i) => i > 0? c + "%" : c; } return Color$1.prototype.toString.call(this, { inGamut: true, // hsl() out of gamut makes no sense commas, format, name: "hsl" + (commas && this.alpha < 1? "a" : ""), ...rest }); } } }); // The Hue, Whiteness Blackness (HWB) colorspace // See https://drafts.csswg.org/css-color-4/#the-hwb-notation // Note that, like HSL, calculations are done directly on // gamma-corrected sRGB values rather than linearising them first. Color$1.defineSpace({ id: "hwb", name: "HWB", coords: { hue: range, whiteness: [0, 100], blackness: [0, 100] }, inGamut (coords) { let rgb = this.to.srgb(coords); return Color$1.inGamut("srgb", rgb); }, white: Color$1.whites.D65, from: { srgb (rgb) { let hsl = Color$1.spaces.hsl.from.srgb(rgb); let h = hsl[0]; // calculate white and black let w = Math.min(...rgb); let b = 1 - Math.max(...rgb); w *= 100; b *= 100; return [h, w, b]; }, hsv (hsv) { let [h, s, v] = hsv; return [h, v * (100 - s) / 100, 100 - v]; }, hsl (hsl) { let hsv = Color$1.spaces.hsv.from.hsl(hsl); return this.hsv(hsv); } }, to: { srgb (hwb) { let [h, w, b] = hwb; // Now convert percentages to [0..1] w /= 100; b /= 100; // Achromatic check (white plus black >= 1) let sum = w + b; if (sum >= 1) { let gray = w / sum; return [gray, gray, gray]; } // From https://drafts.csswg.org/css-color-4/#hwb-to-rgb let rgb = Color$1.spaces.hsl.to.srgb([h, 100, 50]); for (var i = 0; i < 3; i++) { rgb[i] *= (1 - w - b); rgb[i] += w; } return rgb; }, hsv (hwb) { let [h, w, b] = hwb; // Now convert percentages to [0..1] w /= 100; b /= 100; // Achromatic check (white plus black >= 1) let sum = w + b; if (sum >= 1) { let gray = w / sum; return [h, 0, gray]; } let v = 1 - b; let s = 100 - (100 * w) / (100 - b); return [h, s, v * 100]; }, hsl (hwb) { let hsv = Color$1.spaces.hwb.to.hsv(hwb); return (Color$1.spaces.hsv.to.hsl(hsv)); } }, parse (str, parsed = Color$1.parseFunction(str)) { if (parsed && /^hwba?$/.test(parsed.name)) { let hwb = parsed.args; // white and black percentages are converted to [0, 1] by parseFunction hwb[1] *= 100; hwb[2] *= 100; return { spaceId: "hwb", coords: hwb.slice(0, 3), alpha: hwb[3] }; } }, instance: { toString ({format, commas, inGamut, ...rest} = {}) { if (!format) { format = (c, i) => i > 0? c + "%" : c; } return Color$1.prototype.toString.call(this, { inGamut: true, // hwb() out of gamut makes no sense commas: false, // never commas format, name: "hwb", ...rest }); } } }); // The Hue, Whiteness Blackness (HWB) colorspace // See https://drafts.csswg.org/css-color-4/#the-hwb-notation // Note that, like HSL, calculations are done directly on // gamma-corrected sRGB values rather than linearising them first. Color$1.defineSpace({ id: "hsv", name: "HSV", coords: { hue: range, saturation: [0, 100], value: [0, 100] }, inGamut (coords) { let hsl = this.to.hsl(coords); return Color$1.spaces.hsl.inGamut(hsl); }, white: Color$1.whites.D65, from: { // https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion hsl (hsl) { let [h, s, l] = hsl; s /= 100; l /= 100; let v = l + s * Math.min(l, 1 - l); return [ h, // h is the same v === 0? 0 : 200 * (1 - l / v), // s 100 * v ]; }, }, to: { // https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion hsl (hsv) { let [h, s, v] = hsv; s /= 100; v /= 100; let l = 100 * v * (1 - s/2); return [ h, // h is the same (l === 0 || l === 1)? 0 : (v - l) / Math.min(l, 1 - l), l ]; } } }); Color$1.defineSpace({ inherits: "srgb", id: "p3", name: "P3", cssId: "display-p3", // Gamma correction is the same as sRGB // convert an array of display-p3 values to CIE XYZ // using D65 (no chromatic adaptation) // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html // Functions are the same as sRGB, just with different matrices toXYZ_M: [ [0.4865709486482162, 0.26566769316909306, 0.1982172852343625], [0.2289745640697488, 0.6917385218365064, 0.079286914093745], [0.0000000000000000, 0.04511338185890264, 1.043944368900976] ], fromXYZ_M: [ [ 2.493496911941425, -0.9313836179191239, -0.40271078445071684], [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577], [ 0.03584583024378447, -0.07617238926804182, 0.9568845240076872] ] }); Color$1.defineSpace({ inherits: "srgb", id: "a98rgb", name: "Adobe 98 RGB compatible", cssId: "a98-rgb", toLinear(RGB) { return RGB.map(val => Math.pow(Math.abs(val), 563/256)*Math.sign(val)); }, toGamma(RGB) { return RGB.map(val => Math.pow(Math.abs(val), 256/563)*Math.sign(val)); }, // convert an array of linear-light a98-rgb values to CIE XYZ // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html // has greater numerical precision than section 4.3.5.3 of // https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf // but the values below were calculated from first principles // from the chromaticity coordinates of R G B W toXYZ_M: [ [ 0.5766690429101305, 0.1855582379065463, 0.1882286462349947 ], [ 0.29734497525053605, 0.6273635662554661, 0.07529145849399788 ], [ 0.02703136138641234, 0.07068885253582723, 0.9913375368376388 ] ], fromXYZ_M: [ [ 2.0415879038107465, -0.5650069742788596, -0.34473135077832956 ], [ -0.9692436362808795, 1.8759675015077202, 0.04155505740717557 ], [ 0.013444280632031142, -0.11836239223101838, 1.0151749943912054 ] ] }); Color$1.defineSpace({ inherits: "srgb", id: "prophoto", name: "ProPhoto", cssId: "prophoto-rgb", white: Color$1.whites.D50, toLinear(RGB) { // Transfer curve is gamma 1.8 with a small linear portion const Et2 = 16/512; return RGB.map(function (val) { if (val <= Et2) { return val / 16; } return Math.pow(val, 1.8); }); }, toGamma(RGB) { const Et = 1/512; return RGB.map(function (val) { if (val >= Et) { return Math.pow(val, 1/1.8); } return 16 * val; }); }, // convert an array of prophoto-rgb values to CIE XYZ // using D50 (so no chromatic adaptation needed afterwards) // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html toXYZ_M: [ [ 0.7977604896723027, 0.13518583717574031, 0.0313493495815248 ], [ 0.2880711282292934, 0.7118432178101014, 0.00008565396060525902 ], [ 0.0, 0.0, 0.8251046025104601 ] ], fromXYZ_M: [ [ 1.3457989731028281, -0.25558010007997534, -0.05110628506753401 ], [ -0.5446224939028347, 1.5082327413132781, 0.02053603239147973 ], [ 0.0, 0.0, 1.2119675456389454 ] ] }); Color$1.defineSpace({ inherits: "srgb", id: "rec2020", name: "REC.2020", α: 1.09929682680944, β: 0.018053968510807, toLinear(RGB) { const {α, β} = this; return RGB.map(function (val) { if (val < β * 4.5 ) { return val / 4.5; } return Math.pow((val + α -1 ) / α, 2.4); }); }, toGamma(RGB) { const {α, β} = this; return RGB.map(function (val) { if (val > β ) { return α * Math.pow(val, 1/2.4) - (α - 1); } return 4.5 * val; }); }, // convert an array of linear-light rec2020 values to CIE XYZ // using D65 (no chromatic adaptation) // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html // 0 is actually calculated as 4.994106574466076e-17 toXYZ_M: [ [ 0.6369580483012914, 0.14461690358620832, 0.1688809751641721 ], [ 0.2627002120112671, 0.6779980715188708, 0.05930171646986196 ], [ 0.000000000000000, 0.028072693049087428, 1.060985057710791 ] ], // from ITU-R BT.2124-0 Annex 2 p.3 fromXYZ_M: [ [ 1.716651187971268, -0.355670783776392, -0.253366281373660 ], [ -0.666684351832489, 1.616481236634939, 0.0157685458139111 ], [ 0.017639857445311, -0.042770613257809, 0.942103121235474 ] ] }); Color$1.defineSpace({ // Absolute CIE XYZ, with a D65 whitepoint, // as used in most HDR colorspaces as a starting point. // SDR spaces are converted per BT.2048 // so that diffuse, media white is 203 cd/m² id: "absxyzd65", name: "Absolute XYZ D65", coords: { Xa: [0, 9504.7], Ya: [0, 10000], Za: [0, 10888.3] }, white: Color$1.whites.D65, Yw: 203, // absolute luminance of media white inGamut: coords => true, fromXYZ (XYZ) { // First adapt from D50 to D65, with linear Bradford default const W1 = Color$1.whites.D50; const W2 = Color$1.whites.D65; XYZ = Color$1.chromaticAdaptation(W1, W2, XYZ); const {Yw} = this; // Then make XYZ absolute, not relative to media white // Maximum luminance in PQ is 10,000 cd/m² // Relative XYZ has Y=1 for media white return XYZ.map (function (val) { return Math.max(val * Yw, 0); }); }, toXYZ (AbsXYZ) { // First convert to media-white relative XYZ const {Yw} = this; let XYZ = AbsXYZ.map (function (val) { return Math.max(val / Yw, 0); }); // Then adapt to D50 const W1 = Color$1.whites.D65; const W2 = Color$1.whites.D50; return Color$1.chromaticAdaptation(W1, W2, XYZ); } }); Color$1.defineSpace({ id: "jzazbz", cssid: "Jzazbz", name: "Jzazbz", coords: { Jz: [0, 1], az: [-0.5, 0.5], bz: [-0.5, 0.5] }, inGamut: coords => true, // Note that XYZ is relative to D65 white: Color$1.whites.D65, b: 1.15, g: 0.66, n:2610 / (2 ** 14), ninv: (2 ** 14) / 2610, c1: 3424 / (2 ** 12), c2: 2413 / (2 ** 7), c3: 2392 / (2 ** 7), p: 1.7 * 2523 / (2 ** 5), pinv: (2 ** 5) / (1.7 * 2523), d: -0.56, d0: 1.6295499532821566E-11, XYZtoCone_M: [ [ 0.41478972, 0.579999, 0.0146480 ], [ -0.2015100, 1.120649, 0.0531008 ], [ -0.0166008, 0.264800, 0.6684799 ] ], // XYZtoCone_M inverted ConetoXYZ_M: [ [ 1.9242264357876067, -1.0047923125953657, 0.037651404030618 ], [ 0.35031676209499907, 0.7264811939316552, -0.06538442294808501 ], [ -0.09098281098284752, -0.3127282905230739, 1.5227665613052603 ] ], ConetoIab_M: [ [ 0.5, 0.5, 0 ], [ 3.524000, -4.066708, 0.542708 ], [ 0.199076, 1.096799, -1.295875 ] ], // ConetoIab_M inverted IabtoCone_M: [ [ 1, 0.1386050432715393, 0.05804731615611886 ], [ 0.9999999999999999, -0.1386050432715393, -0.05804731615611886 ], [ 0.9999999999999998, -0.09601924202631895, -0.8118918960560388 ] ], fromXYZ (XYZ) { const {b, g, n, p, c1, c2, c3, d, d0, XYZtoCone_M, ConetoIab_M} = this; // First make XYZ absolute, not relative to media white // Maximum luminance in PQ is 10,000 cd/m² // Relative XYZ has Y=1 for media whit