Skip to content

Commit

Permalink
🎨 Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
thibthib committed May 17, 2020
0 parents commit cb5d5b2
Show file tree
Hide file tree
Showing 30 changed files with 4,223 additions and 0 deletions.
Binary file added .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Color space conversion utilities

As part of the [CSS Color Module Level 4 specification](https://drafts.csswg.org/css-color-4), two new color notation are being proposed: `lch()` and `lab()`. Some browsers are already working on implementing those! In order to use them without breaking browser support, we need to be able to convert those notations to older notations, like `rgb()` or `color()`.

Those functions have been ported from the code written by [Chris Lilley](https://svgees.us/) in the CSS Color specification direclty.
36 changes: 36 additions & 0 deletions color-spaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export enum ColorSpace {
AdobeRGB = "AdobeRGB",
CMYK = "CMYK",
HSL = "HSL",
Lab = "Lab",
LCH = "LCH",
P3 = "P3",
ProPhoto = "ProPhoto",
Rec2020 = "Rec2020",
sRGB = "sRGB",
XYZ = "XYZ",
}

export type RGBBasedSpace =
| ColorSpace.AdobeRGB
| ColorSpace.P3
| ColorSpace.ProPhoto
| ColorSpace.Rec2020
| ColorSpace.sRGB;

export const isRGBBased = (space: ColorSpace): space is RGBBasedSpace =>
space === ColorSpace.AdobeRGB ||
space === ColorSpace.P3 ||
space === ColorSpace.ProPhoto ||
space === ColorSpace.Rec2020 ||
space === ColorSpace.sRGB;

export type CSSSpace =
| ColorSpace.AdobeRGB
| ColorSpace.HSL
| ColorSpace.Lab
| ColorSpace.LCH
| ColorSpace.P3
| ColorSpace.ProPhoto
| ColorSpace.Rec2020
| ColorSpace.sRGB;
33 changes: 33 additions & 0 deletions color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { AdobeRGB } from "./spaces/AdobeRGB";
import { CMYK } from "./spaces/CMYK";
import { HSL } from "./spaces/HSL";
import { Lab } from "./spaces/Lab";
import { LCH } from "./spaces/LCH";
import { P3 } from "./spaces/P3";
import { ProPhoto } from "./spaces/ProPhoto";
import { Rec2020 } from "./spaces/Rec2020";
import { sRGB } from "./spaces/sRGB";
import { XYZ } from "./spaces/XYZ";

export type Color =
| AdobeRGB
| CMYK
| HSL
| Lab
| LCH
| P3
| ProPhoto
| Rec2020
| sRGB
| XYZ;
export type CSSColor =
| AdobeRGB
| HSL
| Lab
| LCH
| P3
| ProPhoto
| Rec2020
| sRGB;

export type { AdobeRGB, CMYK, HSL, Lab, LCH, P3, ProPhoto, Rec2020, sRGB, XYZ };
49 changes: 49 additions & 0 deletions css/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { sRGB } from "../spaces/sRGB";
import { P3 } from "../spaces/P3";
import { ProPhoto } from "../spaces/ProPhoto";
import { AdobeRGB } from "../spaces/AdobeRGB";
import { Rec2020 } from "../spaces/Rec2020";
import { ColorSpace } from "../color-spaces";

export type PredefinedSpaces =
| ColorSpace.AdobeRGB
| ColorSpace.P3
| ColorSpace.ProPhoto
| ColorSpace.Rec2020
| ColorSpace.sRGB;

const stringToSpace: { [key: string]: PredefinedSpaces } = {
"a98-rgb": ColorSpace.AdobeRGB,
"display-p3": ColorSpace.P3,
"prophoto-rgb": ColorSpace.ProPhoto,
rec2020: ColorSpace.Rec2020,
sRGB: ColorSpace.sRGB,
};

// color(space, value / ?alpha)
export const parse = (
color: string
): sRGB | P3 | ProPhoto | AdobeRGB | Rec2020 | null => {
const parsed = color.match(/\((.*?)\)/);
if (parsed === null) {
return null;
}
const [space, ...values] = parsed[1].split(" ");
const [red, green, blue] = values.map((value: string) =>
Number.parseFloat(value)
);
return { type: stringToSpace[space], values: [red, green, blue] };
};

export const stringify = (
color: AdobeRGB | P3 | ProPhoto | Rec2020 | sRGB,
alpha?: string
): string => {
const [red, green, blue] = color.values.map((value) => value.toFixed(4));
const space = Object.entries(stringToSpace).find(
([_, value]) => value === color.type
);
return `color(${space ? space[0] : null} ${red} ${green} ${blue}${
alpha ? ` / ${alpha}` : ""
})`;
};
24 changes: 24 additions & 0 deletions css/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ColorSpace, isRGBBased, CSSSpace } from "../color-spaces";
import { parse } from "./parse";
import { LCH } from "../color";
import { convertColorToSpace } from "../spaces/convertColorToSpace";
import { stringify } from "./stringify";
import { forceIntoGamut } from "./gamut-correction";

const startLCH = parse("lch(60% 67 266)") as LCH;
const endLCH = parse("lch(79% 73 175)") as LCH;

export const convertCSSColor = (colorString: string, to: CSSSpace) => {
let color = parse(colorString);

if (color === null) {
return null;
}

if (color.type === ColorSpace.LCH && isRGBBased(to)) {
color = forceIntoGamut(color, to);
}

const converted = convertColorToSpace(color, to);
return stringify(converted);
};
40 changes: 40 additions & 0 deletions css/gamut-correction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { convertColorToSpace } from "../spaces/convertColorToSpace";
import { RGBBasedSpace } from "../color-spaces";
import { LCH } from "../spaces/LCH";

const isLCHWithinRGBSpace = (color: LCH, rgbSpace: RGBBasedSpace) => {
const rgb = convertColorToSpace(color, rgbSpace);
const ε = 0.000005;
return rgb.values.reduce(
(a: boolean, b: number) => a && b >= 0 - ε && b <= 1 + ε,
true
);
};

export const forceIntoGamut = (color: LCH, rgbSpace: RGBBasedSpace): LCH => {
let [l, c, h] = color.values;
// Moves an lch color into the sRGB gamut
// by holding the l and h steady,
// and adjusting the c via binary-search
// until the color is on the sRGB boundary.
if (isLCHWithinRGBSpace(color, rgbSpace)) {
return color;
}

let hiC = c;
let loC = 0;
const ε = 0.0001;
c /= 2;

// .0001 chosen fairly arbitrarily as "close enough"
while (hiC - loC > ε) {
if (isLCHWithinRGBSpace({ ...color, values: [l, c, h] }, rgbSpace)) {
loC = c;
} else {
hiC = c;
}
c = (hiC + loC) / 2;
}

return { ...color, values: [l, c, h] };
};
25 changes: 25 additions & 0 deletions css/hexadecimal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { sRGB } from "../spaces/sRGB";
import { ColorSpace } from "../color-spaces";

export const parse = (color: string): sRGB | null => {
const parsed = color
.replace("#", "")
// shorthand notation
.match(color.length < 7 ? /.{1}/g : /.{2}/g);

if (parsed === null || parsed.length < 3) {
return null;
}
const [red, green, blue] = parsed.map(
(value) => Number.parseInt(value, 16) / (value.length === 1 ? 15 : 255)
);

return { type: ColorSpace.sRGB, values: [red, green, blue] };
};

export const stringify = (color: sRGB, alpha?: string): string => {
const [red, green, blue] = color.values.map((value) =>
(value * 255).toString(16)
);
return `#${red}${green}${blue}${alpha ? `${alpha}` : ""}`;
};
42 changes: 42 additions & 0 deletions css/hsl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { HSL } from "../spaces/HSL";
import { ColorSpace } from "../color-spaces";

// the hue needs to be a number in the half-open range [0, 6)
const parseAngle = (angle: string) => {
let angleValue = Number.parseFloat(angle);
if (angle.includes("grad")) {
angleValue /= 400;
} else if (angle.includes("rad")) {
angleValue /= 2 * Math.PI;
} else if (angle.includes("turn")) {
angleValue /= 1;
} else {
// let's assume it's degrees
angleValue /= 360;
}

return (angleValue === 1 ? 0 : angleValue) * 6;
};

// hsl(h s l / a)
// hue is an angle (deg, grad, rad, turn)
// saturation and lightness are percentages
export const parse = (color: string): HSL | null => {
const parsed = color.match(/^[+-]?\d+(\.\d+)?$/g);
if (parsed === null || parsed.length < 3) {
return null;
}
const [hue, saturation, lightness] = parsed.map((value) =>
value.includes("%") ? Number.parseFloat(value) / 100 : parseAngle(value)
);
return { type: ColorSpace.HSL, values: [hue, saturation, lightness] };
};

export const stringify = (color: HSL, alpha?: string): string => {
const [hue, saturation, lightness] = color.values.map((value, index) =>
index === 0
? `${((value / 6) * 360).toFixed(4)}deg`
: `${(value * 100).toFixed(2)}%`
);
return `hsl(${hue} ${saturation} ${lightness}${alpha ? ` / ${alpha}` : ""})`;
};
23 changes: 23 additions & 0 deletions css/lab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Lab } from "../spaces/Lab";
import { ColorSpace } from "../color-spaces";

