269 lines
6.6 KiB
TypeScript
269 lines
6.6 KiB
TypeScript
import {
|
|
alphaText,
|
|
asColor,
|
|
clamp,
|
|
mappedColor,
|
|
numberText,
|
|
round,
|
|
srgbChannels,
|
|
toColourValue,
|
|
} from "./internal";
|
|
import { parseColour } from "./parse";
|
|
import type {
|
|
ColourFormat,
|
|
ColourInput,
|
|
ConversionRow,
|
|
FormatColourOptions,
|
|
SrgbPreview,
|
|
} from "./types";
|
|
|
|
const FORMAT_LABELS: Record<ColourFormat, string> = {
|
|
hex: "HEX",
|
|
hex8: "HEX + alpha",
|
|
rgb: "RGB",
|
|
rgba: "RGBA",
|
|
hsl: "HSL",
|
|
hsv: "HSV / HSB",
|
|
hwb: "HWB",
|
|
cmyk: "CMYK",
|
|
lab: "CIELAB",
|
|
lch: "CIELCH",
|
|
oklab: "OKLab",
|
|
oklch: "OKLCH",
|
|
p3: "Display P3",
|
|
rec2020: "Rec. 2020",
|
|
a98rgb: "Adobe RGB (1998)",
|
|
prophoto: "ProPhoto RGB",
|
|
"xyz-d50": "XYZ D50",
|
|
"xyz-d65": "XYZ D65",
|
|
css: "Original CSS",
|
|
};
|
|
|
|
export const DEFAULT_CONVERSION_FORMATS: readonly ColourFormat[] = [
|
|
"hex",
|
|
"hex8",
|
|
"rgb",
|
|
"hsl",
|
|
"hsv",
|
|
"hwb",
|
|
"cmyk",
|
|
"lab",
|
|
"lch",
|
|
"oklab",
|
|
"oklch",
|
|
"p3",
|
|
"rec2020",
|
|
] as const;
|
|
|
|
function byte(value: number): number {
|
|
return Math.round(clamp(value) * 255);
|
|
}
|
|
|
|
function byteHex(value: number): string {
|
|
return byte(value).toString(16).padStart(2, "0");
|
|
}
|
|
|
|
function asHex(input: ColourInput, includeAlpha: boolean): string {
|
|
const value = parseColour(input);
|
|
const channels = srgbChannels(value);
|
|
const rgb = channels.map(byteHex).join("");
|
|
return `#${rgb}${includeAlpha ? byteHex(value.alpha) : ""}`;
|
|
}
|
|
|
|
function percent(value: number, precision: number): string {
|
|
return `${numberText(value, precision)}%`;
|
|
}
|
|
|
|
function functional(
|
|
name: string,
|
|
coords: readonly string[],
|
|
alpha: number,
|
|
includeAlpha: boolean,
|
|
): string {
|
|
return `${name}(${coords.join(" ")}${includeAlpha && alpha < 1 ? ` / ${alphaText(alpha)}` : ""})`;
|
|
}
|
|
|
|
function serialiseColorFunction(
|
|
space: string,
|
|
coords: readonly number[],
|
|
alpha: number,
|
|
precision: number,
|
|
): string {
|
|
return `color(${space} ${coords.map((coordinate) => numberText(coordinate, precision)).join(" ")}${
|
|
alpha < 1 ? ` / ${alphaText(alpha)}` : ""
|
|
})`;
|
|
}
|
|
|
|
export function formatColour(
|
|
input: ColourInput,
|
|
format: ColourFormat = "css",
|
|
options: FormatColourOptions = {},
|
|
): string {
|
|
const precision = options.precision ?? 4;
|
|
const value = parseColour(input);
|
|
const alpha = value.alpha;
|
|
const includeAlpha = options.includeAlpha ?? alpha < 1;
|
|
|
|
if (format === "hex") return asHex(value, false);
|
|
if (format === "hex8") return asHex(value, true);
|
|
|
|
if (format === "rgb" || format === "rgba") {
|
|
const channels = srgbChannels(value, options.mapToSrgb ?? true).map(byte);
|
|
const forceAlpha = format === "rgba" || includeAlpha;
|
|
return forceAlpha
|
|
? `rgba(${channels.join(", ")}, ${alphaText(alpha)})`
|
|
: `rgb(${channels.join(", ")})`;
|
|
}
|
|
|
|
if (format === "cmyk") {
|
|
const [red, green, blue] = srgbChannels(value, options.mapToSrgb ?? true);
|
|
const black = 1 - Math.max(red, green, blue);
|
|
const denominator = 1 - black;
|
|
const cyan = denominator <= 1e-12 ? 0 : (1 - red - black) / denominator;
|
|
const magenta =
|
|
denominator <= 1e-12 ? 0 : (1 - green - black) / denominator;
|
|
const yellow = denominator <= 1e-12 ? 0 : (1 - blue - black) / denominator;
|
|
return functional(
|
|
"cmyk",
|
|
[cyan, magenta, yellow, black].map((channel) =>
|
|
percent(channel * 100, precision),
|
|
),
|
|
alpha,
|
|
includeAlpha,
|
|
);
|
|
}
|
|
|
|
const color = asColor(value);
|
|
if (format === "hsl" || format === "hsv" || format === "hwb") {
|
|
const converted = color.to(format);
|
|
const [hue, first, second] = converted.coords.map((coordinate) =>
|
|
Number(coordinate ?? 0),
|
|
);
|
|
return functional(
|
|
format,
|
|
[
|
|
numberText(hue ?? 0, precision),
|
|
percent(first ?? 0, precision),
|
|
percent(second ?? 0, precision),
|
|
],
|
|
alpha,
|
|
includeAlpha,
|
|
);
|
|
}
|
|
|
|
const space =
|
|
format === "p3"
|
|
? "p3"
|
|
: format === "a98rgb"
|
|
? "a98rgb"
|
|
: format === "prophoto"
|
|
? "prophoto"
|
|
: format;
|
|
|
|
if (format === "lab" || format === "lch") {
|
|
const converted = color.to(space);
|
|
const coords = converted.coords.map((coordinate) =>
|
|
Number(coordinate ?? 0),
|
|
);
|
|
return functional(
|
|
format,
|
|
[
|
|
percent(coords[0] ?? 0, precision),
|
|
numberText(coords[1] ?? 0, precision),
|
|
numberText(coords[2] ?? 0, precision),
|
|
],
|
|
alpha,
|
|
includeAlpha,
|
|
);
|
|
}
|
|
if (format === "oklab" || format === "oklch") {
|
|
const converted = color.to(space);
|
|
const coords = converted.coords.map((coordinate) =>
|
|
Number(coordinate ?? 0),
|
|
);
|
|
return functional(
|
|
format,
|
|
[
|
|
percent((coords[0] ?? 0) * 100, precision),
|
|
numberText(coords[1] ?? 0, precision),
|
|
numberText(coords[2] ?? 0, precision),
|
|
],
|
|
alpha,
|
|
includeAlpha,
|
|
);
|
|
}
|
|
if (
|
|
["p3", "rec2020", "a98rgb", "prophoto", "xyz-d50", "xyz-d65"].includes(
|
|
format,
|
|
)
|
|
) {
|
|
const converted = color.to(space);
|
|
const cssSpace =
|
|
format === "p3"
|
|
? "display-p3"
|
|
: format === "a98rgb"
|
|
? "a98-rgb"
|
|
: format === "prophoto"
|
|
? "prophoto-rgb"
|
|
: format;
|
|
return serialiseColorFunction(
|
|
cssSpace,
|
|
converted.coords.map((coordinate) =>
|
|
round(Number(coordinate ?? 0), precision),
|
|
),
|
|
alpha,
|
|
precision,
|
|
);
|
|
}
|
|
|
|
if (format === "css") {
|
|
return color.toString({ precision, inGamut: options.mapToSrgb === true });
|
|
}
|
|
|
|
// Exhaustiveness safeguard for callers compiled against older declarations.
|
|
return color.toString({ precision, inGamut: false });
|
|
}
|
|
|
|
export function conversionRows(
|
|
input: ColourInput,
|
|
formats: readonly ColourFormat[] = DEFAULT_CONVERSION_FORMATS,
|
|
): ConversionRow[] {
|
|
return formats.map((format) => {
|
|
const value = formatColour(input, format);
|
|
return {
|
|
id: format,
|
|
label: FORMAT_LABELS[format],
|
|
value,
|
|
copyValue: value,
|
|
};
|
|
});
|
|
}
|
|
|
|
export const formatRows = conversionRows;
|
|
|
|
export function toSrgbPreview(input: ColourInput): SrgbPreview {
|
|
const value = parseColour(input);
|
|
const original = asColor(value);
|
|
const mapped = mappedColor(value, "srgb");
|
|
const rgb = mapped.coords.map((coordinate) =>
|
|
clamp(Number(coordinate ?? 0)),
|
|
) as [number, number, number];
|
|
const integerChannels = rgb.map(byte);
|
|
return {
|
|
css:
|
|
value.alpha < 1
|
|
? `rgb(${integerChannels.join(" ")} / ${alphaText(value.alpha)})`
|
|
: `rgb(${integerChannels.join(" ")})`,
|
|
hex: formatColour(toColourValue(mapped), value.alpha < 1 ? "hex8" : "hex", {
|
|
includeAlpha: value.alpha < 1,
|
|
}),
|
|
rgb,
|
|
alpha: value.alpha,
|
|
wasMapped: !original.inGamut("srgb"),
|
|
};
|
|
}
|
|
|
|
export function toSrgbCss(input: ColourInput): string {
|
|
return toSrgbPreview(input).css;
|
|
}
|