feat: release Colour Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
asColor,
|
||||
clamp,
|
||||
colorFrom,
|
||||
lineariseSrgb,
|
||||
srgbChannels,
|
||||
toColourValue,
|
||||
} from "./internal";
|
||||
import { compositeSourceOver } from "./composite";
|
||||
import { formatColour, toSrgbCss } from "./format";
|
||||
import { mapToGamut } from "./gamut";
|
||||
import { parseColour } from "./parse";
|
||||
import type {
|
||||
ColourInput,
|
||||
ColourValue,
|
||||
ContrastOptions,
|
||||
ContrastReport,
|
||||
ContrastSuggestion,
|
||||
} from "./types";
|
||||
|
||||
const WHITE: ColourValue = { space: "srgb", coords: [1, 1, 1], alpha: 1 };
|
||||
|
||||
export function flattenColour(
|
||||
foreground: ColourInput,
|
||||
background: ColourInput,
|
||||
): ColourValue {
|
||||
// Browser alpha compositing for ordinary CSS sRGB colours is performed in the
|
||||
// encoded colour space; contrast must inspect the pixels users actually see.
|
||||
return compositeSourceOver(foreground, background, { space: "srgb" }).colour;
|
||||
}
|
||||
|
||||
function opaqueCanvas(input: ColourInput | undefined): ColourValue {
|
||||
const canvas = parseColour(input ?? WHITE);
|
||||
return canvas.alpha >= 1 ? canvas : flattenColour(canvas, WHITE);
|
||||
}
|
||||
|
||||
function flattenedPair(
|
||||
foreground: ColourInput,
|
||||
background: ColourInput,
|
||||
options: ContrastOptions,
|
||||
): { foreground: ColourValue; background: ColourValue } {
|
||||
const canvas = opaqueCanvas(options.canvas);
|
||||
const flattenedBackground = flattenColour(background, canvas);
|
||||
const flattenedForeground = flattenColour(foreground, flattenedBackground);
|
||||
return { foreground: flattenedForeground, background: flattenedBackground };
|
||||
}
|
||||
|
||||
export function relativeLuminance(input: ColourInput): number {
|
||||
const [red, green, blue] = srgbChannels(parseColour(input));
|
||||
return (
|
||||
0.2126 * lineariseSrgb(red) +
|
||||
0.7152 * lineariseSrgb(green) +
|
||||
0.0722 * lineariseSrgb(blue)
|
||||
);
|
||||
}
|
||||
|
||||
export function contrastRatio(
|
||||
foreground: ColourInput,
|
||||
background: ColourInput,
|
||||
options: ContrastOptions = {},
|
||||
): number {
|
||||
const pair = flattenedPair(foreground, background, options);
|
||||
const first = relativeLuminance(pair.foreground);
|
||||
const second = relativeLuminance(pair.background);
|
||||
const lighter = Math.max(first, second);
|
||||
const darker = Math.min(first, second);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
export function contrastReport(
|
||||
foreground: ColourInput,
|
||||
background: ColourInput,
|
||||
options: ContrastOptions = {},
|
||||
): ContrastReport {
|
||||
const foregroundValue = parseColour(foreground);
|
||||
const backgroundValue = parseColour(background);
|
||||
const pair = flattenedPair(foregroundValue, backgroundValue, options);
|
||||
const ratio = contrastRatio(foregroundValue, backgroundValue, options);
|
||||
return {
|
||||
ratio,
|
||||
foreground: foregroundValue,
|
||||
background: backgroundValue,
|
||||
flattenedForeground: pair.foreground,
|
||||
flattenedBackground: pair.background,
|
||||
passes: {
|
||||
aaLarge: ratio >= 3,
|
||||
aaaLarge: ratio >= 4.5,
|
||||
aaNormal: ratio >= 4.5,
|
||||
aaaNormal: ratio >= 7,
|
||||
nonText: ratio >= 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
colour: ColourValue;
|
||||
ratio: number;
|
||||
difference: number;
|
||||
lightness: number;
|
||||
}
|
||||
|
||||
function candidateAtLightness(
|
||||
original: ColourValue,
|
||||
oklch: readonly number[],
|
||||
lightness: number,
|
||||
background: ColourInput,
|
||||
target: number,
|
||||
options: ContrastOptions,
|
||||
): Candidate | undefined {
|
||||
const candidate = mapToGamut(
|
||||
toColourValue(
|
||||
colorFrom(
|
||||
"oklch",
|
||||
[lightness, oklch[1] ?? 0, oklch[2] ?? 0],
|
||||
original.alpha,
|
||||
),
|
||||
),
|
||||
);
|
||||
candidate.alpha = original.alpha;
|
||||
const ratio = contrastRatio(candidate, background, options);
|
||||
if (ratio + 1e-9 < target) return undefined;
|
||||
return {
|
||||
colour: candidate,
|
||||
ratio,
|
||||
difference: asColor(original).deltaEOK(asColor(candidate)),
|
||||
lightness,
|
||||
};
|
||||
}
|
||||
|
||||
export function nearestPassingColour(
|
||||
foreground: ColourInput,
|
||||
background: ColourInput,
|
||||
target = 4.5,
|
||||
options: ContrastOptions = {},
|
||||
): ContrastSuggestion | null {
|
||||
if (!Number.isFinite(target) || target < 1 || target > 21) {
|
||||
throw new RangeError("Contrast target must be between 1 and 21.");
|
||||
}
|
||||
const original = parseColour(foreground);
|
||||
const originalRatio = contrastRatio(original, background, options);
|
||||
const originalOklch = asColor(original)
|
||||
.to("oklch")
|
||||
.coords.map((coordinate) => Number(coordinate ?? 0));
|
||||
const originalLightness = clamp(originalOklch[0] ?? 0);
|
||||
if (originalRatio >= target) {
|
||||
return {
|
||||
colour: original,
|
||||
css: toSrgbCss(original),
|
||||
hex: formatColour(original, original.alpha < 1 ? "hex8" : "hex"),
|
||||
ratio: originalRatio,
|
||||
deltaEOK: 0,
|
||||
direction: originalLightness >= 0.5 ? "lighter" : "darker",
|
||||
};
|
||||
}
|
||||
|
||||
let best: Candidate | undefined;
|
||||
const consider = (candidate: Candidate | undefined): void => {
|
||||
if (!candidate) return;
|
||||
if (
|
||||
!best ||
|
||||
candidate.difference < best.difference - 1e-9 ||
|
||||
(Math.abs(candidate.difference - best.difference) <= 1e-9 &&
|
||||
candidate.ratio < best.ratio)
|
||||
) {
|
||||
best = candidate;
|
||||
}
|
||||
};
|
||||
|
||||
for (let index = 0; index <= 400; index += 1) {
|
||||
consider(
|
||||
candidateAtLightness(
|
||||
original,
|
||||
originalOklch,
|
||||
index / 400,
|
||||
background,
|
||||
target,
|
||||
options,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!best) return null;
|
||||
|
||||
const coarse = best as Candidate;
|
||||
const lower = clamp(coarse.lightness - 1 / 400);
|
||||
const upper = clamp(coarse.lightness + 1 / 400);
|
||||
for (let index = 0; index <= 100; index += 1) {
|
||||
const lightness = lower + ((upper - lower) * index) / 100;
|
||||
consider(
|
||||
candidateAtLightness(
|
||||
original,
|
||||
originalOklch,
|
||||
lightness,
|
||||
background,
|
||||
target,
|
||||
options,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const chosen = best as Candidate;
|
||||
return {
|
||||
colour: chosen.colour,
|
||||
css: toSrgbCss(chosen.colour),
|
||||
hex: formatColour(chosen.colour, chosen.colour.alpha < 1 ? "hex8" : "hex"),
|
||||
ratio: chosen.ratio,
|
||||
deltaEOK: chosen.difference,
|
||||
direction: chosen.lightness >= originalLightness ? "lighter" : "darker",
|
||||
};
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "colorjs.io/dist/color.js" {
|
||||
export { default } from "colorjs.io";
|
||||
export type * from "colorjs.io";
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
asColor,
|
||||
clamp,
|
||||
encodeSrgb,
|
||||
lineariseSrgb,
|
||||
srgbChannels,
|
||||
} from "./internal";
|
||||
import { mapToGamut } from "./gamut";
|
||||
import { parseColour } from "./parse";
|
||||
import type {
|
||||
ColourCoordinates,
|
||||
ColourInput,
|
||||
ColourValue,
|
||||
ColourVisionDeficiency,
|
||||
ColourVisionOptions,
|
||||
DeltaEMethod,
|
||||
} from "./types";
|
||||
|
||||
export function deltaE(
|
||||
first: ColourInput,
|
||||
second: ColourInput,
|
||||
method: DeltaEMethod = "2000",
|
||||
): number {
|
||||
const left = asColor(parseColour(first));
|
||||
const right = asColor(parseColour(second));
|
||||
switch (method) {
|
||||
case "76":
|
||||
return left.deltaE76(right);
|
||||
case "cmc":
|
||||
return left.deltaECMC(right);
|
||||
case "2000":
|
||||
return left.deltaE2000(right);
|
||||
case "ok":
|
||||
return left.deltaEOK(right);
|
||||
case "itp":
|
||||
return left.deltaEITP(right);
|
||||
case "jz":
|
||||
return left.deltaEJz(right);
|
||||
}
|
||||
}
|
||||
|
||||
type Matrix = readonly [
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
readonly [number, number, number],
|
||||
];
|
||||
|
||||
// Full-severity Machado et al. matrices, applied to linear-light sRGB.
|
||||
const VISION_MATRICES: Record<ColourVisionDeficiency, Matrix> = {
|
||||
protanopia: [
|
||||
[0.152286, 1.052583, -0.204868],
|
||||
[0.114503, 0.786281, 0.099216],
|
||||
[-0.003882, -0.048116, 1.051998],
|
||||
],
|
||||
deuteranopia: [
|
||||
[0.367322, 0.860646, -0.227968],
|
||||
[0.280085, 0.672501, 0.047413],
|
||||
[-0.01182, 0.04294, 0.968881],
|
||||
],
|
||||
tritanopia: [
|
||||
[1.255528, -0.076749, -0.178779],
|
||||
[-0.078411, 0.930809, 0.147602],
|
||||
[0.004733, 0.691367, 0.3039],
|
||||
],
|
||||
achromatopsia: [
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
],
|
||||
};
|
||||
|
||||
function multiply(
|
||||
matrix: Matrix,
|
||||
vector: ColourCoordinates,
|
||||
): ColourCoordinates {
|
||||
return matrix.map(
|
||||
(row) => row[0] * vector[0] + row[1] * vector[1] + row[2] * vector[2],
|
||||
) as ColourCoordinates;
|
||||
}
|
||||
|
||||
export function simulateColourVision(
|
||||
input: ColourInput,
|
||||
deficiency: ColourVisionDeficiency,
|
||||
options: ColourVisionOptions = {},
|
||||
): ColourValue {
|
||||
const source = parseColour(input);
|
||||
const severity = clamp(options.severity ?? 1);
|
||||
const encoded = srgbChannels(source);
|
||||
const linear = encoded.map(lineariseSrgb) as ColourCoordinates;
|
||||
const simulated = multiply(VISION_MATRICES[deficiency], linear);
|
||||
const mixed = linear.map(
|
||||
(channel, index) =>
|
||||
channel + ((simulated[index] ?? channel) - channel) * severity,
|
||||
);
|
||||
const result: ColourValue = {
|
||||
space: "srgb",
|
||||
coords: mixed.map(encodeSrgb) as ColourCoordinates,
|
||||
alpha: source.alpha,
|
||||
};
|
||||
return options.mapToSrgb === false
|
||||
? result
|
||||
: mapToGamut(result, { method: "clip" });
|
||||
}
|
||||
|
||||
export function simulateColourVisionSet(
|
||||
input: ColourInput,
|
||||
options: ColourVisionOptions = {},
|
||||
): Record<ColourVisionDeficiency, ColourValue> {
|
||||
return {
|
||||
protanopia: simulateColourVision(input, "protanopia", options),
|
||||
deuteranopia: simulateColourVision(input, "deuteranopia", options),
|
||||
tritanopia: simulateColourVision(input, "tritanopia", options),
|
||||
achromatopsia: simulateColourVision(input, "achromatopsia", options),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { clamp, encodeSrgb, lineariseSrgb, srgbChannels } from "./internal";
|
||||
import { formatColour, toSrgbCss } from "./format";
|
||||
import { parseColour } from "./parse";
|
||||
import type {
|
||||
BlendMode,
|
||||
ColourCoordinates,
|
||||
ColourInput,
|
||||
ColourValue,
|
||||
CompositeLayer,
|
||||
CompositeOptions,
|
||||
CompositeResult,
|
||||
CompositingSpace,
|
||||
SourceOverOptions,
|
||||
} from "./types";
|
||||
|
||||
function luminosity([red, green, blue]: ColourCoordinates): number {
|
||||
return 0.3 * red + 0.59 * green + 0.11 * blue;
|
||||
}
|
||||
|
||||
function saturation(channels: ColourCoordinates): number {
|
||||
return Math.max(...channels) - Math.min(...channels);
|
||||
}
|
||||
|
||||
function clipColour(channels: ColourCoordinates): ColourCoordinates {
|
||||
const lightness = luminosity(channels);
|
||||
const minimum = Math.min(...channels);
|
||||
const maximum = Math.max(...channels);
|
||||
let output = [...channels] as ColourCoordinates;
|
||||
if (minimum < 0) {
|
||||
output = output.map(
|
||||
(channel) =>
|
||||
lightness + ((channel - lightness) * lightness) / (lightness - minimum),
|
||||
) as ColourCoordinates;
|
||||
}
|
||||
if (maximum > 1) {
|
||||
output = output.map(
|
||||
(channel) =>
|
||||
lightness +
|
||||
((channel - lightness) * (1 - lightness)) / (maximum - lightness),
|
||||
) as ColourCoordinates;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function setLuminosity(
|
||||
channels: ColourCoordinates,
|
||||
target: number,
|
||||
): ColourCoordinates {
|
||||
const difference = target - luminosity(channels);
|
||||
return clipColour(
|
||||
channels.map((channel) => channel + difference) as ColourCoordinates,
|
||||
);
|
||||
}
|
||||
|
||||
function setSaturation(
|
||||
channels: ColourCoordinates,
|
||||
target: number,
|
||||
): ColourCoordinates {
|
||||
const indices = [0, 1, 2] as const;
|
||||
const sorted = [...indices].sort(
|
||||
(first, second) => channels[first] - channels[second],
|
||||
);
|
||||
const minimumIndex = sorted[0] ?? 0;
|
||||
const middleIndex = sorted[1] ?? 1;
|
||||
const maximumIndex = sorted[2] ?? 2;
|
||||
const minimum = channels[minimumIndex];
|
||||
const maximum = channels[maximumIndex];
|
||||
const output: ColourCoordinates = [0, 0, 0];
|
||||
if (maximum > minimum) {
|
||||
output[middleIndex] =
|
||||
((channels[middleIndex] - minimum) * target) / (maximum - minimum);
|
||||
output[maximumIndex] = target;
|
||||
}
|
||||
output[minimumIndex] = 0;
|
||||
return output;
|
||||
}
|
||||
|
||||
function softLight(backdrop: number, source: number): number {
|
||||
if (source <= 0.5)
|
||||
return backdrop - (1 - 2 * source) * backdrop * (1 - backdrop);
|
||||
const d =
|
||||
backdrop <= 0.25
|
||||
? ((16 * backdrop - 12) * backdrop + 4) * backdrop
|
||||
: Math.sqrt(backdrop);
|
||||
return backdrop + (2 * source - 1) * (d - backdrop);
|
||||
}
|
||||
|
||||
function blendChannel(
|
||||
backdrop: number,
|
||||
source: number,
|
||||
mode: BlendMode,
|
||||
): number {
|
||||
switch (mode) {
|
||||
case "multiply":
|
||||
return backdrop * source;
|
||||
case "screen":
|
||||
return backdrop + source - backdrop * source;
|
||||
case "overlay":
|
||||
return backdrop <= 0.5
|
||||
? 2 * backdrop * source
|
||||
: 1 - 2 * (1 - backdrop) * (1 - source);
|
||||
case "darken":
|
||||
return Math.min(backdrop, source);
|
||||
case "lighten":
|
||||
return Math.max(backdrop, source);
|
||||
case "color-dodge":
|
||||
return source >= 1 ? 1 : Math.min(1, backdrop / (1 - source));
|
||||
case "color-burn":
|
||||
return source <= 0 ? 0 : 1 - Math.min(1, (1 - backdrop) / source);
|
||||
case "hard-light":
|
||||
return source <= 0.5
|
||||
? 2 * backdrop * source
|
||||
: 1 - 2 * (1 - backdrop) * (1 - source);
|
||||
case "soft-light":
|
||||
return softLight(backdrop, source);
|
||||
case "difference":
|
||||
return Math.abs(backdrop - source);
|
||||
case "exclusion":
|
||||
return backdrop + source - 2 * backdrop * source;
|
||||
case "normal":
|
||||
case "hue":
|
||||
case "saturation":
|
||||
case "color":
|
||||
case "luminosity":
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
function blend(
|
||||
backdrop: ColourCoordinates,
|
||||
source: ColourCoordinates,
|
||||
mode: BlendMode,
|
||||
): ColourCoordinates {
|
||||
if (["hue", "saturation", "color", "luminosity"].includes(mode)) {
|
||||
const nonSeparable =
|
||||
mode === "hue"
|
||||
? setLuminosity(
|
||||
setSaturation(source, saturation(backdrop)),
|
||||
luminosity(backdrop),
|
||||
)
|
||||
: mode === "saturation"
|
||||
? setLuminosity(
|
||||
setSaturation(backdrop, saturation(source)),
|
||||
luminosity(backdrop),
|
||||
)
|
||||
: mode === "color"
|
||||
? setLuminosity(source, luminosity(backdrop))
|
||||
: setLuminosity(backdrop, luminosity(source));
|
||||
return nonSeparable.map((channel) => clamp(channel)) as ColourCoordinates;
|
||||
}
|
||||
return backdrop.map((channel, index) =>
|
||||
clamp(blendChannel(channel, source[index] ?? 0, mode)),
|
||||
) as ColourCoordinates;
|
||||
}
|
||||
|
||||
function channelsFor(
|
||||
input: ColourInput,
|
||||
space: CompositingSpace,
|
||||
): ColourCoordinates {
|
||||
const channels = srgbChannels(parseColour(input));
|
||||
return space === "linear-srgb"
|
||||
? (channels.map(lineariseSrgb) as ColourCoordinates)
|
||||
: channels;
|
||||
}
|
||||
|
||||
function compositeValues(
|
||||
source: ColourValue,
|
||||
backdrop: ColourValue,
|
||||
space: CompositingSpace,
|
||||
mode: BlendMode,
|
||||
opacity: number,
|
||||
): ColourValue {
|
||||
const sourceChannels = channelsFor(source, space);
|
||||
const backdropChannels = channelsFor(backdrop, space);
|
||||
const sourceAlpha = clamp(source.alpha * clamp(opacity));
|
||||
const backdropAlpha = clamp(backdrop.alpha);
|
||||
const outputAlpha = sourceAlpha + backdropAlpha * (1 - sourceAlpha);
|
||||
if (outputAlpha <= 0) return { space: "srgb", coords: [0, 0, 0], alpha: 0 };
|
||||
|
||||
const blended = blend(backdropChannels, sourceChannels, mode);
|
||||
const output = sourceChannels.map((sourceChannel, index) => {
|
||||
const backdropChannel = backdropChannels[index] ?? 0;
|
||||
const blendChannelValue = blended[index] ?? sourceChannel;
|
||||
const premultiplied =
|
||||
sourceAlpha * (1 - backdropAlpha) * sourceChannel +
|
||||
sourceAlpha * backdropAlpha * blendChannelValue +
|
||||
(1 - sourceAlpha) * backdropAlpha * backdropChannel;
|
||||
return premultiplied / outputAlpha;
|
||||
}) as ColourCoordinates;
|
||||
const encoded =
|
||||
space === "linear-srgb"
|
||||
? (output.map(encodeSrgb) as ColourCoordinates)
|
||||
: output;
|
||||
return {
|
||||
space: "srgb",
|
||||
coords: encoded.map((channel) => clamp(channel)) as ColourCoordinates,
|
||||
alpha: outputAlpha,
|
||||
};
|
||||
}
|
||||
|
||||
function result(colour: ColourValue): CompositeResult {
|
||||
return {
|
||||
colour,
|
||||
css: toSrgbCss(colour),
|
||||
hex: formatColour(colour, colour.alpha < 1 ? "hex8" : "hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function compositeSourceOver(
|
||||
foreground: ColourInput,
|
||||
background: ColourInput,
|
||||
options: SourceOverOptions = {},
|
||||
): CompositeResult {
|
||||
return result(
|
||||
compositeValues(
|
||||
parseColour(foreground),
|
||||
parseColour(background),
|
||||
options.space ?? "srgb",
|
||||
options.blendMode ?? "normal",
|
||||
options.opacity ?? 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function compositeLayers(
|
||||
layers: readonly CompositeLayer[],
|
||||
options: CompositeOptions = {},
|
||||
): CompositeResult {
|
||||
if (layers.length === 0)
|
||||
return result({ space: "srgb", coords: [0, 0, 0], alpha: 0 });
|
||||
const ordered =
|
||||
options.order === "top-to-bottom" ? [...layers].reverse() : [...layers];
|
||||
let accumulator: ColourValue = { space: "srgb", coords: [0, 0, 0], alpha: 0 };
|
||||
for (const layer of ordered) {
|
||||
accumulator = compositeValues(
|
||||
parseColour(layer.colour),
|
||||
accumulator,
|
||||
options.space ?? "srgb",
|
||||
layer.blendMode ?? "normal",
|
||||
layer.opacity ?? 1,
|
||||
);
|
||||
}
|
||||
return result(accumulator);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { asColor, toColourValue } from "./internal";
|
||||
import { parseColour } from "./parse";
|
||||
import { formatColour, toSrgbCss } from "./format";
|
||||
import type {
|
||||
ColourInput,
|
||||
ColourValue,
|
||||
GamutMappingOptions,
|
||||
GamutReport,
|
||||
GamutSpaceReport,
|
||||
} from "./types";
|
||||
|
||||
const GAMUT_LABELS: Readonly<Record<string, string>> = {
|
||||
srgb: "sRGB",
|
||||
p3: "Display P3",
|
||||
rec2020: "Rec. 2020",
|
||||
a98rgb: "Adobe RGB (1998)",
|
||||
prophoto: "ProPhoto RGB",
|
||||
};
|
||||
|
||||
export const DEFAULT_GAMUT_SPACES = ["srgb", "p3", "rec2020"] as const;
|
||||
|
||||
export function mapToGamut(
|
||||
input: ColourInput,
|
||||
options: GamutMappingOptions = {},
|
||||
): ColourValue {
|
||||
const target = options.target ?? "srgb";
|
||||
const method = options.method === "clip" ? "clip" : "oklch.c";
|
||||
const converted = asColor(parseColour(input)).to(target);
|
||||
const mapped = converted.inGamut(target)
|
||||
? converted
|
||||
: converted.toGamut({ space: target, method });
|
||||
return toColourValue(mapped.to(target));
|
||||
}
|
||||
|
||||
function reportSpace(input: ColourValue, space: string): GamutSpaceReport {
|
||||
const source = asColor(input);
|
||||
const inGamut = source.inGamut(space);
|
||||
const mapped = mapToGamut(input, { target: space });
|
||||
const format =
|
||||
space === "p3"
|
||||
? "p3"
|
||||
: space === "rec2020"
|
||||
? "rec2020"
|
||||
: space === "a98rgb"
|
||||
? "a98rgb"
|
||||
: space === "prophoto"
|
||||
? "prophoto"
|
||||
: undefined;
|
||||
return {
|
||||
space,
|
||||
label: GAMUT_LABELS[space] ?? space,
|
||||
inGamut,
|
||||
mapped,
|
||||
mappedCss: format ? formatColour(mapped, format) : toSrgbCss(mapped),
|
||||
deltaEOK: inGamut ? 0 : source.deltaEOK(asColor(mapped)),
|
||||
};
|
||||
}
|
||||
|
||||
export function gamutReport(
|
||||
input: ColourInput,
|
||||
spaces: readonly string[] = DEFAULT_GAMUT_SPACES,
|
||||
): GamutReport {
|
||||
const source = parseColour(input);
|
||||
return { source, spaces: spaces.map((space) => reportSpace(source, space)) };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export {
|
||||
ColourParseError,
|
||||
parseColour,
|
||||
parseColourList,
|
||||
tryParseColour,
|
||||
} from "./parse";
|
||||
export {
|
||||
DEFAULT_CONVERSION_FORMATS,
|
||||
conversionRows,
|
||||
formatColour,
|
||||
formatRows,
|
||||
toSrgbCss,
|
||||
toSrgbPreview,
|
||||
} from "./format";
|
||||
export { DEFAULT_GAMUT_SPACES, gamutReport, mapToGamut } from "./gamut";
|
||||
export { compositeLayers, compositeSourceOver } from "./composite";
|
||||
export { interpolateColourStops, interpolateStops } from "./interpolate";
|
||||
export {
|
||||
contrastRatio,
|
||||
contrastReport,
|
||||
flattenColour,
|
||||
nearestPassingColour,
|
||||
relativeLuminance,
|
||||
} from "./accessibility";
|
||||
export {
|
||||
deltaE,
|
||||
simulateColourVision,
|
||||
simulateColourVisionSet,
|
||||
} from "./compare";
|
||||
export {
|
||||
colourHarmony,
|
||||
exportCssVariables,
|
||||
exportCsvPalette,
|
||||
exportDesignTokens,
|
||||
exportJsonPalette,
|
||||
exportPalette,
|
||||
exportScssVariables,
|
||||
exportTailwindPalette,
|
||||
generateHarmony,
|
||||
normaliseTokenName,
|
||||
parseNamedColourList,
|
||||
parsePaletteList,
|
||||
shades,
|
||||
tints,
|
||||
tones,
|
||||
} from "./palette";
|
||||
|
||||
export type * from "./types";
|
||||
@@ -0,0 +1,113 @@
|
||||
import Color from "colorjs.io/dist/color.js";
|
||||
|
||||
import type { ColourCoordinates, ColourInput, ColourValue } from "./types";
|
||||
|
||||
export const EPSILON = 1e-9;
|
||||
|
||||
export function clamp(value: number, minimum = 0, maximum = 1): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
export function round(value: number, precision = 4): number {
|
||||
const factor = 10 ** precision;
|
||||
const result = Math.round((value + Number.EPSILON) * factor) / factor;
|
||||
return Object.is(result, -0) ? 0 : result;
|
||||
}
|
||||
|
||||
export function numberText(value: number, precision = 4): string {
|
||||
return String(round(value, precision));
|
||||
}
|
||||
|
||||
export function alphaText(alpha: number, precision = 3): string {
|
||||
return numberText(clamp(alpha), precision);
|
||||
}
|
||||
|
||||
export function finiteCoordinate(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function toColourValue(color: Color): ColourValue {
|
||||
return {
|
||||
space: color.spaceId,
|
||||
coords: color.coords.map(finiteCoordinate) as ColourCoordinates,
|
||||
alpha: clamp(Number.isFinite(color.alpha) ? color.alpha : 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function isColourValue(value: unknown): value is ColourValue {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<ColourValue>;
|
||||
return (
|
||||
typeof candidate.space === "string" &&
|
||||
Array.isArray(candidate.coords) &&
|
||||
candidate.coords.length === 3 &&
|
||||
candidate.coords.every(
|
||||
(coordinate) =>
|
||||
typeof coordinate === "number" && Number.isFinite(coordinate),
|
||||
) &&
|
||||
typeof candidate.alpha === "number" &&
|
||||
Number.isFinite(candidate.alpha)
|
||||
);
|
||||
}
|
||||
|
||||
export function asColor(input: ColourInput): Color {
|
||||
if (typeof input === "string") return new Color(input);
|
||||
return new Color(input.space, [...input.coords], clamp(input.alpha));
|
||||
}
|
||||
|
||||
export function colorFrom(
|
||||
space: string,
|
||||
coords: readonly number[],
|
||||
alpha = 1,
|
||||
): Color {
|
||||
return new Color(
|
||||
space,
|
||||
coords.map(finiteCoordinate) as ColourCoordinates,
|
||||
clamp(alpha),
|
||||
);
|
||||
}
|
||||
|
||||
export function copyColour(value: ColourValue): ColourValue {
|
||||
return { space: value.space, coords: [...value.coords], alpha: value.alpha };
|
||||
}
|
||||
|
||||
export function mappedColor(
|
||||
input: ColourInput,
|
||||
target = "srgb",
|
||||
method: "oklch.c" | "clip" = "oklch.c",
|
||||
): Color {
|
||||
const converted = asColor(input).to(target);
|
||||
return converted.inGamut(target)
|
||||
? converted
|
||||
: converted.toGamut({ space: target, method });
|
||||
}
|
||||
|
||||
export function srgbChannels(
|
||||
input: ColourInput,
|
||||
map = true,
|
||||
): ColourCoordinates {
|
||||
const color = map ? mappedColor(input, "srgb") : asColor(input).to("srgb");
|
||||
return color.coords.map((coordinate) =>
|
||||
map ? clamp(finiteCoordinate(coordinate)) : finiteCoordinate(coordinate),
|
||||
) as ColourCoordinates;
|
||||
}
|
||||
|
||||
export function lineariseSrgb(channel: number): number {
|
||||
return channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
export function encodeSrgb(channel: number): number {
|
||||
return channel <= 0.0031308
|
||||
? 12.92 * channel
|
||||
: 1.055 * channel ** (1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
export function hueIndex(space: string): number | undefined {
|
||||
if (["hsl", "hsv", "hwb", "okhsl", "okhsv"].includes(space)) return 0;
|
||||
if (["lch", "oklch", "jzczhz", "hct"].includes(space)) return 2;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export { Color };
|
||||
@@ -0,0 +1,170 @@
|
||||
import { asColor, clamp, colorFrom, hueIndex, toColourValue } from "./internal";
|
||||
import { formatColour, toSrgbCss } from "./format";
|
||||
import { parseColour } from "./parse";
|
||||
import type {
|
||||
ColourCoordinates,
|
||||
ColourStop,
|
||||
ColourValue,
|
||||
EasingName,
|
||||
HueInterpolation,
|
||||
InterpolationOptions,
|
||||
InterpolationStep,
|
||||
} from "./types";
|
||||
|
||||
interface PositionedStop {
|
||||
colour: ColourValue;
|
||||
position: number;
|
||||
}
|
||||
|
||||
function ease(value: number, name: EasingName): number {
|
||||
switch (name) {
|
||||
case "ease-in":
|
||||
return value * value;
|
||||
case "ease-out":
|
||||
return 1 - (1 - value) ** 2;
|
||||
case "ease-in-out":
|
||||
return value < 0.5 ? 2 * value * value : 1 - (-2 * value + 2) ** 2 / 2;
|
||||
case "smoothstep":
|
||||
return value * value * (3 - 2 * value);
|
||||
case "linear":
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseStops(
|
||||
stops: readonly ColourStop[],
|
||||
shouldClamp: boolean,
|
||||
): PositionedStop[] {
|
||||
if (stops.length < 2)
|
||||
throw new RangeError("At least two colour stops are required.");
|
||||
const positions = stops.map((stop) => {
|
||||
if (stop.position === undefined) return undefined;
|
||||
if (!Number.isFinite(stop.position))
|
||||
throw new RangeError("Stop positions must be finite numbers.");
|
||||
return shouldClamp ? clamp(stop.position) : stop.position;
|
||||
});
|
||||
positions[0] ??= 0;
|
||||
positions[positions.length - 1] ??= 1;
|
||||
|
||||
let previousKnown = 0;
|
||||
for (let index = 1; index < positions.length; index += 1) {
|
||||
if (positions[index] === undefined) continue;
|
||||
const start = positions[previousKnown] ?? 0;
|
||||
const end = Math.max(start, positions[index] ?? start);
|
||||
positions[index] = end;
|
||||
const gap = index - previousKnown;
|
||||
for (let missing = 1; missing < gap; missing += 1) {
|
||||
positions[previousKnown + missing] =
|
||||
start + ((end - start) * missing) / gap;
|
||||
}
|
||||
previousKnown = index;
|
||||
}
|
||||
|
||||
return stops.map((stop, index) => ({
|
||||
colour: parseColour(stop.colour),
|
||||
position: positions[index] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function hueDelta(from: number, to: number, method: HueInterpolation): number {
|
||||
if (method === "raw") return to - from;
|
||||
const increasing = (((to - from) % 360) + 360) % 360;
|
||||
const decreasing = increasing === 0 ? 0 : increasing - 360;
|
||||
if (method === "increasing") return increasing;
|
||||
if (method === "decreasing") return decreasing;
|
||||
if (method === "shorter") return increasing <= 180 ? increasing : decreasing;
|
||||
if (increasing === 0) return 360;
|
||||
return increasing <= 180 ? decreasing : increasing;
|
||||
}
|
||||
|
||||
export function interpolateColourStops(
|
||||
stops: readonly ColourStop[],
|
||||
position: number,
|
||||
options: InterpolationOptions = {},
|
||||
): ColourValue {
|
||||
if (!Number.isFinite(position))
|
||||
throw new RangeError("Interpolation position must be finite.");
|
||||
const shouldClamp = options.clamp ?? true;
|
||||
const prepared = normaliseStops(stops, shouldClamp);
|
||||
const sample = shouldClamp ? clamp(position) : position;
|
||||
let left = prepared[0];
|
||||
let right = prepared[prepared.length - 1];
|
||||
if (!left || !right)
|
||||
throw new RangeError("At least two colour stops are required.");
|
||||
|
||||
if (sample <= left.position) right = prepared[1] ?? right;
|
||||
else if (sample >= right.position)
|
||||
left = prepared[prepared.length - 2] ?? left;
|
||||
else {
|
||||
for (let index = 1; index < prepared.length; index += 1) {
|
||||
const candidate = prepared[index];
|
||||
if (candidate && sample <= candidate.position) {
|
||||
left = prepared[index - 1] ?? left;
|
||||
right = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const range = right.position - left.position;
|
||||
const local = range === 0 ? 1 : (sample - left.position) / range;
|
||||
const progress = ease(
|
||||
shouldClamp ? clamp(local) : local,
|
||||
options.easing ?? "linear",
|
||||
);
|
||||
const space = options.space ?? "oklch";
|
||||
const leftColor = asColor(left.colour).to(space);
|
||||
const rightColor = asColor(right.colour).to(space);
|
||||
const leftCoords = leftColor.coords.map((coordinate) =>
|
||||
Number(coordinate ?? 0),
|
||||
) as ColourCoordinates;
|
||||
const rightCoords = rightColor.coords.map((coordinate) =>
|
||||
Number(coordinate ?? 0),
|
||||
) as ColourCoordinates;
|
||||
const hueCoordinate = hueIndex(space);
|
||||
const alpha =
|
||||
left.colour.alpha + (right.colour.alpha - left.colour.alpha) * progress;
|
||||
const premultiplied = options.premultiplied ?? true;
|
||||
const coords = leftCoords.map((coordinate, index) => {
|
||||
if (index === hueCoordinate) {
|
||||
return (
|
||||
coordinate +
|
||||
hueDelta(
|
||||
coordinate,
|
||||
rightCoords[index] ?? coordinate,
|
||||
options.hue ?? "shorter",
|
||||
) *
|
||||
progress
|
||||
);
|
||||
}
|
||||
const rightCoordinate = rightCoords[index] ?? coordinate;
|
||||
if (!premultiplied) {
|
||||
return coordinate + (rightCoordinate - coordinate) * progress;
|
||||
}
|
||||
const mixed =
|
||||
coordinate * left.colour.alpha * (1 - progress) +
|
||||
rightCoordinate * right.colour.alpha * progress;
|
||||
return alpha > 1e-12 ? mixed / alpha : 0;
|
||||
}) as ColourCoordinates;
|
||||
return toColourValue(colorFrom(space, coords, alpha));
|
||||
}
|
||||
|
||||
export function interpolateStops(
|
||||
stops: readonly ColourStop[],
|
||||
count: number,
|
||||
options: InterpolationOptions = {},
|
||||
): InterpolationStep[] {
|
||||
if (!Number.isInteger(count) || count < 2 || count > 10_000) {
|
||||
throw new RangeError("Step count must be an integer from 2 to 10,000.");
|
||||
}
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const position = index / (count - 1);
|
||||
const colour = interpolateColourStops(stops, position, options);
|
||||
return {
|
||||
position,
|
||||
colour,
|
||||
css: toSrgbCss(colour),
|
||||
hex: formatColour(colour, colour.alpha < 1 ? "hex8" : "hex"),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { asColor, clamp, colorFrom, toColourValue } from "./internal";
|
||||
import { formatColour } from "./format";
|
||||
import { mapToGamut } from "./gamut";
|
||||
import { interpolateColourStops } from "./interpolate";
|
||||
import { parseColour, tryParseColour } from "./parse";
|
||||
import type {
|
||||
ColourFormat,
|
||||
ColourInput,
|
||||
ColourValue,
|
||||
HarmonyType,
|
||||
PaletteEntry,
|
||||
PaletteExportFormat,
|
||||
PaletteExportOptions,
|
||||
PaletteScaleOptions,
|
||||
} from "./types";
|
||||
|
||||
const HARMONY_OFFSETS: Record<HarmonyType, readonly number[]> = {
|
||||
complementary: [0, 180],
|
||||
analogous: [-30, 0, 30],
|
||||
"split-complementary": [0, 150, 210],
|
||||
triadic: [0, 120, 240],
|
||||
tetradic: [0, 60, 180, 240],
|
||||
square: [0, 90, 180, 270],
|
||||
};
|
||||
|
||||
export function colourHarmony(
|
||||
input: ColourInput,
|
||||
type: HarmonyType,
|
||||
): ColourValue[] {
|
||||
const source = parseColour(input);
|
||||
const converted = asColor(source).to("oklch");
|
||||
const lightness = Number(converted.coords[0] ?? 0);
|
||||
const chroma = Number(converted.coords[1] ?? 0);
|
||||
const hue = Number(converted.coords[2] ?? 0);
|
||||
return HARMONY_OFFSETS[type].map((offset) =>
|
||||
mapToGamut(
|
||||
toColourValue(
|
||||
colorFrom(
|
||||
"oklch",
|
||||
[lightness, chroma, (hue + offset + 360) % 360],
|
||||
source.alpha,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const generateHarmony = colourHarmony;
|
||||
|
||||
function scale(
|
||||
input: ColourInput,
|
||||
endpoint: ColourInput,
|
||||
count: number,
|
||||
options: PaletteScaleOptions,
|
||||
): ColourValue[] {
|
||||
if (!Number.isInteger(count) || count < 1 || count > 1_000) {
|
||||
throw new RangeError("Palette count must be an integer from 1 to 1,000.");
|
||||
}
|
||||
const source = parseColour(input);
|
||||
const values: ColourValue[] = options.includeBase ? [source] : [];
|
||||
const includeEndpoint = options.includeEndpoint ?? false;
|
||||
const denominator = includeEndpoint ? count : count + 1;
|
||||
for (let index = 1; index <= count; index += 1) {
|
||||
values.push(
|
||||
interpolateColourStops(
|
||||
[{ colour: source }, { colour: endpoint }],
|
||||
index / denominator,
|
||||
{ space: options.space ?? "oklab" },
|
||||
),
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function tints(
|
||||
input: ColourInput,
|
||||
count = 5,
|
||||
options: PaletteScaleOptions = {},
|
||||
): ColourValue[] {
|
||||
return scale(input, "#ffffff", count, options);
|
||||
}
|
||||
|
||||
export function shades(
|
||||
input: ColourInput,
|
||||
count = 5,
|
||||
options: PaletteScaleOptions = {},
|
||||
): ColourValue[] {
|
||||
return scale(input, "#000000", count, options);
|
||||
}
|
||||
|
||||
export function tones(
|
||||
input: ColourInput,
|
||||
count = 5,
|
||||
options: PaletteScaleOptions = {},
|
||||
): ColourValue[] {
|
||||
const source = parseColour(input);
|
||||
const lightness = clamp(Number(asColor(source).to("oklab").coords[0] ?? 0));
|
||||
const neutral = toColourValue(
|
||||
colorFrom("oklab", [lightness, 0, 0], source.alpha),
|
||||
);
|
||||
return scale(source, neutral, count, options);
|
||||
}
|
||||
|
||||
function entryFromUnknown(
|
||||
name: string,
|
||||
value: unknown,
|
||||
): PaletteEntry | undefined {
|
||||
const candidate =
|
||||
typeof value === "object" && value !== null
|
||||
? ((value as Record<string, unknown>).$value ??
|
||||
(value as Record<string, unknown>).value ??
|
||||
(value as Record<string, unknown>).colour ??
|
||||
(value as Record<string, unknown>).color)
|
||||
: value;
|
||||
if (typeof candidate !== "string") return undefined;
|
||||
const result = tryParseColour(candidate);
|
||||
return result.ok
|
||||
? { name, colour: result.value, source: candidate }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseJsonPalette(input: string): PaletteEntry[] | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item, index) => {
|
||||
if (typeof item === "string")
|
||||
return {
|
||||
name: `colour-${index + 1}`,
|
||||
colour: parseColour(item),
|
||||
source: item,
|
||||
};
|
||||
if (typeof item !== "object" || item === null)
|
||||
throw new TypeError(`Palette item ${index + 1} is invalid.`);
|
||||
const record = item as Record<string, unknown>;
|
||||
const name =
|
||||
typeof record.name === "string" ? record.name : `colour-${index + 1}`;
|
||||
const entry = entryFromUnknown(name, record);
|
||||
if (!entry)
|
||||
throw new TypeError(
|
||||
`Palette item “${name}” does not contain a valid colour.`,
|
||||
);
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
const entries: PaletteEntry[] = [];
|
||||
const visit = (record: Record<string, unknown>, path: string[]): void => {
|
||||
for (const [name, value] of Object.entries(record)) {
|
||||
if (name.startsWith("$")) continue;
|
||||
const nextPath = [...path, name];
|
||||
const entry = entryFromUnknown(nextPath.join("-"), value);
|
||||
if (entry) entries.push(entry);
|
||||
else if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
visit(value as Record<string, unknown>, nextPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(parsed as Record<string, unknown>, []);
|
||||
if (entries.length > 0) return entries;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parsePaletteList(input: string): PaletteEntry[] {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return [];
|
||||
const json = parseJsonPalette(trimmed);
|
||||
if (json) return json;
|
||||
|
||||
const cssBlock = trimmed.match(/^\s*(?::root|[^{}]+)\s*\{([\s\S]*)\}\s*$/);
|
||||
const listText = cssBlock?.[1] ?? trimmed;
|
||||
|
||||
const lines = listText
|
||||
.split(/[\n;]/)
|
||||
.map((line) => line.trim().replace(/,$/, ""))
|
||||
.filter(Boolean);
|
||||
return lines.map((line, index) => {
|
||||
const whole = tryParseColour(line);
|
||||
if (whole.ok)
|
||||
return { name: `colour-${index + 1}`, colour: whole.value, source: line };
|
||||
const match = line.match(/^(--)?([^:=]+?)\s*[:=]\s*(.+)$/);
|
||||
if (!match)
|
||||
throw new TypeError(`Could not parse palette line ${index + 1}: ${line}`);
|
||||
const name = (match[2] ?? `colour-${index + 1}`).trim();
|
||||
const source = (match[3] ?? "").trim();
|
||||
return { name, colour: parseColour(source), source };
|
||||
});
|
||||
}
|
||||
|
||||
export const parseNamedColourList = parsePaletteList;
|
||||
|
||||
export function normaliseTokenName(input: string): string {
|
||||
const normalised = input
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-{2,}/g, "-");
|
||||
return normalised || "colour";
|
||||
}
|
||||
|
||||
function outputValue(entry: PaletteEntry, format: ColourFormat): string {
|
||||
return formatColour(
|
||||
entry.colour,
|
||||
format === "hex" && entry.colour.alpha < 1 ? "hex8" : format,
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueNames(
|
||||
entries: readonly PaletteEntry[],
|
||||
): Array<{ name: string; entry: PaletteEntry }> {
|
||||
const used = new Map<string, number>();
|
||||
return entries.map((entry) => {
|
||||
const base = normaliseTokenName(entry.name);
|
||||
const occurrence = (used.get(base) ?? 0) + 1;
|
||||
used.set(base, occurrence);
|
||||
return { name: occurrence === 1 ? base : `${base}-${occurrence}`, entry };
|
||||
});
|
||||
}
|
||||
|
||||
export function exportCssVariables(
|
||||
entries: readonly PaletteEntry[],
|
||||
options: PaletteExportOptions = {},
|
||||
): string {
|
||||
const prefix = normaliseTokenName(options.prefix ?? "colour");
|
||||
const lines = uniqueNames(entries).map(
|
||||
({ name, entry }) =>
|
||||
` --${prefix}-${name}: ${outputValue(entry, options.format ?? "hex")};`,
|
||||
);
|
||||
return `:root {\n${lines.join("\n")}\n}`;
|
||||
}
|
||||
|
||||
export function exportScssVariables(
|
||||
entries: readonly PaletteEntry[],
|
||||
options: PaletteExportOptions = {},
|
||||
): string {
|
||||
const prefix = normaliseTokenName(options.prefix ?? "colour");
|
||||
return uniqueNames(entries)
|
||||
.map(
|
||||
({ name, entry }) =>
|
||||
`$${prefix}-${name}: ${outputValue(entry, options.format ?? "hex")};`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function exportJsonPalette(
|
||||
entries: readonly PaletteEntry[],
|
||||
options: PaletteExportOptions = {},
|
||||
): string {
|
||||
const value = Object.fromEntries(
|
||||
uniqueNames(entries).map(({ name, entry }) => [
|
||||
name,
|
||||
outputValue(entry, options.format ?? "hex"),
|
||||
]),
|
||||
);
|
||||
return JSON.stringify(value, null, options.pretty === false ? undefined : 2);
|
||||
}
|
||||
|
||||
export function exportDesignTokens(
|
||||
entries: readonly PaletteEntry[],
|
||||
options: PaletteExportOptions = {},
|
||||
): string {
|
||||
const value = Object.fromEntries(
|
||||
uniqueNames(entries).map(({ name, entry }) => [
|
||||
name,
|
||||
{ $type: "color", $value: outputValue(entry, options.format ?? "hex") },
|
||||
]),
|
||||
);
|
||||
return JSON.stringify(value, null, options.pretty === false ? undefined : 2);
|
||||
}
|
||||
|
||||
export function exportTailwindPalette(
|
||||
entries: readonly PaletteEntry[],
|
||||
options: PaletteExportOptions = {},
|
||||
): string {
|
||||
const value = Object.fromEntries(
|
||||
uniqueNames(entries).map(({ name, entry }) => [
|
||||
name,
|
||||
outputValue(entry, options.format ?? "hex"),
|
||||
]),
|
||||
);
|
||||
return `export default ${JSON.stringify(value, null, options.pretty === false ? undefined : 2)};`;
|
||||
}
|
||||
|
||||
function csvCell(value: string): string {
|
||||
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
}
|
||||
|
||||
export function exportCsvPalette(
|
||||
entries: readonly PaletteEntry[],
|
||||
options: PaletteExportOptions = {},
|
||||
): string {
|
||||
return [
|
||||
"name,value",
|
||||
...uniqueNames(entries).map(
|
||||
({ name, entry }) =>
|
||||
`${csvCell(name)},${csvCell(outputValue(entry, options.format ?? "hex"))}`,
|
||||
),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function exportPalette(
|
||||
entries: readonly PaletteEntry[],
|
||||
format: PaletteExportFormat = "css",
|
||||
options: PaletteExportOptions = {},
|
||||
): string {
|
||||
switch (format) {
|
||||
case "css":
|
||||
return exportCssVariables(entries, options);
|
||||
case "scss":
|
||||
return exportScssVariables(entries, options);
|
||||
case "json":
|
||||
return exportJsonPalette(entries, options);
|
||||
case "tokens":
|
||||
return exportDesignTokens(entries, options);
|
||||
case "tailwind":
|
||||
return exportTailwindPalette(entries, options);
|
||||
case "csv":
|
||||
return exportCsvPalette(entries, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
Color,
|
||||
clamp,
|
||||
copyColour,
|
||||
isColourValue,
|
||||
toColourValue,
|
||||
} from "./internal";
|
||||
import type {
|
||||
ColourInput,
|
||||
ColourParseErrorCode,
|
||||
ColourParseResult,
|
||||
ColourValue,
|
||||
} from "./types";
|
||||
|
||||
export class ColourParseError extends Error {
|
||||
readonly code: ColourParseErrorCode;
|
||||
readonly input: string;
|
||||
|
||||
constructor(code: ColourParseErrorCode, message: string, input: string) {
|
||||
super(message);
|
||||
this.name = "ColourParseError";
|
||||
this.code = code;
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumber(token: string, label: string, input: string): number {
|
||||
const value = Number(token);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new ColourParseError(
|
||||
"non-finite",
|
||||
`${label} must be a finite number.`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseUnitInterval(
|
||||
token: string,
|
||||
label: string,
|
||||
input: string,
|
||||
allowHundred = true,
|
||||
): number {
|
||||
const trimmed = token.trim();
|
||||
const percentage = trimmed.endsWith("%");
|
||||
const value = parseNumber(
|
||||
percentage ? trimmed.slice(0, -1) : trimmed,
|
||||
label,
|
||||
input,
|
||||
);
|
||||
const normalised =
|
||||
percentage || (allowHundred && value > 1) ? value / 100 : value;
|
||||
if (normalised < 0 || normalised > 1) {
|
||||
throw new ColourParseError(
|
||||
"out-of-range",
|
||||
`${label} must be between 0 and 100%.`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
return normalised;
|
||||
}
|
||||
|
||||
function parseAlpha(token: string | undefined, input: string): number {
|
||||
return token === undefined
|
||||
? 1
|
||||
: parseUnitInterval(token, "Alpha", input, false);
|
||||
}
|
||||
|
||||
function parseAngle(token: string, input: string): number {
|
||||
const match = token
|
||||
.trim()
|
||||
.match(
|
||||
/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(deg|grad|rad|turn)?$/i,
|
||||
);
|
||||
if (!match)
|
||||
throw new ColourParseError(
|
||||
"invalid-syntax",
|
||||
"Hue must be a valid CSS angle.",
|
||||
input,
|
||||
);
|
||||
const value = parseNumber(match[1] ?? "", "Hue", input);
|
||||
const unit = match[2]?.toLowerCase() ?? "deg";
|
||||
const degrees =
|
||||
unit === "turn"
|
||||
? value * 360
|
||||
: unit === "rad"
|
||||
? (value * 180) / Math.PI
|
||||
: unit === "grad"
|
||||
? value * 0.9
|
||||
: value;
|
||||
return ((degrees % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function functionParts(
|
||||
body: string,
|
||||
expectedChannels = 3,
|
||||
): { channels: string[]; alpha?: string } {
|
||||
const slash = body.split("/");
|
||||
if (slash.length > 2) return { channels: [] };
|
||||
const channelText = slash[0]?.trim() ?? "";
|
||||
const commaParts = channelText.includes(",")
|
||||
? channelText.split(",").map((part) => part.trim())
|
||||
: channelText.split(/\s+/).filter(Boolean);
|
||||
let alpha = slash[1]?.trim();
|
||||
if (commaParts.length === expectedChannels + 1 && alpha === undefined)
|
||||
alpha = commaParts.pop();
|
||||
return { channels: commaParts, ...(alpha === undefined ? {} : { alpha }) };
|
||||
}
|
||||
|
||||
function parseHsv(input: string): ColourValue | undefined {
|
||||
const match = input.match(/^hs(?:v|b)a?\((.*)\)$/is);
|
||||
if (!match) return undefined;
|
||||
const parts = functionParts(match[1] ?? "");
|
||||
if (parts.channels.length !== 3) {
|
||||
throw new ColourParseError(
|
||||
"invalid-syntax",
|
||||
"HSV needs hue, saturation and value channels.",
|
||||
input,
|
||||
);
|
||||
}
|
||||
const hue = parseAngle(parts.channels[0] ?? "", input);
|
||||
const saturation =
|
||||
parseUnitInterval(parts.channels[1] ?? "", "Saturation", input) * 100;
|
||||
const value =
|
||||
parseUnitInterval(parts.channels[2] ?? "", "Value", input) * 100;
|
||||
return {
|
||||
space: "hsv",
|
||||
coords: [hue, saturation, value],
|
||||
alpha: parseAlpha(parts.alpha, input),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCmyk(input: string): ColourValue | undefined {
|
||||
const match = input.match(/^(?:device-)?cmyka?\((.*)\)$/is);
|
||||
if (!match) return undefined;
|
||||
const parts = functionParts(match[1] ?? "", 4);
|
||||
if (parts.channels.length !== 4) {
|
||||
throw new ColourParseError(
|
||||
"invalid-syntax",
|
||||
"CMYK needs cyan, magenta, yellow and black channels.",
|
||||
input,
|
||||
);
|
||||
}
|
||||
const [cyan, magenta, yellow, black] = parts.channels.map((part, index) =>
|
||||
parseUnitInterval(
|
||||
part,
|
||||
["Cyan", "Magenta", "Yellow", "Black"][index] ?? "Channel",
|
||||
input,
|
||||
),
|
||||
);
|
||||
const c = cyan ?? 0;
|
||||
const m = magenta ?? 0;
|
||||
const y = yellow ?? 0;
|
||||
const k = black ?? 0;
|
||||
return {
|
||||
space: "srgb",
|
||||
coords: [(1 - c) * (1 - k), (1 - m) * (1 - k), (1 - y) * (1 - k)],
|
||||
alpha: parseAlpha(parts.alpha, input),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBareRgb(input: string): ColourValue | undefined {
|
||||
if (!/^\s*[+-]?(?:\d|\.)/.test(input) || !input.includes(","))
|
||||
return undefined;
|
||||
const parts = input.split(",").map((part) => part.trim());
|
||||
if (parts.length < 3 || parts.length > 4 || parts.some((part) => part === ""))
|
||||
return undefined;
|
||||
const channels = parts.slice(0, 3).map((part, index) => {
|
||||
const value = parseNumber(
|
||||
part,
|
||||
["Red", "Green", "Blue"][index] ?? "Channel",
|
||||
input,
|
||||
);
|
||||
if (value < 0 || value > 255) {
|
||||
throw new ColourParseError(
|
||||
"out-of-range",
|
||||
"RGB channels must be between 0 and 255.",
|
||||
input,
|
||||
);
|
||||
}
|
||||
return value / 255;
|
||||
});
|
||||
return {
|
||||
space: "srgb",
|
||||
coords: channels as [number, number, number],
|
||||
alpha: parseAlpha(parts[3], input),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseStringInput(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (/^[\da-f]{3,8}$/i.test(trimmed) && [3, 4, 6, 8].includes(trimmed.length))
|
||||
return `#${trimmed}`;
|
||||
if (/^0x[\da-f]{6,8}$/i.test(trimmed)) return `#${trimmed.slice(2)}`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseColour(input: ColourInput): ColourValue {
|
||||
if (typeof input !== "string") {
|
||||
if (!isColourValue(input)) {
|
||||
throw new ColourParseError(
|
||||
"invalid-syntax",
|
||||
"Colour value is malformed.",
|
||||
String(input),
|
||||
);
|
||||
}
|
||||
if (input.alpha < 0 || input.alpha > 1) {
|
||||
throw new ColourParseError(
|
||||
"out-of-range",
|
||||
"Alpha must be between 0 and 1.",
|
||||
JSON.stringify(input),
|
||||
);
|
||||
}
|
||||
try {
|
||||
// Validate the public space identifier before returning a defensive copy.
|
||||
new Color(input.space, [...input.coords], clamp(input.alpha));
|
||||
return copyColour(input);
|
||||
} catch (error) {
|
||||
throw new ColourParseError(
|
||||
"unsupported-space",
|
||||
error instanceof Error ? error.message : "Unsupported colour space.",
|
||||
JSON.stringify(input),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const text = normaliseStringInput(input);
|
||||
if (text === "")
|
||||
throw new ColourParseError("empty", "Enter a colour.", input);
|
||||
|
||||
const custom = parseHsv(text) ?? parseCmyk(text) ?? parseBareRgb(text);
|
||||
if (custom) return custom;
|
||||
|
||||
try {
|
||||
return toColourValue(new Color(text));
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof Error && error.message ? ` ${error.message}` : "";
|
||||
throw new ColourParseError(
|
||||
"invalid-syntax",
|
||||
`Could not parse “${text}”.${detail}`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function tryParseColour(input: ColourInput): ColourParseResult {
|
||||
try {
|
||||
return { ok: true, value: parseColour(input) };
|
||||
} catch (error) {
|
||||
if (error instanceof ColourParseError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: error.code, message: error.message, input: error.input },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "invalid-syntax",
|
||||
message: "Could not parse this colour.",
|
||||
input: String(input),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function splitTopLevel(input: string): string[] {
|
||||
const output: string[] = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const character = input[index] ?? "";
|
||||
if (quote) {
|
||||
if (character === quote && input[index - 1] !== "\\") quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'") quote = character;
|
||||
else if (character === "(") depth += 1;
|
||||
else if (character === ")") depth = Math.max(0, depth - 1);
|
||||
else if (
|
||||
depth === 0 &&
|
||||
(character === "\n" || character === ";" || character === ",")
|
||||
) {
|
||||
const part = input.slice(start, index).trim();
|
||||
if (part) output.push(part);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
const last = input.slice(start).trim();
|
||||
if (last) output.push(last);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function parseColourList(input: string): ColourValue[] {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const whole = tryParseColour(trimmed);
|
||||
if (whole.ok) return [whole.value];
|
||||
|
||||
let values: unknown;
|
||||
try {
|
||||
values = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
values = undefined;
|
||||
}
|
||||
if (Array.isArray(values)) {
|
||||
return values.map((value) =>
|
||||
parseColour(typeof value === "string" ? value : (value as ColourValue)),
|
||||
);
|
||||
}
|
||||
|
||||
const parts = splitTopLevel(trimmed);
|
||||
if (parts.length <= 1)
|
||||
throw new ColourParseError(whole.error.code, whole.error.message, input);
|
||||
return parts.map(parseColour);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
export type ColourCoordinates = [number, number, number];
|
||||
|
||||
/** A serialisable colour value. Coordinates use Color.js space conventions. */
|
||||
export interface ColourValue {
|
||||
space: string;
|
||||
coords: ColourCoordinates;
|
||||
alpha: number;
|
||||
}
|
||||
|
||||
export type ColourInput = string | ColourValue;
|
||||
|
||||
export type ColourParseErrorCode =
|
||||
| "empty"
|
||||
| "invalid-syntax"
|
||||
| "unsupported-space"
|
||||
| "non-finite"
|
||||
| "out-of-range";
|
||||
|
||||
export interface ColourParseFailure {
|
||||
ok: false;
|
||||
error: {
|
||||
code: ColourParseErrorCode;
|
||||
message: string;
|
||||
input: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ColourParseSuccess {
|
||||
ok: true;
|
||||
value: ColourValue;
|
||||
}
|
||||
|
||||
export type ColourParseResult = ColourParseSuccess | ColourParseFailure;
|
||||
|
||||
export type ColourFormat =
|
||||
| "hex"
|
||||
| "hex8"
|
||||
| "rgb"
|
||||
| "rgba"
|
||||
| "hsl"
|
||||
| "hsv"
|
||||
| "hwb"
|
||||
| "cmyk"
|
||||
| "lab"
|
||||
| "lch"
|
||||
| "oklab"
|
||||
| "oklch"
|
||||
| "p3"
|
||||
| "rec2020"
|
||||
| "a98rgb"
|
||||
| "prophoto"
|
||||
| "xyz-d50"
|
||||
| "xyz-d65"
|
||||
| "css";
|
||||
|
||||
export interface FormatColourOptions {
|
||||
precision?: number;
|
||||
mapToSrgb?: boolean;
|
||||
includeAlpha?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversionRow {
|
||||
id: ColourFormat;
|
||||
label: string;
|
||||
value: string;
|
||||
copyValue: string;
|
||||
}
|
||||
|
||||
export interface SrgbPreview {
|
||||
css: string;
|
||||
hex: string;
|
||||
rgb: ColourCoordinates;
|
||||
alpha: number;
|
||||
wasMapped: boolean;
|
||||
}
|
||||
|
||||
export type GamutMappingMethod = "oklch-chroma" | "clip";
|
||||
|
||||
export interface GamutMappingOptions {
|
||||
target?: string;
|
||||
method?: GamutMappingMethod;
|
||||
}
|
||||
|
||||
export interface GamutSpaceReport {
|
||||
space: string;
|
||||
label: string;
|
||||
inGamut: boolean;
|
||||
mapped: ColourValue;
|
||||
mappedCss: string;
|
||||
deltaEOK: number;
|
||||
}
|
||||
|
||||
export interface GamutReport {
|
||||
source: ColourValue;
|
||||
spaces: GamutSpaceReport[];
|
||||
}
|
||||
|
||||
export type BlendMode =
|
||||
| "normal"
|
||||
| "multiply"
|
||||
| "screen"
|
||||
| "overlay"
|
||||
| "darken"
|
||||
| "lighten"
|
||||
| "color-dodge"
|
||||
| "color-burn"
|
||||
| "hard-light"
|
||||
| "soft-light"
|
||||
| "difference"
|
||||
| "exclusion"
|
||||
| "hue"
|
||||
| "saturation"
|
||||
| "color"
|
||||
| "luminosity";
|
||||
|
||||
export type CompositingSpace = "srgb" | "linear-srgb";
|
||||
|
||||
export interface CompositeLayer {
|
||||
colour: ColourInput;
|
||||
opacity?: number;
|
||||
blendMode?: BlendMode;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CompositeOptions {
|
||||
space?: CompositingSpace;
|
||||
/** Array order. The default models a paint stack from its background upwards. */
|
||||
order?: "bottom-to-top" | "top-to-bottom";
|
||||
}
|
||||
|
||||
export interface SourceOverOptions {
|
||||
space?: CompositingSpace;
|
||||
blendMode?: BlendMode;
|
||||
/** Additional source opacity, multiplied with its own alpha. */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface CompositeResult {
|
||||
colour: ColourValue;
|
||||
css: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export interface ColourStop {
|
||||
colour: ColourInput;
|
||||
/** Normalised position. Missing positions are distributed like a CSS gradient. */
|
||||
position?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type InterpolationSpace =
|
||||
| "srgb"
|
||||
| "srgb-linear"
|
||||
| "hsl"
|
||||
| "hsv"
|
||||
| "lab"
|
||||
| "lch"
|
||||
| "oklab"
|
||||
| "oklch"
|
||||
| "p3";
|
||||
|
||||
export type HueInterpolation =
|
||||
"shorter" | "longer" | "increasing" | "decreasing" | "raw";
|
||||
export type EasingName =
|
||||
"linear" | "ease-in" | "ease-out" | "ease-in-out" | "smoothstep";
|
||||
|
||||
export interface InterpolationOptions {
|
||||
space?: InterpolationSpace;
|
||||
hue?: HueInterpolation;
|
||||
easing?: EasingName;
|
||||
clamp?: boolean;
|
||||
/** Interpolate premultiplied components to avoid transparent-colour fringes. Defaults to true. */
|
||||
premultiplied?: boolean;
|
||||
}
|
||||
|
||||
export interface InterpolationStep {
|
||||
position: number;
|
||||
colour: ColourValue;
|
||||
css: string;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export interface ContrastOptions {
|
||||
/** Canvas below a translucent background. Defaults to white. */
|
||||
canvas?: ColourInput;
|
||||
}
|
||||
|
||||
export interface ContrastReport {
|
||||
ratio: number;
|
||||
foreground: ColourValue;
|
||||
background: ColourValue;
|
||||
flattenedForeground: ColourValue;
|
||||
flattenedBackground: ColourValue;
|
||||
passes: {
|
||||
aaLarge: boolean;
|
||||
aaaLarge: boolean;
|
||||
aaNormal: boolean;
|
||||
aaaNormal: boolean;
|
||||
nonText: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContrastSuggestion {
|
||||
colour: ColourValue;
|
||||
css: string;
|
||||
hex: string;
|
||||
ratio: number;
|
||||
deltaEOK: number;
|
||||
direction: "lighter" | "darker";
|
||||
}
|
||||
|
||||
export type DeltaEMethod = "76" | "cmc" | "2000" | "ok" | "itp" | "jz";
|
||||
|
||||
export type ColourVisionDeficiency =
|
||||
"protanopia" | "deuteranopia" | "tritanopia" | "achromatopsia";
|
||||
|
||||
export interface ColourVisionOptions {
|
||||
severity?: number;
|
||||
mapToSrgb?: boolean;
|
||||
}
|
||||
|
||||
export type HarmonyType =
|
||||
| "complementary"
|
||||
| "analogous"
|
||||
| "split-complementary"
|
||||
| "triadic"
|
||||
| "tetradic"
|
||||
| "square";
|
||||
|
||||
export interface PaletteEntry {
|
||||
name: string;
|
||||
colour: ColourValue;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type PaletteExportFormat =
|
||||
"css" | "scss" | "json" | "tokens" | "tailwind" | "csv";
|
||||
|
||||
export interface PaletteExportOptions {
|
||||
format?: ColourFormat;
|
||||
prefix?: string;
|
||||
pretty?: boolean;
|
||||
}
|
||||
|
||||
export interface PaletteScaleOptions {
|
||||
includeBase?: boolean;
|
||||
includeEndpoint?: boolean;
|
||||
space?: InterpolationSpace;
|
||||
}
|
||||
Reference in New Issue
Block a user