@texel/color 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,7 +85,7 @@ The return value is the new coordinates in the destination space; such as `[r,g,
85
85
 
86
86
  #### `output = gamutMapOKLCH(oklch, gamut = sRGBGamut, targetSpace = gamut.space, out = [0, 0, 0], mapping = MapToCuspL, [cusp])`
87
87
 
88
- Performs fast gamut mapping in OKLCH as [described by Björn Ottoson](https://bottosson.github.io/posts/gamutclipping/) (2021). This takes an input `[l,c,h]` coords in OKLCH space, and ensures the final result will lie within the specified color `gamut` (default `sRGBGamut`). You can further specify a different target space (which default's the the gamut's space), for example to get a linear-light sRGB and avoid the transfer function, or to keep the result in OKLCH:
88
+ Performs fast gamut mapping in OKLCH as [described by Björn Ottoson](https://bottosson.github.io/posts/gamutclipping/) (2021). This takes an input `[l,c,h]` coords in OKLCH space, and ensures the final result will lie within the specified color `gamut` (default `sRGBGamut`). You can further specify a different target space (which default's to the gamut's space), for example to get a linear-light sRGB and avoid the transfer function, or to keep the result in OKLCH:
89
89
 
90
90
  ```js
91
91
  import { gamutMapOKLCH, sRGBGamut, sRGBLinear, OKLCH } from "@texel/color";
@@ -116,7 +116,7 @@ gamutMapOKLCH(oklch, sRGBGamut, sRGB, rgb, MapToL);
116
116
 
117
117
  The `cusp` can also be passed as the last parameter, allowing for faster evaluation for known hues. See below for calculating the cusp.
118
118
 
119
- > :Note: If you output to an OKLab-based target (OKLCH, OKHSL etc), the final step of RGB clipping will be skipped. This produces more predictable OKLab and OKLCH based results, but you will likely want to perform a final clampedRGB() step when converting to a displayable color.
119
+ > **Note:** If you map to an OKLab-based target (OKLCH, OKHSL etc), the final step of RGB clipping will be skipped. This produces more predictable OKLab and OKLCH based results, but you will likely want to perform a final clampedRGB() step when converting to a displayable color.
120
120
 
121
121
  #### `LC = findCuspOKLCH(a, b, gamut, out = [0, 0])`
122
122
 
@@ -142,24 +142,41 @@ const cuspLC = findCuspOKLCH(a, b, gamut);
142
142
 
143
143
  // ... somewhere else in your program ...
144
144
  // pass 'cusp' parameter for faster evaluation
145
+ // expected that your OKLCH coord has the same hue as the cusp (H)
145
146
  gamutMapOKLCH(oklch, gamut, gamut.space, out, MapToCuspL, cuspLC);
146
147
  ```
147
148
 
148
149
  The `a` and `b` can also be from OKLab coordinates, but must be normalized so `a^2 + b^2 == 1`.
149
150
 
150
- #### `str = serialize(coords, inputSpace = sRGB, outputSpace = inputSpace)`
151
+ #### `str = serialize(coords, inputSpace, outputSpace = inputSpace)`
151
152
 
152
- Turns the specified `coords` (assumed to be in `inputSpace`) into a string, first converting if needed to the specified `outputSpace`. If the space is sRGB, a plain `rgb(r,g,b)` string (in bytes) will be used for browser compatibility and performance, otherwise a CSS color string will be returned. Note that not all spaces, such as certain linear spaces, are currently supported by CSS.
153
+ Turns the specified `coords` (assumed to be in `inputSpace`) into a string, first converting if needed to the specified `outputSpace`. If the space is sRGB, a plain `rgb(r,g,b)` string (in bytes) will be used for browser compatibility and performance, otherwise a CSS color string will be returned. Note that not all spaces, such as certain linear spaces, are currently supported by CSS. You can optionally pass an `alpha` component (0..1 range) as the fourth element in the `coords` array for it to be considered.
153
154
 
154
155
  ```js
155
156
  import { serialize, sRGB, DisplayP3, OKLCH } from "@texel/color";
156
157
 
157
158
  serialize([0, 0.5, 1], sRGB); // "rgb(0, 128, 255)"
159
+ serialize([0, 0.5, 1, 0.5], sRGB); // "rgba(0, 128, 255, 0.5)"
158
160
  serialize([0, 0.5, 1], DisplayP3); // "color(display-p3 0 0.5 1)"
161
+ serialize([0, 0.5, 1, 0.35], DisplayP3); // "color(display-p3 0 0.5 1 / 0.35)"
159
162
  serialize([1, 0, 0], OKLCH, sRGB); // "rgb(255, 255, 255)"
