@texel/color 1.0.4 → 1.0.5

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
@@ -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
 
@@ -147,19 +147,35 @@ gamutMapOKLCH(oklch, gamut, gamut.space, out, MapToCuspL, cuspLC);
147
147
 
148
148
  The `a` and `b` can also be from OKLab coordinates, but must be normalized so `a^2 + b^2 == 1`.
149
149
 
150
- #### `str = serialize(coords, inputSpace = sRGB, outputSpace = inputSpace)`
150
+ #### `str = serialize(coords, inputSpace, outputSpace = inputSpace)`
151
151
 
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.
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. You can optionally pass an `alpha` component (0..1 range) as the fourth element in the `coords` array for it to be considered.
153
153
 
154
154
  ```js
155
155
  import { serialize, sRGB, DisplayP3, OKLCH } from "@texel/color";
156
156
 
157
157
  serialize([0, 0.5, 1], sRGB); // "rgb(0, 128, 255)"
158
+ serialize([0, 0.5, 1, 0.5], sRGB); // "rgba(0, 128, 255, 0.5)"
158
159
  serialize([0, 0.5, 1], DisplayP3); // "color(display-p3 0 0.5 1)"
160
+ serialize([0, 0.5, 1, 0.35], DisplayP3); // "color(display-p3 0 0.5 1 / 0.35)"
159
161
  serialize([1, 0, 0], OKLCH, sRGB); // "rgb(255, 255, 255)"
160
162
  serialize([1, 0, 0], OKLCH); // "oklch(1 0 0)"
161
163
  ```
162
164
 
165
+ #### `coords = deserialize(colorString)`
166
+
167
+ 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.
168
+
169
+ ```js
170
+ import { deserialize } from "@texel/color";
171
+
172
+ const { coords, id } = deserialize("color(display-p3 0 0.5 1 / 0.35)");
173
+ console.log(id); // "display-p3"
174
+ console.log(coords); // [ 0, 0.5, 1, 0.35 ]
175
+ ```
176
+
177
+ > **Note:** Parsing is still a WIP area of API design, and complex CSS color string handling is not within the scope of this library.
178
+
163
179
  #### `delta = deltaEOK(oklabA, oklabB)`
164
180
 
165
181
  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 +309,8 @@ import {
293
309
  LMS_to_XYZ_M,
294
310
  XYZ_to_LMS_M,
295
311
  sRGB,
312
+ OKHSLToOKLab,
313
+ DisplayP3Gamut,
296
314
  } from "@texel/color";
297
315
 
298
316
  console.log(XYZ_to_linear_sRGB_M); // [ [a,b,c], ... ]
@@ -301,6 +319,10 @@ OKLab_from(xyzD65, XYZ_to_LMS_M); // XYZ D65 -> OKLab
301
319
  transform(xyzD65, XYZ_to_linear_sRGB_M); // XYZ D65 -> sRGBLinear
302
320
  sRGB.fromBase(in_linear_sRGB, out_sRGB); // linear to gamma transfer function
303
321
  sRGB.toBase(in_sRGB, out_linear_sRGB); // linear to gamma transfer function
322
+
323
+ // OKHSL in a non-sRGB gamut
324
+ // also see OKHSVToOKLab and their inverse functions
325
+ OKHSLToOKLab([h, s, l], DisplayP3Gamut, optionalOutVec);
304
326
  ```
305
327
 
306
328
  ## Notes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texel/color",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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/test/test.js CHANGED
@@ -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,6 +231,92 @@ 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) => {