import { clamp, floatToByte, hexToRGB, vec3 } from "./util.js";
import { LMS_to_OKLab_M, OKLab_to_LMS_M } from "./conversion_matrices.js";
import { listColorSpaces, sRGB, XYZ } from "./spaces.js";
/**
* @typedef {number[][]} Matrix3x3
* @description A 3x3 matrix represented as an array of arrays.
* @example
* const matrix = [
* [a, b, c],
* [d, e, f],
* [g, h, i]
* ];
*/
/**
* @typedef {number[]} Vector
* @description A n-dimensional vector represented as an array of numbers, typically in 3D (X, Y, Z).
* @example
* const vec = [ x, y, z ];
*/
/**
* @typedef {number[][][]} ColorGamutCoefficients
*/
/**
* @typedef {Object} ChromaticAdaptation
* @property {Matrix3x3} from the matrix to convert from the source whitepoint to the destination whitepoint
* @property {Matrix3x3} to the matrix to convert from the destination whitepoint to the source whitepoint
*/
/**
* @typedef {Object} ColorSpace
* @property {String} id the unique identifier for this color space in lowercase
* @property {Matrix3x3} [toXYZ_M] optional matrix to convert this color directly to XYZ D65
* @property {Matrix3x3} [fromXYZ_M] optional matrix to convert XYZ D65 to this color space
* @property {Matrix3x3} [toLMS_M] optional matrix to convert this color space to OKLab's LMS intermediary form
* @property {Matrix3x3} [fromLMS_M] optional matrix to convert OKLab's LMS intermediary form to this color space
* @property {ChromaticAdaptation} [adapt] optional chromatic adaptation matrices
* @property {ColorSpace} [base] an optional base color space that this space is derived from
* @property {function} [toBase] if a base color space exists, this maps the color to the base space form (e.g. gamma to the linear base space)
* @property {function} [fromBase] if a base color space exists, this maps the color from the base space form (e.g. the linear base space to the gamma space)
*/
/**
* @typedef {Object} ColorGamut
* @property {ColorSpace} space the color space associated with this color gamut
* @property {ColorGamutCoefficients} [coefficients] the coefficients used during gamut mapping from OKLab
*/
const tmp3 = vec3();
const cubed3 = (lms) => {
const l = lms[0],
m = lms[1],
s = lms[2];
lms[0] = l * l * l;
lms[1] = m * m * m;
lms[2] = s * s * s;
};
const cbrt3 = (lms) => {
lms[0] = Math.cbrt(lms[0]);
lms[1] = Math.cbrt(lms[1]);
lms[2] = Math.cbrt(lms[2]);
};
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
/**
* Converts OKLab color to another color space.
* @param {Vector} OKLab The OKLab color.
* @param {Matrix3x3} LMS_to_output The transformation matrix from LMS to the output color space.
* @param {Vector} [out=vec3()] The output vector.
* @returns {Vector} The transformed color.
* @method
* @category oklab
*/
export const OKLab_to = (OKLab, LMS_to_output, out = vec3()) => {
transform(OKLab, OKLab_to_LMS_M, out);
cubed3(out);
return transform(out, LMS_to_output, out);
};
/**
* Converts a color from another color space to OKLab.
* @param {Vector} input The input color.
* @param {Matrix3x3} input_to_LMS The transformation matrix from the input color space to LMS.
* @param {Vector} [out=vec3()] The output vector.
* @returns {Vector} The transformed color.
* @method
* @category oklab
*/
export const OKLab_from = (input, input_to_LMS, out = vec3()) => {
transform(input, input_to_LMS, out);
cbrt3(out);
return transform(out, LMS_to_OKLab_M, out);
};
/**
* Transforms a color vector by the specified 3x3 transformation matrix.
* @param {Vector} input The input color.
* @param {Matrix3x3} matrix The transformation matrix.
* @param {Vector} [out=vec3()] The output vector.
* @returns {Vector} The transformed color.
* @method
* @category core
*/
export const transform = (input, matrix, out = vec3()) => {
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;
};
const vec3Copy = (input, output) => {
output[0] = input[0];
output[1] = input[1];
output[2] = input[2];
};
/**
* Serializes a color to a CSS color string.
* @param {Vector} input The input color.
* @param {ColorSpace} inputSpace The input color space.
* @param {ColorSpace} [outputSpace=inputSpace] The output color space.
* @returns {string} The serialized color string.
* @method
* @category core
*/
export const serialize = (input, inputSpace, outputSpace = inputSpace) => {
if (!inputSpace) throw new Error(`must specify an input space`);
// extract alpha if present
let alpha = 1;
if (input.length > 3) {
alpha = input[3];
}
// copy into temp
vec3Copy(input, tmp3);
// convert if needed
if (inputSpace !== outputSpace) {
convert(input, inputSpace, outputSpace, tmp3);
}
const id = outputSpace.id;
if (id == "srgb") {
// uses the legacy rgb() format
const r = floatToByte(tmp3[0]);
const g = floatToByte(tmp3[1]);
const b = floatToByte(tmp3[2]);
const rgb = `${r}, ${g}, ${b}`;
return alpha === 1 ? `rgb(${rgb})` : `rgba(${rgb}, ${alpha})`;
} else {
const alphaSuffix = alpha === 1 ? "" : ` / ${alpha}`;
if (id == "oklab" || id == "oklch") {
// older versions of Safari don't support oklch with 0..1 L but do support %
return `${id}(${tmp3[0] * 100}% ${tmp3[1]} ${tmp3[2]}${alphaSuffix})`;
} else {
return `color(${id} ${tmp3[0]} ${tmp3[1]} ${tmp3[2]}${alphaSuffix})`;
}
}
};
const stripAlpha = (coords) => {
if (coords.length >= 4 && coords[3] === 1) return coords.slice(0, 3);
return coords;
};
const parseFloatValue = (str) => parseFloat(str) || 0;
const parseColorValue = (str, is255 = false) => {
if (is255) return clamp(parseFloatValue(str) / 0xff, 0, 0xff);
else
return str.includes("%")
? parseFloatValue(str) / 100
: parseFloatValue(str);
};
/**
* Deserializes a color string to an object with <code>id</code> (color space string) and <code>coords</code> (the vector, in 3 or 4 dimensions).
* Note this does not return a <code>ColorSpace</code> object; you may want to use the example code below to map the string ID to a <code>ColorSpace</code>, but this will increase the size of your final bundle as it references all spaces.
*
* @example
* import { listColorSpaces, deserialize } from "@texel/color";
*
* const { id, coords } = deserialize(str);
* // now find the actual color space object
* const space = listColorSpaces().find((f) => id === f.id);
* console.log(space, coords);
*
* @param {string} input The color string to deserialize.
* @returns {{id: string, coords: Vector}} The deserialized color object.
* @method
* @category core
*/
export const deserialize = (input) => {
if (typeof input !== "string") {
throw new Error(`expected a string as input`);
}
input = input.trim();
if (input.charAt(0) === "#") {
const rgbIn = input.slice(0, 7);
let alphaByte = input.length > 7 ? parseInt(input.slice(7, 9), 16) : 255;
let alpha = isNaN(alphaByte) ? 1 : alphaByte / 255;
const coords = hexToRGB(rgbIn);
if (alpha !== 1) coords.push(alpha);
return {
id: "srgb",
coords,
};
} else {
const parts = /^(rgb|rgba|oklab|oklch|color)\((.+)\)$/i.exec(input);
if (!parts) {
throw new Error(`could not parse color string ${input}`);
}
const fn = parts[1].toLowerCase();
if (/^rgba?$/i.test(fn) && parts[2].includes(",")) {
const coords = parts[2].split(",").map((v, i) => {
return parseColorValue(v.trim(), i < 3);
});
return {
id: "srgb",
coords: stripAlpha(coords),
};
} else {
let id, coordsStrings;
let div255 = false;
if (/^color$/i.test(fn)) {
const params =
/([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s/]+)(?:\s?\/\s?([^\s]+))?/.exec(
parts[2]
);
if (!params)
throw new Error(`could not parse color() function ${input}`);
id = params[1].toLowerCase();
coordsStrings = params.slice(2, 6);
} else {
if (/^(oklab|oklch)$/i.test(fn)) {
id = fn;
} else if (/rgba?/i.test(fn)) {
id = "srgb";
div255 = true;
} else {
throw new Error(`unknown color function ${fn}`);
}
const params =
/([^\s]+)\s+([^\s]+)\s+([^\s/]+)(?:\s?\/\s?([^\s]+))?/.exec(parts[2]);
if (!params)
throw new Error(`could not parse color() function ${input}`);
coordsStrings = params.slice(1, 6);
}
if (coordsStrings[3] == null) {
coordsStrings = coordsStrings.slice(0, 3);
}
const coords = coordsStrings.map((f, i) => {
return parseColorValue(f.trim(), div255 && i < 3);
});
if (coords.length < 3 || coords.length > 4)
throw new Error(`invalid number of coordinates`);
return {
id,
coords: stripAlpha(coords),
};
}
}
};
/**
* Parses a color string and converts it to the target color space.
* @param {string} input The color string to parse.
* @param {ColorSpace} targetSpace The target color space.
* @param {Vector} [out=vec3()] The output vector.
* @returns {Vector} The parsed and converted color.
* @method
* @category core
*/
export const parse = (input, targetSpace, out = vec3()) => {
if (!targetSpace)
throw new Error(`must specify a target space to parse into`);
const { coords, id } = deserialize(input);
const space = listColorSpaces().find((f) => id === f.id);
if (!space) throw new Error(`could not find space with the id ${id}`);
const alpha = coords.length === 4 ? coords[3] : 1;
// copy 3D coords to output and convert
vec3Copy(coords, out);
convert(out, space, targetSpace, out);
// store alpha
if (alpha !== 1) out[3] = alpha;
// reduce to 3D
if (alpha == 1 && out.length === 4) out.pop();
return out;
};
/**
* Converts a color from one color space to another.
* @param {Vector} input The input color.
* @param {ColorSpace} fromSpace The source color space.
* @param {ColorSpace} toSpace The target color space.
* @param {Vector} [out=vec3()] The output vector.
* @returns {Vector} The converted color.
* @method
* @category core
*/
export const convert = (input, fromSpace, toSpace, out = vec3()) => {
// place into output
vec3Copy(input, out);
if (!fromSpace) throw new Error(`must specify a fromSpace`);
if (!toSpace) throw new Error(`must specify a toSpace`);
// special case: no conversion needed
if (fromSpace == toSpace) {
return out;
}
// e.g. convert OKLCH -> OKLab or sRGB -> sRGBLinear
if (fromSpace.base) {
out = fromSpace.toBase(out, out);
fromSpace = fromSpace.base;
}
// now we have the base space like sRGBLinear or XYZ
let fromBaseSpace = fromSpace;
// and the base we want to get to, linear, OKLab, XYZ etc...
let toBaseSpace = toSpace.base ?? toSpace;
// this is something we may support in future, if there is a nice
// zero-allocation way of achieving it
if (fromSpace.base || toBaseSpace.base) {
throw new Error(`Currently only base of depth=1 is supported`);
}
if (fromBaseSpace === toBaseSpace) {
// do nothing, spaces are the same
} else {
// [from space] -> (adaptation) -> [xyz] -> (adaptation) -> [to space]
// e.g. sRGB to ProPhotoLinear
// sRGB -> sRGBLinear -> XYZ(D65) -> XYZD65ToD50 -> ProPhotoLinear
// ProPhotoLinear -> XYZ(D50) -> XYZD50ToD65 -> sRGBLinear -> sRGB
let xyzIn = fromBaseSpace.id === "xyz";
let xyzOut = toBaseSpace.id === "xyz";
let throughXYZ = false;
let outputOklab = false;
// spaces are different
// check if we have a fast path
// this isn't supported for d50-based whitepoints
if (fromBaseSpace.id === "oklab") {
let mat = toBaseSpace.fromLMS_M;
if (!mat) {
// space doesn't support direct from OKLAB
// let's convert OKLab to XYZ and then use that
mat = XYZ.fromLMS_M;
throughXYZ = true;
xyzIn = true;
}
// convert OKLAB to output (other space, or xyz)
out = OKLab_to(out, mat, out);
} else if (toBaseSpace.id === "oklab") {
let mat = fromBaseSpace.toLMS_M;
if (!mat) {
// space doesn't support direct to OKLAB
// we will need to use XYZ as connection, then convert to OKLAB
throughXYZ = true;
outputOklab = true;
} else {
// direct from space to OKLAB
out = OKLab_from(out, mat, out);
}
} else {
// any other spaces, we use XYZ D65 as a connection
throughXYZ = true;
}
if (throughXYZ) {
// First, convert to XYZ if we need to
if (!xyzIn) {
if (fromBaseSpace.toXYZ) {
out = fromBaseSpace.toXYZ(out, out);
} else if (fromBaseSpace.toXYZ_M) {
out = transform(out, fromBaseSpace.toXYZ_M, out);
} else {
throw new Error(`no toXYZ or toXYZ_M on ${fromBaseSpace.id}`);
}
}
// Then, adapt D50 <-> D65 if we need to
if (fromBaseSpace.adapt) {
out = transform(out, fromBaseSpace.adapt.to, out);
}
if (toBaseSpace.adapt) {
out = transform(out, toBaseSpace.adapt.from, out);
}
// Now, convert XYZ to target if we need to
if (!xyzOut) {
if (outputOklab) {
out = OKLab_from(out, XYZ.toLMS_M, out);
} else if (toBaseSpace.fromXYZ) {
out = toBaseSpace.fromXYZ(out, out);
} else if (toBaseSpace.fromXYZ_M) {
out = transform(out, toBaseSpace.fromXYZ_M, out);
} else {
throw new Error(`no fromXYZ or fromXYZ_M on ${toBaseSpace.id}`);
}
}
}
}
// Now do the final transformation to the target space
// e.g. OKLab -> OKLCH or sRGBLinear -> sRGB
if (toBaseSpace !== toSpace) {
if (toSpace.fromBase) {
out = toSpace.fromBase(out, out);
} else {
throw new Error(`could not transform ${toBaseSpace.id} to ${toSpace.id}`);
}
}
return out;
};
/**
* Calculates the DeltaEOK (color difference) between two OKLab colors.
* @param {Vector} oklab1 The first OKLab color.
* @param {Vector} oklab2 The second OKLab color.
* @returns {number} The delta E value.
* @method
* @category core
*/
export const deltaEOK = (oklab1, oklab2) => {
let dL = oklab1[0] - oklab2[0];
let da = oklab1[1] - oklab2[1];
let db = oklab1[2] - oklab2[2];
return Math.sqrt(dL * dL + da * da + db * db);
};
Source