160
163
  serialize([1, 0, 0], OKLCH); // "oklch(1 0 0)"
161
164
  ```
162
165
 
166
+ #### `info = deserialize(colorString)`
167
+
168
+ The inverse of `serialize`, this will take a string and determine the color space `id` it is referencing, and the 3 or 4 (for alpha) `coords`. This is intentionally limited in functionality, only supporting hex RGB, `rgb()` and `rgba()` bytes, and `oklch()`, `oklab()`, and plain `color()` functions with no modifiers.
169
+
170
+ ```js
171
+ import { deserialize } from "@texel/color";
172
+
173
+ const { coords, id } = deserialize("color(display-p3 0 0.5 1 / 0.35)");
174
+ console.log(id); // "display-p3"
175
+ console.log(coords); // [ 0, 0.5, 1, 0.35 ]
176
+ ```
177
+
178
+ > **Note:** Parsing is still a WIP area of API design, and complex CSS color string handling is not within the scope of this library.
179
+
163
180
  #### `delta = deltaEOK(oklabA, oklabB)`
164
181
 
165
182
  Performs a color difference in OKLab space between two coordinates. As this is a perceptually uniform color space that improves upon CIELAB and its flaws, it should be suitable as a replacement for the CIEDE2000 color difference equation in many situations.
@@ -293,6 +310,8 @@ import {
293
310
  LMS_to_XYZ_M,
294
311
  XYZ_to_LMS_M,
295
312
  sRGB,
313
+ OKHSLToOKLab,
314
+ DisplayP3Gamut,
296
315
  } from "@texel/color";
297
316
 
298
317
  console.log(XYZ_to_linear_sRGB_M); // [ [a,b,c], ... ]
@@ -301,6 +320,10 @@ OKLab_from(xyzD65, XYZ_to_LMS_M); // XYZ D65 -> OKLab
301
320
  transform(xyzD65, XYZ_to_linear_sRGB_M); // XYZ D65 -> sRGBLinear
302
321
  sRGB.fromBase(in_linear_sRGB, out_sRGB); // linear to gamma transfer function
303
322
  sRGB.toBase(in_sRGB, out_linear_sRGB); // linear to gamma transfer function
323
+
324
+ // OKHSL in a non-sRGB gamut
325
+ // also see OKHSVToOKLab and their inverse functions
326
+ OKHSLToOKLab([h, s, l], DisplayP3Gamut, optionalOutVec);
304
327
  ```
305
328
 
306
329
  ## Notes
@@ -309,7 +332,7 @@ sRGB.toBase(in_sRGB, out_linear_sRGB); // linear to gamma transfer function
309
332
 
