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