UNPKG

colorjs.io

Version:

Let’s get serious about color

311 lines (270 loc) 8.62 kB
import * as util from "./util.js"; import ColorSpace from "./space.js"; import defaults from "./defaults.js"; import deltaE2000 from "./deltaE/deltaE2000.js"; import deltaEOK from "./deltaE/deltaEOK.js"; import inGamut from "./inGamut.js"; import to from "./to.js"; import get from "./get.js"; import oklab from "./spaces/oklab.js"; import set from "./set.js"; import clone from "./clone.js"; import getColor from "./getColor.js"; import deltaEMethods from "./deltaE/index.js"; import {WHITES} from "./adapt.js"; /** * Calculate the epsilon to 2 degrees smaller than the specified JND. * @param {Number} jnd - The target "just noticeable difference". * @returns {Number} */ function calcEpsilon (jnd) { // Calculate the epsilon to 2 degrees smaller than the specified JND. const order = (!jnd) ? 0 : Math.floor(Math.log10(Math.abs(jnd))); // Limit to an arbitrary value to ensure value is never too small and causes infinite loops. return Math.max(parseFloat(`1e${order - 2}`), 1e-6); } const GMAPPRESET = { "hct": { method: "hct.c", jnd: 2, deltaEMethod: "hct", blackWhiteClamp: {}, }, "hct-tonal": { method: "hct.c", jnd: 0, deltaEMethod: "hct", blackWhiteClamp: { channel: "hct.t", min: 0, max: 100 }, }, }; /** * Force coordinates to be in gamut of a certain color space. * Mutates the color it is passed. * @param {Object|string} options object or spaceId string * @param {string} options.method - How to force into gamut. * If "clip", coordinates are just clipped to their reference range. * If "css", coordinates are reduced according to the CSS 4 Gamut Mapping Algorithm. * 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 {string} options.deltaEMethod - The delta E method to use while performing gamut mapping. * If no method is specified, delta E 2000 is used. * @param {Number} options.jnd - The "just noticeable difference" to target. * @param {Object} options.blackWhiteClamp - Used to configure SDR black and clamping. * "channel" indicates the "space.channel" to use for determining when to clamp. * "min" indicates the lower limit for black clamping and "max" indicates the upper * limit for white clamping. */ export default function toGamut ( color, { method = defaults.gamut_mapping, space = undefined, deltaEMethod = "", jnd = 2, blackWhiteClamp = {}, } = {}, ) { color = getColor(color); if (util.isString(arguments[1])) { space = arguments[1]; } else if (!space) { space = color.space; } space = ColorSpace.get(space); // 3 spaces: // color.space: current color space // space: space whose gamut we are mapping to // mapSpace: space with the coord we're reducing if (inGamut(color, space, { epsilon: 0 })) { return color; } let spaceColor; if (method === "css") { spaceColor = toGamutCSS(color, { space }); } else { if (method !== "clip" && !inGamut(color, space)) { if (Object.prototype.hasOwnProperty.call(GMAPPRESET, method)) { ({method, jnd, deltaEMethod, blackWhiteClamp} = GMAPPRESET[method]); } // Get the correct delta E method let de = deltaE2000; if (deltaEMethod !== "") { for (let m in deltaEMethods) { if ("deltae" + deltaEMethod.toLowerCase() === m.toLowerCase()) { de = deltaEMethods[m]; break; } } } let clipped = toGamut(to(color, space), { method: "clip", space }); if (de(color, clipped) > jnd) { // Clamp to SDR white and black if required if (Object.keys(blackWhiteClamp).length === 3) { let channelMeta = ColorSpace.resolveCoord(blackWhiteClamp.channel); let channel = get(to(color, channelMeta.space), channelMeta.id); if (util.isNone(channel)) { channel = 0; } if (channel >= blackWhiteClamp.max) { return to({ space: "xyz-d65", coords: WHITES["D65"] }, color.space); } else if (channel <= blackWhiteClamp.min) { return to({ space: "xyz-d65", coords: [0, 0, 0] }, color.space); } } // Reduce a coordinate of a certain color space until the color is in gamut let coordMeta = ColorSpace.resolveCoord(method); let mapSpace = coordMeta.space; let coordId = coordMeta.id; let mappedColor = to(color, mapSpace); // If we were already in the mapped color space, we need to resolve undefined channels mappedColor.coords.forEach((c, i) => { if (util.isNone(c)) { mappedColor.coords[i] = 0; } }); let bounds = coordMeta.range || coordMeta.refRange; let min = bounds[0]; let ε = calcEpsilon(jnd); let low = min; let high = get(mappedColor, coordId); while (high - low > ε) { let clipped = clone(mappedColor); clipped = toGamut(clipped, { space, method: "clip" }); let deltaE = de(mappedColor, clipped); if (deltaE - jnd < ε) { low = get(mappedColor, coordId); } else { high = get(mappedColor, coordId); } set(mappedColor, coordId, (low + high) / 2); } spaceColor = to(mappedColor, space); } else { spaceColor = clipped; } } else { spaceColor = to(color, space); } if (method === "clip" // Dumb coord clipping // finish off smarter gamut mapping with clip to get rid of ε, see #17 || !inGamut(spaceColor, space, { epsilon: 0 }) ) { let bounds = Object.values(space.coords).map(c => c.range || []); spaceColor.coords = spaceColor.coords.map((c, i) => { let [min, max] = bounds[i]; if (min !== undefined) { c = Math.max(min, c); } if (max !== undefined) { c = Math.min(c, max); } return c; }); } } if (space !== color.space) { spaceColor = to(spaceColor, color.space); } color.coords = spaceColor.coords; return color; } toGamut.returns = "color"; // The reference colors to be used if lightness is out of the range 0-1 in the // `Oklch` space. These are created in the `Oklab` space, as it is used by the // DeltaEOK calculation, so it is guaranteed to be imported. const COLORS = { WHITE: { space: oklab, coords: [1, 0, 0] }, BLACK: { space: oklab, coords: [0, 0, 0] }, }; /** * Given a color `origin`, returns a new color that is in gamut using * the CSS Gamut Mapping Algorithm. If `space` is specified, it will be in gamut * in `space`, and returned in `space`. Otherwise, it will be in gamut and * returned in the color space of `origin`. * @param {Object} origin * @param {Object} options * @param {ColorSpace|string} options.space * @returns {Color} */ export function toGamutCSS (origin, {space} = {}) { const JND = 0.02; const ε = 0.0001; origin = getColor(origin); if (!space) { space = origin.space; } space = ColorSpace.get(space); const oklchSpace = ColorSpace.get("oklch"); if (space.isUnbounded) { return to(origin, space); } const origin_OKLCH = to(origin, oklchSpace); let L = origin_OKLCH.coords[0]; // return media white or black, if lightness is out of range if (L >= 1) { const white = to(COLORS.WHITE, space); white.alpha = origin.alpha; return to(white, space); } if (L <= 0) { const black = to(COLORS.BLACK, space); black.alpha = origin.alpha; return to(black, space); } if (inGamut(origin_OKLCH, space, {epsilon: 0})) { return to(origin_OKLCH, space); } function clip (_color) { const destColor = to(_color, space); const spaceCoords = Object.values(space.coords); destColor.coords = destColor.coords.map((coord, index) => { if ("range" in spaceCoords[index]) { const [min, max] = spaceCoords[index].range; return util.clamp(min, coord, max); } return coord; }); return destColor; } let min = 0; let max = origin_OKLCH.coords[1]; let min_inGamut = true; let current = clone(origin_OKLCH); let clipped = clip(current); let E = deltaEOK(clipped, current); if (E < JND) { return clipped; } while ((max - min) > ε) { const chroma = (min + max) / 2; current.coords[1] = chroma; if (min_inGamut && inGamut(current, space, {epsilon: 0})) { min = chroma; } else { clipped = clip(current); E = deltaEOK(clipped, current); if (E < JND) { if ((JND - E < ε)) { break; } else { min_inGamut = false; min = chroma; } } else { max = chroma; } } } return clipped; }