310
333
  [Colorjs](https://colorjs.io/) is fantastic and perhaps the current leading standard in JavaScript, but it's not very practical for creative coding and real-time web applications, where the requirements are often (1) leaner codebases, (2) highly optimized, and (3) minimal GC thrashing.
311
334
 
312
- Colorjs, and simialrly, [Culori](https://culorijs.org/)), are focused on matching CSS spec, which means it will very likely continue to grow in complexity over time, and performance will often be marred (for example, `@texel/color` cusp intersection gamut mapping is ~125 times faster than Colorjs and ~60 times faster than culori).
335
+ Colorjs, and simialrly, [Culori](https://culorijs.org/), are focused on matching CSS spec, which means it will very likely continue to grow in complexity over time, and performance will often be marred (for example, `@texel/color` cusp intersection gamut mapping is ~125 times faster than Colorjs and ~60 times faster than culori).
313
336
 
314
337
  There are many other options such as [color-space](https://www.npmjs.com/package/color-space) or [color-convert](https://www.npmjs.com/package/color-convert), however, these do not support modern spacse such as OKLab and OKHSL, and/or have dubious levels of accuracy (many libraries, for example, do not distinguish between D50 and D65 whitepoints in XYZ).
315
338
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texel/color",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "a minimal and modern color library",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/core.js CHANGED
@@ -1,6 +1,6 @@
1
- import { floatToByte, vec3 } from "./util.js";
1
+ import { clamp, floatToByte, hexToRGB, vec3 } from "./util.js";
2
2
  import { LMS_to_OKLab_M, OKLab_to_LMS_M } from "./conversion_matrices.js";
3
- import { XYZ } from "./spaces.js";
3
+ import { listColorSpaces, sRGB, XYZ } from "./spaces.js";
4
4
 
5
5
  const tmp3 = vec3();
6
6
 
@@ -51,25 +51,131 @@ const vec3Copy = (input, output) => {
51
51
 
52
52
  export const serialize = (input, inputSpace, outputSpace = inputSpace) => {
53
53
  if (!inputSpace) throw new Error(`must specify an input space`);
54
+ // extract alpha if present
55
+ let alpha = 1;
56
+ if (input.length > 3) {
57
+ alpha = input[3];
58
+ }
59
+ // copy into temp
60
+ vec3Copy(input, tmp3);
61
+ // convert if needed
54
62
  if (inputSpace !== outputSpace) {
55
63
  convert(input, inputSpace, outputSpace, tmp3);
56
- } else {
57
- vec3Copy(input, tmp3);
58
64
  }
59
-
60
65
  const id = outputSpace.id;
61
66
  if (id == "srgb") {
62
67
  const r = floatToByte(tmp3[0]);
63
68
  const g = floatToByte(tmp3[1]);
64
69
  const b = floatToByte(tmp3[2]);
65
- return `rgb(${r}, ${g}, ${b})`;
66
- } else if (id == "oklab" || id == "oklch") {
67
- return `${id}(${tmp3[0]} ${tmp3[1]} ${tmp3[2]})`;
70
+ const rgb = `${r}, ${g}, ${b}`;
71
+ return alpha === 1 ? `rgb(${rgb})` : `rgba(${rgb}, ${alpha})`;
68
72
  } else {
69
- return `color(${id} ${tmp3[0]} ${tmp3[1]} ${tmp3[2]})`;
73
+ const alphaSuffix = alpha === 1 ? "" : ` / ${alpha}`;
74
+ if (id == "oklab" || id == "oklch") {
75
+ return `${id}(${tmp3[0]} ${tmp3[1]} ${tmp3[2]}${alphaSuffix})`;
76
+ } else {
77
+ return `color(${id} ${tmp3[0]} ${tmp3[1]} ${tmp3[2]}${alphaSuffix})`;
78
+ }
70
79
  }
71
80
  };
72
81
 
82
+ const stripAlpha = (coords) => {
83
+ if (coords.length >= 4 && coords[3] === 1) return coords.slice(0, 3);
84
+ return coords;
85
+ };
86
+
87
+ export const deserialize = (input) => {
88
+ if (typeof input !== "string") {
89
+ throw new Error(`expected a string as input`);
90
+ }
91
+ input = input.trim();
92
+ if (input.charAt(0) === "#") {
93
+ const rgbIn = input.slice(0, 7);
94
+ let alphaByte = input.length > 7 ? parseInt(input.slice(7, 9), 16) : 255;
95
+ let alpha = isNaN(alphaByte) ? 1 : alphaByte / 255;
96
+ const coords = hexToRGB(rgbIn);
97
+ if (alpha !== 1) coords.push(alpha);
98
+ return {
99
+ id: "srgb",
100
+ coords,
101
+ };
102
+ } else {
103
+ const parts = /^(rgb|rgba|oklab|oklch|color)\((.+)\)$/i.exec(input);
104
+ if (!parts) {
105
+ throw new Error(`could not parse color string ${input}`);
106
+ }
107
+ const fn = parts[1].toLowerCase();
108
+ if (/^rgba?$/i.test(fn)) {
109
+ const hasAlpha = fn == "rgba";
110
+ const coords = parts[2]
111
+ .split(",")
112
+ .map((v, i) =>
113
+ i < 3 ? clamp(parseInt(v, 10) || 0, 0, 255) / 255 : parseFloat(v)
114
+ );
115
+ const expectedLen = hasAlpha ? 4 : 3;
116
+ if (coords.length !== expectedLen) {
117
+ throw new Error(
118
+ `got ${fn} with incorrect number of coords, expected ${expectedLen}`
119
+ );
120
+ }
121
+ return {
122
+ id: "srgb",
123
+ coords: stripAlpha(coords),
124
+ };
125
+ } else {
126
+ let id, coordsStrings;
127
+ if (fn === "color") {
128
+ const params =
129
+ /([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s/]+)(?:\s?\/\s?([^\s]+))?/.exec(
130
+ parts[2]
131
+ );
132
+ if (!params)
133
+ throw new Error(`could not parse color() function ${input}`);
134
+ id = params[1].toLowerCase();
135
+ coordsStrings = params.slice(2, 6);
136
+ } else if (/^(oklab|oklch)$/.test(fn)) {
137
+ id = fn;
138
+ const params =
139
+ /([^\s]+)\s+([^\s]+)\s+([^\s/]+)(?:\s?\/\s?([^\s]+))?/.exec(parts[2]);
140
+ if (!params)
141
+ throw new Error(`could not parse color() function ${input}`);
142
+ coordsStrings = params.slice(1, 6);
143
+ }
144
+
145
+ if (coordsStrings[3] == null) {
146
+ coordsStrings = coordsStrings.slice(0, 3);
147
+ }
148
+
149
+ const coords = coordsStrings.map((f) => parseFloat(f));
150
+ if (coords.length < 3 || coords.length > 4)
151
+ throw new Error(`invalid number of coordinates`);
152
+ return {
153
+ id,
154
+ coords: stripAlpha(coords),
155
+ };
156
+ }
157
+ }
158
+ };
159
+ export const parse = (input, targetSpace, out = vec3()) => {
160
+ if (!targetSpace)
161
+ throw new Error(`must specify a target space to parse into`);
162
+
163
+ const { coords, id } = deserialize(input);
164
+ const space = listColorSpaces().find((f) => id === f.id);
165
+ if (!space) throw new Error(`could not find space with the id ${id}`);
166
+ const alpha = coords.length === 4 ? coords[3] : 1;
167
+
168
+ // copy 3D coords to output and convert
169
+ vec3Copy(coords, out);
170
+ convert(out, space, targetSpace, out);
171
+
172
+ // store alpha
173
+ if (alpha !== 1) out[3] = alpha;
174
+ // reduce to 3D
175
+ if (alpha == 1 && out.length === 4) out.pop();
176
+ return out;
177
+ };
178
+
73
179
  export const convert = (input, fromSpace, toSpace, out = vec3()) => {
74
180
  // place into output
75
181
  vec3Copy(input, out);
package/src/gamut.js CHANGED
@@ -334,7 +334,7 @@ export const gamutMapOKLCH = (
334
334
  // will be applied. The OKLCH result is _basically_ in gamut, but not exactly; you'll need to clip at final stage.
335
335
  // I've documented this behaviour in the readme.
336
336
  const targetSpaceBase = targetSpace.base ?? targetSpace;
337
- if (targetSpaceBase == OKLab) {
337
+ if (targetSpaceBase.id == "oklab") {
338
338
  return convert(out, OKLCH, targetSpace, out);
339
339
  }
340
340
 
package/src/util.js CHANGED
@@ -26,9 +26,12 @@ export const hexToRGB = (str, out = vec3()) => {
26
26
  return out;
27
27
  };
28
28
 
29
- export const RGBtoHex = (rgb) =>
29
+ export const RGBToHex = (rgb) =>
30
30
  `#${rgb.map((n) => floatToByte(n).toString(16).padStart(2, "0")).join("")}`;
31
31
 
32
+ /** @deprecated use RGBToHex */
33
+ export const RGBtoHex = (rgb) => RGBToHex;
34
+
32
35
  export const isRGBInGamut = (lrgb, ep = GAMUT_EPSILON) => {
33
36
  const r = lrgb[0];
34
37
  const g = lrgb[1];
package/test/test.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  floatToByte,
6
6
  hexToRGB,
7
7
  isRGBInGamut,
8
- RGBtoHex,
8
+ RGBToHex,
9
9
  linear_sRGB_to_LMS_M,
10
10
  LMS_to_linear_sRGB_M,
11
11
  LMS_to_XYZ_M,
@@ -42,6 +42,8 @@ import {
42
42
  A98RGBLinear,
43
43
  XYZ_to_linear_A98RGB_M,
44
44
  DisplayP3Gamut,
45
+ deserialize,
46
+ parse,
45
47
  } from "../src/index.js";
46
48
 
47
49
  test("should convert XYZ in different whitepoints", async (t) => {
@@ -229,10 +231,96 @@ test("should serialize", async (t) => {
229
231
  t.deepEqual(serialize([0, 0.5, 1], sRGBLinear), "color(srgb-linear 0 0.5 1)");
230
232
  t.deepEqual(serialize([1, 0, 0], OKLCH, sRGB), "rgb(255, 255, 255)");
231
233
  t.deepEqual(serialize([1, 0, 0], OKLCH), "oklch(1 0 0)");
234
+ t.deepEqual(serialize([1, 0, 0], OKLab), "oklab(1 0 0)");
235
+ t.deepEqual(
236
+ serialize([1, 0, 0, 0.4523], OKLCH, sRGB),
237
+ "rgba(255, 255, 255, 0.4523)"
238
+ );
239
+ t.deepEqual(
240
+ serialize([1, 0, 0, 0.4523], OKLCH, OKLCH),
241
+ "oklch(1 0 0 / 0.4523)"
242
+ );
243
+ t.deepEqual(serialize([1, 0, 0, 0.4523], OKLCH), "oklch(1 0 0 / 0.4523)");
244
+ t.deepEqual(
245
+ serialize([1, 0, 0, 0.4523], DisplayP3),
246
+ "color(display-p3 1 0 0 / 0.4523)"
247
+ );
248
+ });
249
+
250
+ test("should parse to a color coord", async (t) => {
251
+ t.deepEqual(parse("rgb(0, 128, 255)", sRGB), [0, 128 / 0xff, 255 / 0xff]);
252
+ t.deepEqual(parse("rgba(0, 128, 255, .25)", sRGB), [
253
+ 0,
254
+ 128 / 0xff,
255
+ 255 / 0xff,
256
+ 0.25,
257
+ ]);
258
+ let outVec = [0, 0, 0];
259
+ t.deepEqual(parse("rgba(0, 128, 255, 1)", sRGB), [0, 128 / 0xff, 255 / 0xff]);
260
+ let out;
261
+ out = parse("rgba(0, 128, 255, 1)", sRGB, outVec);
262
+ t.deepEqual(out, [0, 128 / 0xff, 255 / 0xff]);
263
+ t.equal(out, outVec);
264
+
265
+ // trims to 3
266
+ outVec = [0, 0, 0, 0];
267
+ out = parse("rgba(0, 128, 255, 1)", sRGB, outVec);
268
+ t.deepEqual(out, [0, 128 / 0xff, 255 / 0xff]);
269
+ t.equal(out, outVec);
270
+
271
+ // ensures 4
272
+ outVec = [0, 0, 0, 0];
273
+ out = parse("rgba(0, 128, 255, 0.91)", sRGB, outVec);
274
+ t.deepEqual(out, [0, 128 / 0xff, 255 / 0xff, 0.91]);
275
+ t.equal(out, outVec);
276
+
277
+ t.deepEqual(
278
+ serialize(parse("oklch(1 0 0)", sRGB), sRGB),
279
+ "rgb(255, 255, 255)"
280
+ );
281
+ });
282
+
283
+ test("should deserialize color string information", async (t) => {
284
+ t.deepEqual(deserialize("rgb(0, 128, 255)"), {
285
+ coords: [0, 128 / 0xff, 255 / 0xff],
286
+ id: "srgb",
287
+ });
288
+ t.deepEqual(deserialize("rgba(0, 128, 255, 0.35)"), {
289
+ coords: [0, 128 / 0xff, 255 / 0xff, 0.35],
290
+ id: "srgb",
291
+ });
292
+ t.deepEqual(deserialize("#ff00cc"), {
293
+ id: "srgb",
294
+ coords: [1, 0, 0.8],
295
+ });
296
+ t.deepEqual(deserialize("#ff00cccc"), {
297
+ id: "srgb",
298
+ coords: [1, 0, 0.8, 0.8],
299
+ });
300
+ t.deepEqual(deserialize("color(srgb-linear 0 0.5 1)"), {
301
+ id: "srgb-linear",
302
+ coords: [0, 0.5, 1],
303
+ });
304
+ t.deepEqual(deserialize("color(srgb-linear 0 0.5 1/0.25)"), {
305
+ id: "srgb-linear",
306
+ coords: [0, 0.5, 1, 0.25],
307
+ });
308
+ t.deepEqual(deserialize("color(srgb-linear 0 0.5 1 / 0.25)"), {
309
+ id: "srgb-linear",
310
+ coords: [0, 0.5, 1, 0.25],
311
+ });
312
+ t.deepEqual(deserialize("oklch(1 0 0)"), {
313
+ id: "oklch",
314
+ coords: [1, 0, 0],
315
+ });
316
+ t.deepEqual(deserialize("oklch(1 0 0/0.25)"), {
317
+ id: "oklch",
318
+ coords: [1, 0, 0, 0.25],
319
+ });
232
320
  });
233
321
 
234
322
  test("utils", async (t) => {
235
- t.deepEqual(RGBtoHex([0, 0.5, 1]), "#0080ff");
323
+ t.deepEqual(RGBToHex([0, 0.5, 1]), "#0080ff");
236
324
  t.deepEqual(hexToRGB("#0080ff"), [0, 0.5019607843137255, 1]);
237
325
  const tmp = [0, 0, 0];
238
326
  hexToRGB("#0080ff", tmp);