export interface RGB { r: number; g: number; b: number; } export interface RGBA extends RGB { a?: number; } const NAMED_COLORS: Record = { white: { r: 255, g: 255, b: 255 }, black: { r: 0, g: 0, b: 0 }, transparent: { r: 0, g: 0, b: 0 }, red: { r: 255, g: 0, b: 0 }, green: { r: 0, g: 128, b: 0 }, blue: { r: 0, g: 0, b: 255 }, }; export function hexToRgb(hex: string): RGB | null { const match = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (!match) return null; return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16), }; } export function rgbToHex(r: number, g: number, b: number): string { return `#${[r, g, b] .map((x) => Math.round(x).toString(16).padStart(2, "0")) .join("")}`; } export function parseRgb(color: string): RGBA | null { const match = color.match( /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/i, ); if (!match) return null; return { r: parseInt(match[1], 10), g: parseInt(match[2], 10), b: parseInt(match[3], 10), a: match[4] ? parseFloat(match[4]) : undefined, }; } export function parseColor(color: string): RGBA | null { const hex = hexToRgb(color); if (hex) return hex; const rgb = parseRgb(color); if (rgb) { return { ...rgb, a: rgb.a ?? 1, }; } return null; } export function parseColorToRgb(color: string): RGB | null { const lower = color.toLowerCase().trim(); const named = NAMED_COLORS[lower]; if (named) return named; const parsed = parseColor(color); if (!parsed) return null; return { r: parsed.r, g: parsed.g, b: parsed.b, }; } export function normalizeColor(color: string): string { if (color.startsWith("#")) { return color.toLowerCase(); } const rgb = parseRgb(color); if (rgb) { return rgbToHex(rgb.r, rgb.g, rgb.b); } return color.toLowerCase(); } export function isColorLike(value: string): boolean { return ( value.startsWith("#") || value.startsWith("rgb") || value.startsWith("hsl") || /^(transparent|white|black|red|blue|green|yellow|orange|purple|pink|gray|grey)$/i.test( value, ) ); } export function familyDistance(c1: RGB, c2: RGB): number { const maxDistance = Math.sqrt(255 ** 2 * 3); return ( Math.sqrt((c1.r - c2.r) ** 2 + (c1.g - c2.g) ** 2 + (c1.b - c2.b) ** 2) / maxDistance ); } export function colorSimilarity(c1: RGB, c2: RGB): number { return 1 - familyDistance(c1, c2); } export function calculateDeltaE(c1: RGB, c2: RGB): number { const lab1 = rgbToLab(c1); const lab2 = rgbToLab(c2); return Math.sqrt( Math.pow(lab2.l - lab1.l, 2) + Math.pow(lab2.a - lab1.a, 2) + Math.pow(lab2.b - lab1.b, 2), ); } export interface NearestByDeltaEOptions { threshold?: number; limit?: number; } export interface DeltaEMatch { item: T; distance: number; confidence: number; } export function nearestByDeltaE( target: RGB | string, candidates: readonly T[], resolveColor: (candidate: T) => RGB | string | null | undefined, options: NearestByDeltaEOptions = {}, ): Array> { const targetRgb = typeof target === "string" ? parseColorToRgb(target) : target; if (!targetRgb) return []; const threshold = options.threshold ?? Number.POSITIVE_INFINITY; const confidenceScale = Number.isFinite(threshold) && threshold > 0 ? threshold : 100; const matches: Array> = []; for (const candidate of candidates) { const resolved = resolveColor(candidate); const rgb = typeof resolved === "string" ? parseColorToRgb(resolved) : resolved; if (!rgb) continue; const distance = calculateDeltaE(targetRgb, rgb); if (distance <= threshold) { matches.push({ item: candidate, distance, confidence: Math.max(0, 1 - distance / confidenceScale), }); } } matches.sort((a, b) => a.distance - b.distance); return matches.slice(0, options.limit ?? matches.length); } function rgbToLab(rgb: RGB): { l: number; a: number; b: number } { let r = rgb.r / 255; let g = rgb.g / 255; let b = rgb.b / 255; r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; let x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047; let y = r * 0.2126729 + g * 0.7151522 + b * 0.072175; let z = (r * 0.0193339 + g * 0.119192 + b * 0.9503041) / 1.08883; x = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116; y = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116; z = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116; return { l: 116 * y - 16, a: 500 * (x - y), b: 200 * (y - z), }; }