@thejaredwilcurt/csslop 0.0.16 → 0.0.18
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/README.md +22 -3
- package/package.json +8 -7
- package/src/index.js +3 -1
- package/src/rules/custom-properties.js +89 -0
- package/src/rules/optimize.js +294 -1
- package/src/rules/selectors.js +304 -0
- package/src/rules/stringify.js +61 -337
- package/src/value/color-mix.js +500 -0
- package/src/value/colors.js +54 -483
- package/src/value/light-dark.js +188 -0
- package/src/value/minify.js +85 -167
- package/src/value/relative-colors.js +336 -0
- package/src/value/syntax.js +56 -0
package/src/value/colors.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file Provides color parsing, color space conversion (sRGB, OKLab, OKLCH, HSL, HWB),
|
|
2
|
+
* @file Provides color parsing, color space conversion (sRGB, OKLab, OKLCH, HSL, HWB), and hex formatting for CSS value minification.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { namedColors } from './named-colors.js';
|
|
6
|
-
import {
|
|
7
|
-
parseAlphaString,
|
|
8
|
-
roundCompactNumber
|
|
9
|
-
} from './shared.js';
|
|
6
|
+
import { parseAlphaString } from './shared.js';
|
|
10
7
|
|
|
11
8
|
/**
|
|
12
9
|
* Reverse lookup from "r,g,b" to the shortest CSS named color keyword.
|
|
@@ -281,26 +278,6 @@ function oklchToOklab (L, C, H) {
|
|
|
281
278
|
};
|
|
282
279
|
}
|
|
283
280
|
|
|
284
|
-
/**
|
|
285
|
-
* Interpolates between two hue angles along the shorter arc, per the CSS Color specification.
|
|
286
|
-
*
|
|
287
|
-
* @param {number} h1 The first hue angle in degrees.
|
|
288
|
-
* @param {number} h2 The second hue angle in degrees.
|
|
289
|
-
* @param {number} t The interpolation factor from 0 to 1.
|
|
290
|
-
* @return {number} The interpolated hue angle in degrees, normalized to 0–360.
|
|
291
|
-
*/
|
|
292
|
-
function interpolateHueShorter (h1, h2, t) {
|
|
293
|
-
let diff = h2 - h1;
|
|
294
|
-
if (diff > 180) {
|
|
295
|
-
diff -= 360;
|
|
296
|
-
}
|
|
297
|
-
if (diff < -180) {
|
|
298
|
-
diff += 360;
|
|
299
|
-
}
|
|
300
|
-
let result = h1 + diff * t;
|
|
301
|
-
return ((result % 360) + 360) % 360;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
281
|
/**
|
|
305
282
|
* Parse a hex color string to [r, g, b, a].
|
|
306
283
|
*
|
|
@@ -430,469 +407,84 @@ function oklabToRgb (L, a, b) {
|
|
|
430
407
|
}
|
|
431
408
|
|
|
432
409
|
/**
|
|
433
|
-
*
|
|
410
|
+
* CIE Lab D50 reference white point.
|
|
434
411
|
*
|
|
435
|
-
* @
|
|
436
|
-
* @param {number} C The chroma component.
|
|
437
|
-
* @param {number} H The hue angle in degrees.
|
|
438
|
-
* @param {number|undefined} alpha The alpha value from 0 to 1, or undefined for fully opaque.
|
|
439
|
-
* @return {string} A minified oklch() function string.
|
|
412
|
+
* @type {Array}
|
|
440
413
|
*/
|
|
441
|
-
|
|
442
|
-
const fmtL = roundCompactNumber(L, 3);
|
|
443
|
-
const fmtC = roundCompactNumber(C, 3);
|
|
444
|
-
const fmtH = roundCompactNumber(H, 1);
|
|
445
|
-
if (alpha !== undefined && alpha < 1) {
|
|
446
|
-
return 'oklch(' + fmtL + ' ' + fmtC + ' ' + fmtH + '/' + roundCompactNumber(alpha, 3) + ')';
|
|
447
|
-
}
|
|
448
|
-
return 'oklch(' + fmtL + ' ' + fmtC + ' ' + fmtH + ')';
|
|
449
|
-
}
|
|
414
|
+
const D50_WHITE_XYZ = [0.96422, 1, 0.82521];
|
|
450
415
|
|
|
451
416
|
/**
|
|
452
|
-
*
|
|
417
|
+
* Converts a CIE Lab f-function value back to a linear-light ratio.
|
|
453
418
|
*
|
|
454
|
-
* @param {
|
|
455
|
-
* @return {
|
|
419
|
+
* @param {number} f The f value.
|
|
420
|
+
* @return {number} The linear ratio.
|
|
456
421
|
*/
|
|
457
|
-
function
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (functionMatch) {
|
|
462
|
-
// Split arguments on whitespace, commas, or slash separators
|
|
463
|
-
const parts = functionMatch[1].trim().split(/[\s,/]+/).map((part) => {
|
|
464
|
-
return part.trim();
|
|
465
|
-
}).filter((part) => {
|
|
466
|
-
return part.length > 0;
|
|
467
|
-
});
|
|
468
|
-
parts.forEach((part, index) => {
|
|
469
|
-
if (part.toLowerCase() === 'none') {
|
|
470
|
-
indices.push(index);
|
|
471
|
-
}
|
|
472
|
-
});
|
|
422
|
+
function labFToLinearRatio (f) {
|
|
423
|
+
const delta = 6 / 29;
|
|
424
|
+
if (f > delta) {
|
|
425
|
+
return f * f * f;
|
|
473
426
|
}
|
|
474
|
-
return
|
|
427
|
+
return 3 * delta * delta * (f - 16 / 116);
|
|
475
428
|
}
|
|
476
429
|
|
|
477
430
|
/**
|
|
478
|
-
*
|
|
431
|
+
* Converts CIE Lab (D50) components to linear-light sRGB using Bradford adaptation to D65.
|
|
479
432
|
*
|
|
480
|
-
* @param {
|
|
481
|
-
* @param {
|
|
482
|
-
* @
|
|
433
|
+
* @param {number} L The lightness component.
|
|
434
|
+
* @param {number} a The green-red axis component.
|
|
435
|
+
* @param {number} b The blue-yellow axis component.
|
|
436
|
+
* @return {object} An object with r, g, b linear sRGB components.
|
|
483
437
|
*/
|
|
484
|
-
function
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (!parsed) {
|
|
489
|
-
return null;
|
|
490
|
-
}
|
|
491
|
-
parsedArgs.push(parsed);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// If any color is unresolvable (var(), currentcolor), whitespace-strip only
|
|
495
|
-
if (parsedArgs.some((parsedArg) => {
|
|
496
|
-
return !parsedArg.color;
|
|
497
|
-
})) {
|
|
498
|
-
return normalizeUnresolvableNColorMix(colorSpace, parsedArgs);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
const percentages = normalizeNColorPercentages(parsedArgs);
|
|
502
|
-
const percentageSum = percentages.reduce((sum, value) => {
|
|
503
|
-
return sum + value;
|
|
504
|
-
}, 0);
|
|
505
|
-
|
|
506
|
-
// All-zero percentages → transparent black
|
|
507
|
-
if (percentageSum === 0) {
|
|
508
|
-
return rgbaToHex(0, 0, 0, 0);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
let alphaMultiplier = 1;
|
|
512
|
-
if (percentageSum < 100) {
|
|
513
|
-
alphaMultiplier = percentageSum / 100;
|
|
514
|
-
} else if (percentageSum > 100) {
|
|
515
|
-
for (let i = 0; i < percentages.length; i++) {
|
|
516
|
-
percentages[i] = percentages[i] / percentageSum * 100;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
438
|
+
function labToLinearSrgb (L, a, b) {
|
|
439
|
+
const fy = (L + 16) / 116;
|
|
440
|
+
const fx = fy + a / 500;
|
|
441
|
+
const fz = fy - b / 200;
|
|
519
442
|
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
}, 0);
|
|
524
|
-
const weights = percentages.map((value) => {
|
|
525
|
-
return value / totalPercentage;
|
|
526
|
-
});
|
|
527
|
-
const colors = parsedArgs.map((parsedArg) => {
|
|
528
|
-
return parsedArg.color;
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
if (colorSpace === 'srgb') {
|
|
532
|
-
return mixNColorsSrgb(colors, weights, alphaMultiplier);
|
|
533
|
-
}
|
|
443
|
+
const x = labFToLinearRatio(fx) * D50_WHITE_XYZ[0];
|
|
444
|
+
const y = labFToLinearRatio(fy) * D50_WHITE_XYZ[1];
|
|
445
|
+
const z = labFToLinearRatio(fz) * D50_WHITE_XYZ[2];
|
|
534
446
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
447
|
+
// Matrix from D50 XYZ (Bradford-adapted) to linear sRGB.
|
|
448
|
+
const red = 3.1338561 * x - 1.6168667 * y - 0.4906146 * z;
|
|
449
|
+
const green = -0.9787684 * x + 1.9161415 * y + 0.0334540 * z;
|
|
450
|
+
const blue = 0.0719453 * x - 0.2289914 * y + 1.4052427 * z;
|
|
538
451
|
|
|
539
|
-
return
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Normalize percentages for an N-color color-mix() expression.
|
|
544
|
-
* When no percentages are specified, each color gets an equal share of 100%.
|
|
545
|
-
* When some are unspecified, the remaining percentage is split equally among them.
|
|
546
|
-
*
|
|
547
|
-
* @param {Array} parsedArgs The parsed color-mix arguments.
|
|
548
|
-
* @return {Array} An array of normalized percentage values.
|
|
549
|
-
*/
|
|
550
|
-
function normalizeNColorPercentages (parsedArgs) {
|
|
551
|
-
const percentages = parsedArgs.map((parsedArg) => {
|
|
552
|
-
return parsedArg.percentage;
|
|
553
|
-
});
|
|
554
|
-
if (percentages.every((value) => {
|
|
555
|
-
return value === null;
|
|
556
|
-
})) {
|
|
557
|
-
const equalWeight = 100 / parsedArgs.length;
|
|
558
|
-
return parsedArgs.map(() => {
|
|
559
|
-
return equalWeight;
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
const specifiedSum = percentages.reduce((sum, value) => {
|
|
563
|
-
return sum + (value !== null ? value : 0);
|
|
564
|
-
}, 0);
|
|
565
|
-
const unspecifiedCount = percentages.filter((value) => {
|
|
566
|
-
return value === null;
|
|
567
|
-
}).length;
|
|
568
|
-
if (unspecifiedCount > 0) {
|
|
569
|
-
const remaining = Math.max(0, 100 - specifiedSum);
|
|
570
|
-
const percentagePerUnspecified = remaining / unspecifiedCount;
|
|
571
|
-
return percentages.map((value) => {
|
|
572
|
-
return value !== null ? value : percentagePerUnspecified;
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
return percentages;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Build a whitespace-stripped color-mix() string for an unresolvable N-color expression.
|
|
580
|
-
*
|
|
581
|
-
* @param {string} colorSpace The interpolation color space.
|
|
582
|
-
* @param {Array} parsedArgs The parsed color-mix arguments.
|
|
583
|
-
* @return {string} A whitespace-stripped color-mix() expression.
|
|
584
|
-
*/
|
|
585
|
-
function normalizeUnresolvableNColorMix (colorSpace, parsedArgs) {
|
|
586
|
-
const parts = parsedArgs.map((parsedArg) => {
|
|
587
|
-
const rawColor = parsedArg.raw.trim();
|
|
588
|
-
const percentageString = parsedArg.percentage !== null ? ' ' + parsedArg.percentage + '%' : '';
|
|
589
|
-
return rawColor + percentageString;
|
|
590
|
-
});
|
|
591
|
-
return 'color-mix(in ' + colorSpace + ',' + parts.join(',') + ')';
|
|
452
|
+
return { r: red, g: green, b: blue };
|
|
592
453
|
}
|
|
593
454
|
|
|
594
455
|
/**
|
|
595
|
-
*
|
|
456
|
+
* Converts CIE Lab (D50) components to clamped, gamma-encoded sRGB (0–1).
|
|
596
457
|
*
|
|
597
|
-
* @param {
|
|
598
|
-
* @param {
|
|
599
|
-
* @param {number}
|
|
600
|
-
* @return {
|
|
458
|
+
* @param {number} L The lightness component.
|
|
459
|
+
* @param {number} a The green-red axis component.
|
|
460
|
+
* @param {number} b The blue-yellow axis component.
|
|
461
|
+
* @return {object} An object with r, g, b sRGB components.
|
|
601
462
|
*/
|
|
602
|
-
function
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
g += colors[i][1] * weights[i];
|
|
610
|
-
b += colors[i][2] * weights[i];
|
|
611
|
-
alpha += colors[i][3] * weights[i];
|
|
612
|
-
}
|
|
613
|
-
return rgbaToHex(Math.round(r), Math.round(g), Math.round(b), alpha * alphaMultiplier);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Mix N colors in the OKLab color space using weighted averages.
|
|
618
|
-
*
|
|
619
|
-
* @param {Array} colors Array of [r, g, b, a] color arrays.
|
|
620
|
-
* @param {Array} weights Array of weight values for each color.
|
|
621
|
-
* @param {number} alphaMultiplier Multiplier for the final alpha channel.
|
|
622
|
-
* @return {string} A hex color string.
|
|
623
|
-
*/
|
|
624
|
-
function mixNColorsOklab (colors, weights, alphaMultiplier) {
|
|
625
|
-
const oklabValues = colors.map((color) => {
|
|
626
|
-
return rgbToOklab(color[0], color[1], color[2]);
|
|
627
|
-
});
|
|
628
|
-
let L = 0;
|
|
629
|
-
let a = 0;
|
|
630
|
-
let b = 0;
|
|
631
|
-
let alpha = 0;
|
|
632
|
-
for (let i = 0; i < oklabValues.length; i++) {
|
|
633
|
-
L += oklabValues[i].L * weights[i];
|
|
634
|
-
a += oklabValues[i].a * weights[i];
|
|
635
|
-
b += oklabValues[i].b * weights[i];
|
|
636
|
-
alpha += colors[i][3] * weights[i];
|
|
637
|
-
}
|
|
638
|
-
alpha *= alphaMultiplier;
|
|
639
|
-
const rgb = oklabToRgb(L, a, b);
|
|
640
|
-
return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha >= 1 ? 1 : alpha);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* Evaluate a color-mix() expression. Returns a minified CSS color string or null.
|
|
645
|
-
*
|
|
646
|
-
* @param {string} expr The full color-mix() expression string.
|
|
647
|
-
* @return {string|null} A minified CSS color string, or null if the expression cannot be evaluated.
|
|
648
|
-
*/
|
|
649
|
-
function evaluateColorMix (expr) {
|
|
650
|
-
// Parse: color-mix(in <space> [<hue-method>], <color> [<p>%], <color> [<p>%])
|
|
651
|
-
// We need to handle nested parentheses for inner color functions
|
|
652
|
-
const inner = extractBalancedArgs(expr, 'color-mix');
|
|
653
|
-
if (!inner) {
|
|
654
|
-
return null;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Parse the interpolation method
|
|
658
|
-
const inMatch = inner.match(/^in\s+(srgb|oklch|oklab)(?:\s+shorter\s+hue)?\s*,\s*/i);
|
|
659
|
-
if (!inMatch) {
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const colorSpace = inMatch[1].toLowerCase();
|
|
664
|
-
const rest = inner.slice(inMatch[0].length);
|
|
665
|
-
|
|
666
|
-
// Split color arguments (handling nested parens)
|
|
667
|
-
const args = splitColorMixArgs(rest);
|
|
668
|
-
if (args.length < 2) {
|
|
669
|
-
return null;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// N-color path (3+ colors)
|
|
673
|
-
if (args.length > 2) {
|
|
674
|
-
return evaluateNColorMix(colorSpace, args);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Parse each argument: "<color> [<percentage>]"
|
|
678
|
-
const parsed1 = parseColorMixArg(args[0].trim());
|
|
679
|
-
const parsed2 = parseColorMixArg(args[1].trim());
|
|
680
|
-
if (!parsed1 || !parsed2) {
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// Check for unresolvable colors (var(), currentcolor, etc.)
|
|
685
|
-
if (!parsed1.color || !parsed2.color) {
|
|
686
|
-
// Can still do normalization but not computation
|
|
687
|
-
return normalizeColorMix(colorSpace, parsed1, parsed2);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Normalize percentages per CSS spec
|
|
691
|
-
let p1 = parsed1.percentage;
|
|
692
|
-
let p2 = parsed2.percentage;
|
|
693
|
-
|
|
694
|
-
if (p1 === null && p2 === null) {
|
|
695
|
-
p1 = 50;
|
|
696
|
-
p2 = 50;
|
|
697
|
-
} else if (p1 === null) {
|
|
698
|
-
p1 = 100 - p2;
|
|
699
|
-
} else if (p2 === null) {
|
|
700
|
-
p2 = 100 - p1;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
let alphaMultiplier = 1;
|
|
704
|
-
const pSum = p1 + p2;
|
|
705
|
-
if (pSum === 0) {
|
|
706
|
-
return null;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
if (pSum < 100) {
|
|
710
|
-
alphaMultiplier = pSum / 100;
|
|
711
|
-
} else if (pSum > 100) {
|
|
712
|
-
p1 = p1 / pSum * 100;
|
|
713
|
-
p2 = p2 / pSum * 100;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Trivial cases
|
|
717
|
-
if (p1 === 0) {
|
|
718
|
-
return rgbaToHex(parsed2.color[0], parsed2.color[1], parsed2.color[2], parsed2.color[3]);
|
|
719
|
-
}
|
|
720
|
-
if (p2 === 0) {
|
|
721
|
-
return rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// CSS spec: 'none' channels are missing — fill from the other color before mixing
|
|
725
|
-
const nones1 = findNoneChannels(parsed1.raw);
|
|
726
|
-
const nones2 = findNoneChannels(parsed2.raw);
|
|
727
|
-
for (const idx of nones1) {
|
|
728
|
-
if (idx < parsed1.color.length) {
|
|
729
|
-
parsed1.color[idx] = parsed2.color[idx];
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
for (const idx of nones2) {
|
|
733
|
-
if (idx < parsed2.color.length) {
|
|
734
|
-
parsed2.color[idx] = parsed1.color[idx];
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const t1 = p1 / (p1 + p2);
|
|
739
|
-
const t2 = p2 / (p1 + p2);
|
|
740
|
-
const [r1, g1, b1, a1] = parsed1.color;
|
|
741
|
-
const [r2, g2, b2, a2] = parsed2.color;
|
|
742
|
-
|
|
743
|
-
if (colorSpace === 'srgb') {
|
|
744
|
-
const r = Math.round(r1 * t1 + r2 * t2);
|
|
745
|
-
const g = Math.round(g1 * t1 + g2 * t2);
|
|
746
|
-
const b = Math.round(b1 * t1 + b2 * t2);
|
|
747
|
-
const a = (a1 * t1 + a2 * t2) * alphaMultiplier;
|
|
748
|
-
return rgbaToHex(r, g, b, a);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
if (colorSpace === 'oklab') {
|
|
752
|
-
const lab1 = rgbToOklab(r1, g1, b1);
|
|
753
|
-
const lab2 = rgbToOklab(r2, g2, b2);
|
|
754
|
-
const L = lab1.L * t1 + lab2.L * t2;
|
|
755
|
-
const a = lab1.a * t1 + lab2.a * t2;
|
|
756
|
-
const b = lab1.b * t1 + lab2.b * t2;
|
|
757
|
-
const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
|
|
758
|
-
// Check if result fits in sRGB gamut
|
|
759
|
-
const rgb = oklabToRgb(L, a, b);
|
|
760
|
-
if (alpha >= 1) {
|
|
761
|
-
return rgbaToHex(rgb[0], rgb[1], rgb[2], 1);
|
|
762
|
-
}
|
|
763
|
-
return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
if (colorSpace === 'oklch') {
|
|
767
|
-
const lch1 = rgbToOklch(r1, g1, b1);
|
|
768
|
-
const lch2 = rgbToOklch(r2, g2, b2);
|
|
769
|
-
const L = lch1.L * t1 + lch2.L * t2;
|
|
770
|
-
const C = lch1.C * t1 + lch2.C * t2;
|
|
771
|
-
const H = interpolateHueShorter(lch1.H, lch2.H, t2);
|
|
772
|
-
const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
|
|
773
|
-
return formatOklch(L, C, H, alpha);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
return null;
|
|
463
|
+
function labToSrgb (L, a, b) {
|
|
464
|
+
const linear = labToLinearSrgb(L, a, b);
|
|
465
|
+
return {
|
|
466
|
+
r: Math.max(0, Math.min(1, delinearize(linear.r))),
|
|
467
|
+
g: Math.max(0, Math.min(1, delinearize(linear.g))),
|
|
468
|
+
b: Math.max(0, Math.min(1, delinearize(linear.b)))
|
|
469
|
+
};
|
|
777
470
|
}
|
|
778
471
|
|
|
779
472
|
/**
|
|
780
|
-
*
|
|
473
|
+
* Convert a standalone lab() value (CIE Lab, D50) to hex if it fits in sRGB gamut; returns null if out-of-gamut.
|
|
781
474
|
*
|
|
782
|
-
* @param {
|
|
783
|
-
* @param {
|
|
784
|
-
* @
|
|
475
|
+
* @param {number} L The Lab lightness component.
|
|
476
|
+
* @param {number} a The Lab a-axis component.
|
|
477
|
+
* @param {number} b The Lab b-axis component.
|
|
478
|
+
* @param {number} alpha The alpha value from 0 to 1.
|
|
479
|
+
* @return {string|null} A hex color string, or null if out-of-gamut.
|
|
785
480
|
*/
|
|
786
|
-
function
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
if (start === -1) {
|
|
481
|
+
function convertLabToHex (L, a, b, alpha) {
|
|
482
|
+
const linear = labToLinearSrgb(L, a, b);
|
|
483
|
+
if (linear.r < -0.002 || linear.r > 1.002 || linear.g < -0.002 || linear.g > 1.002 || linear.b < -0.002 || linear.b > 1.002) {
|
|
790
484
|
return null;
|
|
791
485
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
while (position < expr.length && depth > 0) {
|
|
795
|
-
if (expr[position] === '(') {
|
|
796
|
-
depth++;
|
|
797
|
-
} else if (expr[position] === ')') {
|
|
798
|
-
depth--;
|
|
799
|
-
}
|
|
800
|
-
position++;
|
|
801
|
-
}
|
|
802
|
-
return expr.slice(start + prefix.length, position - 1);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* Split color-mix arguments at top-level commas (handling nested parens).
|
|
807
|
-
*
|
|
808
|
-
* @param {string} str The color arguments string, with arguments separated by commas.
|
|
809
|
-
* @return {Array} An array of argument strings split at each top-level comma.
|
|
810
|
-
*/
|
|
811
|
-
function splitColorMixArgs (str) {
|
|
812
|
-
const args = [];
|
|
813
|
-
let depth = 0;
|
|
814
|
-
let start = 0;
|
|
815
|
-
for (let position = 0; position < str.length; position++) {
|
|
816
|
-
if (str[position] === '(') {
|
|
817
|
-
depth++;
|
|
818
|
-
} else if (str[position] === ')') {
|
|
819
|
-
depth--;
|
|
820
|
-
} else if (str[position] === ',' && depth === 0) {
|
|
821
|
-
args.push(str.slice(start, position));
|
|
822
|
-
start = position + 1;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
args.push(str.slice(start));
|
|
826
|
-
return args;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
/**
|
|
830
|
-
* Parse a single color-mix argument: "<color> [<percentage>]" or "<percentage> <color>".
|
|
831
|
-
*
|
|
832
|
-
* @param {string} arg The color-mix argument string to parse.
|
|
833
|
-
* @return {object|null} An object with color (Array or null), percentage (number or null), raw (string), and hasVar (boolean), or null if unparseable.
|
|
834
|
-
*/
|
|
835
|
-
function parseColorMixArg (arg) {
|
|
836
|
-
arg = arg.trim();
|
|
837
|
-
|
|
838
|
-
// Try: percentage at end, e.g. "red 50%" or "rgb(0 0 0)50%"
|
|
839
|
-
let match = arg.match(/^(.+?)\s*(\d+(?:\.\d+)?)%\s*$/);
|
|
840
|
-
if (match) {
|
|
841
|
-
const colorStr = match[1].trim();
|
|
842
|
-
const percentage = parseFloat(match[2]);
|
|
843
|
-
const color = parseColor(colorStr);
|
|
844
|
-
// Check if color contains var() or currentcolor (cannot be evaluated statically)
|
|
845
|
-
return { color, percentage, raw: colorStr, hasVar: /var\(|currentcolor/i.test(colorStr) };
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
// Try: percentage at start, e.g. "50% red"
|
|
849
|
-
match = arg.match(/^(\d+(?:\.\d+)?)%\s+(.+)$/);
|
|
850
|
-
if (match) {
|
|
851
|
-
const colorStr = match[2].trim();
|
|
852
|
-
const percentage = parseFloat(match[1]);
|
|
853
|
-
const color = parseColor(colorStr);
|
|
854
|
-
// Check if color contains var() or currentcolor (cannot be evaluated statically)
|
|
855
|
-
return { color, percentage, raw: colorStr, hasVar: /var\(|currentcolor/i.test(colorStr) };
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// No percentage
|
|
859
|
-
const color = parseColor(arg);
|
|
860
|
-
// Check if color contains var() or currentcolor (cannot be evaluated statically)
|
|
861
|
-
return { color, percentage: null, raw: arg, hasVar: /var\(|currentcolor/i.test(arg) };
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
/**
|
|
865
|
-
* Normalize a color-mix expression when we can't fully compute it.
|
|
866
|
-
*
|
|
867
|
-
* @param {string} colorSpace The interpolation color space ("srgb", "oklab", or "oklch").
|
|
868
|
-
* @param {object} parsed1 The parsed first color argument with color, percentage, and raw fields.
|
|
869
|
-
* @param {object} parsed2 The parsed second color argument with color, percentage, and raw fields.
|
|
870
|
-
* @return {string} A normalized color-mix() expression with default percentages and color space elided.
|
|
871
|
-
*/
|
|
872
|
-
function normalizeColorMix (colorSpace, parsed1, parsed2) {
|
|
873
|
-
// Normalize percentages: strip explicit 50%/50% (the defaults)
|
|
874
|
-
let p1Str = '';
|
|
875
|
-
let p2Str = '';
|
|
876
|
-
if (parsed1.percentage !== null && parsed1.percentage !== 50) {
|
|
877
|
-
p1Str = ' ' + parsed1.percentage + '%';
|
|
878
|
-
}
|
|
879
|
-
if (parsed2.percentage !== null && parsed2.percentage !== 50) {
|
|
880
|
-
p2Str = ' ' + parsed2.percentage + '%';
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// Use the raw color strings (but try to minify known colors)
|
|
884
|
-
let c1 = parsed1.raw;
|
|
885
|
-
let c2 = parsed2.raw;
|
|
886
|
-
if (parsed1.color) {
|
|
887
|
-
c1 = rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
|
|
888
|
-
}
|
|
889
|
-
if (parsed2.color) {
|
|
890
|
-
c2 = rgbaToHex(parsed2.color[0], parsed2.color[1], parsed2.color[2], parsed2.color[3]);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// oklab is the default interpolation method per CSS Color 5 — elide it
|
|
894
|
-
const spacePrefix = colorSpace === 'oklab' ? '' : 'in ' + colorSpace + ',';
|
|
895
|
-
return 'color-mix(' + spacePrefix + c1 + p1Str + ',' + c2 + p2Str + ')';
|
|
486
|
+
const srgb = labToSrgb(L, a, b);
|
|
487
|
+
return rgbaToHex(Math.round(srgb.r * 255), Math.round(srgb.g * 255), Math.round(srgb.b * 255), alpha !== undefined ? alpha : 1);
|
|
896
488
|
}
|
|
897
489
|
|
|
898
490
|
/**
|
|
@@ -916,26 +508,6 @@ function convertOklabToHex (L, a, b, alpha) {
|
|
|
916
508
|
return rgbaToHex(r, g, bl, alpha !== undefined ? alpha : 1);
|
|
917
509
|
}
|
|
918
510
|
|
|
919
|
-
/**
|
|
920
|
-
* Handle color(from ...) relative color syntax for simple identity cases.
|
|
921
|
-
*
|
|
922
|
-
* @param {string} expr The color(from ...) expression string.
|
|
923
|
-
* @return {string|null} A hex color string if the relative color is a simple identity transform, or null otherwise.
|
|
924
|
-
*/
|
|
925
|
-
function evaluateRelativeColor (expr) {
|
|
926
|
-
// Match: color(from <base-color> srgb r g b [/ <alpha>]) identity transform pattern
|
|
927
|
-
const match = expr.match(/^color\(\s*from\s+(.+?)\s+srgb\s+r\s+g\s+b(?:\s*\/\s*([\d.]+%?))?\s*\)$/i);
|
|
928
|
-
if (!match) {
|
|
929
|
-
return null;
|
|
930
|
-
}
|
|
931
|
-
const baseColor = parseColor(match[1]);
|
|
932
|
-
if (!baseColor) {
|
|
933
|
-
return null;
|
|
934
|
-
}
|
|
935
|
-
const alpha = parseAlphaString(match[2], baseColor[3]);
|
|
936
|
-
return rgbaToHex(baseColor[0], baseColor[1], baseColor[2], alpha);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
511
|
export {
|
|
940
512
|
hslToRgbChannels,
|
|
941
513
|
rgbaToHex,
|
|
@@ -943,9 +515,8 @@ export {
|
|
|
943
515
|
namedColors,
|
|
944
516
|
parseColor,
|
|
945
517
|
parseHex,
|
|
946
|
-
|
|
518
|
+
convertLabToHex,
|
|
947
519
|
convertOklabToHex,
|
|
948
|
-
evaluateRelativeColor,
|
|
949
520
|
shortestColor,
|
|
950
521
|
srgbToOklab,
|
|
951
522
|
oklabToSrgb,
|