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/dist/utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Color } from "./Color.js";
2
- import { config, systemColors } from "./config.js";
3
- import { colorBases, colorModels, colorFunctions, colorSpaces, colorTypes, namedColors } from "./converters.js";
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
- * Unregisters one or more color types from the registry.
80
+ * Normalizes a component value to a valid range based on the component definition.
383
81
  *
384
- * @param types - Names of color types to remove.
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 unregister(...types) {
387
- for (const type of types) {
388
- delete colorTypes[type];
389
- delete colorBases[type];
390
- delete colorFunctions[type];
391
- delete colorModels[type];
392
- delete colorSpaces[type];
393
- }
394
- cache.delete("graph");
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 componentProps = Object.values(components).reduce((arr, props) => ((arr[props.index] = props), arr), []);
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 = componentProps[i];
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 = componentProps[i]?.precision ?? 3;
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: Object.fromEntries(converter.components.map((comp, index) => [comp, { index, value: [0, 1], precision: 5 }])),
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
+ }