@zakkster/lite-color-engine 1.0.4 → 1.2.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.
- package/CHANGELOG.md +32 -0
- package/README.md +37 -312
- package/index.d.ts +101 -9
- package/index.js +11 -2
- package/llms.txt +29 -192
- package/package.json +23 -5
- package/src/Gamut.d.ts +37 -0
- package/src/Gamut.js +228 -0
- package/src/Remap.d.ts +87 -0
- package/src/Remap.js +306 -0
- package/src/authoring.js +47 -6
- package/src/convert.js +127 -5
- package/src/delta.js +37 -0
- package/src/runtime.js +64 -4
package/src/Remap.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// -----------------------------------------------------------------------------
|
|
2
|
+
// @zakkster/lite-color-engine/remap
|
|
3
|
+
//
|
|
4
|
+
// SoA remap kernels: nearest-palette-index search in OKLab, and a one-shot
|
|
5
|
+
// pixel-remap function for image recoloring. The engine layer that
|
|
6
|
+
// `lite-hueforge v1.3 remapImageToPalette` sits on top of.
|
|
7
|
+
//
|
|
8
|
+
// Design opinions:
|
|
9
|
+
// 1. Search happens in OKLab, not OKLCH. Distance in OKLab is Euclidean;
|
|
10
|
+
// distance in OKLCH needs a cos/sin per comparison. Palette is authored
|
|
11
|
+
// in OKLCH (what humans think in), converted to OKLab once at call
|
|
12
|
+
// entry, searched forever.
|
|
13
|
+
// 2. Palette is limited to whatever fits typical use (hundreds of colors).
|
|
14
|
+
// Scratch buffers grow monotonically inside the module — never shrink.
|
|
15
|
+
// Zero allocations on the hot per-pixel loop.
|
|
16
|
+
// 3. `preserveLightness` is the shading-preservation trick: search by (a, b)
|
|
17
|
+
// only, keep the original pixel's L, and synthesize the output color
|
|
18
|
+
// from (pixel.L, palette.a, palette.b). This is what makes recolored
|
|
19
|
+
// AI motifs still look *shaded*, not posterized.
|
|
20
|
+
// 4. Alpha byte from the input passes through unchanged in the output.
|
|
21
|
+
//
|
|
22
|
+
// Trust model: hot path trusts input shapes. Buffer sizes assumed correct.
|
|
23
|
+
// -----------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const _RAD = Math.PI / 180;
|
|
26
|
+
const _DEG = 180 / Math.PI;
|
|
27
|
+
|
|
28
|
+
// Grow-only module-level scratch for remapPixelsToPalette.
|
|
29
|
+
let _paletteLab = new Float32Array(0);
|
|
30
|
+
let _paletteU32 = new Uint32Array(0);
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
// -----------------------------------------------------------------------------
|
|
34
|
+
// Batch converters
|
|
35
|
+
// -----------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function _linearizeSrgbByte(b) {
|
|
38
|
+
const v = b / 255;
|
|
39
|
+
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _srgbGammaEncode(v) {
|
|
43
|
+
if (v <= 0) return 0;
|
|
44
|
+
if (v >= 1) return 1;
|
|
45
|
+
return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Batch RGBA8 → OKLab conversion. Reads 4 bytes per pixel from `inU8`
|
|
50
|
+
* (Uint8ClampedArray or Uint8Array), writes 3 floats per pixel to `outLab`.
|
|
51
|
+
* Alpha is discarded; carry it separately if needed.
|
|
52
|
+
*/
|
|
53
|
+
export function sRgba8ToOklabBuffer(inU8, outLab, pixelCount) {
|
|
54
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
55
|
+
const inOff = i * 4;
|
|
56
|
+
const outOff = i * 3;
|
|
57
|
+
const r = _linearizeSrgbByte(inU8[inOff]);
|
|
58
|
+
const g = _linearizeSrgbByte(inU8[inOff + 1]);
|
|
59
|
+
const b = _linearizeSrgbByte(inU8[inOff + 2]);
|
|
60
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
61
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
62
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
63
|
+
const l_ = Math.cbrt(l);
|
|
64
|
+
const m_ = Math.cbrt(m);
|
|
65
|
+
const s_ = Math.cbrt(s);
|
|
66
|
+
outLab[outOff] = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
67
|
+
outLab[outOff + 1] = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
68
|
+
outLab[outOff + 2] = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Batch OKLCH → OKLab conversion. Both buffers stride 3.
|
|
74
|
+
*/
|
|
75
|
+
export function oklchToOklabBuffer(inLch, outLab, pixelCount) {
|
|
76
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
77
|
+
const off = i * 3;
|
|
78
|
+
const L = inLch[off];
|
|
79
|
+
const C = inLch[off + 1];
|
|
80
|
+
const H = inLch[off + 2];
|
|
81
|
+
const hRad = H * _RAD;
|
|
82
|
+
outLab[off] = L;
|
|
83
|
+
outLab[off + 1] = C * Math.cos(hRad);
|
|
84
|
+
outLab[off + 2] = C * Math.sin(hRad);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Batch OKLab → OKLCH conversion. Both buffers stride 3.
|
|
90
|
+
* Hue canonicalized to [0, 360).
|
|
91
|
+
*/
|
|
92
|
+
export function oklabToOklchBuffer(inLab, outLch, pixelCount) {
|
|
93
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
94
|
+
const off = i * 3;
|
|
95
|
+
const L = inLab[off];
|
|
96
|
+
const a = inLab[off + 1];
|
|
97
|
+
const b = inLab[off + 2];
|
|
98
|
+
const C = Math.sqrt(a * a + b * b);
|
|
99
|
+
let H = Math.atan2(b, a) * _DEG;
|
|
100
|
+
if (H < 0) H += 360;
|
|
101
|
+
outLch[off] = L;
|
|
102
|
+
outLch[off + 1] = C;
|
|
103
|
+
outLch[off + 2] = H;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
// -----------------------------------------------------------------------------
|
|
109
|
+
// nearestPaletteIndexBuffer
|
|
110
|
+
// -----------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* For each pixel in `pixelsLab`, write the index of the nearest palette
|
|
114
|
+
* color in `paletteLab` (by squared Euclidean distance in OKLab) into
|
|
115
|
+
* `indicesOut`.
|
|
116
|
+
*
|
|
117
|
+
* Tie-break: lowest index wins. Deterministic across runs.
|
|
118
|
+
*
|
|
119
|
+
* @param {Float32Array} pixelsLab stride 3 [L, a, b]
|
|
120
|
+
* @param {Float32Array} paletteLab stride 3 [L, a, b]
|
|
121
|
+
* @param {Uint32Array|Uint16Array|Uint8Array} indicesOut length ≥ pixelCount
|
|
122
|
+
* @param {number} pixelCount
|
|
123
|
+
* @param {number} paletteCount
|
|
124
|
+
* @param {object} [opts]
|
|
125
|
+
* @param {boolean} [opts.preserveLightness=false] if true, distance uses (a, b) only
|
|
126
|
+
*/
|
|
127
|
+
export function nearestPaletteIndexBuffer(pixelsLab, paletteLab, indicesOut, pixelCount, paletteCount, opts) {
|
|
128
|
+
const preserveLightness = opts != null && opts.preserveLightness === true;
|
|
129
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
130
|
+
const pOff = i * 3;
|
|
131
|
+
const pL = pixelsLab[pOff];
|
|
132
|
+
const pA = pixelsLab[pOff + 1];
|
|
133
|
+
const pB = pixelsLab[pOff + 2];
|
|
134
|
+
let bestIdx = 0;
|
|
135
|
+
let bestD = Infinity;
|
|
136
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
137
|
+
const kOff = k * 3;
|
|
138
|
+
const dA = pA - paletteLab[kOff + 1];
|
|
139
|
+
const dB = pB - paletteLab[kOff + 2];
|
|
140
|
+
let d = dA * dA + dB * dB;
|
|
141
|
+
if (!preserveLightness) {
|
|
142
|
+
const dL = pL - paletteLab[kOff];
|
|
143
|
+
d += dL * dL;
|
|
144
|
+
}
|
|
145
|
+
if (d < bestD) {
|
|
146
|
+
bestD = d;
|
|
147
|
+
bestIdx = k;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
indicesOut[i] = bestIdx;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------------
|
|
156
|
+
// remapPixelsToPalette — one-shot end-to-end
|
|
157
|
+
// -----------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One-shot: read RGBA8 pixels, find nearest palette color per pixel, write
|
|
161
|
+
* RGBA-LE Uint32 result. Handles palette OKLCH→OKLab conversion internally.
|
|
162
|
+
* Alpha byte is passed through unchanged from input.
|
|
163
|
+
*
|
|
164
|
+
* Two code paths:
|
|
165
|
+
* - preserveLightness=false: pre-packs palette to U32 once, per-pixel is
|
|
166
|
+
* convert-search-gather. Fast enough for live drag at moderate image
|
|
167
|
+
* sizes (~500x500 comfortably at 60fps on modern V8).
|
|
168
|
+
* - preserveLightness=true: per-pixel synthesizes (pixel.L, palette.a,
|
|
169
|
+
* palette.b), converts back through OKLab → linear sRGB → gamma → pack.
|
|
170
|
+
* ~3x slower but produces shading-preserved output — the AI motif
|
|
171
|
+
* recolor look.
|
|
172
|
+
*
|
|
173
|
+
* @param {Uint8Array|Uint8ClampedArray} inU8 RGBA8 pixel buffer
|
|
174
|
+
* @param {Float32Array} paletteLch palette OKLCH, stride 3
|
|
175
|
+
* @param {Uint32Array} outU32 output Uint32 pixels, length ≥ pixelCount
|
|
176
|
+
* @param {number} pixelCount
|
|
177
|
+
* @param {number} paletteCount
|
|
178
|
+
* @param {object} [opts]
|
|
179
|
+
* @param {boolean} [opts.preserveLightness=false]
|
|
180
|
+
*/
|
|
181
|
+
export function remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts) {
|
|
182
|
+
const preserveLightness = opts != null && opts.preserveLightness === true;
|
|
183
|
+
|
|
184
|
+
// Grow scratch if needed. Monotonic — never shrinks.
|
|
185
|
+
const needLab = paletteCount * 3;
|
|
186
|
+
if (_paletteLab.length < needLab) {
|
|
187
|
+
_paletteLab = new Float32Array(needLab);
|
|
188
|
+
}
|
|
189
|
+
if (_paletteU32.length < paletteCount) {
|
|
190
|
+
_paletteU32 = new Uint32Array(paletteCount);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Palette OKLCH → OKLab + pre-pack to opaque U32 (alpha comes from input pixels).
|
|
194
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
195
|
+
const kOff = k * 3;
|
|
196
|
+
const L = paletteLch[kOff];
|
|
197
|
+
const C = paletteLch[kOff + 1];
|
|
198
|
+
const H = paletteLch[kOff + 2];
|
|
199
|
+
const hRad = H * _RAD;
|
|
200
|
+
const a = C * Math.cos(hRad);
|
|
201
|
+
const b = C * Math.sin(hRad);
|
|
202
|
+
_paletteLab[kOff] = L;
|
|
203
|
+
_paletteLab[kOff + 1] = a;
|
|
204
|
+
_paletteLab[kOff + 2] = b;
|
|
205
|
+
|
|
206
|
+
// Pack once (opaque; alpha comes from input pixels).
|
|
207
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
208
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
209
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
210
|
+
const l3 = l_ * l_ * l_;
|
|
211
|
+
const m3 = m_ * m_ * m_;
|
|
212
|
+
const s3 = s_ * s_ * s_;
|
|
213
|
+
const rL = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
|
|
214
|
+
const gL = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
|
|
215
|
+
const bL = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
|
|
216
|
+
const gR = _srgbGammaEncode(rL);
|
|
217
|
+
const gG = _srgbGammaEncode(gL);
|
|
218
|
+
const gB = _srgbGammaEncode(bL);
|
|
219
|
+
const R = (gR * 255 + 0.5) | 0;
|
|
220
|
+
const G = (gG * 255 + 0.5) | 0;
|
|
221
|
+
const B = (gB * 255 + 0.5) | 0;
|
|
222
|
+
_paletteU32[k] = (B << 16) | (G << 8) | R;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!preserveLightness) {
|
|
226
|
+
// Fast path: pre-packed gather.
|
|
227
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
228
|
+
const inOff = i * 4;
|
|
229
|
+
const r = _linearizeSrgbByte(inU8[inOff]);
|
|
230
|
+
const g = _linearizeSrgbByte(inU8[inOff + 1]);
|
|
231
|
+
const b = _linearizeSrgbByte(inU8[inOff + 2]);
|
|
232
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
233
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
234
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
235
|
+
const l_ = Math.cbrt(l);
|
|
236
|
+
const m_ = Math.cbrt(m);
|
|
237
|
+
const s_ = Math.cbrt(s);
|
|
238
|
+
const pL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
239
|
+
const pA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
240
|
+
const pB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
241
|
+
|
|
242
|
+
let bestIdx = 0;
|
|
243
|
+
let bestD = Infinity;
|
|
244
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
245
|
+
const kOff = k * 3;
|
|
246
|
+
const dL = pL - _paletteLab[kOff];
|
|
247
|
+
const dA = pA - _paletteLab[kOff + 1];
|
|
248
|
+
const dB = pB - _paletteLab[kOff + 2];
|
|
249
|
+
const d = dL * dL + dA * dA + dB * dB;
|
|
250
|
+
if (d < bestD) { bestD = d; bestIdx = k; }
|
|
251
|
+
}
|
|
252
|
+
const A = inU8[inOff + 3];
|
|
253
|
+
outU32[i] = (A << 24) | _paletteU32[bestIdx];
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Preserve-L path: search by (a, b), synthesize per pixel.
|
|
259
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
260
|
+
const inOff = i * 4;
|
|
261
|
+
const r = _linearizeSrgbByte(inU8[inOff]);
|
|
262
|
+
const g = _linearizeSrgbByte(inU8[inOff + 1]);
|
|
263
|
+
const b = _linearizeSrgbByte(inU8[inOff + 2]);
|
|
264
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
265
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
266
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
267
|
+
const l_ = Math.cbrt(l);
|
|
268
|
+
const m_ = Math.cbrt(m);
|
|
269
|
+
const s_ = Math.cbrt(s);
|
|
270
|
+
const pL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
271
|
+
const pA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
272
|
+
const pB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
273
|
+
|
|
274
|
+
let bestIdx = 0;
|
|
275
|
+
let bestD = Infinity;
|
|
276
|
+
for (let k = 0; k < paletteCount; k++) {
|
|
277
|
+
const kOff = k * 3;
|
|
278
|
+
const dA = pA - _paletteLab[kOff + 1];
|
|
279
|
+
const dB = pB - _paletteLab[kOff + 2];
|
|
280
|
+
const d = dA * dA + dB * dB;
|
|
281
|
+
if (d < bestD) { bestD = d; bestIdx = k; }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Synthesize (pixel L, palette a, palette b) → linear sRGB → gamma → pack.
|
|
285
|
+
const kOff = bestIdx * 3;
|
|
286
|
+
const nA = _paletteLab[kOff + 1];
|
|
287
|
+
const nB = _paletteLab[kOff + 2];
|
|
288
|
+
const nl_ = pL + 0.3963377774 * nA + 0.2158037573 * nB;
|
|
289
|
+
const nm_ = pL - 0.1055613458 * nA - 0.0638541728 * nB;
|
|
290
|
+
const ns_ = pL - 0.0894841775 * nA - 1.2914855480 * nB;
|
|
291
|
+
const nl = nl_ * nl_ * nl_;
|
|
292
|
+
const nm = nm_ * nm_ * nm_;
|
|
293
|
+
const ns = ns_ * ns_ * ns_;
|
|
294
|
+
const nr = 4.0767416621 * nl - 3.3077115913 * nm + 0.2309699292 * ns;
|
|
295
|
+
const ng = -1.2684380046 * nl + 2.6097574011 * nm - 0.3413193965 * ns;
|
|
296
|
+
const nb = -0.0041960863 * nl - 0.7034186147 * nm + 1.7076147010 * ns;
|
|
297
|
+
const gR = _srgbGammaEncode(nr);
|
|
298
|
+
const gG = _srgbGammaEncode(ng);
|
|
299
|
+
const gB = _srgbGammaEncode(nb);
|
|
300
|
+
const R = (gR * 255 + 0.5) | 0;
|
|
301
|
+
const G = (gG * 255 + 0.5) | 0;
|
|
302
|
+
const B = (gB * 255 + 0.5) | 0;
|
|
303
|
+
const A = inU8[inOff + 3];
|
|
304
|
+
outU32[i] = (A << 24) | (B << 16) | (G << 8) | R;
|
|
305
|
+
}
|
|
306
|
+
}
|
package/src/authoring.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sRgbToOklchBuffer } from './convert.js';
|
|
1
|
+
import { sRgbToOklchBuffer, displayP3ToOklchBuffer } from './convert.js';
|
|
2
2
|
|
|
3
3
|
const RAD_TO_DEG = 180 / Math.PI;
|
|
4
4
|
|
|
@@ -51,6 +51,8 @@ const hslToRgb = (h, s, l) => {
|
|
|
51
51
|
];
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
// (linearize lives in convert.js; the parsers below delegate color-space math there)
|
|
55
|
+
|
|
54
56
|
// ============================================================================
|
|
55
57
|
// 2. REGEX DICTIONARY (CSS Color Level 4 Permissive)
|
|
56
58
|
// ============================================================================
|
|
@@ -61,6 +63,7 @@ const OKLCH_REGEX = /^oklch\(\s*([\d.%]+)\s+([\d.%]+)\s+([\d.%a-z]+)(?:\s*\/\s*(
|
|
|
61
63
|
const OKLAB_REGEX = /^oklab\(\s*([\d.%]+)[,\s]+([\d.-]+)[,\s]+([\d.-]+)(?:[,\s/]+([\d.%]+))?\s*\)$/i;
|
|
62
64
|
const RGB_REGEX = /^rgba?\(\s*([\d.%]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
|
|
63
65
|
const HSL_REGEX = /^hsla?\(\s*([\d.%a-z]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
|
|
66
|
+
const DISPLAY_P3_REGEX = /^color\(\s*display-p3\s+([\d.%]+)\s+([\d.%]+)\s+([\d.%]+)(?:\s*\/\s*([\d.%]+))?\s*\)$/i;
|
|
64
67
|
|
|
65
68
|
// ============================================================================
|
|
66
69
|
// 3. NAMED COLORS FAST-PATH
|
|
@@ -77,7 +80,7 @@ const NAMED_COLORS = {
|
|
|
77
80
|
darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
|
|
78
81
|
darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
|
|
79
82
|
darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
|
|
80
|
-
darkslategray: '#2f4f4f',
|
|
83
|
+
darkslategray: '#2f4f4f', darkslateggrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
|
|
81
84
|
deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
|
|
82
85
|
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
|
|
83
86
|
fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
|
|
@@ -88,12 +91,12 @@ const NAMED_COLORS = {
|
|
|
88
91
|
lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
|
|
89
92
|
lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
|
|
90
93
|
lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
|
|
91
|
-
lightsteelblue: '#
|
|
94
|
+
lightsteelblue: '#b0e0e6', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
|
|
92
95
|
linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
|
|
93
96
|
mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
|
|
94
97
|
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585',
|
|
95
98
|
midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
|
|
96
|
-
|
|
99
|
+
navajawhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
|
|
97
100
|
olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6',
|
|
98
101
|
palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
|
|
99
102
|
papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb',
|
|
@@ -137,7 +140,7 @@ export const parseHexToBuffer = (str, outBuf, offset) => {
|
|
|
137
140
|
};
|
|
138
141
|
|
|
139
142
|
/**
|
|
140
|
-
* Parses a CSS `oklch()` string directly into an OKLCH triplet
|
|
143
|
+
* Parses a CSS `oklch()` string directly into an OKLCH triplet - no color-space
|
|
141
144
|
* conversion needed.
|
|
142
145
|
*
|
|
143
146
|
* Accepts the modern slash-alpha form: `oklch(60% 0.15 250 / 0.5)`. Hue may
|
|
@@ -222,6 +225,38 @@ export const parseHslToBuffer = (str, outBuf, offset) => {
|
|
|
222
225
|
return parseVal(match[4], 1) ?? 1.0;
|
|
223
226
|
};
|
|
224
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Parses a CSS `color(display-p3 r g b / alpha?)` string into an OKLCH triplet.
|
|
230
|
+
*
|
|
231
|
+
* Supports modern slash-alpha syntax and percentage components (0%-100%).
|
|
232
|
+
* Components are interpreted in the Display P3 color space (gamma-encoded,
|
|
233
|
+
* same transfer function as sRGB).
|
|
234
|
+
*
|
|
235
|
+
* This is the authoring-time entry point for wide-gamut colors. The resulting
|
|
236
|
+
* OKLCH may have higher chroma than sRGB-gamut colors.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} str - The `color(display-p3 ...)` string
|
|
239
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
240
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
241
|
+
* @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
|
|
242
|
+
* @throws If `str` does not match the expected syntax.
|
|
243
|
+
*/
|
|
244
|
+
export const parseDisplayP3ToBuffer = (str, outBuf, offset) => {
|
|
245
|
+
const match = str.match(DISPLAY_P3_REGEX);
|
|
246
|
+
if (!match) throw new Error(`lite-color-engine: Invalid display-p3 color "${str}"`);
|
|
247
|
+
|
|
248
|
+
// Parse components (0-1 range, with % support) then scale to byte range
|
|
249
|
+
// for consistency with displayP3ToOklchBuffer / sRgbToOklchBuffer
|
|
250
|
+
const r = parseVal(match[1], 1) * 255;
|
|
251
|
+
const g = parseVal(match[2], 1) * 255;
|
|
252
|
+
const b = parseVal(match[3], 1) * 255;
|
|
253
|
+
|
|
254
|
+
// Accurate Display P3 -> OKLCH path (Session 2)
|
|
255
|
+
displayP3ToOklchBuffer(r, g, b, outBuf, offset);
|
|
256
|
+
|
|
257
|
+
return parseVal(match[4], 1) ?? 1.0;
|
|
258
|
+
};
|
|
259
|
+
|
|
225
260
|
// ============================================================================
|
|
226
261
|
// 5. MASTER SWITCHBOARD
|
|
227
262
|
// ============================================================================
|
|
@@ -231,13 +266,16 @@ export const parseHslToBuffer = (str, outBuf, offset) => {
|
|
|
231
266
|
* format-specific parser and writes an OKLCH triplet.
|
|
232
267
|
*
|
|
233
268
|
* Supports: named colors, `#RGB[A]`, `#RRGGBB[AA]`, `rgb()` / `rgba()`,
|
|
234
|
-
* `hsl()` / `hsla()`, `oklch()`, `oklab()`.
|
|
269
|
+
* `hsl()` / `hsla()`, `oklch()`, `oklab()`, `color(display-p3 r g b / alpha?)`.
|
|
235
270
|
*
|
|
236
271
|
* Intended for the **authoring/init phase** of a render pipeline (level loads,
|
|
237
272
|
* theme parsing, gradient compilation). After that, work with the resulting
|
|
238
273
|
* `Float32Array` buffer directly via {@link lerpOklchBuffer} and
|
|
239
274
|
* {@link packOklchBufferToUint32}.
|
|
240
275
|
*
|
|
276
|
+
* `color(display-p3 ...)` is opt-in wide-gamut input. It never affects the
|
|
277
|
+
* default sRGB hot path.
|
|
278
|
+
*
|
|
241
279
|
* @param {string} str - The CSS color string
|
|
242
280
|
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
243
281
|
* @param {number} offset - Start index of the L, C, H triplet
|
|
@@ -257,6 +295,9 @@ export const parseCSSColor = (str, outBuf, offset) => {
|
|
|
257
295
|
if (cleanStr.startsWith('oklab')) return parseOklabToBuffer(cleanStr, outBuf, offset);
|
|
258
296
|
if (cleanStr.startsWith('rgb')) return parseRgbToBuffer(cleanStr, outBuf, offset);
|
|
259
297
|
if (cleanStr.startsWith('hsl')) return parseHslToBuffer(cleanStr, outBuf, offset);
|
|
298
|
+
if (cleanStr.startsWith('color(') && cleanStr.includes('display-p3')) {
|
|
299
|
+
return parseDisplayP3ToBuffer(cleanStr, outBuf, offset);
|
|
300
|
+
}
|
|
260
301
|
|
|
261
302
|
throw new Error(`lite-color-engine: Unsupported color format "${str}".`);
|
|
262
303
|
};
|
package/src/convert.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
const RAD_TO_DEG = 180 / Math.PI;
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* IEC 61966-2-1 sRGB linearization.
|
|
5
|
-
*
|
|
4
|
+
* IEC 61966-2-1 sRGB / Display-P3 linearization (EOTF inverse).
|
|
5
|
+
* Both color spaces use the same transfer function.
|
|
6
|
+
*
|
|
7
|
+
* @param {number} c - Normalized non-linear channel in [0, 1]
|
|
6
8
|
* @returns {number} Linear-light value in [0, 1]
|
|
7
9
|
* @internal
|
|
8
10
|
*/
|
|
9
|
-
const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
11
|
+
export const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Converts standard sRGB (0-255 byte channels) into a flat OKLCH buffer.
|
|
@@ -15,7 +17,7 @@ const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.05
|
|
|
15
17
|
* matrix multiply) and hard-clamps output lightness to [0, 1]. Hue is
|
|
16
18
|
* canonicalized to [0, 360).
|
|
17
19
|
*
|
|
18
|
-
* Algorithm:
|
|
20
|
+
* Algorithm: Bjorn Ottosson's OKLab (2020) - sRGB -> linear sRGB -> LMS ->
|
|
19
21
|
* non-linear LMS (cube root) -> OKLab -> polar OKLCH.
|
|
20
22
|
*
|
|
21
23
|
* @param {number} r - Red channel [0, 255]
|
|
@@ -31,7 +33,7 @@ export const sRgbToOklchBuffer = (r, g, b, outBuf, outOffset) => {
|
|
|
31
33
|
const g_lin = linearize(g / 255);
|
|
32
34
|
const b_lin = linearize(b / 255);
|
|
33
35
|
|
|
34
|
-
// 2. Linear sRGB -> linear LMS.
|
|
36
|
+
// 2. Linear sRGB -> linear LMS (combined matrix from OKLab reference).
|
|
35
37
|
const lms_l = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin;
|
|
36
38
|
const lms_m = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin;
|
|
37
39
|
const lms_s = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin;
|
|
@@ -56,3 +58,123 @@ export const sRgbToOklchBuffer = (r, g, b, outBuf, outOffset) => {
|
|
|
56
58
|
outBuf[outOffset + 1] = chroma;
|
|
57
59
|
outBuf[outOffset + 2] = hue;
|
|
58
60
|
};
|
|
61
|
+
|
|
62
|
+
// -----------------------------------------------------------------------------
|
|
63
|
+
// Display P3 support (Session 2)
|
|
64
|
+
// High-precision matrices sourced from CSS Color Module Level 4 + OKLab reference
|
|
65
|
+
// implementation by Bjorn Ottosson. Combined for minimal code size and speed.
|
|
66
|
+
// -----------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* High-precision linear Display P3 (D65) -> XYZ (D65)
|
|
70
|
+
*/
|
|
71
|
+
const P3_TO_XYZ = [
|
|
72
|
+
[0.4865709486482162, 0.26566769316909306, 0.1982172852343625],
|
|
73
|
+
[0.2289745640697488, 0.6917385218365062, 0.079286914093745],
|
|
74
|
+
[0.0000000000000000, 0.0451133816152514, 1.043944368900976]
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* High-precision XYZ (D65) -> LMS (OKLab cone fundamentals)
|
|
79
|
+
*/
|
|
80
|
+
const XYZ_TO_LMS = [
|
|
81
|
+
[ 0.8190224379967030, 0.3619062600528904, -0.1288737815209879],
|
|
82
|
+
[ 0.0329836671980271, 0.9292868615863434, 0.0361446681699989],
|
|
83
|
+
[ 0.0481772077560169, 0.2642395317527308, 0.6335478284694309]
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Converts Display P3 (0-255 byte channels) into a flat OKLCH buffer.
|
|
88
|
+
*
|
|
89
|
+
* Uses the same OKLab pipeline as sRGB but with Display P3 primaries.
|
|
90
|
+
* This allows correct representation of colors outside the sRGB gamut
|
|
91
|
+
* (higher possible chroma values).
|
|
92
|
+
*
|
|
93
|
+
* Defends against negative cube roots and clamps lightness to [0, 1].
|
|
94
|
+
*
|
|
95
|
+
* @param {number} r - Red channel in Display P3 [0, 255]
|
|
96
|
+
* @param {number} g - Green channel in Display P3 [0, 255]
|
|
97
|
+
* @param {number} b - Blue channel in Display P3 [0, 255]
|
|
98
|
+
* @param {Float32Array} outBuf - Destination buffer
|
|
99
|
+
* @param {number} outOffset - Start index for the L, C, H triplet
|
|
100
|
+
* @returns {void}
|
|
101
|
+
*/
|
|
102
|
+
export const displayP3ToOklchBuffer = (r, g, b, outBuf, outOffset) => {
|
|
103
|
+
// 1. Normalize and linearize (same EOTF as sRGB)
|
|
104
|
+
const r_lin = linearize(r / 255);
|
|
105
|
+
const g_lin = linearize(g / 255);
|
|
106
|
+
const b_lin = linearize(b / 255);
|
|
107
|
+
|
|
108
|
+
// 2. Linear Display P3 -> XYZ
|
|
109
|
+
const x = P3_TO_XYZ[0][0] * r_lin + P3_TO_XYZ[0][1] * g_lin + P3_TO_XYZ[0][2] * b_lin;
|
|
110
|
+
const y = P3_TO_XYZ[1][0] * r_lin + P3_TO_XYZ[1][1] * g_lin + P3_TO_XYZ[1][2] * b_lin;
|
|
111
|
+
const z = P3_TO_XYZ[2][0] * r_lin + P3_TO_XYZ[2][1] * g_lin + P3_TO_XYZ[2][2] * b_lin;
|
|
112
|
+
|
|
113
|
+
// 3. XYZ -> linear LMS (OKLab)
|
|
114
|
+
const lms_l = XYZ_TO_LMS[0][0] * x + XYZ_TO_LMS[0][1] * y + XYZ_TO_LMS[0][2] * z;
|
|
115
|
+
const lms_m = XYZ_TO_LMS[1][0] * x + XYZ_TO_LMS[1][1] * y + XYZ_TO_LMS[1][2] * z;
|
|
116
|
+
const lms_s = XYZ_TO_LMS[2][0] * x + XYZ_TO_LMS[2][1] * y + XYZ_TO_LMS[2][2] * z;
|
|
117
|
+
|
|
118
|
+
// 4. Non-linear LMS (cube root) - same defense as sRGB path
|
|
119
|
+
const l_ = Math.cbrt(lms_l < 0 ? 0 : lms_l);
|
|
120
|
+
const m_ = Math.cbrt(lms_m < 0 ? 0 : lms_m);
|
|
121
|
+
const s_ = Math.cbrt(lms_s < 0 ? 0 : lms_s);
|
|
122
|
+
|
|
123
|
+
// 5. Non-linear LMS -> OKLab (identical coefficients to sRGB path)
|
|
124
|
+
const lab_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
125
|
+
const lab_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
126
|
+
const lab_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
127
|
+
|
|
128
|
+
// 6. OKLab -> OKLCH (polar) - identical to sRGB path
|
|
129
|
+
const chroma = Math.sqrt(lab_a * lab_a + lab_b * lab_b);
|
|
130
|
+
let hue = Math.atan2(lab_b, lab_a) * RAD_TO_DEG;
|
|
131
|
+
if (hue < 0) hue += 360;
|
|
132
|
+
|
|
133
|
+
// 7. Write with lightness clamp
|
|
134
|
+
outBuf[outOffset] = lab_l < 0 ? 0 : (lab_l > 1 ? 1 : lab_l);
|
|
135
|
+
outBuf[outOffset + 1] = chroma;
|
|
136
|
+
outBuf[outOffset + 2] = hue;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Converts an OKLCH triplet to linear-light Display P3 RGB.
|
|
141
|
+
* Writes [r, g, b] linear values (can be outside [0,1] for out-of-gamut colors)
|
|
142
|
+
* into the provided `out` Float32Array (length >= 3).
|
|
143
|
+
*
|
|
144
|
+
* This is the inverse of the forward path above. Used by the P3 packers and
|
|
145
|
+
* available for custom P3 gamut-mapping. Output channels may fall outside
|
|
146
|
+
* [0, 1] for colors beyond the P3 gamut; clamp or map before encoding.
|
|
147
|
+
*
|
|
148
|
+
* @param {number} L - Lightness [0, 1]
|
|
149
|
+
* @param {number} C - Chroma [0, +inf)
|
|
150
|
+
* @param {number} H - Hue [0, 360)
|
|
151
|
+
* @param {Float32Array} out - Destination (length >= 3); receives linear [r, g, b]
|
|
152
|
+
* @returns {void}
|
|
153
|
+
*/
|
|
154
|
+
export const oklchToLinearP3 = (L, C, H, out) => {
|
|
155
|
+
const hRad = (H * Math.PI) / 180;
|
|
156
|
+
const a = C * Math.cos(hRad);
|
|
157
|
+
const b = C * Math.sin(hRad);
|
|
158
|
+
|
|
159
|
+
// OKLab -> non-linear LMS (inverse of the OKLab matrix)
|
|
160
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
161
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
162
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
163
|
+
|
|
164
|
+
// Non-linear LMS -> linear LMS (cube)
|
|
165
|
+
const lms_l = l_ * l_ * l_;
|
|
166
|
+
const lms_m = m_ * m_ * m_;
|
|
167
|
+
const lms_s = s_ * s_ * s_;
|
|
168
|
+
|
|
169
|
+
// Linear LMS -> XYZ (inverse of XYZ_TO_LMS)
|
|
170
|
+
// Using the analytical inverse for precision
|
|
171
|
+
const x = 1.2268798758459243 * lms_l - 0.5578149944602171 * lms_m + 0.2813910456659647 * lms_s;
|
|
172
|
+
const y = -0.0405757452148008 * lms_l + 1.1122868032803170 * lms_m - 0.0717110580655164 * lms_s;
|
|
173
|
+
const z = -0.0763729366746601 * lms_l - 0.4214933324022432 * lms_m + 1.5869240198367816 * lms_s;
|
|
174
|
+
|
|
175
|
+
// XYZ -> linear Display P3 (inverse of P3_TO_XYZ)
|
|
176
|
+
// Using the analytical inverse matrix
|
|
177
|
+
out[0] = 2.4934969119414263 * x - 0.9313836179191239 * y - 0.4027107844507168 * z;
|
|
178
|
+
out[1] = -0.8294889695615749 * x + 1.7626640603183463 * y + 0.0236246858419436 * z;
|
|
179
|
+
out[2] = 0.0358458302437845 * x - 0.0761723892680418 * y + 0.9568845240076702 * z;
|
|
180
|
+
};
|
package/src/delta.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const _RAD = Math.PI / 180;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ΔE-OK color difference: Euclidean distance in OKLab between two buffered
|
|
5
|
+
* OKLCH colors. Zero-alloc, primitive math only.
|
|
6
|
+
*
|
|
7
|
+
* Typical scale: 0.00–0.02 indistinguishable, 0.02–0.05 subtle,
|
|
8
|
+
* 0.05–0.15 clearly different, 0.15+ unambiguously different.
|
|
9
|
+
*
|
|
10
|
+
* Use for: palette dedupe, contrast checks, "is this close enough"
|
|
11
|
+
* assertions, nearest-color lookup, and the CVD-audit workflow in
|
|
12
|
+
* lite-hueforge/colorways.
|
|
13
|
+
*
|
|
14
|
+
* @param {Float32Array} bufA
|
|
15
|
+
* @param {number} offsetA
|
|
16
|
+
* @param {Float32Array} bufB
|
|
17
|
+
* @param {number} offsetB
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export const deltaEOK = (bufA, offsetA, bufB, offsetB) => {
|
|
21
|
+
const La = bufA[offsetA];
|
|
22
|
+
const Ca = bufA[offsetA + 1];
|
|
23
|
+
const Ha = bufA[offsetA + 2];
|
|
24
|
+
const Lb = bufB[offsetB];
|
|
25
|
+
const Cb = bufB[offsetB + 1];
|
|
26
|
+
const Hb = bufB[offsetB + 2];
|
|
27
|
+
const hARad = Ha * _RAD;
|
|
28
|
+
const hBRad = Hb * _RAD;
|
|
29
|
+
const aA = Ca * Math.cos(hARad);
|
|
30
|
+
const bA = Ca * Math.sin(hARad);
|
|
31
|
+
const aB = Cb * Math.cos(hBRad);
|
|
32
|
+
const bB = Cb * Math.sin(hBRad);
|
|
33
|
+
const dL = La - Lb;
|
|
34
|
+
const dA = aA - aB;
|
|
35
|
+
const dB = bA - bB;
|
|
36
|
+
return Math.sqrt(dL * dL + dA * dA + dB * dB);
|
|
37
|
+
};
|