Files
colour-tools/src/colour/dtcg.ts
T
zemion ed030cada6
Verify / verify (push) Canceled after 0s
Release Colour Tools 0.2.0
2026-09-02 07:11:11 +02:00

462 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseColour } from "./parse";
import type { PaletteEntry } from "./types";
const MAX_SOURCE = 2 * 1024 * 1024;
const MAX_TOKENS = 10_000;
const MAX_DEPTH = 32;
interface TokenRecord {
path: string[];
object: Record<string, unknown>;
inheritedType?: string;
}
interface TokenIndex {
byPath: ReadonlyMap<string, TokenRecord>;
byObject: WeakMap<object, TokenRecord>;
byValue: WeakMap<object, TokenRecord>;
}
export interface DtcgDiagnostic {
path: string;
severity: "warning" | "error";
message: string;
}
export interface DtcgImport {
entries: PaletteEntry[];
diagnostics: DtcgDiagnostic[];
skippedNonColourTokens: number;
schema?: string;
}
function pathText(path: readonly string[]): string {
return path.join(".") || "(root)";
}
function collectTokens(root: Record<string, unknown>): {
records: TokenRecord[];
diagnostics: DtcgDiagnostic[];
} {
const records: TokenRecord[] = [];
const diagnostics: DtcgDiagnostic[] = [];
const visit = (
object: Record<string, unknown>,
path: string[],
inheritedType: string | undefined,
depth: number,
) => {
if (depth > MAX_DEPTH)
throw new Error(`DTCG group nesting exceeds ${MAX_DEPTH} levels.`);
const localType =
typeof object.$type === "string" ? object.$type : inheritedType;
if ("$value" in object || "$ref" in object) {
if (records.length >= MAX_TOKENS)
throw new Error(
`DTCG input exceeds ${MAX_TOKENS.toLocaleString()} tokens.`,
);
records.push({
path,
object,
...(localType ? { inheritedType: localType } : {}),
});
return;
}
for (const [name, value] of Object.entries(object)) {
if (name === "$root") {
if (value && typeof value === "object" && !Array.isArray(value))
visit(value as Record<string, unknown>, path, localType, depth + 1);
else
diagnostics.push({
path: pathText(path),
severity: "error",
message: "$root must contain a token object.",
});
continue;
}
if (name.startsWith("$")) continue;
const nextPath = [...path, name];
if (/[$.{}]/u.test(name) || name.length > 200)
diagnostics.push({
path: pathText(nextPath),
severity: "error",
message:
"Token/group names must be at most 200 characters and cannot contain $, period, or braces.",
});
if (/[$.{}]/u.test(name) || name.length > 200) continue;
if (!value || typeof value !== "object" || Array.isArray(value)) {
diagnostics.push({
path: pathText(nextPath),
severity: "warning",
message: "Non-object group member was skipped.",
});
continue;
}
visit(value as Record<string, unknown>, nextPath, localType, depth + 1);
}
};
visit(root, [], undefined, 0);
return { records, diagnostics };
}
function jsonPointer(root: unknown, pointer: string): unknown {
// DTCG 2025.10 specifies #/ as its document-root spelling. Accept the RFC
// 6901 empty fragment as well for interoperable local documents.
if (pointer === "#" || pointer === "#/") return root;
if (!pointer.startsWith("#/"))
throw new Error(
"Only local JSON Pointer references beginning #/ are supported.",
);
let value = root;
for (const raw of pointer.slice(2).split("/")) {
let decoded: string;
try {
decoded = decodeURIComponent(raw);
} catch {
throw new Error(`JSON Pointer has invalid percent encoding: ${pointer}`);
}
if (/~(?:[^01]|$)/u.test(decoded))
throw new Error(`JSON Pointer has invalid ~ escaping: ${pointer}`);
const key = decoded.replaceAll("~1", "/").replaceAll("~0", "~");
if (!value || typeof value !== "object" || !Object.hasOwn(value, key))
throw new Error(`JSON Pointer does not resolve: ${pointer}`);
value = (value as Record<string, unknown>)[key];
}
return value;
}
function resolveEmbeddedReferences(
value: unknown,
root: Record<string, unknown>,
index: TokenIndex,
visiting: Set<string>,
depth: number,
): unknown {
if (depth > 64)
throw new Error("DTCG value/reference nesting exceeds 64 levels.");
if (Array.isArray(value))
return value.map((item) =>
resolveEmbeddedReferences(item, root, index, visiting, depth + 1),
);
if (!value || typeof value !== "object") return value;
const object = value as Record<string, unknown>;
if ("$ref" in object) {
if (typeof object.$ref !== "string")
throw new Error("$ref must be a JSON Pointer string.");
if (Object.keys(object).length !== 1)
throw new Error(
"A property-level $ref object cannot have sibling fields.",
);
const key = `pointer:${object.$ref}`;
if (visiting.has(key)) throw new Error("JSON Pointer cycle detected.");
visiting.add(key);
try {
const resolved = jsonPointer(root, object.$ref);
if (
resolved &&
typeof resolved === "object" &&
!Array.isArray(resolved) &&
("$value" in resolved || "$ref" in resolved)
) {
const target = index.byObject.get(resolved);
if (target) return resolveValue(target, root, index, visiting);
}
return resolveEmbeddedReferences(
resolved,
root,
index,
visiting,
depth + 1,
);
} finally {
visiting.delete(key);
}
}
return Object.fromEntries(
Object.entries(object).map(([name, item]) => [
name,
resolveEmbeddedReferences(item, root, index, visiting, depth + 1),
]),
);
}
function tokenPathReference(value: string, index: TokenIndex): TokenRecord {
const match = /^\{([^{}]+)\}$/u.exec(value);
if (!match)
throw new Error("Token reference must use a complete {group.token} path.");
const found = index.byPath.get(match[1]!);
if (!found) throw new Error(`Token reference does not resolve: ${value}`);
return found;
}
function resolveValue(
record: TokenRecord,
root: Record<string, unknown>,
index: TokenIndex,
visiting: Set<string>,
): unknown {
const key = pathText(record.path);
if (visiting.has(key)) throw new Error("Token reference cycle detected.");
visiting.add(key);
try {
if ("$ref" in record.object) {
if (typeof record.object.$ref !== "string")
throw new Error("$ref must be a JSON Pointer string.");
const resolved = jsonPointer(root, record.object.$ref);
if (resolved && typeof resolved === "object") {
const target = index.byObject.get(resolved);
if (target) return resolveValue(target, root, index, visiting);
}
return resolveEmbeddedReferences(resolved, root, index, visiting, 0);
}
const value = record.object.$value;
if (typeof value === "string" && /^\{[^{}]+\}$/u.test(value))
return resolveValue(
tokenPathReference(value, index),
root,
index,
visiting,
);
return resolveEmbeddedReferences(value, root, index, visiting, 0);
} finally {
visiting.delete(key);
}
}
function referencedRecord(
record: TokenRecord,
root: Record<string, unknown>,
index: TokenIndex,
): TokenRecord | undefined {
if ("$ref" in record.object) {
if (typeof record.object.$ref !== "string")
throw new Error("$ref must be a JSON Pointer string.");
const resolved = jsonPointer(root, record.object.$ref);
if (resolved && typeof resolved === "object")
return index.byObject.get(resolved) ?? index.byValue.get(resolved);
return undefined;
}
const value = record.object.$value;
return typeof value === "string" && /^\{[^{}]+\}$/u.test(value)
? tokenPathReference(value, index)
: undefined;
}
function resolveType(
record: TokenRecord,
root: Record<string, unknown>,
index: TokenIndex,
visiting: Set<string>,
): string | undefined {
const direct =
typeof record.object.$type === "string"
? record.object.$type
: record.inheritedType;
if (direct) return direct;
const key = pathText(record.path);
if (visiting.has(key)) throw new Error("Token reference cycle detected.");
visiting.add(key);
try {
const target = referencedRecord(record, root, index);
return target ? resolveType(target, root, index, visiting) : undefined;
} finally {
visiting.delete(key);
}
}
function componentArray(value: unknown): number[] {
if (
!Array.isArray(value) ||
value.length !== 3 ||
value.some(
(component) =>
typeof component !== "number" || !Number.isFinite(component),
)
)
throw new Error(
"Color components must be an array of three finite numbers; 'none' components are not supported.",
);
return value as number[];
}
function inRange(value: number, minimum: number, maximum: number): boolean {
return value >= minimum && value <= maximum;
}
function validateComponents(space: string, components: readonly number[]) {
const [first, second, third] = components as [number, number, number];
const rgbLike = new Set([
"srgb",
"srgb-linear",
"display-p3",
"a98-rgb",
"prophoto-rgb",
"rec2020",
"xyz-d50",
"xyz-d65",
]);
if (rgbLike.has(space)) {
if (!components.every((component) => inRange(component, 0, 1)))
throw new Error(`${space} components must each be from 0 to 1.`);
return;
}
if (space === "hsl" || space === "hwb") {
if (
!inRange(first, 0, 360) ||
first === 360 ||
!inRange(second, 0, 100) ||
!inRange(third, 0, 100)
)
throw new Error(
`${space.toUpperCase()} requires hue 0<360 and percentage components 0100.`,
);
return;
}
if (space === "lab") {
if (!inRange(first, 0, 100))
throw new Error("Lab lightness must be from 0 to 100.");
return;
}
if (space === "lch") {
if (
!inRange(first, 0, 100) ||
second < 0 ||
!inRange(third, 0, 360) ||
third === 360
)
throw new Error(
"LCH requires lightness 0100, non-negative chroma and hue 0<360.",
);
return;
}
if (space === "oklab") {
if (!inRange(first, 0, 1))
throw new Error("OKLab lightness must be from 0 to 1.");
return;
}
if (space === "oklch") {
if (
!inRange(first, 0, 1) ||
second < 0 ||
!inRange(third, 0, 360) ||
third === 360
)
throw new Error(
"OKLCH requires lightness 01, non-negative chroma and hue 0<360.",
);
return;
}
throw new Error(`Unsupported DTCG colorSpace: ${space}.`);
}
function dtcgColour(value: unknown): PaletteEntry["colour"] {
if (typeof value === "string") return parseColour(value);
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Color $value must be a DTCG color object.");
const object = value as Record<string, unknown>;
if (typeof object.colorSpace !== "string")
throw new Error("Color $value requires a colorSpace string.");
if (
object.hex !== undefined &&
(typeof object.hex !== "string" || !/^#[0-9a-f]{6}$/iu.test(object.hex))
)
throw new Error("Optional color hex fallback must use exactly #RRGGBB.");
const components = componentArray(object.components);
validateComponents(object.colorSpace, components);
const alpha = object.alpha === undefined ? 1 : object.alpha;
if (
typeof alpha !== "number" ||
!Number.isFinite(alpha) ||
alpha < 0 ||
alpha > 1
)
throw new Error("Color alpha must be a finite number from 0 to 1.");
const slash = alpha < 1 ? ` / ${alpha}` : "";
const [first, second, third] = components;
const css =
object.colorSpace === "hsl"
? `hsl(${first} ${second}% ${third}%${slash})`
: object.colorSpace === "hwb"
? `hwb(${first} ${second}% ${third}%${slash})`
: ["lab", "lch"].includes(object.colorSpace)
? `${object.colorSpace}(${first}% ${second} ${third}${slash})`
: ["oklab", "oklch"].includes(object.colorSpace)
? `${object.colorSpace}(${first} ${second} ${third}${slash})`
: `color(${object.colorSpace} ${components.join(" ")}${slash})`;
return parseColour(css);
}
export function parseDtcgTokens(source: string): DtcgImport {
if (source.length > MAX_SOURCE)
throw new Error("DTCG input exceeds the 2 MiB limit.");
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new Error("DTCG input is not valid JSON.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("DTCG document must be a JSON object.");
const root = parsed as Record<string, unknown>;
const inventory = collectTokens(root);
const byPath = new Map(
inventory.records.map((record) => [pathText(record.path), record]),
);
const byObject = new WeakMap<object, TokenRecord>();
const byValue = new WeakMap<object, TokenRecord>();
for (const record of inventory.records) {
byObject.set(record.object, record);
const value = record.object.$value;
if (value && typeof value === "object") byValue.set(value, record);
}
const index: TokenIndex = { byPath, byObject, byValue };
const diagnostics = [...inventory.diagnostics];
const entries: PaletteEntry[] = [];
let skippedNonColourTokens = 0;
for (const record of inventory.records) {
const path = pathText(record.path);
try {
const type = resolveType(record, root, index, new Set());
if (type !== "color") {
skippedNonColourTokens += 1;
if (!type)
diagnostics.push({
path,
severity: "error",
message:
"Token has no explicit, inherited, or reference-resolved $type; type guessing is not allowed.",
});
continue;
}
const raw = resolveValue(record, root, index, new Set());
if (typeof raw === "string" && !/^\{[^{}]+\}$/u.test(raw))
diagnostics.push({
path,
severity: "warning",
message:
"Legacy string color value was accepted; DTCG 2025.10 uses a structured color object.",
});
entries.push({
name: record.path.length ? record.path.join("-") : "root",
colour: dtcgColour(raw),
source: JSON.stringify(raw),
});
} catch (error) {
diagnostics.push({
path,
severity: "error",
message:
error instanceof Error
? error.message
: "Color token could not be resolved.",
});
}
}
return {
entries,
diagnostics,
skippedNonColourTokens,
...(typeof root.$schema === "string" ? { schema: root.$schema } : {}),
};
}