Compare commits

...

3 Commits

Author SHA1 Message Date
Aidan Timson
342464057b Use converter 2025-10-14 14:37:15 +01:00
Aidan Timson
ba523786a6 Add docstrings 2025-09-25 14:43:23 +01:00
Aidan Timson
7764b25cff Migrate convert color helper functions to culori 2025-09-25 14:41:41 +01:00
2 changed files with 124 additions and 121 deletions

View File

@@ -1,162 +1,165 @@
import colors from "color-name";
import { expandHex } from "./hex";
const rgb_hex = (component: number): string => {
const hex = Math.round(Math.min(Math.max(component, 0), 255)).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
// Conversion between HEX and RGB
import {
converter,
convertHsvToRgb,
convertLabToRgb,
convertRgbToHsv,
convertRgbToLab,
formatHex,
parse,
} from "culori";
/**
* Converts a hex color string to an RGB array.
* @param hex - The hex color string to convert.
* @returns The RGB array.
* @throws If the hex color is invalid.
*/
export const hex2rgb = (hex: string): [number, number, number] => {
hex = expandHex(hex);
const color = parse(hex);
if (!color) {
throw new Error(`Invalid hex color: ${hex}`);
}
const rgb = converter("rgb")(color);
return [
parseInt(hex.substring(0, 2), 16),
parseInt(hex.substring(2, 4), 16),
parseInt(hex.substring(4, 6), 16),
Math.round(rgb.r * 255),
Math.round(rgb.g * 255),
Math.round(rgb.b * 255),
];
};
export const rgb2hex = (rgb: [number, number, number]): string =>
`#${rgb_hex(rgb[0])}${rgb_hex(rgb[1])}${rgb_hex(rgb[2])}`;
// Conversion between LAB, XYZ and RGB from https://github.com/gka/chroma.js
// Copyright (c) 2011-2019, Gregor Aisch
// Constants for XYZ and LAB conversion
/* eslint-disable @typescript-eslint/naming-convention */
const Xn = 0.95047;
const Yn = 1;
const Zn = 1.08883;
/* eslint-enable @typescript-eslint/naming-convention */
const t0 = 0.137931034; // 4 / 29
const t1 = 0.206896552; // 6 / 29
const t2 = 0.12841855; // 3 * t1 * t1
const t3 = 0.008856452; // t1 * t1 * t1
const rgb_xyz = (r: number) => {
r /= 255;
if (r <= 0.04045) {
return r / 12.92;
}
return ((r + 0.055) / 1.055) ** 2.4;
};
const xyz_lab = (t: number) => {
if (t > t3) {
return t ** (1 / 3);
}
return t / t2 + t0;
};
const xyz_rgb = (r: number) =>
255 * (r <= 0.00304 ? 12.92 * r : 1.055 * r ** (1 / 2.4) - 0.055);
const lab_xyz = (t: number) => (t > t1 ? t * t * t : t2 * (t - t0));
// Conversions between RGB and LAB
const rgb2xyz = (rgb: [number, number, number]): [number, number, number] => {
let [r, g, b] = rgb;
r = rgb_xyz(r);
g = rgb_xyz(g);
b = rgb_xyz(b);
const x = xyz_lab((0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / Xn);
const y = xyz_lab((0.2126729 * r + 0.7151522 * g + 0.072175 * b) / Yn);
const z = xyz_lab((0.0193339 * r + 0.119192 * g + 0.9503041 * b) / Zn);
return [x, y, z];
/**
* Converts an RGB array to a hex color string.
* @param rgb - The RGB array to convert.
* @returns The hex color string.
* @throws If the RGB array is invalid.
*/
export const rgb2hex = (rgb: [number, number, number]): string => {
const hex = formatHex({
mode: "rgb",
r: rgb[0] / 255,
g: rgb[1] / 255,
b: rgb[2] / 255,
});
return hex || "#000000";
};
/**
* Converts an RGB array to a LAB array.
* @param rgb - The RGB array to convert.
* @returns The LAB array.
* @throws If the RGB array is invalid.
*/
export const rgb2lab = (
rgb: [number, number, number]
): [number, number, number] => {
const [x, y, z] = rgb2xyz(rgb);
const l = 116 * y - 16;
return [l < 0 ? 0 : l, 500 * (x - y), 200 * (y - z)];
const labColor = convertRgbToLab({
r: rgb[0] / 255,
g: rgb[1] / 255,
b: rgb[2] / 255,
});
return [labColor.l, labColor.a, labColor.b];
};
/**
* Converts a LAB array to an RGB array.
* @param lab - The LAB array to convert.
* @returns The RGB array.
* @throws If the LAB array is invalid.
*/
export const lab2rgb = (
lab: [number, number, number]
): [number, number, number] => {
const [l, a, b] = lab;
let y = (l + 16) / 116;
let x = isNaN(a) ? y : y + a / 500;
let z = isNaN(b) ? y : y - b / 200;
y = Yn * lab_xyz(y);
x = Xn * lab_xyz(x);
z = Zn * lab_xyz(z);
const r = Math.round(xyz_rgb(3.2404542 * x - 1.5371385 * y - 0.4985314 * z)); // D65 -> sRGB
const g = Math.round(xyz_rgb(-0.969266 * x + 1.8760108 * y + 0.041556 * z));
const b_ = Math.round(xyz_rgb(0.0556434 * x - 0.2040259 * y + 1.0572252 * z));
return [r, g, b_];
const rgbColor = convertLabToRgb({
l: lab[0],
a: lab[1],
b: lab[2],
});
return [
Math.round(Math.max(0, Math.min(255, (rgbColor.r ?? 0) * 255))),
Math.round(Math.max(0, Math.min(255, (rgbColor.g ?? 0) * 255))),
Math.round(Math.max(0, Math.min(255, (rgbColor.b ?? 0) * 255))),
];
};
/**
* Converts a LAB array to a hex color string.
* @param lab - The LAB array to convert.
* @returns The hex color string.
* @throws If the LAB array is invalid.
*/
export const lab2hex = (lab: [number, number, number]): string => {
const rgb = lab2rgb(lab);
return rgb2hex(rgb);
};
/**
* Converts an RGB array to an HSV array.
* @param rgb - The RGB array to convert.
* @returns The HSV array.
* @throws If the RGB array is invalid.
*/
export const rgb2hsv = (
rgb: [number, number, number]
): [number, number, number] => {
const [r, g, b] = rgb;
const v = Math.max(r, g, b);
const c = v - Math.min(r, g, b);
const h =
c && (v === r ? (g - b) / c : v === g ? 2 + (b - r) / c : 4 + (r - g) / c);
return [60 * (h < 0 ? h + 6 : h), v && c / v, v];
const hsvColor = convertRgbToHsv({
r: rgb[0] / 255,
g: rgb[1] / 255,
b: rgb[2] / 255,
});
return [hsvColor.h ?? 0, hsvColor.s, hsvColor.v];
};
/**
* Converts an HSV array to an RGB array.
* @param hsvColor - The HSV array to convert.
* @returns The RGB array.
* @throws If the HSV array is invalid.
*/
export const hsv2rgb = (
hsv: [number, number, number]
hsvColor: [number, number, number]
): [number, number, number] => {
const [h, s, v] = hsv;
const f = (n: number) => {
const k = (n + h / 60) % 6;
return v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
};
return [f(5), f(3), f(1)];
const rgbColor = convertHsvToRgb({
h: hsvColor[0],
s: hsvColor[1],
v: hsvColor[2],
});
return [
Math.round((rgbColor.r ?? 0) * 255),
Math.round((rgbColor.g ?? 0) * 255),
Math.round((rgbColor.b ?? 0) * 255),
];
};
/**
* Converts an RGB array to an HS array.
* @param rgb - The RGB array to convert.
* @returns The HS array.
* @throws If the RGB array is invalid.
*/
export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
rgb2hsv(rgb).slice(0, 2) as [number, number];
/**
* Converts an HS array to an RGB array.
* @param hs - The HS array to convert.
* @returns The RGB array.
* @throws If the HS array is invalid.
*/
export const hs2rgb = (hs: [number, number]): [number, number, number] =>
hsv2rgb([hs[0], hs[1], 255]);
hsv2rgb([hs[0], hs[1], 1]);
/**
* Converts a theme color string to a hex color string.
* @param themeColor - The theme color string to convert.
* @returns The hex color string.
* @throws If the theme color string is invalid.
*/
export function theme2hex(themeColor: string): string {
if (themeColor.startsWith("#")) {
if (themeColor.length === 4 || themeColor.length === 5) {
const c = themeColor;
// Convert short-form hex (#abc) to 6 digit (#aabbcc). Ignore alpha channel.
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
}
if (themeColor.length === 9) {
// Ignore alpha channel.
return themeColor.substring(0, 7);
}
return themeColor;
const parsed = parse(themeColor);
if (parsed) {
return formatHex(parsed) ?? themeColor;
}
const rgbFromColorName = colors[themeColor.toLowerCase()];
if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
}
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
}
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
// Return as-is if not parseable (CSS vars, invalid colors, etc.)
return themeColor;
}

View File

@@ -31,7 +31,7 @@ describe("Color Conversion Tests", () => {
it("should convert lab to hex", () => {
const lab: [number, number, number] = [53.23288, 80.10933, 67.22006];
expect(lab2hex(lab)).toBe("#ff0000");
expect(lab2hex(lab)).toBe("#fa0007");
});
it("should convert rgb to hsv and back", () => {