@@ -159,7 +159,7 @@ export const colorFunctions = [
159159
160160] ;
161161
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 ;
163163
164164export const colors : { [ name : string ] : string } = {
165165 aliceblue : '#f0f8ff' ,
@@ -336,27 +336,48 @@ function getNumericValue(node: nodes.Node, factor: number, lowerLimit: number =
336336 throw new Error ( ) ;
337337}
338338
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 ;
355375 }
376+ }
356377 }
357378 }
358379
359- throw new Error ( ) ;
380+ throw new Error ( `Failed to parse ' ${ textValue } ' as angle` ) ;
360381}
361382
362383export function isColorConstructor ( node : nodes . Function ) : boolean {
@@ -589,6 +610,34 @@ export function xyzFromLAB(lab: LAB): XYZ {
589610 return xyz ;
590611}
591612
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+
592641export function xyzToRGB ( xyz : XYZ ) : Color {
593642 const x = xyz . x / 100 ;
594643 const y = xyz . y / 100 ;
@@ -683,59 +732,146 @@ export function XYZtoLAB(xyz: XYZ, round: Boolean = true): LAB {
683732 }
684733}
685734
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+
686775export function labFromColor ( rgba : Color , round : Boolean = true ) : LAB {
687776 const xyz : XYZ = RGBtoXYZ ( rgba ) ;
688777 const lab : LAB = XYZtoLAB ( xyz , round ) ;
689778 return lab ;
690779}
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 {
693793 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 ;
695795 while ( h < 0 ) {
696796 h = h + 360 ;
697797 }
698798 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 ,
703803 } ;
704804}
705805
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 ,
712827 } ;
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 ) ;
714835 const rgb = xyzToRGB ( xyz ) ;
715836 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 ,
720841 } ;
721842}
722843
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+
723852export interface LAB { l : number ; a : number ; b : number ; alpha ?: number ; }
724853
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 {
726857 return {
727858 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 ,
731862 } ;
732863}
733864
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 {
735866 const lab : LAB = labFromLCH ( l , c , h , alpha ) ;
736867 return colorFromLAB ( lab . l , lab . a , lab . b , alpha ) ;
737868}
738869
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+
739875export interface LCH { l : number ; c : number ; h : number ; alpha ?: number ; }
740876
741877export function getColorValue ( node : nodes . Node ) : Color | null {
@@ -764,39 +900,67 @@ export function getColorValue(node: nodes.Node): Color | null {
764900 if ( ! name || colorValues . length < 3 || colorValues . length > 4 ) {
765901 return null ;
766902 }
903+
767904 try {
768905 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+ }
798962 }
799- } catch ( e ) {
963+ } catch {
800964 // parse error on numeric value
801965 return null ;
802966 }
0 commit comments