// lab(l a b / a)
// l is a percentage from 0 to Infinity
// a and b are unbounded numbers
// we do not process alpha values
export const parse = (color: string): Lab | null => {
const parsed = color.match(/-?[\d.%?]+/g);
if (parsed === null || parsed.length < 3) {
return null;
}
const [L, a, b] = parsed.map(Number.parseFloat);
return { type: ColorSpace.Lab, values: [L, a, b] };
};

export const stringify = (color: Lab, alpha?: string): string => {
const [L, a, b] = color.values.map(
(value: number, index: number) =>
`${value.toFixed(index === 0 ? 2 : 4)}${index === 0 ? "%" : ""}`
);
return `lab(${L} ${a} ${b}${alpha ? ` / ${alpha}` : ""})`;
};
23 changes: 23 additions & 0 deletions css/lch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { LCH } from "../spaces/LCH";
import { ColorSpace } from "../color-spaces";

// lch(l c h / a)
// l is a percentage from 0 to Infinity
// c and h are unbounded numbers
// we do not process alpha values
export const parse = (color: string): LCH | null => {
const parsed = color.match(/-?[\d.%?]+/g);
if (parsed === null || parsed.length < 3) {
return null;
}
const [l, c, h] = parsed.map(Number.parseFloat);
return { type: ColorSpace.LCH, values: [l, c, h] };
};

