Files
colour-tools/src/colour/format.ts
T

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;
}