Skip to content

Commit

Permalink
Merge branch 'main' into hotfix-color-space-types
Browse files Browse the repository at this point in the history
* main:
  Fix toPrecition (was off by one for fractional inputs) (color-js#384)
  Add the HCT color space (color-js#380)
  Add gamutSpace to ColorSpace
  Add CJS type defs for node16 resolution. (color-js#383)
  • Loading branch information
jgerigmeyer committed Jan 30, 2024
2 parents 6812280 + 69c1981 commit 5d8844a
Show file tree
Hide file tree
Showing 11 changed files with 263 additions and 25 deletions.
26 changes: 18 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,30 @@
"files": [
"dist/",
"src/",
"types/dist/",
"types/src/",
"types/index.d.ts"
"types/index.d.ts",
"types/index.d.cts"
],
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/color.js",
"require": "./dist/color.cjs"
"import": {
"types": "./types/index.d.ts",
"default": "./dist/color.js"
},
"require": {
"types": "./types/index.d.cts",
"default": "./dist/color.cjs"
}
},
"./fn": {
"types": "./types/src/index-fn.d.ts",
"import": "./src/index-fn.js",
"require": "./dist/color-fn.cjs"
"import": {
"types": "./types/src/index-fn.d.ts",
"default": "./src/index-fn.js"
},
"require": {
"types": "./types/src/index-fn.d.cts",
"default": "./dist/color-fn.cjs"
}
},
"./dist/*": "./dist/*"
},
Expand Down
32 changes: 27 additions & 5 deletions src/space.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ export default class ColorSpace {
this.formats.color.id = 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;

Expand All @@ -68,11 +92,9 @@ export default class ColorSpace {
}

inGamut (coords, {epsilon = ε} = {}) {
if (this.isPolar) {
// Do not check gamut through polar coordinates
coords = this.toBase(coords);

return this.base.inGamut(coords, {epsilon});
if (!this.equals(this.gamutSpace)) {
coords = this.to(this.gamutSpace, coords);
return this.gamutSpace.inGamut(coords, {epsilon});
}

let coordMeta = Object.values(this.coords);
Expand Down
154 changes: 154 additions & 0 deletions src/spaces/hct.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import ColorSpace from "../space.js";
import {constrain} from "../angles.js";
import xyz_d65 from "./xyz-d65.js";
import {fromCam16, toCam16, environment} from "./cam16.js";
import {WHITES} from "../adapt.js";

const white = WHITES.D65;
const ε = 216 / 24389; // 6^3/29^3 == (24/116)^3
const κ = 24389 / 27; // 29^3/3^3

function toLstar (y) {
// Convert XYZ Y to L*

const fy = (y > ε) ? Math.cbrt(y) : (κ * y + 16) / 116;
return (116.0 * fy) - 16.0;
}

function fromLstar (lstar) {
// Convert L* back to XYZ Y

return (lstar > 8) ? Math.pow((lstar + 16) / 116, 3) : lstar / κ;
}

function fromHct (coords, env) {
// Use Newton's method to try and converge as quick as possible or
// converge as close as we can. While the requested precision is achieved
// most of the time, it may not always be achievable. Especially past the
// visible spectrum, the algorithm will likely struggle to get the same
// precision. If, for whatever reason, we cannot achieve the accuracy we
// seek in the allotted iterations, just return the closest we were able to
// get.

let [h, c, t] = coords;
let xyz = [];
let j = 0;

// Shortcut out for black
if (t === 0) {
return [0.0, 0.0, 0.0];
}

// Calculate the Y we need to target
let y = fromLstar(t);

// A better initial guess yields better results. Polynomials come from
// curve fitting the T vs J response.
if (t > 0) {
j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233;
}
else {
j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t - 21.928975842194614;
}

// Threshold of how close is close enough, and max number of attempts.
// More precision and more attempts means more time spent iterating. Higher
// required precision gives more accuracy but also increases the chance of
// not hitting the goal. 2e-12 allows us to convert round trip with
// reasonable accuracy of six decimal places or more.
const threshold = 2e-12;
const max_attempts = 15;

let attempt = 0;
let last = Infinity;
let best = j;

// Try to find a J such that the returned y matches the returned y of the L*
while (attempt <= max_attempts) {
xyz = fromCam16({J: j, C: c, h: h}, env);

// If we are within range, return XYZ
// If we are closer than last time, save the values
const delta = Math.abs(xyz[1] - y);
if (delta < last) {
if (delta <= threshold) {
return xyz;
}
best = j;
last = delta;
}

// f(j_root) = (j ** (1 / 2)) * 0.1
// f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0
// f(j_root) = Y = y / 100
// f(j) = (y ** 2) / j - 1
// f'(j) = (2 * y) / j
j = j - (xyz[1] - y) * j / (2 * xyz[1]);

attempt += 1;
}

// We could not acquire the precision we desired,
// return our closest attempt.
return fromCam16({J: j, C: c, h: h}, env);
}

function toHct (xyz, env) {
// Calculate HCT by taking the L* of CIE LCh D65 and CAM16 chroma and hue.

const t = toLstar(xyz[1]);
if (t === 0.0) {
return [0.0, 0.0, 0.0];
}
const cam16 = toCam16(xyz, viewingConditions);
return [constrain(cam16.h), cam16.C, t];
}

// Pre-calculate everything we can with the viewing conditions
export const viewingConditions = environment(
white, 200 / Math.PI * fromLstar(50.0),
fromLstar(50.0) * 100,
"average",
false
);

// https://material.io/blog/science-of-color-design
// This is not a port of the material-color-utilities,
// but instead implements the full color space as described,
// combining CAM16 JCh and Lab D65. This does not clamp conversion
// to HCT to specific chroma bands and provides support for wider
// gamuts than Google currently supports and does so at a greater
// precision (> 8 bits back to sRGB).
// This implementation comes from https://github.com/facelessuser/coloraide
// which is licensed under MIT.
export default new ColorSpace({
id: "hct",
name: "HCT",
coords: {
h: {
refRange: [0, 360],
type: "angle",
name: "Hue",
},
c: {
refRange: [0, 145],
name: "Colorfulness",
},
t: {
refRange: [0, 100],
name: "Tone",
}
},

base: xyz_d65,

fromBase (xyz) {
return toHct(xyz, viewingConditions);
},
toBase (hct) {
return fromHct(hct, viewingConditions);
},
formats: {
color: {}
},
});
1 change: 1 addition & 0 deletions src/spaces/index-fn.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export {default as REC_2020} from "./rec2020.js";
export {default as OKLab} from "./oklab.js";
export {default as OKLCH} from "./oklch.js";
export {default as CAM16_JMh} from "./cam16.js";
export {default as HCT} from "./hct.js";

export * from "./index-fn-hdr.js";
16 changes: 6 additions & 10 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function serializeNumber (n, {precision, unit }) {
return "none";
}

return toPrecision(n, precision) + (unit ?? "");
return toPrecision(n, precision) + (unit ?? "");
}

/**
Expand All @@ -55,16 +55,12 @@ export function skipNone (n) {
*/
export function toPrecision (n, precision) {
n = +n;
precision = +precision;
let integerLength = (Math.floor(n) + "").length;

if (precision > integerLength) {
return +n.toFixed(precision - integerLength);
}
else {
let p10 = 10 ** (integerLength - precision);
return Math.round(n / p10) * p10;
if (n === 0) {
return 0;
}
precision = +precision;
const multiplier = Math.pow(10, precision - Math.floor(Math.log10(Math.abs(n))) - 1);
return Math.round(n * multiplier) / multiplier;
}

const angleFactor = {
Expand Down
48 changes: 48 additions & 0 deletions test/conversions.js
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,54 @@ const tests = {
}
]
},
{
name: "HCT",
data: {
toSpace: "hct",
},
tests: [
{
name: "sRGB white to HCT",
args: "white",
expect: [209.5429, 2.871589, 100.0]
},
{
name: "sRGB red to HCT",
args: "red",
expect: [27.4098, 113.3564, 53.23712]
},
{
name: "sRGB lime to HCT",
args: "lime",
expect: [142.1404, 108.4065, 87.73552]
},
{
name: "sRGB blue to HCT",
args: "blue",
expect: [282.7622, 87.22804, 32.30087]
},
{
name: "sRGB cyan to HCT",
args: "cyan",
expect: [196.5475, 58.96368, 91.11475]
},
{
name: "sRGB magenta to HCT",
args: "magenta",
expect: [334.6332, 107.3899, 60.32273]
},
{
name: "sRGB yellow to HCT",
args: "yellow",
expect: [111.0456, 75.50438, 97.13856]
},
{
name: "sRGB black to HCT",
args: "black",
expect: [0.0, 0.0, 0.0]
}
]
},
{
name: "Get coordinates",
data: {
Expand Down
1 change: 0 additions & 1 deletion types/dist/color.d.ts

This file was deleted.

4 changes: 4 additions & 0 deletions types/index.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Definitions by: Adam Thompson-Sharpe <https://github.com/MysteryBlokHed>
// Minimum TypeScript Version: 4.1
export { default } from "./index.js";
export * from "./index.js";
3 changes: 2 additions & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Definitions by: Adam Thompson-Sharpe <https://github.com/MysteryBlokHed>
// Minimum TypeScript Version: 4.1
export { default } from "./dist/color.js";
export { default } from "./src/index.js";

export type {
ColorConstructor,
ColorObject,
Expand Down
1 change: 1 addition & 0 deletions types/src/index-fn.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./index-fn.js";
2 changes: 2 additions & 0 deletions types/src/space.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface Options {
cssId?: string | undefined;
referred?: string | undefined;
formats?: Record<string, Format> | undefined;
gamutSpace?: "self" | string | ColorSpace | null | undefined;
}

export type Ref =
Expand Down Expand Up @@ -91,6 +92,7 @@ export default class ColorSpace {
formats: Record<string, Format>;
referred?: string | undefined;
white: White;
gamutSpace: ColorSpace;

from (color: Color | ColorObject): Coords;
from (space: string | ColorSpace, coords: Coords): Coords;
Expand Down

0 comments on commit 5d8844a

Please sign in to comment.