@thejaredwilcurt/csslop 0.0.17 → 0.0.19
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/package.json +8 -9
- package/src/rules/custom-properties.js +89 -0
- package/src/rules/selectors.js +304 -0
- package/src/rules/stringify.js +36 -380
- package/src/value/color-mix.js +500 -0
- package/src/value/colors.js +58 -466
- package/src/value/minify.js +33 -1
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
|
-
* @
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
}
|
|
519
|
-
|
|
520
|
-
// Compute weights
|
|
521
|
-
const totalPercentage = percentages.reduce((sum, value) => {
|
|
522
|
-
return sum + value;
|
|
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
|
-
}
|
|
534
|
-
|
|
535
|
-
if (colorSpace === 'oklab') {
|
|
536
|
-
return mixNColorsOklab(colors, weights, alphaMultiplier);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
return null;
|
|
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;
|
|
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.
|
|
437
|
+
*/
|
|
438
|
+
function labToLinearSrgb (L, a, b) {
|
|
439
|
+
const fy = (L + 16) / 116;
|
|
440
|
+
const fx = fy + a / 500;
|
|
441
|
+
const fz = fy - b / 200;
|
|
442
|
+
|
|
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];
|
|
446
|
+
|
|
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;
|
|
451
|
+
|
|
452
|
+
return { r: red, g: green, b: blue };
|
|
576
453
|
}
|
|
577
454
|
|
|
578
455
|
/**
|
|
579
|
-
*
|
|
456
|
+
* Converts CIE Lab (D50) components to clamped, gamma-encoded sRGB (0–1).
|
|
580
457
|
*
|
|
581
|
-
* @param {
|
|
582
|
-
* @param {
|
|
583
|
-
* @
|
|
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.
|
|
584
462
|
*/
|
|
585
|
-
function
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
* Mix N colors in the sRGB color space using weighted averages.
|
|
596
|
-
*
|
|
597
|
-
* @param {Array} colors Array of [r, g, b, a] color arrays.
|
|
598
|
-
* @param {Array} weights Array of weight values for each color.
|
|
599
|
-
* @param {number} alphaMultiplier Multiplier for the final alpha channel.
|
|
600
|
-
* @return {string} A hex color string.
|
|
601
|
-
*/
|
|
602
|
-
function mixNColorsSrgb (colors, weights, alphaMultiplier) {
|
|
603
|
-
let r = 0;
|
|
604
|
-
let g = 0;
|
|
605
|
-
let b = 0;
|
|
606
|
-
let alpha = 0;
|
|
607
|
-
for (let i = 0; i < colors.length; i++) {
|
|
608
|
-
r += colors[i][0] * weights[i];
|
|
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
|
/**
|
|
@@ -923,7 +515,7 @@ export {
|
|
|
923
515
|
namedColors,
|
|
924
516
|
parseColor,
|
|
925
517
|
parseHex,
|
|
926
|
-
|
|
518
|
+
convertLabToHex,
|
|
927
519
|
convertOklabToHex,
|
|
928
520
|
shortestColor,
|
|
929
521
|
srgbToOklab,
|
package/src/value/minify.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
import { isUnicodeCharset } from '../context.js';
|
|
6
6
|
import { resolveUnicodeEscape } from '../utilities.js';
|
|
7
7
|
|
|
8
|
+
import { evaluateColorMix } from './color-mix.js';
|
|
8
9
|
import {
|
|
10
|
+
convertLabToHex,
|
|
9
11
|
convertOklabToHex,
|
|
10
|
-
evaluateColorMix,
|
|
11
12
|
hslToRgbChannels,
|
|
12
13
|
hwbToRgbChannels,
|
|
13
14
|
parseHex,
|
|
@@ -359,6 +360,24 @@ function convertColorsToHex (val) {
|
|
|
359
360
|
}
|
|
360
361
|
}
|
|
361
362
|
|
|
363
|
+
// Convert in-gamut lab() (CIE Lab, D50) to hex when it produces a shorter representation
|
|
364
|
+
val = val.replace(/\blab\(\s*(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lStr, aStr, bStr, alphaStr) => {
|
|
365
|
+
const alpha = parseAlphaString(alphaStr);
|
|
366
|
+
const l = parseFloat(lStr);
|
|
367
|
+
const aNumber = parseFloat(aStr);
|
|
368
|
+
const a = aStr.endsWith('%') ? aNumber * 1.25 : aNumber;
|
|
369
|
+
const bNumber = parseFloat(bStr);
|
|
370
|
+
const b = bStr.endsWith('%') ? bNumber * 1.25 : bNumber;
|
|
371
|
+
const hex = convertLabToHex(l, a, b, alpha);
|
|
372
|
+
if (!hex) {
|
|
373
|
+
return match; // out-of-gamut: keep native lab form
|
|
374
|
+
}
|
|
375
|
+
if (hex.length < match.length) {
|
|
376
|
+
return hex;
|
|
377
|
+
}
|
|
378
|
+
return match;
|
|
379
|
+
});
|
|
380
|
+
|
|
362
381
|
// Minify whitespace and numeric precision inside wide-gamut and functional color notations
|
|
363
382
|
val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
|
|
364
383
|
// Collapse whitespace to single space
|
|
@@ -401,6 +420,14 @@ function convertColorsToHex (val) {
|
|
|
401
420
|
}
|
|
402
421
|
return before + rounded;
|
|
403
422
|
});
|
|
423
|
+
// Remove trailing ".0" from numbers so integer channel values stay integer
|
|
424
|
+
// (e.g. display-p3 1.0 0.0 0.0 becomes display-p3 1 0 0)
|
|
425
|
+
minified = minified.replace(/(-?\d*)\.0\b/g, (match, integer) => {
|
|
426
|
+
if (integer === '' || integer === '-' || integer === '-0') {
|
|
427
|
+
return '0';
|
|
428
|
+
}
|
|
429
|
+
return integer;
|
|
430
|
+
});
|
|
404
431
|
return func + '(' + minified.trim() + ')';
|
|
405
432
|
});
|
|
406
433
|
|
|
@@ -717,6 +744,11 @@ function applyPropertyOptimizations (val, property) {
|
|
|
717
744
|
});
|
|
718
745
|
}
|
|
719
746
|
|
|
747
|
+
if (property === 'syntax') {
|
|
748
|
+
// Remove whitespace around pipe separators in @property syntax descriptors
|
|
749
|
+
val = val.replace(/\s*\|\s*/g, '|');
|
|
750
|
+
}
|
|
751
|
+
|
|
720
752
|
// Simplify clamp() where all three arguments are identical (e.g. clamp(1rem,1rem,1rem) → 1rem)
|
|
721
753
|
val = val.replace(/\bclamp\(([^,]+),\1,\1\)/gi, '$1');
|
|
722
754
|
|