@@ -159,7 +159,7 @@ export const colorFunctions = [
159
159
160
160
] ;
161
161
162
- const colorFunctionNameRegExp = / ^ ( r g b | r g b a | h s l | h s l a | h w b | l a b | l c h ) $ / i ;
162
+ const colorFunctionNameRegExp = / ^ (?: r g b a ? | h s l a ? | h w b | l a b | l c h | o k l a b | o k l c h ) $ / iu ;
163
163
164
164
export const colors : { [ name : string ] : string } = {
165
165
aliceblue : '#f0f8ff' ,
@@ -336,27 +336,48 @@ function getNumericValue(node: nodes.Node, factor: number, lowerLimit: number =
336
336
throw new Error ( ) ;
337
337
}
338
338
339
- function getAngle ( node : nodes . Node ) {
340
- const val = node . getText ( ) ;
341
- const m = val . match ( / ^ ( [ - + ] ? [ 0 - 9 ] * \. ? [ 0 - 9 ] + ) ( d e g | r a d | g r a d | t u r n ) ? $ / ) ;
342
- if ( m ) {
343
- switch ( m [ 2 ] ) {
344
- case 'deg' :
345
- return parseFloat ( val ) % 360 ;
346
- case 'rad' :
347
- return ( parseFloat ( val ) * 180 / Math . PI ) % 360 ;
348
- case 'grad' :
349
- return ( parseFloat ( val ) * 0.9 ) % 360 ;
350
- case 'turn' :
351
- return ( parseFloat ( val ) * 360 ) % 360 ;
352
- default :
353
- if ( 'undefined' === typeof m [ 2 ] ) {
354
- return parseFloat ( val ) % 360 ;
339
+ const DEGREES_PER_CIRCLE = 360 ; // Number of degrees in a full circle
340
+ const GRAD_TO_DEGREE_FACTOR = 0.9 ; // Conversion factor: grads to degrees
341
+ const RADIANS_TO_DEGREES_FACTOR = DEGREES_PER_CIRCLE / 2 / Math . PI ; // Conversion factor: radians to degrees
342
+
343
+ function getAngle ( node : nodes . Node ) : number {
344
+ const textValue = node . getText ( ) ;
345
+
346
+ // Hue angle keyword `none` is the equivilient of `0deg`
347
+ if ( textValue === 'none' ) {
348
+ return 0 ;
349
+ }
350
+
351
+ const m = / ^ (?< numberString > [ - + ] ? [ 0 - 9 ] * \. ? [ 0 - 9 ] + ) (?< unit > d e g | r a d | g r a d | t u r n ) ? $ / iu. exec ( textValue ) ;
352
+ if ( m ?. groups ?. [ 'numberString' ] ) {
353
+ const value = Number . parseFloat ( m . groups [ 'numberString' ] ) ;
354
+ if ( ! Number . isNaN ( value ) ) {
355
+ switch ( m . groups [ 'unit' ] ) {
356
+ case 'deg' : {
357
+ return value % DEGREES_PER_CIRCLE ;
358
+ }
359
+
360
+ case 'grad' : {
361
+ return ( value * GRAD_TO_DEGREE_FACTOR ) % DEGREES_PER_CIRCLE ;
362
+ }
363
+
364
+ case 'rad' : {
365
+ return ( value * RADIANS_TO_DEGREES_FACTOR ) % DEGREES_PER_CIRCLE ;
366
+ }
367
+
368
+ case 'turn' : {
369
+ return ( value * DEGREES_PER_CIRCLE ) % DEGREES_PER_CIRCLE ;
370
+ }
371
+
372
+ default : {
373
+ // Unitless angles are treated as degrees
374
+ return value % DEGREES_PER_CIRCLE ;
355
375
}
376
+ }
356
377
}
357
378
}
358
379
359
- throw new Error ( ) ;
380
+ throw new Error ( `Failed to parse ' ${ textValue } ' as angle` ) ;
360
381
}
361
382
362
383
export function isColorConstructor ( node : nodes . Function ) : boolean {
@@ -589,6 +610,34 @@ export function xyzFromLAB(lab: LAB): XYZ {
589
610
return xyz ;
590
611
}
591
612
613
+ export function xyzFromOKLAB ( lab : LAB ) : XYZ {
614
+ // Convert from OKLab to XYZ
615
+ // References: https://bottosson.github.io/posts/oklab/
616
+
617
+ // lab.l is in 0-1 range
618
+ // lab.a and lab.b are in -0.4 to 0.4 range
619
+ const l = lab . l + 0.396_337_777_4 * lab . a + 0.215_803_757_3 * lab . b ;
620
+ const m = lab . l - 0.105_561_345_8 * lab . a - 0.063_854_172_8 * lab . b ;
621
+ const s = lab . l - 0.089_484_177_5 * lab . a - 1.291_485_548 * lab . b ;
622
+
623
+ // Apply non-linearity using exponentiation
624
+ const l3 = l ** 3 ;
625
+ const m3 = m ** 3 ;
626
+ const s3 = s ** 3 ;
627
+
628
+ // Convert to XYZ
629
+ const x = 1.227_013_851_1 * l3 - 0.557_799_980_7 * m3 + 0.281_256_149 * s3 ;
630
+ const y = - 0.040_580_178_4 * l3 + 1.112_256_869_6 * m3 - 0.071_676_678_7 * s3 ;
631
+ const z = - 0.076_381_284_5 * l3 - 0.421_481_978_4 * m3 + 1.586_163_220_4 * s3 ;
632
+
633
+ return {
634
+ x : x * 100 ,
635
+ y : y * 100 ,
636
+ z : z * 100 ,
637
+ alpha : lab . alpha ?? 1 ,
638
+ } ;
639
+ }
640
+
592
641
export function xyzToRGB ( xyz : XYZ ) : Color {
593
642
const x = xyz . x / 100 ;
594
643
const y = xyz . y / 100 ;
@@ -683,59 +732,146 @@ export function XYZtoLAB(xyz: XYZ, round: Boolean = true): LAB {
683
732
}
684
733
}
685
734
735
+ export function XYZtoOKLAB ( xyz : XYZ , round = true ) : LAB {
736
+ // Convert XYZ to OKLab
737
+ // References: https://bottosson.github.io/posts/oklab/
738
+
739
+ // Normalize XYZ values
740
+ const x = xyz . x / 100 ;
741
+ const y = xyz . y / 100 ;
742
+ const z = xyz . z / 100 ;
743
+
744
+ // Convert to LMS
745
+ const l = 0.818_933_010_1 * x + 0.361_866_742_4 * y - 0.128_859_713_7 * z ;
746
+ const m = 0.032_984_543_6 * x + 0.929_311_871_5 * y + 0.036_145_638_7 * z ;
747
+ const s = 0.048_200_301_8 * x + 0.264_366_269_1 * y + 0.633_851_707 * z ;
748
+
749
+ // Apply non-linearity
750
+ const l_ = Math . cbrt ( l ) ;
751
+ const m_ = Math . cbrt ( m ) ;
752
+ const s_ = Math . cbrt ( s ) ;
753
+
754
+ // Convert to OKLab
755
+ const L = 0.210_454_255_3 * l_ + 0.793_617_785 * m_ - 0.004_072_046_8 * s_ ;
756
+ const a = 1.977_998_495_1 * l_ - 2.428_592_205 * m_ + 0.450_593_709_9 * s_ ;
757
+ const b = 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766 * s_ ;
758
+
759
+ return round
760
+ // 5 decimal places for precision
761
+ ? {
762
+ l : Number ( L . toFixed ( 5 ) ) ,
763
+ a : Number ( a . toFixed ( 5 ) ) ,
764
+ b : Number ( b . toFixed ( 5 ) ) ,
765
+ alpha : xyz . alpha ,
766
+ }
767
+ : {
768
+ l : L ,
769
+ a,
770
+ b,
771
+ alpha : xyz . alpha ,
772
+ } ;
773
+ }
774
+
686
775
export function labFromColor ( rgba : Color , round : Boolean = true ) : LAB {
687
776
const xyz : XYZ = RGBtoXYZ ( rgba ) ;
688
777
const lab : LAB = XYZtoLAB ( xyz , round ) ;
689
778
return lab ;
690
779
}
691
- export function lchFromColor ( rgba : Color ) : LCH {
692
- const lab : LAB = labFromColor ( rgba , false ) ;
780
+
781
+ export function oklabFromColor ( rgba : Color , round = true ) : LAB {
782
+ const xyz : XYZ = RGBtoXYZ ( rgba ) ;
783
+ const lab : LAB = XYZtoOKLAB ( xyz , round ) ;
784
+ // Convert lightness to a percentage of oklab
785
+ return { ...lab , l : lab . l * 100 } ;
786
+ }
787
+
788
+ /**
789
+ * Calculate chroma and hue from Lab values
790
+ * Returns LCH values without formatting/rounding
791
+ */
792
+ function labToLCH ( lab : LAB ) : LCH {
693
793
const c : number = Math . sqrt ( Math . pow ( lab . a , 2 ) + Math . pow ( lab . b , 2 ) ) ;
694
- let h : number = Math . atan2 ( lab . b , lab . a ) * ( 180 / Math . PI ) ;
794
+ let h : number = Math . atan2 ( lab . b , lab . a ) * RADIANS_TO_DEGREES_FACTOR ;
695
795
while ( h < 0 ) {
696
796
h = h + 360 ;
697
797
}
698
798
return {
699
- l : Math . round ( ( lab . l + Number . EPSILON ) * 100 ) / 100 ,
700
- c : Math . round ( ( c + Number . EPSILON ) * 100 ) / 100 ,
701
- h : Math . round ( ( h + Number . EPSILON ) * 100 ) / 100 ,
702
- alpha : lab . alpha
799
+ l : lab . l ,
800
+ c : c ,
801
+ h : h ,
802
+ alpha : lab . alpha ,
703
803
} ;
704
804
}
705
805
706
- export function colorFromLAB ( l : number , a : number , b : number , alpha : number = 1.0 ) : Color {
707
- const lab : LAB = {
708
- l,
709
- a,
710
- b,
711
- alpha
806
+ export function lchFromColor ( rgba : Color ) : LCH {
807
+ const lab : LAB = labFromColor ( rgba , false ) ;
808
+ const lch : LCH = labToLCH ( lab ) ;
809
+
810
+ return {
811
+ l : Math . round ( ( lch . l + Number . EPSILON ) * 100 ) / 100 ,
812
+ c : Math . round ( ( lch . c + Number . EPSILON ) * 100 ) / 100 ,
813
+ h : Math . round ( ( lch . h + Number . EPSILON ) * 100 ) / 100 ,
814
+ alpha : lch . alpha ,
815
+ } ;
816
+ }
817
+
818
+ export function oklchFromColor ( rgba : Color ) : LCH {
819
+ const lab : LAB = oklabFromColor ( rgba , false ) ;
820
+ const lch : LCH = labToLCH ( lab ) ;
821
+
822
+ return {
823
+ l : Number ( ( lch . l ) . toFixed ( 3 ) ) ,
824
+ c : Number ( lch . c . toFixed ( 5 ) ) ,
825
+ h : Number ( lch . h . toFixed ( 3 ) ) ,
826
+ alpha : lch . alpha ,
712
827
} ;
713
- const xyz = xyzFromLAB ( lab ) ;
828
+ }
829
+
830
+ /**
831
+ * Generic function to convert LAB/OKLAB to Color
832
+ */
833
+ function labToColor ( lab : LAB , xyzConverter : ( lab : LAB ) => XYZ ) : Color {
834
+ const xyz = xyzConverter ( lab ) ;
714
835
const rgb = xyzToRGB ( xyz ) ;
715
836
return {
716
- red : ( rgb . red >= 0 ? ( rgb . red <= 255 ? rgb . red : 255 ) : 0 ) / 255.0 ,
717
- green : ( rgb . green >= 0 ? ( rgb . green <= 255 ? rgb . green : 255 ) : 0 ) / 255.0 ,
718
- blue : ( rgb . blue >= 0 ? ( rgb . blue <= 255 ? rgb . blue : 255 ) : 0 ) / 255.0 ,
719
- alpha
837
+ red : ( rgb . red >= 0 ? Math . min ( rgb . red , 255 ) : 0 ) / 255 ,
838
+ green : ( rgb . green >= 0 ? Math . min ( rgb . green , 255 ) : 0 ) / 255 ,
839
+ blue : ( rgb . blue >= 0 ? Math . min ( rgb . blue , 255 ) : 0 ) / 255 ,
840
+ alpha : lab . alpha ?? 1 ,
720
841
} ;
721
842
}
722
843
844
+ export function colorFromLAB ( l : number , a : number , b : number , alpha = 1 ) : Color {
845
+ return labToColor ( { l, a, b, alpha } , xyzFromLAB ) ;
846
+ }
847
+
848
+ export function colorFromOKLAB ( l : number , a : number , b : number , alpha = 1 ) : Color {
849
+ return labToColor ( { l, a, b, alpha } , xyzFromOKLAB ) ;
850
+ }
851
+
723
852
export interface LAB { l : number ; a : number ; b : number ; alpha ?: number ; }
724
853
725
- export function labFromLCH ( l : number , c : number , h : number , alpha : number = 1.0 ) : LAB {
854
+ const DEGREES_TO_RADIANS_FACTOR = Math . PI / 180 ;
855
+
856
+ export function labFromLCH ( l : number , c : number , h : number , alpha = 1 ) : LAB {
726
857
return {
727
858
l : l ,
728
- a : c * Math . cos ( h * ( Math . PI / 180 ) ) ,
729
- b : c * Math . sin ( h * ( Math . PI / 180 ) ) ,
730
- alpha : alpha
859
+ a : c * Math . cos ( h * DEGREES_TO_RADIANS_FACTOR ) ,
860
+ b : c * Math . sin ( h * DEGREES_TO_RADIANS_FACTOR ) ,
861
+ alpha : alpha ,
731
862
} ;
732
863
}
733
864
734
- export function colorFromLCH ( l : number , c : number , h : number , alpha : number = 1.0 ) : Color {
865
+ export function colorFromLCH ( l : number , c : number , h : number , alpha = 1 ) : Color {
735
866
const lab : LAB = labFromLCH ( l , c , h , alpha ) ;
736
867
return colorFromLAB ( lab . l , lab . a , lab . b , alpha ) ;
737
868
}
738
869
870
+ export function colorFromOKLCH ( l : number , c : number , h : number , alpha = 1 ) : Color | null {
871
+ const lab : LAB = labFromLCH ( l , c , h , alpha ) ; // Conversion is the same as LCH->LAB for OKLCH-OKLAB
872
+ return colorFromOKLAB ( lab . l , lab . a , lab . b , alpha ) ;
873
+ }
874
+
739
875
export interface LCH { l : number ; c : number ; h : number ; alpha ?: number ; }
740
876
741
877
export function getColorValue ( node : nodes . Node ) : Color | null {
@@ -764,39 +900,67 @@ export function getColorValue(node: nodes.Node): Color | null {
764
900
if ( ! name || colorValues . length < 3 || colorValues . length > 4 ) {
765
901
return null ;
766
902
}
903
+
767
904
try {
768
905
const alpha = colorValues . length === 4 ? getNumericValue ( colorValues [ 3 ] , 1 ) : 1 ;
769
- if ( name === 'rgb' || name === 'rgba' ) {
770
- return {
771
- red : getNumericValue ( colorValues [ 0 ] , 255.0 ) ,
772
- green : getNumericValue ( colorValues [ 1 ] , 255.0 ) ,
773
- blue : getNumericValue ( colorValues [ 2 ] , 255.0 ) ,
774
- alpha
775
- } ;
776
- } else if ( name === 'hsl' || name === 'hsla' ) {
777
- const h = getAngle ( colorValues [ 0 ] ) ;
778
- const s = getNumericValue ( colorValues [ 1 ] , 100.0 ) ;
779
- const l = getNumericValue ( colorValues [ 2 ] , 100.0 ) ;
780
- return colorFromHSL ( h , s , l , alpha ) ;
781
- } else if ( name === 'hwb' ) {
782
- const h = getAngle ( colorValues [ 0 ] ) ;
783
- const w = getNumericValue ( colorValues [ 1 ] , 100.0 ) ;
784
- const b = getNumericValue ( colorValues [ 2 ] , 100.0 ) ;
785
- return colorFromHWB ( h , w , b , alpha ) ;
786
- } else if ( name === 'lab' ) {
787
- // Reference: https://mina86.com/2021/srgb-lab-lchab-conversions/
788
- const l = getNumericValue ( colorValues [ 0 ] , 100.0 ) ;
789
- // Since these two values can be negative, a lower limit of -1 has been added
790
- const a = getNumericValue ( colorValues [ 1 ] , 125.0 , - 1 ) ;
791
- const b = getNumericValue ( colorValues [ 2 ] , 125.0 , - 1 ) ;
792
- return colorFromLAB ( l * 100 , a * 125 , b * 125 , alpha ) ;
793
- } else if ( name === 'lch' ) {
794
- const l = getNumericValue ( colorValues [ 0 ] , 100.0 ) ;
795
- const c = getNumericValue ( colorValues [ 1 ] , 230.0 ) ;
796
- const h = getAngle ( colorValues [ 2 ] ) ;
797
- return colorFromLCH ( l * 100 , c * 230 , h , alpha ) ;
906
+ switch ( name ) {
907
+ case 'rgb' :
908
+ case 'rgba' : {
909
+ return {
910
+ red : getNumericValue ( colorValues [ 0 ] , 255 ) ,
911
+ green : getNumericValue ( colorValues [ 1 ] , 255 ) ,
912
+ blue : getNumericValue ( colorValues [ 2 ] , 255 ) ,
913
+ alpha,
914
+ } ;
915
+ }
916
+
917
+ case 'hsl' :
918
+ case 'hsla' : {
919
+ const h = getAngle ( colorValues [ 0 ] ) ;
920
+ const s = getNumericValue ( colorValues [ 1 ] , 100 ) ;
921
+ const l = getNumericValue ( colorValues [ 2 ] , 100 ) ;
922
+ return colorFromHSL ( h , s , l , alpha ) ;
923
+ }
924
+
925
+ case 'hwb' : {
926
+ const h = getAngle ( colorValues [ 0 ] ) ;
927
+ const w = getNumericValue ( colorValues [ 1 ] , 100 ) ;
928
+ const b = getNumericValue ( colorValues [ 2 ] , 100 ) ;
929
+ return colorFromHWB ( h , w , b , alpha ) ;
930
+ }
931
+
932
+ case 'lab' : {
933
+ // Reference: https://mina86.com/2021/srgb-lab-lchab-conversions/
934
+ const l = getNumericValue ( colorValues [ 0 ] , 100 ) ;
935
+ // Since these two values can be negative, a lower limit of -1 has been added
936
+ const a = getNumericValue ( colorValues [ 1 ] , 125 , - 1 ) ;
937
+ const b = getNumericValue ( colorValues [ 2 ] , 125 , - 1 ) ;
938
+ return colorFromLAB ( l * 100 , a * 125 , b * 125 , alpha ) ;
939
+ }
940
+
941
+ case 'lch' : {
942
+ const l = getNumericValue ( colorValues [ 0 ] , 100 ) ;
943
+ const c = getNumericValue ( colorValues [ 1 ] , 230 ) ;
944
+ const h = getAngle ( colorValues [ 2 ] ) ;
945
+ return colorFromLCH ( l * 100 , c * 230 , h , alpha ) ;
946
+ }
947
+
948
+ case 'oklab' : {
949
+ const l = getNumericValue ( colorValues [ 0 ] , 1 ) ;
950
+ // Since these two values can be negative, a lower limit of -1 has been added
951
+ const a = getNumericValue ( colorValues [ 1 ] , 0.4 , - 1 ) ;
952
+ const b = getNumericValue ( colorValues [ 2 ] , 0.4 , - 1 ) ;
953
+ return colorFromOKLAB ( l , a * 0.4 , b * 0.4 , alpha ) ;
954
+ }
955
+
956
+ case 'oklch' : {
957
+ const l = getNumericValue ( colorValues [ 0 ] , 1 ) ;
958
+ const c = getNumericValue ( colorValues [ 1 ] , 0.4 ) ;
959
+ const h = getAngle ( colorValues [ 2 ] ) ;
960
+ return colorFromOKLCH ( l , c * 0.4 , h , alpha ) ;
961
+ }
798
962
}
799
- } catch ( e ) {
963
+ } catch {
800
964
// parse error on numeric value
801
965
return null ;
802
966
}
0 commit comments