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/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,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
- * Extracts a balanced expression from a string starting at a given index.
80
+ * Normalizes a component value to a valid range based on the component definition.
412
81
  *
413
- * - If the character at `start` is '(', extracts the full parenthetical expression (including nested parentheses).
414
- * - Otherwise, extracts a contiguous sequence of alphanumeric characters, hyphens, percent signs, or '#'.
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 extractBalancedExpression(input, start) {
421
- let i = start;
422
- let expression = "";
423
- let depth = 0;
424
- if (input[i] !== "(") {
425
- while (i < input.length && /[a-zA-Z0-9-%#]/.test(input[i])) {
426
- expression += input[i];
427
- i++;
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 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), []);
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.slice(0, 3).map((v, i) => {
467
- const prop = componentProps[i];
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 === "angle")
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.slice(0, 3).map((v, i) => {
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 = componentProps[i]?.precision ?? 3;
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: 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
+ },
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
+ }