@texel/color 1.0.3 → 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
@@ -93,9 +93,7 @@ import { gamutMapOKLCH, sRGBGamut, sRGBLinear, OKLCH } from "@texel/color";
93
93
  // gamut map to sRGB but return linear sRGB
94
94
  const lrgb = gamutMapOKLCH(oklch, sRGBGamut, sRGBLinear);
95
95
 
96
- // or gamut map to sRGB but return OKLCH
97
- // note, when finally converting this back to sRGB you will likely
98
- // want to clip the result to 0..1 bounds due to floating point loss
96
+ // or gamut map to sRGB but return OKLCH (does not perform RGB clip)
99
97
  const lch = gamutMapOKLCH(oklch, sRGBGamut, OKLCH);
100
98
  ```
101
99
 
@@ -118,6 +116,8 @@ gamutMapOKLCH(oklch, sRGBGamut, sRGB, rgb, MapToL);
118
116
 
119
117
  The `cusp` can also be passed as the last parameter, allowing for faster evaluation for known hues. See below for calculating the cusp.
120
118
 
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
+
121
121
  #### `LC = findCuspOKLCH(a, b, gamut, out = [0, 0])`
122
122
 
123
123
  Finds the 'cusp' of a given OKLab hue plane (denoted with normalized `a` and `b` values in OKLab space), returning the `[L, C]` (lightness and chroma). This is useful for pre-computing aspects of gamut mapping when you are working across a known hue:
@@ -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.3",
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
@@ -288,11 +288,16 @@ export const gamutMapOKLCH = (
288
288
  // convert oklch to base gamut space (i.e. linear sRGB)
289
289
  convert(out, OKLCH, gamutSpaceBase, rgbVec);
290
290
 
291
- // Note: if the distance lies under this threshold, it's unlikely
292
- // that gamut mapping will do anything, and it may simply produce a new
293
- // point that lies the same or similar distance away from the gamut edge
294
- // see test/test-gamut-epsilon.js
295
- const RGB_CLIP_EPSILON = 0.000074;
291
+ // Note: this is a possible area of performance improvement for some edge cases
292
+ // If you gamut map without clipping, you end up lying on the edge of the gamut,
293
+ // but in some cases very slightly out of gamut. Gamut mapping *again* is redundant
294
+ // as it will produce the same result; and in those cases, it should just skip straight
295
+ // to clipping. So, in theory, a small epsilon like 1e-7 would catch these and prevent redundant gamut mapping.
296
+ // See test/check-gamut-epsilon.js
297
+ // However, in practice, inputs to this function are likely not going to be already-mapped-but-not-clipped points,
298
+ // so we are talking about a very negligible improvement, and it is probably better to be accurate in as many cases
299
+ // as possible than to shave off a little time.
300
+ const RGB_CLIP_EPSILON = 0;
296
301
 
297
302
  // check where the point lies in gamut space
298
303
  if (!isRGBInGamut(rgbVec, RGB_CLIP_EPSILON)) {
@@ -320,6 +325,19 @@ export const gamutMapOKLCH = (
320
325
  out[0] = lerp(LTarget, L, t);
321
326
  out[1] *= t;
322
327
 
328
+ // Note: if requested targetSpace is base=OKLCH, we can return early.
329
+ // note this creates a potential difference compared to going to other targetSpaces:
330
+ // a) mapping to sRGB then finally converting back to OKLCH the result may be lossy in
331
+ // some coordinates that you might not expect, e.g. hue loss when hue should be unchanged
332
+ // during gamut mapping, due to conversion / floating point math
333
+ // b) if you map to sRGB it will perform clipping, but if you map to OKLCH then no sRGB clipping
334
+ // will be applied. The OKLCH result is _basically_ in gamut, but not exactly; you'll need to clip at final stage.
335
+ // I've documented this behaviour in the readme.
336
+ const targetSpaceBase = targetSpace.base ?? targetSpace;
337
+ if (targetSpaceBase.id == "oklab") {
338
+ return convert(out, OKLCH, targetSpace, out);
339
+ }
340
+
323
341
  // now that we have a LCH that sits on (or nearly on) the gamut, convert again to linear space
324
342
  convert(out, OKLCH, gamutSpaceBase, rgbVec);
325
343
  }
@@ -13,12 +13,13 @@ import {
13
13
  constrainAngle,
14
14
  floatToByte,
15
15
  isRGBInGamut,
16
+ clampedRGB,
16
17
  } from "../src/index.js";
17
18
  import arrayAlmostEqual from "./almost-equal.js";
18
19
 
19
20
  const settings = {
20
21
  dimensions: [768, 768],
21
- animate: true,
22
+ animate: false,
22
23
  playbackRate: "throttle",
23
24
  fps: 2,
24
25
  attributes: {
@@ -34,7 +35,8 @@ const sketch = ({ width, height }) => {
34
35
 
35
36
  context.fillStyle = "gray";
36
37
  context.fillRect(0, 0, width, height);
37
- const H = constrainAngle((frame * 45) / 2);
38
+ const H = 264.1;
39
+ // const H = constrainAngle((frame * 45) / 2);
38
40
 
39
41
  // console.time("map");
40
42
  // console.profile("map");
@@ -123,7 +125,7 @@ const sketch = ({ width, height }) => {
123
125
  context.lineTo(...LCtoXY(mapped));
124
126
  context.stroke();
125
127
  context.globalAlpha = 1;
126
- drawLCPoint(context, mapped.slice(0, 2), radius, "white");
128
+ drawLCPoint(context, mapped.slice(0, 2), radius / 4, "white");
127
129
  } else {
128
130
  drawLCPoint(context, lc, radius);
129
131
  }
@@ -0,0 +1,136 @@
1
+ // When gamut mapping with OKLCH approximation,
2
+ // the resulting points do not always lie exactly in gamut.
3
+ // The same may be true of OKHSL to RGB spaces.
4
+ // Let's figure out how far away they are:
5
+ // if a given point is under this threshold, gamut mapping
6
+ // will be redundant as it will just produce the same epsilon.
7
+ // This value is used in gamut.js as the RGB_CLIP_EPSILON
8
+
9
+ import {
10
+ clampedRGB,
11
+ convert,
12
+ degToRad,
13
+ findCuspOKLCH,
14
+ findGamutIntersectionOKLCH,
15
+ gamutMapOKLCH,
16
+ isRGBInGamut,
17
+ lerp,
18
+ MapToCuspL,
19
+ OKLCH,
20
+ sRGB,
21
+ sRGBGamut,
22
+ sRGBLinear,
23
+ } from "../src/index.js";
24
+
25
+ const gamut = sRGBGamut;
26
+ const target = gamut.space.base; // linear form
27
+
28
+ // slice the plane into a square
29
+ const slices = 100;
30
+ let avgDelta = 0;
31
+ let avgCount = 0;
32
+ let minDelta = Infinity;
33
+ let maxDelta = -Infinity;
34
+
35
+ // a very small number which still catches many gamut-mapped points
36
+ // but produces very little difference in practical and visual results
37
+ const RGB_CLIP_EPSILON = 0.0000001;
38
+ let totalPointsOutOfGamut = 0;
39
+ let totalPointsUnderEpsilon = 0;
40
+ let totalPointsUnderEpsilonBeforeMapping = 0;
41
+
42
+ // this particular hue is a little funky
43
+ // https://github.com/color-js/color.js/issues/81
44
+ // it produces out of gamut sRGB, however, the oklab gamut approximation seems to handle it fine
45
+ const hue = 264.1;
46
+ const lightness = 0.4;
47
+ for (let chroma = 0.22; chroma < 0.285; chroma += 0.001) {
48
+ const oklch = [lightness, chroma, hue];
49
+ const rgb = convert(oklch, OKLCH, sRGBLinear);
50
+ if (!isRGBInGamut(rgb, 0)) {
51
+ const mappedOKLCH = gamutMapWithoutClipOKLCH(oklch);
52
+ const mappedRGB = convert(mappedOKLCH, OKLCH, sRGBLinear);
53
+ const delta = clipDelta(mappedRGB);
54
+ if (!delta.every((n) => n == 0)) console.log("hue", hue, "delta", delta);
55
+ }
56
+ }
57
+
58
+ // test all hue planes Nº difference apart
59
+ // we will see some gamut mapped points still do not lie exactly in gamut
60
+ for (let H = 0; H < 360; H += 0.5) {
61
+ for (let y = 0; y < slices; y++) {
62
+ for (let x = 0; x < slices; x++) {
63
+ const u = x / (slices - 1);
64
+ const v = y / (slices - 1);
65
+ const L = 1 - v;
66
+ const C = u * 0.4;
67
+
68
+ // try conversion
69
+ let rgbl = convert([L, C, H], OKLCH, target);
70
+
71
+ // not exactly in space
72
+ if (!isRGBInGamut(rgbl, 0)) {
73
+ // check epsilons
74
+ if (isRGBInGamut(rgbl, RGB_CLIP_EPSILON)) {
75
+ totalPointsUnderEpsilonBeforeMapping++;
76
+ }
77
+
78
+ const oklch = gamutMapWithoutClipOKLCH([L, C, H]);
79
+ rgbl = convert(oklch, OKLCH, target);
80
+
81
+ const [dr, dg, db] = clipDelta(rgbl);
82
+ const avg = (dr + dg + db) / 3;
83
+ const min = Math.min(dr, dg, db);
84
+ const max = Math.max(dr, dg, db);
85
+ avgDelta += avg;
86
+ avgCount++;
87
+ minDelta = Math.min(min, minDelta);
88
+ maxDelta = Math.max(max, maxDelta);
89
+
90
+ totalPointsOutOfGamut++;
91
+ if (isRGBInGamut(rgbl, RGB_CLIP_EPSILON)) {
92
+ totalPointsUnderEpsilon++;
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ function clipDelta(rgb) {
100
+ const clipped = clampedRGB(rgb);
101
+ const dr = Math.abs(rgb[0] - clipped[0]);
102
+ const dg = Math.abs(rgb[1] - clipped[1]);
103
+ const db = Math.abs(rgb[2] - clipped[2]);
104
+ return [dr, dg, db];
105
+ }
106
+
107
+ function gamutMapWithoutClipOKLCH(oklch) {
108
+ const [L, C, H] = oklch;
109
+ // we aren't in gamut, so let's map toward it
110
+ const hueAngle = degToRad(H);
111
+ const aNorm = Math.cos(hueAngle);
112
+ const bNorm = Math.sin(hueAngle);
113
+
114
+ const out = [L, C, H];
115
+ // choose our strategy
116
+ const cusp = findCuspOKLCH(aNorm, bNorm, gamut);
117
+ const LTarget = MapToCuspL(out, cusp);
118
+
119
+ let t = findGamutIntersectionOKLCH(aNorm, bNorm, L, C, LTarget, cusp, gamut);
120
+ out[0] = lerp(LTarget, L, t);
121
+ out[1] *= t;
122
+ return out;
123
+ }
124
+
125
+ avgDelta /= avgCount;
126
+
127
+ console.log("Min Epsilon:", minDelta);
128
+ console.log("Max Epsilon:", maxDelta);
129
+ console.log("Average Epsilon:", avgDelta);
130
+ console.log("Compare against epsilon:", RGB_CLIP_EPSILON);
131
+ console.log("Total points out of gamut:", totalPointsOutOfGamut);
132
+ console.log(
133
+ "Total points under epsilon (before map):",
134
+ totalPointsUnderEpsilonBeforeMapping
135
+ );
136
+ console.log("Total points under epsilon (after map):", totalPointsUnderEpsilon);
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) => {
@@ -1,107 +0,0 @@
1
- // When gamut mapping with OKLCH approximation,
2
- // the resulting points do not always lie exactly in gamut.
3
- // The same may be true of OKHSL to RGB spaces.
4
- // Let's figure out how far away they are:
5
- // if a given point is under this threshold, gamut mapping
6
- // will be redundant as it will just produce the same epsilon.
7
- // This value is used in gamut.js as the RGB_CLIP_EPSILON
8
-
9
- import {
10
- A98RGBGamut,
11
- clampedRGB,
12
- convert,
13
- degToRad,
14
- DisplayP3Gamut,
15
- findCuspOKLCH,
16
- findGamutIntersectionOKLCH,
17
- gamutMapOKLCH,
18
- isRGBInGamut,
19
- lerp,
20
- MapToCuspL,
21
- OKLCH,
22
- Rec2020Gamut,
23
- sRGBGamut,
24
- } from "../src/index.js";
25
-
26
- const huePlanes = Array(360)
27
- .fill()
28
- .map((_, i) => i);
29
- const gamut = sRGBGamut;
30
- const target = gamut.space.base; // linear form
31
-
32
- // slice the plane into a square
33
- const slices = 100;
34
- let avgDelta = 0;
35
- let avgCount = 0;
36
- let minDelta = Infinity;
37
- let maxDelta = -Infinity;
38
-
39
- const EPSILON = 0.000074;
40
- let totalPointsOutOfGamut = 0;
41
- let totalPointsUnderEpsilon = 0;
42
-
43
- for (let H = 0; H < 360; H += 0.5) {
44
- for (let y = 0; y < slices; y++) {
45
- for (let x = 0; x < slices; x++) {
46
- const u = x / (slices - 1);
47
- const v = y / (slices - 1);
48
- const L = 1 - v;
49
- const C = u * 0.4;
50
-
51
- // try conversion
52
- const rgbl = convert([L, C, H], OKLCH, target);
53
-
54
- // not exactly in space
55
- if (!isRGBInGamut(rgbl, 0)) {
56
- // we aren't in gamut, so let's map toward it
57
- const hueAngle = degToRad(H);
58
- const aNorm = Math.cos(hueAngle);
59
- const bNorm = Math.sin(hueAngle);
60
-
61
- const out = [L, C, H];
62
- // choose our strategy
63
- const cusp = findCuspOKLCH(aNorm, bNorm, gamut);
64
- const LTarget = MapToCuspL(out, cusp);
65
-
66
- let t = findGamutIntersectionOKLCH(
67
- aNorm,
68
- bNorm,
69
- L,
70
- C,
71
- LTarget,
72
- cusp,
73
- gamut
74
- );
75
- out[0] = lerp(LTarget, L, t);
76
- out[1] *= t;
77
-
78
- // convert again to rgb linear
79
- const rgbl = convert(out, OKLCH, target);
80
- const clipped = clampedRGB(rgbl);
81
- const dr = Math.abs(rgbl[0] - clipped[0]);
82
- const dg = Math.abs(rgbl[1] - clipped[1]);
83
- const db = Math.abs(rgbl[2] - clipped[2]);
84
- const avg = (dr + dg + db) / 3;
85
- const min = Math.min(dr, dg, db);
86
- const max = Math.max(dr, dg, db);
87
- avgDelta += avg;
88
- avgCount++;
89
- minDelta = Math.min(min, minDelta);
90
- maxDelta = Math.max(max, maxDelta);
91
-
92
- totalPointsOutOfGamut++;
93
- if (isRGBInGamut(rgbl, EPSILON)) {
94
- totalPointsUnderEpsilon++;
95
- }
96
- }
97
- }
98
- }
99
- }
100
- avgDelta /= avgCount;
101
-
102
- console.log("Min Epsilon:", minDelta);
103
- console.log("Max Epsilon:", maxDelta);
104
- console.log("Average Epsilon:", avgDelta);
105
- console.log("Compare against epsilon:", EPSILON);
106
- console.log("Total points out of gamut:", totalPointsOutOfGamut);
107
- console.log("Total points under epsilon:", totalPointsUnderEpsilon);