Skip to content

Commit 4d94b30

Browse files
committed
feature:(microsoft#305) Add support for oklab and oklch color functions
__RESOLVES:__ _feature_ - *Add new CSS color functions* \[[microsoft#305](https://github.com/microsoft/vscode-css-languageservice/issues/305)\] __DESCRIPTION:__ CSS Color Module Level 4 adds support for `oklch` and `oklab` to browsers. They are becoming more popular, and I wanted to use them with color previews in my editor. This feature is built of of the original [microsoft#306](microsoft#306) which included steps for `lab` and `lch` but not the `ok` variants. Generally this code was designed using real world color [examples](https://www.oddcontrast.com/#hex__*f00__*4d216f80__srgb). __STEPS TO TEST:__
1 parent d50a147 commit 4d94b30

File tree

4 files changed

+352
-79
lines changed

4 files changed

+352
-79
lines changed

src/languageFacts/colors.ts

Lines changed: 235 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const colorFunctions = [
159159

160160
];
161161

162-
const colorFunctionNameRegExp = /^(rgb|rgba|hsl|hsla|hwb|lab|lch)$/i;
162+
const colorFunctionNameRegExp = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)$/iu;
163163

164164
export 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]+)(deg|rad|grad|turn)?$/);
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>deg|rad|grad|turn)?$/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

362383
export 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+
592641
export 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+
686775
export 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+
723852
export 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+
739875
export interface LCH { l: number; c: number; h: number; alpha?: number; }
740876

741877
export 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

Comments
 (0)