Skip to content

Commit

Permalink
fix: use oklab implementation from W3C (#350)
Browse files Browse the repository at this point in the history
  • Loading branch information
gka committed Aug 17, 2024
1 parent a57362f commit f5e7432
Show file tree
Hide file tree
Showing 19 changed files with 192 additions and 122 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Changelog

### 3.0.0
### 3.0.0-0
* 🎉 NEW: add support for modern CSS color spaces `lab()`, `lch()`, `oklab()`, `oklch()`.
* 🎉 NEW: you can now control the standard white reference point for the CIE Lab and CIE Lch color spaces via `setLabWhitePoint`.
* chroma.css will no longer return legacy CSS colors like `rgb(255, 255, 0)` but modern CSS colors like `rgb(255 255 0)`.
Expand Down
58 changes: 42 additions & 16 deletions src/io/css/css2rgb.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import hsl2rgb from '../hsl/hsl2rgb.js';
import lab2rgb from '../lab/lab2rgb.js';
import lch2rgb from '../lch/lch2rgb.js';
// import oklab2rgb from '../oklab/oklab2rgb.js';
import oklab2rgb from '../oklab/oklab2rgb.js';
import oklch2rgb from '../oklch/oklch2rgb.js';
import input from '../input.js';
import limit from '../../utils/limit.js';
import { getLabWhitePoint, setLabWhitePoint } from '../lab/lab-constants.js';

const RE_RGB = /^rgb\(\s*(-?\d+) \s*(-?\d+)\s* \s*(-?\d+)\s*\)$/;
const RE_RGB_LEGACY = /^rgb\(\s*(-?\d+),\s*(-?\d+)\s*,\s*(-?\d+)\s*\)$/;
Expand Down Expand Up @@ -36,9 +38,11 @@ const RE_HSLA_LEGACY =
const RE_LAB =
/^lab\(\s*(-?\d+(?:\.\d+)?%?) \s*(-?\d+(?:\.\d+)?%?) \s*(-?\d+(?:\.\d+)?%?)\s*(?:\/\s*(\d+(?:\.\d+)?))?\)?$/;
const RE_LCH =
/^lch\(\s*(-?\d+(?:\.\d+)?%?) \s*(?:(-?\d+(?:\.\d+)?%?)|none) \s*(-?\d+(?:\.\d+)?(?:deg)?|none)\s*(?:\/\s*(\d+(?:\.\d+)?))?\)?$/;
// const RE_OKLAB =
// /^oklab\(\s*(-?\d+(?:\.\d+)?%?) \s*(-?\d+(?:\.\d+)?%?) \s*(-?\d+(?:\.\d+)?%?)\s*(?:\/\s*(\d+(?:\.\d+)?))?\)?$/;
/^lch\(\s*(-?\d+(?:\.\d+)?%?) \s*((?:-?\d+(?:\.\d+)?%?)|none) \s*(-?\d+(?:\.\d+)?(?:deg)?|none)\s*(?:\/\s*(\d+(?:\.\d+)?))?\)?$/;
const RE_OKLAB =
/^oklab\(\s*(-?\d+(?:\.\d+)?%?) \s*(-?\d+(?:\.\d+)?%?) \s*(-?\d+(?:\.\d+)?%?)\s*(?:\/\s*(\d+(?:\.\d+)?))?\)?$/;
const RE_OKLCH =
/^oklch\(\s*(-?\d+(?:\.\d+)?%?) \s*(?:(-?\d+(?:\.\d+)?%?)|none) \s*(-?\d+(?:\.\d+)?(?:deg)?|none)\s*(?:\/\s*(\d+(?:\.\d+)?))?\)?$/;

const { round } = Math;

Expand All @@ -47,8 +51,8 @@ const roundRGB = (rgb) => {
};

const percentToAbsolute = (pct, min = 0, max = 100, signed = false) => {
if (pct.endsWith('%')) {
pct = parseFloat(pct.substr(0, pct.length - 1)) / 100;
if (typeof pct === 'string' && pct.endsWith('%')) {
pct = parseFloat(pct.substring(0, pct.length - 1)) / 100;
if (signed) {
// signed percentages are in the range -100% to 100%
pct = min + (pct + 1) * 0.5 * (max - min);
Expand Down Expand Up @@ -144,7 +148,12 @@ const css2rgb = (css) => {
lab[0] = percentToAbsolute(lab[0], 0, 100);
lab[1] = percentToAbsolute(lab[1], -125, 125, true);
lab[2] = percentToAbsolute(lab[2], -125, 125, true);
// convert to D50 Lab whitepoint
const wp = getLabWhitePoint();
setLabWhitePoint('d50');
const rgb = roundRGB(lab2rgb(lab));
// convert back to original Lab whitepoint
setLabWhitePoint(wp);
rgb[3] = m[4] !== undefined ? +m[4] : 1;
return rgb;
}
Expand All @@ -154,20 +163,35 @@ const css2rgb = (css) => {
lch[0] = percentToAbsolute(lch[0], 0, 100);
lch[1] = percentToAbsolute(noneToValue(lch[1], 0), 0, 150, false);
lch[2] = +noneToValue(lch[2].replace('deg', ''), 0);
// convert to D50 Lab whitepoint
const wp = getLabWhitePoint();
setLabWhitePoint('d50');
const rgb = roundRGB(lch2rgb(lch));
// convert back to original Lab whitepoint
setLabWhitePoint(wp);
rgb[3] = m[4] !== undefined ? +m[4] : 1;
return rgb;
}

// if ((m = css.match(RE_OKLAB))) {
// const oklab = m.slice(1, 4);
// oklab[0] = percentToAbsolute(oklab[0], 0, 1);
// oklab[1] = percentToAbsolute(oklab[1], -0.4, 0.4, true);
// oklab[2] = percentToAbsolute(oklab[2], -0.4, 0.4, true);
// const rgb = roundRGB(oklab2rgb(oklab));
// rgb[3] = m[4] !== undefined ? +m[4] : 1;
// return rgb;
// }
if ((m = css.match(RE_OKLAB))) {
const oklab = m.slice(1, 4);
oklab[0] = percentToAbsolute(oklab[0], 0, 1);
oklab[1] = percentToAbsolute(oklab[1], -0.4, 0.4, true);
oklab[2] = percentToAbsolute(oklab[2], -0.4, 0.4, true);
const rgb = roundRGB(oklab2rgb(oklab));
rgb[3] = m[4] !== undefined ? +m[4] : 1;
return rgb;
}

if ((m = css.match(RE_OKLCH))) {
const oklch = m.slice(1, 4);
oklch[0] = percentToAbsolute(oklch[0], 0, 1);
oklch[1] = percentToAbsolute(noneToValue(oklch[1], 0), 0, 0.4, false);
oklch[2] = +noneToValue(oklch[2].replace('deg', ''), 0);
const rgb = roundRGB(oklch2rgb(oklch));
rgb[3] = m[4] !== undefined ? +m[4] : 1;
return rgb;
}
};

css2rgb.test = (s) => {
Expand All @@ -180,7 +204,9 @@ css2rgb.test = (s) => {
RE_HSL.test(s) ||
RE_HSLA.test(s) ||
RE_LAB.test(s) ||
// RE_OKLAB.test(s) ||
RE_LCH.test(s) ||
RE_OKLAB.test(s) ||
RE_OKLCH.test(s) ||
// legacy
RE_RGB_LEGACY.test(s) ||
RE_RGBA_LEGACY.test(s) ||
Expand Down
9 changes: 4 additions & 5 deletions src/io/css/hsl2css.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { unpack, last } from '../../utils/index.js';
const rnd = (a) => Math.round(a * 100) / 100;
import { unpack, last, rnd2 } from '../../utils/index.js';

/*
* supported arguments:
Expand All @@ -12,9 +11,9 @@ const rnd = (a) => Math.round(a * 100) / 100;
const hsl2css = (...args) => {
const hsla = unpack(args, 'hsla');
let mode = last(args) || 'lsa';
hsla[0] = rnd(hsla[0] || 0) + 'deg';
hsla[1] = rnd(hsla[1] * 100) + '%';
hsla[2] = rnd(hsla[2] * 100) + '%';
hsla[0] = rnd2(hsla[0] || 0) + 'deg';
hsla[1] = rnd2(hsla[1] * 100) + '%';
hsla[2] = rnd2(hsla[2] * 100) + '%';
if (mode === 'hsla' || (hsla.length > 3 && hsla[3] < 1)) {
hsla[3] = '/ ' + (hsla.length > 3 ? hsla[3] : 1);
mode = 'hsla';
Expand Down
9 changes: 4 additions & 5 deletions src/io/css/lab2css.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { unpack, last } from '../../utils/index.js';
const rnd = (a) => Math.round(a * 100) / 100;
import { unpack, last, rnd2 } from '../../utils/index.js';

/*
* supported arguments:
Expand All @@ -11,9 +10,9 @@ const rnd = (a) => Math.round(a * 100) / 100;
const lab2css = (...args) => {
const laba = unpack(args, 'lab');
let mode = last(args) || 'lab';
laba[0] = rnd(laba[0]) + '%';
laba[1] = rnd(laba[1]);
laba[2] = rnd(laba[2]);
laba[0] = rnd2(laba[0]) + '%';
laba[1] = rnd2(laba[1]);
laba[2] = rnd2(laba[2]);
if (mode === 'laba' || (laba.length > 3 && laba[3] < 1)) {
laba[3] = '/ ' + (laba.length > 3 ? laba[3] : 1);
} else {
Expand Down
9 changes: 4 additions & 5 deletions src/io/css/lch2css.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { unpack, last } from '../../utils/index.js';
const rnd = (a) => Math.round(a * 100) / 100;
import { unpack, last, rnd2 } from '../../utils/index.js';

/*
* supported arguments:
Expand All @@ -11,9 +10,9 @@ const rnd = (a) => Math.round(a * 100) / 100;
const lch2css = (...args) => {
const lcha = unpack(args, 'lch');
let mode = last(args) || 'lab';
lcha[0] = rnd(lcha[0]) + '%';
lcha[1] = rnd(lcha[1]);
lcha[2] = rnd(lcha[2]) + 'deg'; // add deg unit to hue
lcha[0] = rnd2(lcha[0]) + '%';
lcha[1] = rnd2(lcha[1]);
lcha[2] = rnd2(lcha[2]) + 'deg'; // add deg unit to hue
if (mode === 'lcha' || (lcha.length > 3 && lcha[3] < 1)) {
lcha[3] = '/ ' + (lcha.length > 3 ? lcha[3] : 1);
} else {
Expand Down
9 changes: 4 additions & 5 deletions src/io/css/oklab2css.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { unpack } from '../../utils/index.js';
const rnd = (a) => Math.round(a * 100) / 100;
import { unpack, rnd2, rnd3 } from '../../utils/index.js';

const oklab2css = (...args) => {
const laba = unpack(args, 'lab');
laba[0] = rnd(laba[0] * 100) + '%';
laba[1] = rnd(laba[1]);
laba[2] = rnd(laba[2]);
laba[0] = rnd2(laba[0] * 100) + '%';
laba[1] = rnd3(laba[1]);
laba[2] = rnd3(laba[2]);
if (laba.length > 3 && laba[3] < 1) {
laba[3] = '/ ' + (laba.length > 3 ? laba[3] : 1);
} else {
Expand Down
9 changes: 4 additions & 5 deletions src/io/css/oklch2css.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { unpack } from '../../utils/index.js';
const rnd = (a) => Math.round(a * 100) / 100;
import { unpack, rnd2, rnd3 } from '../../utils/index.js';

const oklab2css = (...args) => {
const laba = unpack(args, 'lab');
laba[0] = rnd(laba[0] * 100) + '%';
laba[1] = rnd(laba[1]);
laba[2] = rnd(laba[2]) + 'deg';
laba[0] = rnd2(laba[0] * 100) + '%';
laba[1] = rnd3(laba[1]);
laba[2] = rnd2(laba[2]) + 'deg';
if (laba.length > 3 && laba[3] < 1) {
laba[3] = '/ ' + (laba.length > 3 ? laba[3] : 1);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/io/lab/lab2rgb.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,4 @@ const xyz2rgb = (x, y, z) => {
};

export default lab2rgb;
export { xyz2rgb };
1 change: 1 addition & 0 deletions src/io/lab/rgb2lab.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ const rgb2xyz = (r, g, b) => {
};

export default rgb2lab;
export { rgb2xyz };
51 changes: 26 additions & 25 deletions src/io/oklab/oklab2rgb.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
import { unpack } from '../../utils/index.js';
const { pow, sign } = Math;
import multiplyMatrices from '../../utils/multiply-matrices.js';
import { xyz2rgb } from '../lab/lab2rgb.js';

/*
* L* [0..100]
* a [-100..100]
* b [-100..100]
*/
const oklab2rgb = (...args) => {
args = unpack(args, 'lab');
const [L, a, b] = args;

const l = pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
const m = pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
const s = pow(L - 0.0894841775 * a - 1.291485548 * b, 3);

return [
255 * lrgb2rgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
255 * lrgb2rgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
255 * lrgb2rgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
args.length > 3 ? args[3] : 1
];
const [L, a, b, ...rest] = args;
const [X, Y, Z] = OKLab_to_XYZ([L, a, b]);
const [r, g, b_] = xyz2rgb(X, Y, Z);
return [r, g, b_, ...(rest.length > 0 && rest[0] < 1 ? [rest[0]] : [])];
};

export default oklab2rgb;
// from https://www.w3.org/TR/css-color-4/#color-conversion-code
function OKLab_to_XYZ(OKLab) {
// Given OKLab, convert to XYZ relative to D65
var LMStoXYZ = [
[1.2268798758459243, -0.5578149944602171, 0.2813910456659647],
[-0.0405757452148008, 1.112286803280317, -0.0717110580655164],
[-0.0763729366746601, -0.4214933324022432, 1.5869240198367816]
];
var OKLabtoLMS = [
[1.0, 0.3963377773761749, 0.2158037573099136],
[1.0, -0.1055613458156586, -0.0638541728258133],
[1.0, -0.0894841775298119, -1.2914855480194092]
];

function lrgb2rgb(c) {
const abs = Math.abs(c);
if (abs > 0.0031308) {
return (sign(c) || 1) * (1.055 * pow(abs, 1 / 2.4) - 0.055);
}
return c * 12.92;
var LMSnl = multiplyMatrices(OKLabtoLMS, OKLab);
return multiplyMatrices(
LMStoXYZ,
LMSnl.map((c) => c ** 3)
);
}

export default oklab2rgb;
54 changes: 29 additions & 25 deletions src/io/oklab/rgb2oklab.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,37 @@
import { unpack } from '../../utils/index.js';
const { cbrt, pow, sign } = Math;
import multiplyMatrices from '../../utils/multiply-matrices.js';
import { rgb2xyz } from '../lab/rgb2lab.js';

const rgb2oklab = (...args) => {
// OKLab color space implementation taken from
// https://bottosson.github.io/posts/oklab/
const [r, g, b, ...rest] = unpack(args, 'rgb');
const [lr, lg, lb] = [
rgb2lrgb(r / 255),
rgb2lrgb(g / 255),
rgb2lrgb(b / 255)
];
const l = cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
const m = cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
const s = cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);

return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
...(rest.length > 0 && rest[0] < 1 ? [rest[0]] : [])
];
const xyz = rgb2xyz(r, g, b);
const oklab = XYZ_to_OKLab(xyz);
return [...oklab, ...(rest.length > 0 && rest[0] < 1 ? [rest[0]] : [])];
};

export default rgb2oklab;
// from https://www.w3.org/TR/css-color-4/#color-conversion-code
function XYZ_to_OKLab(XYZ) {
// Given XYZ relative to D65, convert to OKLab
const XYZtoLMS = [
[0.819022437996703, 0.3619062600528904, -0.1288737815209879],
[0.0329836539323885, 0.9292868615863434, 0.0361446663506424],
[0.0481771893596242, 0.2642395317527308, 0.6335478284694309]
];
const LMStoOKLab = [
[0.210454268309314, 0.7936177747023054, -0.0040720430116193],
[1.9779985324311684, -2.4285922420485799, 0.450593709617411],
[0.0259040424655478, 0.7827717124575296, -0.8086757549230774]
];

function rgb2lrgb(c) {
const abs = Math.abs(c);
if (abs < 0.04045) {
return c / 12.92;
}
return (sign(c) || 1) * pow((abs + 0.055) / 1.055, 2.4);
const LMS = multiplyMatrices(XYZtoLMS, XYZ);
// JavaScript Math.cbrt returns a sign-matched cube root
// beware if porting to other languages
// especially if tempted to use a general power function
return multiplyMatrices(
LMStoOKLab,
LMS.map((c) => Math.cbrt(c))
);
// L in range [0,1]. For use in CSS, multiply by 100 and add a percent
}

export default rgb2oklab;
4 changes: 2 additions & 2 deletions src/io/oklch/oklch2rgb.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import oklab2rgb from '../oklab/oklab2rgb.js';

const oklch2rgb = (...args) => {
args = unpack(args, 'lch');
const [l, c, h] = args;
const [l, c, h, ...rest] = args;
const [L, a, b_] = lch2lab(l, c, h);
const [r, g, b] = oklab2rgb(L, a, b_);
return [r, g, b, args.length > 3 ? args[3] : 1];
return [r, g, b, ...(rest.length > 0 && rest[0] < 1 ? [rest[0]] : [])];
};

export default oklch2rgb;
5 changes: 4 additions & 1 deletion src/utils/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const { PI, min, max } = Math;

const rnd2 = (a) => Math.round(a * 100) / 100;
const rnd3 = (a) => Math.round(a * 100) / 100;

export { default as clip_rgb } from './clip_rgb.js';
export { default as limit } from './limit.js';
export { default as type } from './type.js';
Expand All @@ -11,4 +14,4 @@ const PITHIRD = PI / 3;
const DEG2RAD = PI / 180;
const RAD2DEG = 180 / PI;

export { PI, TWOPI, PITHIRD, DEG2RAD, RAD2DEG, min, max };
export { PI, TWOPI, PITHIRD, DEG2RAD, RAD2DEG, min, max, rnd2, rnd3 };
Loading

0 comments on commit f5e7432

Please sign in to comment.