@thejaredwilcurt/csslop 0.0.17 → 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.
@@ -1,12 +1,9 @@
1
1
  /**
2
- * @file Provides color parsing, color space conversion (sRGB, OKLab, OKLCH, HSL, HWB), color-mix evaluation, and hex formatting for CSS value minification.
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
- * Format an OKLCH result as a minified string.
410
+ * CIE Lab D50 reference white point.
434
411
  *
435
- * @param {number} L The lightness component.
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
- function formatOklch (L, C, H, alpha) {
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
- * Detect which channel indices have 'none' in a raw color function string.
417
+ * Converts a CIE Lab f-function value back to a linear-light ratio.
453
418
  *
454
- * @param {string} rawColorStr The raw CSS color function string (e.g. "rgb(none 0 0)").
455
- * @return {Array} An array of zero-based channel indices where 'none' was found.
419
+ * @param {number} f The f value.
420
+ * @return {number} The linear ratio.
456
421
  */
457
- function findNoneChannels (rawColorStr) {
458
- const indices = [];
459
- // Match rgb/rgba/hsl/hsla/hwb function calls and extract their arguments
460
- const functionMatch = rawColorStr.match(/\b(?:rgba?|hsla?|hwb)\(([^)]*)\)/i);
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 indices;
427
+ return 3 * delta * delta * (f - 16 / 116);
475
428
  }
476
429
 
477
430
  /**
478
- * Evaluate an N-color (3+) color-mix() expression. Returns a minified CSS color string or null.
431
+ * Converts CIE Lab (D50) components to linear-light sRGB using Bradford adaptation to D65.
479
432
  *
480
- * @param {string} colorSpace The interpolation color space ("srgb", "oklab", or "oklch").
481
- * @param {Array} args The raw argument strings for each color.
482
- * @return {string|null} A minified CSS color string, or null if the expression cannot be evaluated.
483
- */
484
- function evaluateNColorMix (colorSpace, args) {
485
- const parsedArgs = [];
486
- for (const arg of args) {
487
- const parsed = parseColorMixArg(arg.trim());
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
- }
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
- * Build a whitespace-stripped color-mix() string for an unresolvable N-color expression.
456
+ * Converts CIE Lab (D50) components to clamped, gamma-encoded sRGB (0–1).
580
457
  *
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.
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 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(',') + ')';
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
- * Extract the balanced content inside a function call.
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 {string} expr The expression string containing the function call.
783
- * @param {string} funcName The function name to locate (e.g. "color-mix").
784
- * @return {string|null} The content between the matching parentheses, or null if not found.
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 extractBalancedArgs (expr, funcName) {
787
- const prefix = funcName + '(';
788
- const start = expr.indexOf(prefix);
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
- let depth = 1;
793
- let position = start + prefix.length;
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
- evaluateColorMix,
518
+ convertLabToHex,
927
519
  convertOklabToHex,
928
520
  shortestColor,
929
521
  srgbToOklab,
@@ -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