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