saturon 0.2.3 → 0.3.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/LICENSE +1 -1
- package/README.md +5 -4
- package/dist/Color.d.ts +50 -47
- package/dist/Color.js +122 -229
- package/dist/converters.d.ts +35 -141
- package/dist/converters.js +8 -422
- package/dist/engine.d.ts +148 -0
- package/dist/engine.js +1900 -0
- package/dist/index.umd.js +1774 -1300
- package/dist/index.umd.min.js +143 -1
- package/dist/math.d.ts +2 -12
- package/dist/math.js +1 -11
- package/dist/tests/Color.test.d.ts +17 -0
- package/dist/tests/Color.test.js +1062 -0
- package/dist/tests/wpt.test.js +2129 -0
- package/dist/types.d.ts +62 -72
- package/dist/utils.d.ts +10 -141
- package/dist/utils.js +108 -1052
- package/package.json +10 -11
- package/dist/temp.js +0 -3
- /package/dist/{temp.d.ts → tests/wpt.test.d.ts} +0 -0
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Color } from "./Color.js";
|
|
2
|
-
import { config
|
|
3
|
-
import {
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
import { colorModels } from "./converters.js";
|
|
4
4
|
import { fitMethods } from "./math.js";
|
|
5
5
|
/** Global cache for internal Color operations. */
|
|
6
6
|
export const cache = new Map();
|
|
@@ -76,378 +76,22 @@ export function use(...pluginFns) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
const getterRegistry = {
|
|
80
|
-
"color-types": () => Object.keys(colorTypes),
|
|
81
|
-
"color-bases": () => Object.keys(colorBases),
|
|
82
|
-
"color-functions": () => Object.keys(colorFunctions),
|
|
83
|
-
"color-models": () => Object.keys(colorModels),
|
|
84
|
-
"color-spaces": () => Object.keys(colorSpaces),
|
|
85
|
-
"named-colors": () => Object.keys(namedColors),
|
|
86
|
-
"system-colors": () => Object.keys(systemColors),
|
|
87
|
-
"output-types": () => {
|
|
88
|
-
return Object.keys(colorTypes).filter((key) => {
|
|
89
|
-
const type = colorTypes[key];
|
|
90
|
-
return (typeof type["fromBridge"] === "function" &&
|
|
91
|
-
typeof type["format"] === "function");
|
|
92
|
-
});
|
|
93
|
-
},
|
|
94
|
-
plugins: () => Object.keys(plugins),
|
|
95
|
-
"fit-methods": () => ["none", "clip", ...Object.keys(fitMethods)],
|
|
96
|
-
};
|
|
97
|
-
/**
|
|
98
|
-
* Retrieve a list of registered items of a specified type.
|
|
99
|
-
*
|
|
100
|
-
* @param type - The getter type, e.g. "color-types".
|
|
101
|
-
* @returns The array returned by the getter.
|
|
102
|
-
*/
|
|
103
|
-
export function get(type) {
|
|
104
|
-
const fn = getterRegistry[type];
|
|
105
|
-
return fn();
|
|
106
|
-
}
|
|
107
|
-
const converterRegistry = {
|
|
108
|
-
/* eslint-disable no-unused-vars */
|
|
109
|
-
"color-type": { fn: registerColorType },
|
|
110
|
-
"color-base": { fn: registerColorBase },
|
|
111
|
-
"color-function": { fn: registerColorFunction },
|
|
112
|
-
"color-space": { fn: registerColorSpace },
|
|
113
|
-
"named-color": { fn: registerNamedColor },
|
|
114
|
-
"fit-method": { fn: registerFitMethod },
|
|
115
|
-
/* eslint-enable no-unused-vars */
|
|
116
|
-
};
|
|
117
|
-
/**
|
|
118
|
-
* Bulk register multiple converters of a specified type.
|
|
119
|
-
*
|
|
120
|
-
* @param type - The converter type, e.g. "color-function".
|
|
121
|
-
* @param entries - Array of { name, value } objects.
|
|
122
|
-
*/
|
|
123
|
-
export function register(type, entries) {
|
|
124
|
-
const fn = converterRegistry[type].fn;
|
|
125
|
-
for (const entry of entries)
|
|
126
|
-
fn(entry.name, entry.value); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Registers a new `<color>` converter under the specified name.
|
|
130
|
-
*
|
|
131
|
-
* @param name - Unique name for the `<color>` converter.
|
|
132
|
-
* @param converter - Converter implementing color conversion logic.
|
|
133
|
-
* @throws If the name is already used or the converter is invalid.
|
|
134
|
-
*/
|
|
135
|
-
export function registerColorType(name, converter) {
|
|
136
|
-
const n = name.replace(/(?:\s+)/g, "-").toLowerCase();
|
|
137
|
-
const types = colorTypes;
|
|
138
|
-
if (n in types) {
|
|
139
|
-
throw new Error(`The name '${n}' is already used.`);
|
|
140
|
-
}
|
|
141
|
-
if (typeof converter !== "object" || converter === null) {
|
|
142
|
-
throw new TypeError("Converter must be a non-null object.");
|
|
143
|
-
}
|
|
144
|
-
const requiredFns = [
|
|
145
|
-
["isValid", "function"],
|
|
146
|
-
["bridge", "string"],
|
|
147
|
-
["toBridge", "function"],
|
|
148
|
-
["parse", "function"],
|
|
149
|
-
];
|
|
150
|
-
for (const [key, type] of requiredFns) {
|
|
151
|
-
if (typeof converter[key] !== type) {
|
|
152
|
-
throw new TypeError(`Converter.${String(key)} must be a ${type}.`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const hasFromBridge = "fromBridge" in converter;
|
|
156
|
-
const hasFormat = "format" in converter;
|
|
157
|
-
if (hasFromBridge && typeof converter.fromBridge !== "function") {
|
|
158
|
-
throw new TypeError("Converter.fromBridge must be a function if provided.");
|
|
159
|
-
}
|
|
160
|
-
if (hasFormat && typeof converter.format !== "function") {
|
|
161
|
-
throw new TypeError("Converter.format must be a function if provided.");
|
|
162
|
-
}
|
|
163
|
-
if (hasFromBridge !== hasFormat) {
|
|
164
|
-
throw new Error("Converter.fromBridge and Converter.format must either both be provided or both be omitted.");
|
|
165
|
-
}
|
|
166
|
-
if (converter.bridge in colorModels === false) {
|
|
167
|
-
throw new Error(`Converter.bridge '${converter.bridge}' does not correspond to any registered color model.`);
|
|
168
|
-
}
|
|
169
|
-
types[n] = converter;
|
|
170
|
-
cache.delete("graph");
|
|
171
|
-
cache.delete("paths");
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Registers a new `<color-base>` converter under the specified name.
|
|
175
|
-
*
|
|
176
|
-
* @param name - The unique name for the `<color-base>` converter.
|
|
177
|
-
* @param converter - Converter implementing color conversion logic.
|
|
178
|
-
* @throws If the name is already used or the converter is invalid.
|
|
179
|
-
*/
|
|
180
|
-
export function registerColorBase(name, converter) {
|
|
181
|
-
const n = name.replace(/(?:\s+)/g, "-").toLowerCase();
|
|
182
|
-
const bases = colorBases;
|
|
183
|
-
const types = colorTypes;
|
|
184
|
-
if (n in types) {
|
|
185
|
-
throw new Error(`The name '${n}' is already used.`);
|
|
186
|
-
}
|
|
187
|
-
if (typeof converter !== "object" || converter === null) {
|
|
188
|
-
throw new TypeError("Converter must be a non-null object.");
|
|
189
|
-
}
|
|
190
|
-
const requiredFns = [
|
|
191
|
-
["isValid", "function"],
|
|
192
|
-
["bridge", "string"],
|
|
193
|
-
["toBridge", "function"],
|
|
194
|
-
["parse", "function"],
|
|
195
|
-
];
|
|
196
|
-
for (const [key, type] of requiredFns) {
|
|
197
|
-
if (typeof converter[key] !== type) {
|
|
198
|
-
throw new TypeError(`Converter.${String(key)} must be a ${type}.`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const hasFromBridge = "fromBridge" in converter;
|
|
202
|
-
const hasFormat = "format" in converter;
|
|
203
|
-
if (hasFromBridge && typeof converter.fromBridge !== "function") {
|
|
204
|
-
throw new TypeError("Converter.fromBridge must be a function if provided.");
|
|
205
|
-
}
|
|
206
|
-
if (hasFormat && typeof converter.format !== "function") {
|
|
207
|
-
throw new TypeError("Converter.format must be a function if provided.");
|
|
208
|
-
}
|
|
209
|
-
if (hasFromBridge !== hasFormat) {
|
|
210
|
-
throw new Error("Converter.fromBridge and Converter.format must either both be provided or both be omitted.");
|
|
211
|
-
}
|
|
212
|
-
if (converter.bridge in colorModels === false) {
|
|
213
|
-
throw new Error(`Converter.bridge '${converter.bridge}' does not correspond to any registered color model.`);
|
|
214
|
-
}
|
|
215
|
-
bases[n] = converter;
|
|
216
|
-
types[n] = converter;
|
|
217
|
-
cache.delete("graph");
|
|
218
|
-
cache.delete("paths");
|
|
219
|
-
}
|
|
220
|
-
/**
|
|
221
|
-
* Registers a new `<color-function>` converter under the specified name.
|
|
222
|
-
*
|
|
223
|
-
* @param name - The unique name for the `<color-function>` converter.
|
|
224
|
-
* @param converter - Converter implementing color conversion logic.
|
|
225
|
-
* @throws If the name is already used or the converter is invalid.
|
|
226
|
-
*/
|
|
227
|
-
export function registerColorFunction(name, converter) {
|
|
228
|
-
const n = name.replace(/(?:\s+)/g, "").toLowerCase();
|
|
229
|
-
const models = colorModels;
|
|
230
|
-
const functions = colorFunctions;
|
|
231
|
-
const types = colorTypes;
|
|
232
|
-
if (typeof converter.components === "object" &&
|
|
233
|
-
converter.components !== null &&
|
|
234
|
-
!Array.isArray(converter.components)) {
|
|
235
|
-
const normalized = {};
|
|
236
|
-
for (const key of Object.keys(converter.components)) {
|
|
237
|
-
normalized[key.toLowerCase()] = converter.components[key];
|
|
238
|
-
}
|
|
239
|
-
converter.components = normalized;
|
|
240
|
-
}
|
|
241
|
-
else {
|
|
242
|
-
throw new TypeError("Converter.components must be a non-null object.");
|
|
243
|
-
}
|
|
244
|
-
if (n in types) {
|
|
245
|
-
throw new Error(`The name '${n}' is already used.`);
|
|
246
|
-
}
|
|
247
|
-
if (typeof converter !== "object" || converter === null) {
|
|
248
|
-
throw new TypeError("Converter must be a non-null object.");
|
|
249
|
-
}
|
|
250
|
-
const requiredFns = [
|
|
251
|
-
["bridge", "string"],
|
|
252
|
-
["toBridge", "function"],
|
|
253
|
-
["fromBridge", "function"],
|
|
254
|
-
];
|
|
255
|
-
for (const [key, type] of requiredFns) {
|
|
256
|
-
if (typeof converter[key] !== type) {
|
|
257
|
-
throw new TypeError(`Converter.${String(key)} must be a ${type}.`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
if (converter.bridge in colorModels === false) {
|
|
261
|
-
throw new Error(`Converter.bridge '${converter.bridge}' does not correspond to any registered color model.`);
|
|
262
|
-
}
|
|
263
|
-
if ("targetGamut" in converter && converter.targetGamut !== null && typeof converter.targetGamut !== "string") {
|
|
264
|
-
throw new TypeError(`Converter.targetGamut must be a string or null.`);
|
|
265
|
-
}
|
|
266
|
-
if ("supportsLegacy" in converter && typeof converter.supportsLegacy !== "boolean") {
|
|
267
|
-
throw new TypeError(`Converter.supportsLegacy must be a boolean.`);
|
|
268
|
-
}
|
|
269
|
-
if ("alphaVariant" in converter && typeof converter.alphaVariant !== "string") {
|
|
270
|
-
throw new TypeError(`Converter.alphaVariant must be a string.`);
|
|
271
|
-
}
|
|
272
|
-
const componentNames = Object.keys(converter.components);
|
|
273
|
-
if (new Set(componentNames).size !== componentNames.length) {
|
|
274
|
-
throw new Error("Converter.components must have unique component names.");
|
|
275
|
-
}
|
|
276
|
-
if (componentNames.includes("none")) {
|
|
277
|
-
throw new Error('Converter.components cannot have a component named "none".');
|
|
278
|
-
}
|
|
279
|
-
const colorConv = modelConverterToColorConverter(n, converter);
|
|
280
|
-
models[n] = converter;
|
|
281
|
-
functions[n] = colorConv;
|
|
282
|
-
types[n] = colorConv;
|
|
283
|
-
cache.delete("graph");
|
|
284
|
-
cache.delete("paths");
|
|
285
|
-
}
|
|
286
|
-
/**
|
|
287
|
-
* Registers a new color space converter for `<color()>` function under the specified name.
|
|
288
|
-
*
|
|
289
|
-
* @param name - The unique name for the color space converter.
|
|
290
|
-
* @param converter - Converter implementing color space conversion logic.
|
|
291
|
-
* @throws If the name is already used or the converter is invalid.
|
|
292
|
-
*/
|
|
293
|
-
export function registerColorSpace(name, converter) {
|
|
294
|
-
const n = name.replace(/(?:\s+)/g, "-").toLowerCase();
|
|
295
|
-
const spaces = colorSpaces;
|
|
296
|
-
const models = colorModels;
|
|
297
|
-
const functions = colorFunctions;
|
|
298
|
-
const types = colorTypes;
|
|
299
|
-
if (n in types) {
|
|
300
|
-
throw new Error(`The name '${n}' is already used.`);
|
|
301
|
-
}
|
|
302
|
-
if (typeof converter !== "object" || converter === null) {
|
|
303
|
-
throw new TypeError("Converter must be a non-null object.");
|
|
304
|
-
}
|
|
305
|
-
if (!Array.isArray(converter.components) || converter.components.some((c) => typeof c !== "string")) {
|
|
306
|
-
throw new TypeError("Converter.components must be an array of strings.");
|
|
307
|
-
}
|
|
308
|
-
if (typeof converter.bridge !== "string") {
|
|
309
|
-
throw new TypeError("Converter.bridge must be a string.");
|
|
310
|
-
}
|
|
311
|
-
const matrixChecks = [
|
|
312
|
-
["toBridgeMatrix", "toBridgeMatrix"],
|
|
313
|
-
["fromBridgeMatrix", "fromBridgeMatrix"],
|
|
314
|
-
];
|
|
315
|
-
for (const [key, label] of matrixChecks) {
|
|
316
|
-
const matrix = converter[key];
|
|
317
|
-
if (!Array.isArray(matrix) ||
|
|
318
|
-
matrix.some((row) => !Array.isArray(row) || row.some((val) => typeof val !== "number"))) {
|
|
319
|
-
throw new TypeError(`Converter.${label} must be a 2D array of numbers.`);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
if (converter.bridge in colorModels === false) {
|
|
323
|
-
throw new Error(`Converter.bridge '${converter.bridge}' does not correspond to any registered color model.`);
|
|
324
|
-
}
|
|
325
|
-
if ("targetGamut" in converter && converter.targetGamut !== null) {
|
|
326
|
-
throw new TypeError("Converter.targetGamut must be null if provided.");
|
|
327
|
-
}
|
|
328
|
-
if ("toLinear" in converter && typeof converter.toLinear !== "function") {
|
|
329
|
-
throw new TypeError("Converter.toLinear must be a function if provided.");
|
|
330
|
-
}
|
|
331
|
-
if ("fromLinear" in converter && typeof converter.fromLinear !== "function") {
|
|
332
|
-
throw new TypeError("Converter.fromLinear must be a function if provided.");
|
|
333
|
-
}
|
|
334
|
-
const modelConv = spaceConverterToModelConverter(n, converter);
|
|
335
|
-
const colorConv = modelConverterToColorConverter(n, modelConv);
|
|
336
|
-
spaces[n] = modelConv;
|
|
337
|
-
models[n] = modelConv;
|
|
338
|
-
functions[n] = colorConv;
|
|
339
|
-
types[n] = colorConv;
|
|
340
|
-
cache.delete("graph");
|
|
341
|
-
cache.delete("paths");
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Registers a new `<named-color>` with the specified RGB value.
|
|
345
|
-
*
|
|
346
|
-
* @param name - Color name (letters only, case-insensitive).
|
|
347
|
-
* @param rgb - RGB array [r, g, b].
|
|
348
|
-
* @throws If RGB is invalid, or the name or value is already registered.
|
|
349
|
-
*/
|
|
350
|
-
export function registerNamedColor(name, rgb) {
|
|
351
|
-
if (!Array.isArray(rgb) || rgb.length !== 3) {
|
|
352
|
-
throw new Error(`RGB value must be an array of exactly three numbers, received length ${rgb.length}.`);
|
|
353
|
-
}
|
|
354
|
-
const n = name.replace(/[^a-zA-Z]/g, "").toLowerCase();
|
|
355
|
-
const names = namedColors;
|
|
356
|
-
if (names[n]) {
|
|
357
|
-
throw new Error(`<named-color> '${n}' is already registered.`);
|
|
358
|
-
}
|
|
359
|
-
const duplicate = Object.entries(names).find(([, value]) => value.every((channel, i) => channel === rgb[i]));
|
|
360
|
-
if (duplicate) {
|
|
361
|
-
throw new Error(`RGB value [${rgb.join(", ")}] is already registered as '${duplicate[0]}'.`);
|
|
362
|
-
}
|
|
363
|
-
names[n] = rgb;
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* Registers a new fit method under a specified name.
|
|
367
|
-
*
|
|
368
|
-
* @param name - Name for the fit method (whitespace → hyphens, lowercased).
|
|
369
|
-
* @param method - The fit function.
|
|
370
|
-
* @throws If name exists or method is not a function.
|
|
371
|
-
*/
|
|
372
|
-
export function registerFitMethod(name, method) {
|
|
373
|
-
const n = name.trim().replace(/\s+/g, "-").toLowerCase();
|
|
374
|
-
const methods = fitMethods;
|
|
375
|
-
if (n in methods)
|
|
376
|
-
throw new Error(`Fit method '${n}' already exists.`);
|
|
377
|
-
if (typeof method !== "function")
|
|
378
|
-
throw new TypeError("Fit method must be a function.");
|
|
379
|
-
methods[n] = method;
|
|
380
|
-
}
|
|
381
79
|
/**
|
|
382
|
-
*
|
|
80
|
+
* Normalizes a component value to a valid range based on the component definition.
|
|
383
81
|
*
|
|
384
|
-
* @param
|
|
82
|
+
* @param component - The numeric component value to normalize
|
|
83
|
+
* @param value - The component definition that specifies the valid range (either a [min, max] tuple, "hue" for [0, 360], or a default [0, 100] range)
|
|
84
|
+
* @returns The normalized component value, clamped to the valid range or 0/max/min for NaN/Infinity cases
|
|
385
85
|
*/
|
|
386
|
-
export function
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
cache.delete("paths");
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* Cleans and normalizes a CSS color string.
|
|
399
|
-
*
|
|
400
|
-
* @param color - The CSS color string.
|
|
401
|
-
* @returns The normalized string.
|
|
402
|
-
*/
|
|
403
|
-
export function clean(color) {
|
|
404
|
-
return color
|
|
405
|
-
.trim()
|
|
406
|
-
.replace(/\s+/g, " ")
|
|
407
|
-
.replace(/\( /g, "(")
|
|
408
|
-
.replace(/ \)/g, ")")
|
|
409
|
-
.replace(/\s*,\s*/g, ", ")
|
|
410
|
-
.replace(/ ,/g, ",")
|
|
411
|
-
.replace(/calc\(NaN\)/g, "0")
|
|
412
|
-
.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Extracts a balanced expression from a string starting at a given index.
|
|
416
|
-
*
|
|
417
|
-
* - If the character at `start` is '(', extracts the full parenthetical expression (including nested parentheses).
|
|
418
|
-
* - Otherwise, extracts a contiguous sequence of alphanumeric characters, hyphens, percent signs, or '#'.
|
|
419
|
-
*
|
|
420
|
-
* @param input - The string to extract from.
|
|
421
|
-
* @param start - Index to start extraction.
|
|
422
|
-
* @returns An object with the extracted `expression` and the index `end` after it.
|
|
423
|
-
*/
|
|
424
|
-
export function extractBalancedExpression(input, start) {
|
|
425
|
-
let i = start;
|
|
426
|
-
let expression = "";
|
|
427
|
-
let depth = 0;
|
|
428
|
-
if (input[i] !== "(") {
|
|
429
|
-
while (i < input.length && /[a-zA-Z0-9-%#]/.test(input[i])) {
|
|
430
|
-
expression += input[i];
|
|
431
|
-
i++;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
if (input[i] === "(") {
|
|
435
|
-
expression += "(";
|
|
436
|
-
i++;
|
|
437
|
-
depth = 1;
|
|
438
|
-
while (i < input.length && depth > 0) {
|
|
439
|
-
const char = input[i];
|
|
440
|
-
if (char === "(")
|
|
441
|
-
depth++;
|
|
442
|
-
else if (char === ")")
|
|
443
|
-
depth--;
|
|
444
|
-
if (depth > 0)
|
|
445
|
-
expression += char;
|
|
446
|
-
i++;
|
|
447
|
-
}
|
|
448
|
-
expression += ")";
|
|
449
|
-
}
|
|
450
|
-
return { expression, end: i };
|
|
86
|
+
export function normalize(component, value) {
|
|
87
|
+
const [min, max] = Array.isArray(value) ? value : value === "hue" ? [0, 360] : [0, 100];
|
|
88
|
+
if (Number.isNaN(component))
|
|
89
|
+
return 0;
|
|
90
|
+
if (component === Infinity)
|
|
91
|
+
return max;
|
|
92
|
+
if (component === -Infinity)
|
|
93
|
+
return min;
|
|
94
|
+
return typeof component === "number" ? component : 0;
|
|
451
95
|
}
|
|
452
96
|
/**
|
|
453
97
|
* Fits or clips color coordinates to a specified model and gamut.
|
|
@@ -461,14 +105,13 @@ export function extractBalancedExpression(input, start) {
|
|
|
461
105
|
export function fit(coords, model, options = {}) {
|
|
462
106
|
const { method = config.defaults.fit, precision } = options;
|
|
463
107
|
const { components } = colorModels[model];
|
|
464
|
-
const
|
|
108
|
+
const defs = Object.values(components).reduce((arr, props) => ((arr[props.index] = props), arr), []);
|
|
465
109
|
let clipped;
|
|
466
|
-
if (method === "none")
|
|
110
|
+
if (method === "none")
|
|
467
111
|
clipped = coords;
|
|
468
|
-
}
|
|
469
112
|
else if (method === "clip") {
|
|
470
113
|
clipped = coords.map((v, i) => {
|
|
471
|
-
const prop =
|
|
114
|
+
const prop = defs[i];
|
|
472
115
|
if (!prop)
|
|
473
116
|
throw new Error(`Missing component properties for index ${i}.`);
|
|
474
117
|
if (prop.value === "hue")
|
|
@@ -489,687 +132,12 @@ export function fit(coords, model, options = {}) {
|
|
|
489
132
|
if (typeof precision === "number" || precision === null)
|
|
490
133
|
p = precision;
|
|
491
134
|
else if (typeof precision === "undefined")
|
|
492
|
-
p =
|
|
135
|
+
p = defs[i]?.precision ?? 3;
|
|
493
136
|
else
|
|
494
137
|
throw new TypeError(`Invalid precision value: ${precision}.`);
|
|
495
138
|
return p === null ? v : Number(v.toFixed(p));
|
|
496
139
|
});
|
|
497
140
|
}
|
|
498
|
-
/**
|
|
499
|
-
* Converts a color model converter to `<color>` converter.
|
|
500
|
-
*
|
|
501
|
-
* @param name - The name of the color model.
|
|
502
|
-
* @param converter - The color model converter definition.
|
|
503
|
-
* @returns An object of type `ColorConverter`.
|
|
504
|
-
*/
|
|
505
|
-
export function modelConverterToColorConverter(name, converter) {
|
|
506
|
-
const evaluateComponent = (token, value, base = {}, commaSeparated = false, relative = false) => {
|
|
507
|
-
const parsePercent = (str, min, max) => {
|
|
508
|
-
const percent = parseFloat(str);
|
|
509
|
-
if (isNaN(percent))
|
|
510
|
-
throw new Error(`Invalid percentage value: '${str}'.`);
|
|
511
|
-
if (value === "percentage")
|
|
512
|
-
return percent;
|
|
513
|
-
if (min < 0 && max > 0)
|
|
514
|
-
return ((percent / 100) * (max - min)) / 2;
|
|
515
|
-
return (percent / 100) * (max - min) + min;
|
|
516
|
-
};
|
|
517
|
-
const parseHue = (token) => {
|
|
518
|
-
const value = parseFloat(token);
|
|
519
|
-
if (isNaN(value))
|
|
520
|
-
throw new Error(`Invalid hue value: '${token}'.`);
|
|
521
|
-
if (token.slice(-3) === "deg")
|
|
522
|
-
return value;
|
|
523
|
-
if (token.slice(-3) === "rad")
|
|
524
|
-
return value * (180 / pi);
|
|
525
|
-
if (token.slice(-4) === "grad")
|
|
526
|
-
return value * 0.9;
|
|
527
|
-
if (token.slice(-4) === "turn")
|
|
528
|
-
return value * 360;
|
|
529
|
-
return value;
|
|
530
|
-
};
|
|
531
|
-
const parseCalc = (token, _min, _max) => {
|
|
532
|
-
const tokenize = (s) => {
|
|
533
|
-
const out = [];
|
|
534
|
-
for (let i = 0; i < s.length;) {
|
|
535
|
-
const c = s[i];
|
|
536
|
-
if (/\s/.test(c)) {
|
|
537
|
-
i++;
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
if (s.slice(i, i + 2) === "**") {
|
|
541
|
-
out.push({ type: "operator", value: "**" });
|
|
542
|
-
i += 2;
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(s[i + 1] || ""))) {
|
|
546
|
-
let num = "";
|
|
547
|
-
while (i < s.length && /[0-9.]/.test(s[i]))
|
|
548
|
-
num += s[i++];
|
|
549
|
-
if (i < s.length && /[eE]/.test(s[i])) {
|
|
550
|
-
num += s[i++];
|
|
551
|
-
if (/[+-]/.test(s[i]))
|
|
552
|
-
num += s[i++];
|
|
553
|
-
while (i < s.length && /[0-9]/.test(s[i]))
|
|
554
|
-
num += s[i++];
|
|
555
|
-
}
|
|
556
|
-
out.push({ type: "number", value: parseFloat(num) });
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
|
-
if (/[a-zA-Z_]/.test(c)) {
|
|
560
|
-
let id = "";
|
|
561
|
-
while (i < s.length && /[a-zA-Z0-9_]/.test(s[i]))
|
|
562
|
-
id += s[i++];
|
|
563
|
-
out.push({ type: "identifier", value: id });
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
if ("+-*/%(),".includes(c)) {
|
|
567
|
-
out.push({ type: "operator", value: c });
|
|
568
|
-
i++;
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
571
|
-
throw new Error(`Unexpected character: ${c}`);
|
|
572
|
-
}
|
|
573
|
-
return out;
|
|
574
|
-
};
|
|
575
|
-
const parse = (tokens) => {
|
|
576
|
-
let pos = 0;
|
|
577
|
-
const cur = () => (pos < tokens.length ? tokens[pos] : null);
|
|
578
|
-
const nxt = () => {
|
|
579
|
-
if (pos >= tokens.length)
|
|
580
|
-
throw new Error("Unexpected end of input");
|
|
581
|
-
return tokens[pos++];
|
|
582
|
-
};
|
|
583
|
-
const expect = (v) => {
|
|
584
|
-
const t = cur();
|
|
585
|
-
if (!t || t.value !== v)
|
|
586
|
-
throw new Error(`Expected "${v}" but got "${t ? t.value : "end of input"}`);
|
|
587
|
-
nxt();
|
|
588
|
-
};
|
|
589
|
-
const parsePrimary = () => {
|
|
590
|
-
const t = cur();
|
|
591
|
-
if (!t)
|
|
592
|
-
throw new Error("Unexpected end of input");
|
|
593
|
-
if (t.type === "number") {
|
|
594
|
-
nxt();
|
|
595
|
-
return { type: "number", value: t.value };
|
|
596
|
-
}
|
|
597
|
-
if (t.type === "identifier") {
|
|
598
|
-
nxt();
|
|
599
|
-
if (cur() && cur().value === "(") {
|
|
600
|
-
nxt();
|
|
601
|
-
const args = [];
|
|
602
|
-
if (cur() && cur().value !== ")") {
|
|
603
|
-
args.push(parseAdd());
|
|
604
|
-
while (cur() && cur().value === ",") {
|
|
605
|
-
nxt();
|
|
606
|
-
args.push(parseAdd());
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
expect(")");
|
|
610
|
-
return { type: "call", func: t.value, args };
|
|
611
|
-
}
|
|
612
|
-
return { type: "var", name: t.value };
|
|
613
|
-
}
|
|
614
|
-
if (t.value === "(") {
|
|
615
|
-
nxt();
|
|
616
|
-
const e = parseAdd();
|
|
617
|
-
expect(")");
|
|
618
|
-
return e;
|
|
619
|
-
}
|
|
620
|
-
throw new Error(`Unexpected token: ${t.value}`);
|
|
621
|
-
};
|
|
622
|
-
const parseUnary = () => {
|
|
623
|
-
if (cur() && (cur().value === "+" || cur().value === "-")) {
|
|
624
|
-
const op = nxt().value;
|
|
625
|
-
return { type: "unary", op, arg: parseUnary() };
|
|
626
|
-
}
|
|
627
|
-
return parsePrimary();
|
|
628
|
-
};
|
|
629
|
-
const parsePower = () => {
|
|
630
|
-
let left = parseUnary();
|
|
631
|
-
while (cur() && cur().value === "**") {
|
|
632
|
-
const op = nxt().value;
|
|
633
|
-
left = { type: "binary", op, left, right: parseUnary() };
|
|
634
|
-
}
|
|
635
|
-
return left;
|
|
636
|
-
};
|
|
637
|
-
const parseMul = () => {
|
|
638
|
-
let left = parsePower();
|
|
639
|
-
while (cur() && ["*", "/", "%"].includes(String(cur().value))) {
|
|
640
|
-
const op = nxt().value;
|
|
641
|
-
left = { type: "binary", op, left, right: parsePower() };
|
|
642
|
-
}
|
|
643
|
-
return left;
|
|
644
|
-
};
|
|
645
|
-
const parseAdd = () => {
|
|
646
|
-
let left = parseMul();
|
|
647
|
-
while (cur() && (cur().value === "+" || cur().value === "-")) {
|
|
648
|
-
const op = nxt().value;
|
|
649
|
-
left = { type: "binary", op, left, right: parseMul() };
|
|
650
|
-
}
|
|
651
|
-
return left;
|
|
652
|
-
};
|
|
653
|
-
const ast = parseAdd();
|
|
654
|
-
if (pos < tokens.length)
|
|
655
|
-
throw new Error(`Extra tokens after expression: ${tokens
|
|
656
|
-
.slice(pos)
|
|
657
|
-
.map((t) => t.value)
|
|
658
|
-
.join(" ")}`);
|
|
659
|
-
return ast;
|
|
660
|
-
};
|
|
661
|
-
// eslint-disable-next-line no-unused-vars
|
|
662
|
-
const evaluate = (ast, env) => {
|
|
663
|
-
switch (ast.type) {
|
|
664
|
-
case "number":
|
|
665
|
-
return ast.value;
|
|
666
|
-
case "var": {
|
|
667
|
-
const v = env[ast.name];
|
|
668
|
-
if (v === undefined)
|
|
669
|
-
throw new Error(`Unknown variable: ${ast.name}`);
|
|
670
|
-
if (typeof v === "function")
|
|
671
|
-
throw new Error(`Expected variable but found function: ${ast.name}`);
|
|
672
|
-
return v;
|
|
673
|
-
}
|
|
674
|
-
case "binary": {
|
|
675
|
-
const L = evaluate(ast.left, env), R = evaluate(ast.right, env);
|
|
676
|
-
switch (ast.op) {
|
|
677
|
-
case "+":
|
|
678
|
-
return L + R;
|
|
679
|
-
case "-":
|
|
680
|
-
return L - R;
|
|
681
|
-
case "*":
|
|
682
|
-
return L * R;
|
|
683
|
-
case "/":
|
|
684
|
-
return L / R;
|
|
685
|
-
case "%":
|
|
686
|
-
return L % R;
|
|
687
|
-
case "**":
|
|
688
|
-
return L ** R;
|
|
689
|
-
default:
|
|
690
|
-
throw new Error(`Unknown binary operator: ${ast.op}`);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
case "unary": {
|
|
694
|
-
const v = evaluate(ast.arg, env);
|
|
695
|
-
switch (ast.op) {
|
|
696
|
-
case "+":
|
|
697
|
-
return +v;
|
|
698
|
-
case "-":
|
|
699
|
-
return -v;
|
|
700
|
-
default:
|
|
701
|
-
throw new Error(`Unknown unary operator: ${ast.op}`);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
case "call": {
|
|
705
|
-
const fn = env[ast.func];
|
|
706
|
-
if (typeof fn !== "function")
|
|
707
|
-
throw new Error(`Unknown function: ${ast.func}`);
|
|
708
|
-
return fn(...ast.args.map((a) => evaluate(a, env))); // eslint-disable-line no-unused-vars
|
|
709
|
-
}
|
|
710
|
-
default: {
|
|
711
|
-
throw new Error(`Unknown AST node type: ${ast.type}`); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
let inner = token.slice(5, -1).trim();
|
|
716
|
-
if (inner === "infinity")
|
|
717
|
-
return Infinity;
|
|
718
|
-
if (inner === "-infinity")
|
|
719
|
-
return -Infinity;
|
|
720
|
-
if (inner === "NaN")
|
|
721
|
-
return NaN;
|
|
722
|
-
inner = inner.replace(/(\d+(\.\d+)?)%/g, (m) => {
|
|
723
|
-
if (relative === true)
|
|
724
|
-
throw new Error("<hue> and <percentage> values are converted to <number> in relative syntax.");
|
|
725
|
-
const r = parsePercent(m, _min, _max);
|
|
726
|
-
return r !== undefined ? String(r) : "0";
|
|
727
|
-
});
|
|
728
|
-
inner = inner.replace(/(\d+(\.\d+)?)(deg|rad|grad|turn)/g, (_, num, __, unit) => {
|
|
729
|
-
if (relative === true)
|
|
730
|
-
throw new Error("<hue> and <percentage> values are converted to <number> in relative syntax.");
|
|
731
|
-
return String(parseHue(`${parseFloat(num)}${unit}`));
|
|
732
|
-
});
|
|
733
|
-
const caclEnv = {
|
|
734
|
-
...base,
|
|
735
|
-
pi,
|
|
736
|
-
e,
|
|
737
|
-
tau: pi * 2,
|
|
738
|
-
pow,
|
|
739
|
-
sqrt,
|
|
740
|
-
sin,
|
|
741
|
-
cos,
|
|
742
|
-
tan,
|
|
743
|
-
asin,
|
|
744
|
-
acos,
|
|
745
|
-
atan,
|
|
746
|
-
atan2,
|
|
747
|
-
exp,
|
|
748
|
-
log,
|
|
749
|
-
log10,
|
|
750
|
-
log2,
|
|
751
|
-
abs,
|
|
752
|
-
min,
|
|
753
|
-
max,
|
|
754
|
-
hypot,
|
|
755
|
-
round,
|
|
756
|
-
ceil,
|
|
757
|
-
floor,
|
|
758
|
-
sign,
|
|
759
|
-
trunc,
|
|
760
|
-
random,
|
|
761
|
-
};
|
|
762
|
-
try {
|
|
763
|
-
const tokens = tokenize(inner);
|
|
764
|
-
const ast = parse(tokens);
|
|
765
|
-
const result = evaluate(ast, caclEnv);
|
|
766
|
-
return result;
|
|
767
|
-
}
|
|
768
|
-
catch (err) {
|
|
769
|
-
throw new Error(`Evaluation error: ${err}`);
|
|
770
|
-
}
|
|
771
|
-
};
|
|
772
|
-
const evaluateHue = () => {
|
|
773
|
-
if (/^-?(?:\d+|\d*\.\d+)(?:deg|rad|grad|turn)$/.test(token)) {
|
|
774
|
-
return parseHue(token);
|
|
775
|
-
}
|
|
776
|
-
if (/^-?(?:\d+|\d*\.\d+)$/.test(token)) {
|
|
777
|
-
return parseFloat(token);
|
|
778
|
-
}
|
|
779
|
-
const [min, max] = [0, 360];
|
|
780
|
-
if (token.slice(0, 5) === "calc(") {
|
|
781
|
-
return parseCalc(token, min, max);
|
|
782
|
-
}
|
|
783
|
-
throw new Error(`Invalid hue value: '${token}'. Must be a number, or a number with a unit (deg, rad, grad, turn).`);
|
|
784
|
-
};
|
|
785
|
-
const evaluatePercent = () => {
|
|
786
|
-
if (/^-?(?:\d+|\d*\.\d+)$/.test(token)) {
|
|
787
|
-
if (commaSeparated && supportsLegacy === true) {
|
|
788
|
-
throw new Error("The legacy color syntax does not allow numbers for <percentage> components.");
|
|
789
|
-
}
|
|
790
|
-
return parseFloat(token);
|
|
791
|
-
}
|
|
792
|
-
const [min, max] = [0, 100];
|
|
793
|
-
if (token[token.length - 1] === "%") {
|
|
794
|
-
return parsePercent(token, min, max);
|
|
795
|
-
}
|
|
796
|
-
if (token.slice(0, 5) === "calc(") {
|
|
797
|
-
return parseCalc(token, min, max);
|
|
798
|
-
}
|
|
799
|
-
throw new Error(`Invalid percentage value: '${token}'. Must be a percentage or a number.`);
|
|
800
|
-
};
|
|
801
|
-
const evaluateNumber = () => {
|
|
802
|
-
if (/^-?(?:\d+|\d*\.\d+)$/.test(token)) {
|
|
803
|
-
return parseFloat(token);
|
|
804
|
-
}
|
|
805
|
-
const [min, max] = value;
|
|
806
|
-
if (token[token.length - 1] === "%") {
|
|
807
|
-
return parsePercent(token, min, max);
|
|
808
|
-
}
|
|
809
|
-
if (token.slice(0, 5) === "calc(") {
|
|
810
|
-
return parseCalc(token, min, max);
|
|
811
|
-
}
|
|
812
|
-
throw new Error(`Invalid number value: '${token}'. Must be a number${relative === false ? " or a percentage" : ""}.`);
|
|
813
|
-
};
|
|
814
|
-
if (token === "none")
|
|
815
|
-
return NaN;
|
|
816
|
-
if (token in base)
|
|
817
|
-
return base[token];
|
|
818
|
-
if (value === "hue")
|
|
819
|
-
return evaluateHue();
|
|
820
|
-
if (value === "percentage")
|
|
821
|
-
return evaluatePercent();
|
|
822
|
-
if (Array.isArray(value))
|
|
823
|
-
return evaluateNumber();
|
|
824
|
-
throw new Error(`Unable to parse component token: ${token}`);
|
|
825
|
-
};
|
|
826
|
-
const parseAST = (ast) => {
|
|
827
|
-
const { fn, space, fromOrigin, c1, c2, c3, alpha, commaSeparated } = ast;
|
|
828
|
-
const { components, supportsLegacy } = converter;
|
|
829
|
-
components.alpha = { index: 3, value: [0, 1], precision: 3 };
|
|
830
|
-
if (commaSeparated && supportsLegacy !== true) {
|
|
831
|
-
throw new Error(`<${fn}()> does not support comma-separated syntax.`);
|
|
832
|
-
}
|
|
833
|
-
const sorted = Object.entries(components).sort((a, b) => a[1].index - b[1].index);
|
|
834
|
-
if (fromOrigin) {
|
|
835
|
-
let colorSpace;
|
|
836
|
-
if (fn === "color") {
|
|
837
|
-
colorSpace = space;
|
|
838
|
-
}
|
|
839
|
-
else if (fn in colorModels) {
|
|
840
|
-
colorSpace = fn;
|
|
841
|
-
}
|
|
842
|
-
else {
|
|
843
|
-
for (const model in colorModels) {
|
|
844
|
-
if (colorModels[model].alphaVariant === fn) {
|
|
845
|
-
colorSpace = model;
|
|
846
|
-
break;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
const originComponents = Color.from(fromOrigin)
|
|
851
|
-
.in(colorSpace)
|
|
852
|
-
.toObject({ fit: "none", precision: null });
|
|
853
|
-
const evaluatedComponents = [c1, c2, c3, alpha].map((token, i) => {
|
|
854
|
-
const [, meta] = sorted[i];
|
|
855
|
-
return evaluateComponent(token, meta.value, originComponents, commaSeparated, true);
|
|
856
|
-
});
|
|
857
|
-
return evaluatedComponents.slice(0, 4);
|
|
858
|
-
}
|
|
859
|
-
else {
|
|
860
|
-
const result = [];
|
|
861
|
-
const percentFlags = [];
|
|
862
|
-
const tokens = [c1, c2, c3, alpha];
|
|
863
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
864
|
-
const [, meta] = sorted[i];
|
|
865
|
-
const token = tokens[i];
|
|
866
|
-
if (commaSeparated && token === "none") {
|
|
867
|
-
throw new Error(`${fn}() cannot use "none" in comma-separated syntax.`);
|
|
868
|
-
}
|
|
869
|
-
if (meta.index !== 3 &&
|
|
870
|
-
meta.value !== "hue" &&
|
|
871
|
-
meta.value !== "percentage" &&
|
|
872
|
-
token.slice(0, 5) !== "calc(") {
|
|
873
|
-
percentFlags.push(token.trim()[token.length - 1] === "%");
|
|
874
|
-
}
|
|
875
|
-
if (token) {
|
|
876
|
-
const value = evaluateComponent(token, meta.value, {}, commaSeparated);
|
|
877
|
-
result[meta.index] = value;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
if (commaSeparated && percentFlags.length > 1) {
|
|
881
|
-
const allPercent = percentFlags.every(Boolean);
|
|
882
|
-
const nonePercent = percentFlags.every((f) => !f);
|
|
883
|
-
if (!allPercent && !nonePercent) {
|
|
884
|
-
throw new Error(`${fn}()'s <number> components must all be numbers or all percentages.`);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return result.slice(0, 4);
|
|
888
|
-
}
|
|
889
|
-
};
|
|
890
|
-
/**
|
|
891
|
-
* Index reference
|
|
892
|
-
*
|
|
893
|
-
* | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|
|
894
|
-
* -------------------------------------------------------------
|
|
895
|
-
* | rgb | 255 | , | 0 | , | 0 | | | |
|
|
896
|
-
* | rgb | 255 | , | 0 | , | 0 | , | 0.5 | |
|
|
897
|
-
* | rgb | 255 | 0 | 0 | | | | | |
|
|
898
|
-
* | rgb | 255 | 0 | 0 | / | 0.5 | | | |
|
|
899
|
-
* | rgb | from | red | r | g | b | | | |
|
|
900
|
-
* | rgb | from | red | r | g | b | / | alpha | |
|
|
901
|
-
* | color | srgb | 1 | 0 | 0 | | | | |
|
|
902
|
-
* | color | srgb | 1 | 0 | 0 | / | 0.5 | | |
|
|
903
|
-
* | color | from | red | srgb | r | g | b | | |
|
|
904
|
-
* | color | from | red | srgb | r | g | b | / | alpha |
|
|
905
|
-
*/
|
|
906
|
-
const getAST = (tokens) => {
|
|
907
|
-
const getAlpha = (index, separator = "/") => {
|
|
908
|
-
if (tokens[index] !== undefined) {
|
|
909
|
-
if (tokens[index] === separator) {
|
|
910
|
-
return { value: tokens[index + 1], hasAlpha: true };
|
|
911
|
-
}
|
|
912
|
-
throw new Error("Invalid alpha separator");
|
|
913
|
-
}
|
|
914
|
-
return { value: "1", hasAlpha: false };
|
|
915
|
-
};
|
|
916
|
-
let fn, space, fromOrigin, c1, c2, c3, alpha, commaSeparated = false, expectedLength;
|
|
917
|
-
if (tokens[0] === "color") {
|
|
918
|
-
fn = "color";
|
|
919
|
-
if (tokens[1] === "from") {
|
|
920
|
-
// color(from red srgb r g b) OR color(from red srgb r g b / alpha)
|
|
921
|
-
space = tokens[3];
|
|
922
|
-
fromOrigin = tokens[2];
|
|
923
|
-
c1 = tokens[4];
|
|
924
|
-
c2 = tokens[5];
|
|
925
|
-
c3 = tokens[6];
|
|
926
|
-
const { value, hasAlpha } = getAlpha(7);
|
|
927
|
-
expectedLength = hasAlpha ? 9 : 7;
|
|
928
|
-
alpha = value;
|
|
929
|
-
}
|
|
930
|
-
else {
|
|
931
|
-
// color(srgb 1 0 0) OR color(srgb 1 0 0 / 0.5)
|
|
932
|
-
space = tokens[1];
|
|
933
|
-
fromOrigin = null;
|
|
934
|
-
c1 = tokens[2];
|
|
935
|
-
c2 = tokens[3];
|
|
936
|
-
c3 = tokens[4];
|
|
937
|
-
const { value, hasAlpha } = getAlpha(5);
|
|
938
|
-
expectedLength = hasAlpha ? 7 : 5;
|
|
939
|
-
alpha = value;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
else {
|
|
943
|
-
fn = tokens[0];
|
|
944
|
-
space = null;
|
|
945
|
-
if (tokens[1] === "from") {
|
|
946
|
-
// rgb(from red r g b) OR rgb(from red r g b / alpha)
|
|
947
|
-
fromOrigin = tokens[2];
|
|
948
|
-
c1 = tokens[3];
|
|
949
|
-
c2 = tokens[4];
|
|
950
|
-
c3 = tokens[5];
|
|
951
|
-
const { value, hasAlpha } = getAlpha(6);
|
|
952
|
-
expectedLength = hasAlpha ? 8 : 6;
|
|
953
|
-
alpha = value;
|
|
954
|
-
}
|
|
955
|
-
else {
|
|
956
|
-
fromOrigin = null;
|
|
957
|
-
c1 = tokens[1];
|
|
958
|
-
if (tokens[2] === "," && tokens[4] === ",") {
|
|
959
|
-
// rgb(255, 0, 0) OR rgb(255, 0, 0, 0.5)
|
|
960
|
-
commaSeparated = true;
|
|
961
|
-
c2 = tokens[3];
|
|
962
|
-
c3 = tokens[5];
|
|
963
|
-
const { value, hasAlpha } = getAlpha(6, ",");
|
|
964
|
-
expectedLength = hasAlpha ? 8 : 6;
|
|
965
|
-
if (hasAlpha && tokens[6] !== ",") {
|
|
966
|
-
throw new Error("Comma optional syntax requires no commas at all.");
|
|
967
|
-
}
|
|
968
|
-
alpha = value;
|
|
969
|
-
}
|
|
970
|
-
else {
|
|
971
|
-
// rgb(255 0 0) OR rgb(255 0 0 / 0.5)
|
|
972
|
-
c2 = tokens[2];
|
|
973
|
-
c3 = tokens[3];
|
|
974
|
-
const { value, hasAlpha } = getAlpha(4);
|
|
975
|
-
expectedLength = hasAlpha ? 6 : 4;
|
|
976
|
-
alpha = value;
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
if (tokens.length !== expectedLength) {
|
|
981
|
-
throw new Error(`Invalid number of tokens for ${fn}(): expected ${expectedLength} but got ${tokens.length}.`);
|
|
982
|
-
}
|
|
983
|
-
return { fn, space, fromOrigin, c1, c2, c3, alpha, commaSeparated };
|
|
984
|
-
};
|
|
985
|
-
/**
|
|
986
|
-
* Tokenization examples
|
|
987
|
-
*
|
|
988
|
-
* ─── rgb() ────────────────────────────────────────────────
|
|
989
|
-
* "rgb(255, 0, 0)" --> [ "rgb", "255", ",", "0", ",", "0" ]
|
|
990
|
-
* "rgb(255, 0, 0, 0.5)" --> [ "rgb", "255", ",", "0", ",", "0", ",", "0.5" ]
|
|
991
|
-
* "rgb(255 0 0)" --> [ "rgb", "255", "0", "0" ]
|
|
992
|
-
* "rgb(255 0 0 / 0.5)" --> [ "rgb", "255", "0", "0", "/", "0.5" ]
|
|
993
|
-
* "rgb(from red r g b)" --> [ "rgb", "from", "red", "r", "g", "b" ]
|
|
994
|
-
* "rgb(from red r g b / a)" --> [ "rgb", "from", "red", "r", "g", "b", "/", "alpha" ]
|
|
995
|
-
*
|
|
996
|
-
* ─── color() ─────────────────────────────────────────────
|
|
997
|
-
* "color(srgb 1 0 0)" --> [ "color", "srgb", "1", "0", "0" ]
|
|
998
|
-
* "color(srgb 1 0 0 / 0.5)" --> [ "color", "srgb", "1", "0", "0", "/", "0.5" ]
|
|
999
|
-
* "color(from red srgb r g b)" --> [ "color", "from", "red", "srgb", "r", "g", "b" ]
|
|
1000
|
-
* "color(from red srgb r g b / a)" --> [ "color", "from", "red", "srgb", "r", "g", "b", "/", "alpha" ]
|
|
1001
|
-
*/
|
|
1002
|
-
const tokenize = (str) => {
|
|
1003
|
-
const tokens = [];
|
|
1004
|
-
let i = 0;
|
|
1005
|
-
let funcName = "";
|
|
1006
|
-
while (i < str.length && str[i] !== "(") {
|
|
1007
|
-
funcName += str[i];
|
|
1008
|
-
i++;
|
|
1009
|
-
}
|
|
1010
|
-
funcName = funcName.trim();
|
|
1011
|
-
tokens.push(funcName);
|
|
1012
|
-
const innerStart = str.indexOf("(") + 1;
|
|
1013
|
-
const innerStr = str.slice(innerStart, -1).trim();
|
|
1014
|
-
i = 0;
|
|
1015
|
-
if (innerStr.slice(0, 5) === "from ") {
|
|
1016
|
-
tokens.push("from");
|
|
1017
|
-
i += 5;
|
|
1018
|
-
while (i < innerStr.length && innerStr[i] === " ")
|
|
1019
|
-
i++;
|
|
1020
|
-
const colorStart = i;
|
|
1021
|
-
while (i < innerStr.length && innerStr[i] !== " ")
|
|
1022
|
-
i++;
|
|
1023
|
-
const colorStr = innerStr.slice(colorStart, i);
|
|
1024
|
-
if (colorStr.includes("(")) {
|
|
1025
|
-
const { expression, end } = extractBalancedExpression(innerStr, colorStart);
|
|
1026
|
-
tokens.push(expression);
|
|
1027
|
-
i = end;
|
|
1028
|
-
}
|
|
1029
|
-
else {
|
|
1030
|
-
tokens.push(colorStr);
|
|
1031
|
-
}
|
|
1032
|
-
while (i < innerStr.length && innerStr[i] === " ")
|
|
1033
|
-
i++;
|
|
1034
|
-
}
|
|
1035
|
-
if (tokens[0] === "color" && i < innerStr.length) {
|
|
1036
|
-
const spaceStart = i;
|
|
1037
|
-
while (i < innerStr.length && innerStr[i] !== " ")
|
|
1038
|
-
i++;
|
|
1039
|
-
tokens.push(innerStr.slice(spaceStart, i));
|
|
1040
|
-
while (i < innerStr.length && innerStr[i] === " ")
|
|
1041
|
-
i++;
|
|
1042
|
-
}
|
|
1043
|
-
while (i < innerStr.length) {
|
|
1044
|
-
const char = innerStr[i];
|
|
1045
|
-
if (char === ",") {
|
|
1046
|
-
tokens.push(",");
|
|
1047
|
-
i++;
|
|
1048
|
-
if (innerStr[i] === " ")
|
|
1049
|
-
i++;
|
|
1050
|
-
}
|
|
1051
|
-
else if (char === "/") {
|
|
1052
|
-
tokens.push("/");
|
|
1053
|
-
i++;
|
|
1054
|
-
if (innerStr[i] === " ")
|
|
1055
|
-
i++;
|
|
1056
|
-
}
|
|
1057
|
-
else if (char === " ") {
|
|
1058
|
-
i++;
|
|
1059
|
-
}
|
|
1060
|
-
else if (/[a-zA-Z#]/.test(char)) {
|
|
1061
|
-
const identStart = i;
|
|
1062
|
-
let ident = "";
|
|
1063
|
-
while (i < innerStr.length && /[a-zA-Z0-9-%#]/.test(innerStr[i])) {
|
|
1064
|
-
ident += innerStr[i];
|
|
1065
|
-
i++;
|
|
1066
|
-
}
|
|
1067
|
-
if (i < innerStr.length && innerStr[i] === "(") {
|
|
1068
|
-
const { expression, end } = extractBalancedExpression(innerStr, identStart);
|
|
1069
|
-
tokens.push(expression);
|
|
1070
|
-
i = end;
|
|
1071
|
-
}
|
|
1072
|
-
else {
|
|
1073
|
-
tokens.push(ident);
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
else if (/[\d.-]/.test(char)) {
|
|
1077
|
-
let num = "";
|
|
1078
|
-
while (i < innerStr.length && /[\d.eE+-]/.test(innerStr[i])) {
|
|
1079
|
-
num += innerStr[i];
|
|
1080
|
-
i++;
|
|
1081
|
-
}
|
|
1082
|
-
if (i < innerStr.length && innerStr[i] === "%") {
|
|
1083
|
-
num += "%";
|
|
1084
|
-
i++;
|
|
1085
|
-
tokens.push(num);
|
|
1086
|
-
}
|
|
1087
|
-
else if (i < innerStr.length && /[a-zA-Z]/.test(innerStr[i])) {
|
|
1088
|
-
let unit = "";
|
|
1089
|
-
while (i < innerStr.length && /[a-zA-Z]/.test(innerStr[i])) {
|
|
1090
|
-
unit += innerStr[i];
|
|
1091
|
-
i++;
|
|
1092
|
-
}
|
|
1093
|
-
tokens.push(num + unit);
|
|
1094
|
-
}
|
|
1095
|
-
else {
|
|
1096
|
-
tokens.push(num);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
else {
|
|
1100
|
-
throw new Error(`Unexpected character: ${char}`);
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
return tokens;
|
|
1104
|
-
};
|
|
1105
|
-
const validateRelativeColorSpace = (str, name) => {
|
|
1106
|
-
const prefix = "color(from ";
|
|
1107
|
-
if (str.slice(0, 11) !== prefix || str[str.length - 1] !== ")") {
|
|
1108
|
-
return false;
|
|
1109
|
-
}
|
|
1110
|
-
const innerStr = str.slice(prefix.length, -1).trim();
|
|
1111
|
-
const { expression, end } = extractBalancedExpression(innerStr, 0);
|
|
1112
|
-
if (!expression) {
|
|
1113
|
-
return false;
|
|
1114
|
-
}
|
|
1115
|
-
const rest = innerStr.slice(end).trim();
|
|
1116
|
-
const parts = rest.split(/\s+/);
|
|
1117
|
-
if (parts.length < 1) {
|
|
1118
|
-
return false;
|
|
1119
|
-
}
|
|
1120
|
-
const colorSpace = parts[0];
|
|
1121
|
-
return colorSpace === name;
|
|
1122
|
-
};
|
|
1123
|
-
const { components, bridge, fromBridge, toBridge, alphaVariant, supportsLegacy } = converter;
|
|
1124
|
-
const { PI: pi, E: e, pow, sqrt, sin, cos, tan, asin, acos, atan, atan2, exp, log, log10, log2, abs, min, max, hypot, round, ceil, floor, sign, trunc, random, } = Math;
|
|
1125
|
-
return {
|
|
1126
|
-
isValid: (str) => {
|
|
1127
|
-
const { alphaVariant = name } = converter;
|
|
1128
|
-
if (name in colorSpaces) {
|
|
1129
|
-
const startsWithColor = str.slice(0, `color(${name} `.length) === `color(${name} `;
|
|
1130
|
-
const startsWithFrom = str.slice(0, "color(from".length) === "color(from" && validateRelativeColorSpace(str, name);
|
|
1131
|
-
return (startsWithColor || startsWithFrom) && str[str.length - 1] === ")";
|
|
1132
|
-
}
|
|
1133
|
-
return ((str.slice(0, `${name}(`.length) === `${name}(` ||
|
|
1134
|
-
str.slice(0, `${alphaVariant}(`.length) === `${alphaVariant}(`) &&
|
|
1135
|
-
str[str.length - 1] === ")");
|
|
1136
|
-
},
|
|
1137
|
-
bridge,
|
|
1138
|
-
toBridge: (coords) => [...toBridge(coords.slice(0, 3)), coords[3] ?? 1],
|
|
1139
|
-
parse: (str) => {
|
|
1140
|
-
const tokens = tokenize(str);
|
|
1141
|
-
const ast = getAST(tokens);
|
|
1142
|
-
const coords = parseAST(ast);
|
|
1143
|
-
return [...coords.slice(0, 3), coords[3] ?? 1];
|
|
1144
|
-
},
|
|
1145
|
-
fromBridge: (coords) => [...fromBridge(coords), coords[3] ?? 1],
|
|
1146
|
-
format: ([c1, c2, c3, a = 1], options = {}) => {
|
|
1147
|
-
const { legacy = false, fit: fitMethod = config.defaults.fit, precision, units = false } = options;
|
|
1148
|
-
const clipped = fit([c1, c2, c3], name, { method: fitMethod, precision });
|
|
1149
|
-
const alpha = Number(min(max(a, 0), 1).toFixed(3)).toString();
|
|
1150
|
-
const formatted = clipped.map((v, index) => {
|
|
1151
|
-
v = isNaN(v) ? 0 : v;
|
|
1152
|
-
if ((units || legacy) && components) {
|
|
1153
|
-
const def = Object.values(components).find((comp) => comp.index === index);
|
|
1154
|
-
if (def?.value === "percentage")
|
|
1155
|
-
return `${v}%`;
|
|
1156
|
-
if (def?.value === "hue" && units)
|
|
1157
|
-
return `${v}deg`;
|
|
1158
|
-
}
|
|
1159
|
-
return v.toString();
|
|
1160
|
-
});
|
|
1161
|
-
if (name in colorSpaces) {
|
|
1162
|
-
return `color(${name} ${formatted.join(" ")}${a !== 1 ? ` / ${alpha}` : ""})`;
|
|
1163
|
-
}
|
|
1164
|
-
if (legacy && supportsLegacy) {
|
|
1165
|
-
return a === 1
|
|
1166
|
-
? `${name}(${formatted.join(", ")})`
|
|
1167
|
-
: `${alphaVariant || name}(${formatted.join(", ")}, ${alpha})`;
|
|
1168
|
-
}
|
|
1169
|
-
return `${name}(${formatted.join(" ")}${a !== 1 ? ` / ${alpha}` : ""})`;
|
|
1170
|
-
},
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
141
|
/**
|
|
1174
142
|
* Converts a color space converter to a color model converter.
|
|
1175
143
|
*
|
|
@@ -1183,7 +151,10 @@ export function spaceConverterToModelConverter(name, converter) {
|
|
|
1183
151
|
return {
|
|
1184
152
|
supportsLegacy: false,
|
|
1185
153
|
targetGamut: converter.targetGamut === null ? null : name,
|
|
1186
|
-
components:
|
|
154
|
+
components: {
|
|
155
|
+
...Object.fromEntries(converter.components.map((comp, index) => [comp, { index, value: [0, 1], precision: 5 }])),
|
|
156
|
+
alpha: { index: 3, value: [0, 1], precision: 3 },
|
|
157
|
+
},
|
|
1187
158
|
bridge: converter.bridge,
|
|
1188
159
|
toBridge: (coords) => {
|
|
1189
160
|
return multiplyMatrices(toBridgeMatrix, coords.map((c) => toLinear(c)));
|
|
@@ -1191,3 +162,88 @@ export function spaceConverterToModelConverter(name, converter) {
|
|
|
1191
162
|
fromBridge: (coords) => multiplyMatrices(fromBridgeMatrix, coords).map((c) => fromLinear(c)),
|
|
1192
163
|
};
|
|
1193
164
|
}
|
|
165
|
+
export function hueDelta(a, b) {
|
|
166
|
+
const d = (((b - a) % 360) + 360) % 360;
|
|
167
|
+
return d > 180 ? d - 360 : d;
|
|
168
|
+
}
|
|
169
|
+
export function hueDeltaLong(a, b) {
|
|
170
|
+
const d = hueDelta(a, b);
|
|
171
|
+
return d >= 0 ? d - 360 : d + 360;
|
|
172
|
+
}
|
|
173
|
+
export function interpHue(a, b, t, method) {
|
|
174
|
+
if (Number.isNaN(a) || Number.isNaN(b))
|
|
175
|
+
return NaN;
|
|
176
|
+
switch (method) {
|
|
177
|
+
case "shorter":
|
|
178
|
+
return (((a + t * hueDelta(a, b)) % 360) + 360) % 360;
|
|
179
|
+
case "longer":
|
|
180
|
+
return (((a + t * hueDeltaLong(a, b)) % 360) + 360) % 360;
|
|
181
|
+
case "increasing":
|
|
182
|
+
return (((a * (1 - t) + (b < a ? b + 360 : b) * t) % 360) + 360) % 360;
|
|
183
|
+
case "decreasing":
|
|
184
|
+
return (((a * (1 - t) + (b > a ? b - 360 : b) * t) % 360) + 360) % 360;
|
|
185
|
+
}
|
|
186
|
+
throw new Error(`Invalid hue interpolation: ${method}`);
|
|
187
|
+
}
|
|
188
|
+
export function mixTwo(colorA, colorB, t, model = "oklab", hue = "shorter") {
|
|
189
|
+
const A = colorA.in(model).coords.slice();
|
|
190
|
+
const B = colorB.in(model).coords.slice();
|
|
191
|
+
const { components } = colorModels[model]; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
192
|
+
if (!components)
|
|
193
|
+
throw new Error(`Model ${model} does not have defined components.`);
|
|
194
|
+
const norm = (v) => (Array.isArray(v) ? v : v === "hue" ? [0, 360] : [0, 100]);
|
|
195
|
+
const repair = (a, b, v) => {
|
|
196
|
+
const [min, max] = norm(v);
|
|
197
|
+
const fixInf = (x) => (x === Infinity ? max : x === -Infinity ? min : x);
|
|
198
|
+
a = fixInf(a);
|
|
199
|
+
b = fixInf(b);
|
|
200
|
+
const aNaN = Number.isNaN(a);
|
|
201
|
+
const bNaN = Number.isNaN(b);
|
|
202
|
+
if (aNaN && bNaN)
|
|
203
|
+
return { a: NaN, b: NaN };
|
|
204
|
+
if (aNaN)
|
|
205
|
+
return { a: b, b };
|
|
206
|
+
if (bNaN)
|
|
207
|
+
return { a, b: a };
|
|
208
|
+
return { a, b, ok: true };
|
|
209
|
+
};
|
|
210
|
+
const hueIndex = components.h?.index ?? -1;
|
|
211
|
+
for (const key in components) {
|
|
212
|
+
const { index, value } = components[key];
|
|
213
|
+
if (index > 3)
|
|
214
|
+
continue;
|
|
215
|
+
const r = repair(A[index], B[index], value);
|
|
216
|
+
if (r.ok) {
|
|
217
|
+
const [min, max] = norm(value);
|
|
218
|
+
const fixInf = (x) => (x === Infinity ? max : x === -Infinity ? min : x);
|
|
219
|
+
A[index] = fixInf(A[index]);
|
|
220
|
+
B[index] = fixInf(B[index]);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
A[index] = r.a;
|
|
224
|
+
B[index] = r.b;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
t = Math.min(1, Math.max(0, t));
|
|
228
|
+
if (t === 0)
|
|
229
|
+
return new Color(model, [...A]);
|
|
230
|
+
if (t === 1)
|
|
231
|
+
return new Color(model, [...B]);
|
|
232
|
+
const aA = A[3] ?? 1;
|
|
233
|
+
const aB = B[3] ?? 1;
|
|
234
|
+
const resolvedA = A.slice(0, 3);
|
|
235
|
+
const resolvedB = B.slice(0, 3);
|
|
236
|
+
if (aA < 1 || aB < 1) {
|
|
237
|
+
const premixed = resolvedA.map((a, i) => {
|
|
238
|
+
const b = resolvedB[i];
|
|
239
|
+
return i === hueIndex ? interpHue(a, b, t, hue) : a * aA * (1 - t) + b * aB * t;
|
|
240
|
+
});
|
|
241
|
+
const a = aA * (1 - t) + aB * t;
|
|
242
|
+
const out = a > 0
|
|
243
|
+
? premixed.map((v, i) => (i === hueIndex ? v : v / a))
|
|
244
|
+
: resolvedA.map((_, i) => (i === hueIndex ? premixed[i] : 0));
|
|
245
|
+
return new Color(model, [...out, a]);
|
|
246
|
+
}
|
|
247
|
+
const mixed = resolvedA.map((a, i) => i === hueIndex ? interpHue(a, resolvedB[i], t, hue) : a + (resolvedB[i] - a) * t);
|
|
248
|
+
return new Color(model, [...mixed, 1]);
|
|
249
|
+
}
|