@texel/color 1.0.0

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.
@@ -0,0 +1,138 @@
1
+ import Color from "colorjs.io";
2
+ import {
3
+ convert,
4
+ OKLCH,
5
+ sRGB,
6
+ sRGBGamut,
7
+ listColorSpaces,
8
+ DisplayP3Gamut,
9
+ DisplayP3,
10
+ gamutMapOKLCH,
11
+ constrainAngle,
12
+ findCuspOKLCH,
13
+ degToRad,
14
+ MapToCuspL,
15
+ } from "../src/index.js";
16
+
17
+ const fixName = (name) => {
18
+ return name
19
+ .replace("display-", "")
20
+ .replace("a98-rgb", "a98rgb")
21
+ .replace("prophoto-rgb", "prophoto");
22
+ };
23
+
24
+ // TODO: test okhsl with latest version of colorjs
25
+ const spaces = listColorSpaces().filter((f) => !/ok(hsv|hsl)/i.test(f.id));
26
+ const spacesForColorjs = spaces.map((s) => fixName(s.id));
27
+
28
+ const vecs = Array(128 * 128)
29
+ .fill()
30
+ .map((_, i, lst) => {
31
+ const t = i / (lst.length - 1);
32
+ return (
33
+ Array(3)
34
+ .fill()
35
+ // -0.5 .. 1.5
36
+ .map(() => t + (t * 2 - 1) * 0.5)
37
+ );
38
+ });
39
+
40
+ const tmp = [0, 0, 0];
41
+
42
+ let now, elapsedColorjs, elapsedOurs;
43
+
44
+ //// OKLCH to sRGB with gamut mapping (direct path)
45
+
46
+ const hueCusps = Array(360).fill(null);
47
+ const oklchVecs = Array(512 * 256)
48
+ .fill()
49
+ .map((_, i, lst) => {
50
+ const t0 = i / (lst.length - 1);
51
+ const t1 = i / lst.length;
52
+ const H = constrainAngle(Math.round(t1 * 360));
53
+ if (!hueCusps[H]) {
54
+ const Hr = degToRad(H);
55
+ const a = Math.cos(Hr);
56
+ const b = Math.sin(Hr);
57
+ hueCusps[H] = findCuspOKLCH(a, b, sRGBGamut);
58
+ }
59
+ return [t0, t0, H];
60
+ });
61
+
62
+ now = performance.now();
63
+ for (let vec of oklchVecs) {
64
+ new Color("oklch", vec).to("srgb").toGamut({ space: "srgb", method: "css" });
65
+ }
66
+ elapsedColorjs = performance.now() - now;
67
+
68
+ now = performance.now();
69
+ for (let vec of oklchVecs) {
70
+ // you can omit the cusp and it will be found on the fly
71
+ // however the test will run slightly slower (e.g. ~100x faster rather than ~120x)
72
+ const cusp = hueCusps[vec[2]];
73
+ gamutMapOKLCH(vec, sRGBGamut, sRGB, tmp, MapToCuspL, cusp);
74
+ }
75
+ elapsedOurs = performance.now() - now;
76
+
77
+ console.log("OKLCH to sRGB with gamut mapping --");
78
+ console.log("Colorjs: %s ms", elapsedColorjs.toFixed(2));
79
+ console.log("Ours: %s ms", elapsedOurs.toFixed(2));
80
+ console.log("Speedup: %sx faster", (elapsedColorjs / elapsedOurs).toFixed(1));
81
+
82
+ //// conversions
83
+
84
+ now = performance.now();
85
+ for (let vec of vecs) {
86
+ for (let i = 0; i < spacesForColorjs.length; i++) {
87
+ for (let j = 0; j < spacesForColorjs.length; j++) {
88
+ const a = spacesForColorjs[i];
89
+ const b = spacesForColorjs[j];
90
+ new Color(a, vec).to(b);
91
+ }
92
+ }
93
+ }
94
+ elapsedColorjs = performance.now() - now;
95
+
96
+ now = performance.now();
97
+ for (let vec of vecs) {
98
+ for (let i = 0; i < spaces.length; i++) {
99
+ for (let j = 0; j < spaces.length; j++) {
100
+ const a = spaces[i];
101
+ const b = spaces[j];
102
+ convert(vec, a, b, tmp);
103
+ }
104
+ }
105
+ }
106
+ elapsedOurs = performance.now() - now;
107
+ console.log();
108
+ console.log("All Conversions --");
109
+ console.log("Colorjs: %s ms", elapsedColorjs.toFixed(2));
110
+ console.log("Ours: %s ms", elapsedOurs.toFixed(2));
111
+ console.log("Speedup: %sx faster", (elapsedColorjs / elapsedOurs).toFixed(1));
112
+
113
+ //// gamut mapping
114
+
115
+ now = performance.now();
116
+ for (let vec of vecs) {
117
+ for (let i = 0; i < spacesForColorjs.length; i++) {
118
+ const a = spacesForColorjs[i];
119
+ new Color(a, vec).to("p3").toGamut({ space: "p3", method: "css" });
120
+ }
121
+ }
122
+ elapsedColorjs = performance.now() - now;
123
+
124
+ now = performance.now();
125
+ for (let vec of vecs) {
126
+ for (let i = 0; i < spaces.length; i++) {
127
+ const a = spaces[i];
128
+ convert(vec, a, OKLCH, tmp);
129
+ gamutMapOKLCH(tmp, DisplayP3Gamut, DisplayP3, tmp);
130
+ }
131
+ }
132
+ elapsedOurs = performance.now() - now;
133
+
134
+ console.log();
135
+ console.log("Conversion + Gamut Mapping --");
136
+ console.log("Colorjs: %s ms", elapsedColorjs.toFixed(2));
137
+ console.log("Ours: %s ms", elapsedOurs.toFixed(2));
138
+ console.log("Speedup: %sx faster", (elapsedColorjs / elapsedOurs).toFixed(1));
@@ -0,0 +1,51 @@
1
+ import {
2
+ convert,
3
+ OKLCH,
4
+ sRGB,
5
+ sRGBGamut,
6
+ listColorSpaces,
7
+ gamutMapOKLCH,
8
+ } from "../src/index.js";
9
+
10
+ const spaces = listColorSpaces().filter((f) => !/ok(hsv|hsl)/i.test(f.id));
11
+
12
+ const vecs = Array(128 * 128)
13
+ .fill()
14
+ .map((_, i, lst) => {
15
+ const t = i / (lst.length - 1);
16
+ return (
17
+ Array(3)
18
+ .fill()
19
+ // -0.5 .. 1.5
20
+ .map(() => t + (t * 2 - 1) * 0.5)
21
+ );
22
+ });
23
+
24
+ const tmp = [0, 0, 0];
25
+
26
+ // console.time("bench");
27
+
28
+ for (let vec of vecs) {
29
+ for (let i = 0; i < spaces.length; i++) {
30
+ for (let j = 0; j < spaces.length; j++) {
31
+ const a = spaces[i];
32
+ const b = spaces[j];
33
+
34
+ // convert A to B
35
+ convert(vec, a, b, tmp);
36
+ // convert B to OKLCH
37
+ convert(tmp, b, OKLCH, tmp);
38
+ // gamut map OKLCH
39
+ gamutMapOKLCH(tmp, sRGBGamut, sRGB, tmp);
40
+ }
41
+ }
42
+ }
43
+
44
+ // benchmark for EOK
45
+ // for (let i = 0; i < 1000; i++) {
46
+ // for (let vec of vecs) {
47
+ // deltaEOK(vec, [0, 0.25, 1]);
48
+ // }
49
+ // }
50
+
51
+ // console.timeEnd("bench");
@@ -0,0 +1,3 @@
1
+ import * as colors from "../";
2
+ const rgb = colors.convert([0.5, 0.15, 30], colors.OKLCH, colors.sRGB);
3
+ console.log(rgb);
@@ -0,0 +1,210 @@
1
+ import canvasSketch from "canvas-sketch";
2
+ import {
3
+ findCuspOKLCH,
4
+ gamutMapOKLCH,
5
+ MapToAdaptiveCuspL,
6
+ A98RGBGamut,
7
+ convert,
8
+ DisplayP3Gamut,
9
+ OKLCH,
10
+ Rec2020Gamut,
11
+ sRGBGamut,
12
+ degToRad,
13
+ constrainAngle,
14
+ floatToByte,
15
+ isRGBInGamut,
16
+ } from "../src/index.js";
17
+ import arrayAlmostEqual from "./almost-equal.js";
18
+
19
+ const settings = {
20
+ dimensions: [768, 768],
21
+ animate: true,
22
+ playbackRate: "throttle",
23
+ fps: 2,
24
+ attributes: {
25
+ colorSpace: "display-p3",
26
+ },
27
+ };
28
+
29
+ const sketch = ({ width, height }) => {
30
+ return ({ context, width, height, frame, playhead }) => {
31
+ const { colorSpace = "srgb" } = context.getContextAttributes();
32
+ const gamut = colorSpace === "srgb" ? sRGBGamut : DisplayP3Gamut;
33
+ const mapping = MapToAdaptiveCuspL;
34
+
35
+ context.fillStyle = "gray";
36
+ context.fillRect(0, 0, width, height);
37
+ const H = constrainAngle((frame * 45) / 2);
38
+
39
+ // console.time("map");
40
+ // console.profile("map");
41
+ const tmp = [0, 0, 0];
42
+ const pixels = new Uint8ClampedArray(width * height * 4).fill(0xff);
43
+ for (let y = 0; y < height; y++) {
44
+ for (let x = 0; x < width; x++) {
45
+ const u = x / width;
46
+ const v = y / height;
47
+
48
+ const [L, C] = UVtoLC([u, v]);
49
+
50
+ let oklch = [L, C, H];
51
+ let rgb = convert(oklch, OKLCH, gamut.space, tmp);
52
+ if (!isRGBInGamut(rgb, 0)) {
53
+ rgb[0] = 0.25;
54
+ rgb[1] = 0.25;
55
+ rgb[2] = 0.25;
56
+ // if we wanted to fill the whole space with mapped colors
57
+ // rgb = gamutMapOKLCH(oklch, gamut, sRGB, tmp, mapping);
58
+ }
59
+
60
+ const idx = x + y * width;
61
+ pixels[idx * 4 + 0] = floatToByte(rgb[0]);
62
+ pixels[idx * 4 + 1] = floatToByte(rgb[1]);
63
+ pixels[idx * 4 + 2] = floatToByte(rgb[2]);
64
+ }
65
+ }
66
+ context.putImageData(
67
+ new ImageData(pixels, width, height, { colorSpace }),
68
+ 0,
69
+ 0
70
+ );
71
+ // console.profileEnd("map");
72
+ // console.timeEnd("map");
73
+
74
+ const A = [1, 0];
75
+ const B = [0, 0];
76
+ const lineWidth = width * 0.003;
77
+ context.lineWidth = lineWidth;
78
+
79
+ const gamuts = [
80
+ { defaultColor: "yellow", gamut: sRGBGamut },
81
+ { defaultColor: "palegreen", gamut: DisplayP3Gamut },
82
+ { defaultColor: "red", gamut: Rec2020Gamut },
83
+ { defaultColor: "pink", gamut: A98RGBGamut },
84
+ ];
85
+
86
+ const hueAngle = degToRad(H);
87
+ const a = Math.cos(hueAngle);
88
+ const b = Math.sin(hueAngle);
89
+
90
+ for (let { gamut: dispGamut, defaultColor } of gamuts) {
91
+ const gamutCusp = findCuspOKLCH(a, b, dispGamut);
92
+ const gamutTri = [A, gamutCusp, B];
93
+ drawLCTriangle(
94
+ context,
95
+ gamutTri,
96
+ gamut === dispGamut ? "white" : defaultColor
97
+ );
98
+ }
99
+
100
+ context.strokeStyle = "white";
101
+
102
+ const steps = 64;
103
+ for (let i = 0; i < steps; i++) {
104
+ // get some LC point that is very likely to be out of gamut
105
+ const ox = 0.5;
106
+ const oy = 0.5;
107
+ const r = 1;
108
+ const t = (i / steps) * degToRad(360) + degToRad(-180);
109
+ const xy = [Math.cos(t) * r + ox, Math.sin(t) * r + oy];
110
+ const [L, C] = UVtoLC(xy);
111
+ const oklch = [L, C, H];
112
+ const lc = oklch.slice(0, 2);
113
+
114
+ const mapped = gamutMapOKLCH(oklch, gamut, OKLCH, tmp, mapping);
115
+
116
+ const radius = width * 0.01;
117
+ const didChange = !arrayAlmostEqual(mapped, oklch);
118
+ if (didChange) {
119
+ context.globalAlpha = 0.5;
120
+ drawLCPoint(context, lc, radius / 2, "white");
121
+ context.beginPath();
122
+ context.lineTo(...LCtoXY(lc));
123
+ context.lineTo(...LCtoXY(mapped));
124
+ context.stroke();
125
+ context.globalAlpha = 1;
126
+ drawLCPoint(context, mapped.slice(0, 2), radius, "white");
127
+ } else {
128
+ drawLCPoint(context, lc, radius);
129
+ }
130
+ }
131
+
132
+ const fontSize = width * 0.03;
133
+ const boxHeight = fontSize * gamuts.length;
134
+ const pad = width * 0.05;
135
+ const padleft = width * 0.1;
136
+ context.fillStyle = "black";
137
+
138
+ for (let i = 0; i < gamuts.length; i++) {
139
+ const { gamut: dispGamut, defaultColor } = gamuts[i];
140
+ const curColor = dispGamut === gamut ? "white" : defaultColor;
141
+
142
+ context.font = `${fontSize}px monospace`;
143
+ context.textAlign = "right";
144
+ context.textBaseline = "top";
145
+ context.fillStyle = curColor;
146
+ const x = width - pad - padleft;
147
+ const y = height - boxHeight + i * fontSize - pad;
148
+ context.fillText(dispGamut.space.id, x, y);
149
+ context.beginPath();
150
+ context.lineTo(x + fontSize / 2, y + fontSize * 0.4);
151
+ context.lineTo(x + padleft, y + fontSize * 0.4);
152
+ context.strokeStyle = curColor;
153
+ context.stroke();
154
+ }
155
+
156
+ context.fillStyle = "white";
157
+ context.fillText(
158
+ `Hue: ${H.toFixed(0)}º`,
159
+ width - pad,
160
+ height - pad - boxHeight - fontSize * 2
161
+ );
162
+ };
163
+
164
+ function LCtoXY(okLC) {
165
+ const x = (okLC[1] / 1) * width;
166
+ const y = (1 - okLC[0]) * height;
167
+ return [x, y];
168
+ }
169
+
170
+ function XYtoLC(xy) {
171
+ return UVtoLC([xy[0] / width, xy[1] / height]);
172
+ }
173
+
174
+ function UVtoLC(xy) {
175
+ const L = 1 - xy[1];
176
+ const C = xy[0] * 1;
177
+ return [L, C];
178
+ }
179
+
180
+ function drawLCTriangle(context, triangle, color = "white") {
181
+ context.beginPath();
182
+ triangle.forEach((oklch) => {
183
+ const [x, y] = LCtoXY(oklch);
184
+ context.lineTo(x, y);
185
+ });
186
+ context.closePath();
187
+ context.strokeStyle = color;
188
+ context.stroke();
189
+ }
190
+
191
+ function drawLCPoint(
192
+ context,
193
+ okLC,
194
+ radius = width * 0.01,
195
+ color = "white",
196
+ fill = true
197
+ ) {
198
+ context.beginPath();
199
+ context.arc(...LCtoXY(okLC), radius, 0, Math.PI * 2);
200
+ if (fill) {
201
+ context.fillStyle = color;
202
+ context.fill();
203
+ } else {
204
+ context.strokeStyle = color;
205
+ context.stroke();
206
+ }
207
+ }
208
+ };
209
+
210
+ canvasSketch(sketch, settings);
package/test/logo.js ADDED
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs/promises";
2
+ import {
3
+ ChunkType,
4
+ ColorType,
5
+ colorTypeToChannels,
6
+ encode,
7
+ encode_iCCP,
8
+ } from "png-tools";
9
+ import { deflate } from "pako";
10
+ import {
11
+ convert,
12
+ DisplayP3Gamut,
13
+ floatToByte,
14
+ hexToRGB,
15
+ OKHSLToOKLab,
16
+ OKLab,
17
+ radToDeg,
18
+ } from "../src/index.js";
19
+
20
+ const gamut = DisplayP3Gamut;
21
+
22
+ // or regular sRGB...
23
+ // const gamut = sRGBGamut;
24
+
25
+ const isLogo = process.argv.includes("--logo");
26
+
27
+ const colorType = ColorType.RGB;
28
+ const channels = colorTypeToChannels(colorType);
29
+ const depth = 8;
30
+ const width = isLogo ? 512 : 1024;
31
+ const height = isLogo ? 512 : 512;
32
+ const uint8 = new Uint8ClampedArray(width * height * channels);
33
+
34
+ let rgb = [0, 0, 0];
35
+ for (let y = 0; y < height; y++) {
36
+ for (let x = 0; x < width; x++) {
37
+ const u = x / width;
38
+ const v = y / height;
39
+
40
+ if (isLogo) {
41
+ const u2 = u * 2 - 1;
42
+ const v2 = v * 2 - 1;
43
+ const hueAngle = Math.atan2(v2, u2);
44
+ let H = radToDeg(hueAngle);
45
+ const hueSteps = 45 / 2;
46
+ H = Math.round(H / hueSteps) * hueSteps;
47
+
48
+ const S = 1;
49
+ const L = 0.7;
50
+
51
+ const oklab = OKHSLToOKLab([H, S, L], gamut);
52
+ convert(oklab, OKLab, gamut.space, rgb);
53
+ } else {
54
+ const margin = 0.1 * Math.min(width, height);
55
+ if (
56
+ x >= margin &&
57
+ y >= margin &&
58
+ x < width - margin &&
59
+ y < height - margin
60
+ ) {
61
+ const u2 = inverseLerp(margin, width - margin, x);
62
+ const v2 = inverseLerp(margin, height - margin, y);
63
+ const hueSteps = 8;
64
+ const H = (Math.floor(u2 * hueSteps) / hueSteps) * 360;
65
+ const satSteps = 4;
66
+ const S = 1 - Math.floor(v2 * satSteps) / satSteps;
67
+ const L = 0.75;
68
+ const oklab = OKHSLToOKLab([H, S, L], gamut);
69
+ convert(oklab, OKLab, gamut.space, rgb);
70
+ } else {
71
+ hexToRGB("#e9e3d5", rgb);
72
+ }
73
+ }
74
+
75
+ const idx = x + y * width;
76
+ const [r, g, b] = rgb.map((f) => floatToByte(f));
77
+ uint8[idx * channels + 0] = r;
78
+ uint8[idx * channels + 1] = g;
79
+ uint8[idx * channels + 2] = b;
80
+ }
81
+ }
82
+
83
+ // optional color profile chunk
84
+ let iCCP = null;
85
+ if (gamut == DisplayP3Gamut) {
86
+ const iccFile = await fs.readFile("test/profiles/DisplayP3.icc");
87
+ const name = "Display P3";
88
+ const data = deflate(iccFile);
89
+ iCCP = {
90
+ type: ChunkType.iCCP,
91
+ data: encode_iCCP({ name, data }),
92
+ };
93
+ }
94
+
95
+ const png = encode(
96
+ {
97
+ width,
98
+ height,
99
+ data: uint8,
100
+ colorType,
101
+ depth,
102
+ ancillary: [iCCP].filter(Boolean),
103
+ },
104
+ deflate
105
+ );
106
+
107
+ fs.writeFile(`test/${isLogo ? "logo" : "banner"}.png`, png);
108
+
109
+ function inverseLerp(min, max, t) {
110
+ if (Math.abs(min - max) < Number.EPSILON) return 0;
111
+ else return (t - min) / (max - min);
112
+ }
package/test/logo.png ADDED
Binary file
Binary file
@@ -0,0 +1,87 @@
1
+ import test from "tape";
2
+ import Color from "colorjs.io";
3
+ import arrayAlmostEqual from "./almost-equal.js";
4
+ import { listColorSpaces, convert } from "../src/index.js";
5
+
6
+ test("should approximately match colorjs.io conversions", async (t) => {
7
+ // note: we skip okhsv/hsl as colorjs.io doesn't support in the current npm version
8
+ const spaces = listColorSpaces().filter((f) => !/ok(hsl|hsv)/i.test(f.id));
9
+ const vecs = [
10
+ [0.12341, 0.12001, 0.05212],
11
+ [1, 1, 1],
12
+ [1, 0, 0],
13
+ [0, 0, 0],
14
+ // some other inputs
15
+ [0.95, 1, 1.089],
16
+ [0.45, 1.236, -0.019],
17
+ [0, 1, 0],
18
+ [0.922, -0.671, 0.263],
19
+ [0, 0, 1],
20
+ [0.153, -1.415, -0.449],
21
+ ];
22
+
23
+ // just a further sanity check, uncomment to go wild
24
+ // for (let i = 0; i < 100; i++)
25
+ // vecs.push([
26
+ // Math.random() * 2 - 1,
27
+ // Math.random() * 2 - 1,
28
+ // Math.random() * 2 - 1,
29
+ // ]);
30
+
31
+ const fixName = (name) => {
32
+ return name
33
+ .replace("display-", "")
34
+ .replace("a98-rgb", "a98rgb")
35
+ .replace("prophoto-rgb", "prophoto");
36
+ };
37
+
38
+ for (let vec of vecs) {
39
+ for (let i = 0; i < spaces.length; i++) {
40
+ for (let j = 0; j < spaces.length; j++) {
41
+ const a = spaces[i];
42
+ const b = spaces[j];
43
+ const suffix = `${a.id}-to-${b.id}`;
44
+
45
+ console.log(suffix);
46
+ const expected0 = convert(vec, a, b);
47
+ const tmp = vec.slice();
48
+ const expected1 = convert(vec, a, b, tmp);
49
+
50
+ const colorjsid_a = fixName(a.id);
51
+ const colorjsid_b = fixName(b.id);
52
+ t.deepEqual(expected0, tmp, `${suffix} copies into`);
53
+ t.deepEqual(expected0, expected1, `${suffix} copies into`);
54
+ t.equal(expected1, tmp, `${suffix} copies into and returns`);
55
+
56
+ // ColorJS returns NaN for display-p3 0 0 0 --> OKLCH
57
+ const outCoords = new Color(colorjsid_a, vec)
58
+ .to(colorjsid_b)
59
+ .coords.map((n) => n || 0);
60
+
61
+ // Colorjs does not appear to have as high precision as the latest
62
+ // CSS working draft spec which uses rational numbers
63
+ // so I have lowered tolerance for A98RGB, and consider it an upstream bug.
64
+ // please open a PR/issue if you feel otherwise!
65
+ const tolerance =
66
+ colorjsid_a.includes("a98") || colorjsid_b.includes("a98")
67
+ ? 0.0000001
68
+ : undefined;
69
+
70
+ if (!arrayAlmostEqual(expected0, outCoords, tolerance)) {
71
+ console.error(
72
+ `\nError: %s - In (%s) Out (%s) Expected (%s)`,
73
+ suffix,
74
+ vec,
75
+ expected0,
76
+ outCoords
77
+ );
78
+ }
79
+ t.equal(
80
+ arrayAlmostEqual(expected0, outCoords, tolerance),
81
+ true,
82
+ suffix
83
+ );
84
+ }
85
+ }
86
+ }
87
+ });