export const stringify = (color: LCH, alpha?: string): string => {
const [L, C, H] = color.values.map(
(value: number, index: number) =>
`${value.toFixed(index === 0 ? 2 : 4)}${index === 0 ? "%" : ""}`
);
return `lch(${L} ${C} ${H}${alpha ? ` / ${alpha}` : ""})`;
};
22 changes: 22 additions & 0 deletions css/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { parse as colorParse } from "./color";
import { parse as hexadecimalParse } from "./hexadecimal";
import { parse as hslParse } from "./hsl";
import { parse as labParse } from "./lab";
import { parse as lchParse } from "./lch";
import { parse as rgbParse } from "./rgb";
import { CSSColor } from "../color";

export const parse = (color: string): CSSColor | null => {
if (color.includes("color(")) {
return colorParse(color);
} else if (color.includes("hsl(")) {
return hslParse(color);
} else if (color.includes("lab(")) {
return labParse(color);
} else if (color.includes("lch(")) {
return lchParse(color);
} else if (color.startsWith("#")) {
return hexadecimalParse(color);
}
return rgbParse(color);
};
25 changes: 25 additions & 0 deletions css/rgb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { sRGB } from "../spaces/sRGB";
import { ColorSpace } from "../color-spaces";

// rgb(r g b / a)
// red, green and blue values can be either a percentage or a number between 0 and 255
// we do not process alpha values
export const parse = (color: string): sRGB | null => {
const parsed = color.match(/-?[\d.%?]+/g);
if (parsed === null || parsed.length < 3) {
return null;
}
const [red, green, blue] = parsed.map((value: string) =>
value.includes("%")
? Number.parseFloat(value) / 100
: Number.parseFloat(value) / 255
);
return { type: ColorSpace.sRGB, values: [red, green, blue] };
};

export const stringify = (color: sRGB, alpha?: string): string => {
const [red, green, blue] = color.values.map(
(value: number) => `${(value * 100).toFixed(2)}%`
);
return `rgb(${red} ${green} ${blue}${alpha ? ` / ${alpha}` : ""})`;
};
25 changes: 25 additions & 0 deletions css/stringify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ColorSpace } from "../color-spaces";
import { stringify as colorStringify } from "./color";
import { stringify as hslStringify } from "./hsl";
import { stringify as labStringify } from "./lab";
import { stringify as lchStringify } from "./lch";
import { stringify as rgbStringify } from "./rgb";
import { CSSColor } from "../color";

export const stringify = (color: CSSColor): string => {
switch (color.type) {
case ColorSpace.AdobeRGB:
case ColorSpace.P3:
case ColorSpace.ProPhoto:
case ColorSpace.Rec2020:
return colorStringify(color);
case ColorSpace.HSL:
return hslStringify(color);
case ColorSpace.Lab:
return labStringify(color);
case ColorSpace.LCH:
return lchStringify(color);
case ColorSpace.sRGB:
return rgbStringify(color);
}
};
3 changes: 3 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./color-spaces";
export * from "./spaces/convertColorToSpace";
export * from "./css/convert";
Loading

0 comments on commit cb5d5b2

Please sign in to comment.