cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
packages/kumo-figma/scripts/color-utils.ts
153lines · modecode
| 1 | import { rgb, type Oklch } from "culori"; |
| 2 | |
| 3 | /** |
| 4 | * Figma color format with RGB values in 0-1 range |
| 5 | */ |
| 6 | export type FigmaColor = { r: number; g: number; b: number; a?: number }; |
| 7 | |
| 8 | /** |
| 9 | * Converts various color formats to Figma RGB format (0-1 range) |
| 10 | * |
| 11 | * @param value - Color value in oklch, hex, or CSS color format |
| 12 | * @returns Figma RGB color object with values in 0-1 range |
| 13 | * |
| 14 | * @example |
| 15 | * resolveColor("oklch(21% 0.006 285.885)") // OKLCH format |
| 16 | * resolveColor("oklch(87% 0 0 / 0.8)") // OKLCH with alpha |
| 17 | * resolveColor("#f6821f") // Hex color |
| 18 | * resolveColor("transparent") // Special case |
| 19 | * resolveColor("var(--color-x, #fff)") // CSS variable with fallback |
| 20 | */ |
| 21 | export function resolveColor(value: string): FigmaColor { |
| 22 | const trimmed = value.trim(); |
| 23 | |
| 24 | // Handle transparent |
| 25 | if (trimmed === "transparent") { |
| 26 | return { r: 0, g: 0, b: 0, a: 0 }; |
| 27 | } |
| 28 | |
| 29 | // Handle CSS var() with fallback - extract the fallback value |
| 30 | if (trimmed.startsWith("var(")) { |
| 31 | const fallback = extractCssVarFallback(trimmed); |
| 32 | if (!fallback) { |
| 33 | throw new Error(`Cannot resolve CSS variable without fallback: ${value}`); |
| 34 | } |
| 35 | return resolveColor(fallback); |
| 36 | } |
| 37 | |
| 38 | // Handle OKLCH format |
| 39 | if (trimmed.startsWith("oklch(")) { |
| 40 | return convertOklchToFigma(trimmed); |
| 41 | } |
| 42 | |
| 43 | // Handle hex colors |
| 44 | if (trimmed.startsWith("#")) { |
| 45 | return convertHexToFigma(trimmed); |
| 46 | } |
| 47 | |
| 48 | throw new Error(`Unsupported color format: ${value}`); |
| 49 | } |
| 50 | |
| 51 | function extractCssVarFallback(value: string): string | null { |
| 52 | // CSS syntax: var(--name[, fallback]) |
| 53 | // The fallback can contain nested functions like oklch(...), so regexes that stop |
| 54 | // at the first ')' will truncate. Parse by tracking parentheses depth. |
| 55 | const s = value.trim(); |
| 56 | if (!s.startsWith("var(")) return null; |
| 57 | |
| 58 | const end = s.trimEnd(); |
| 59 | if (!end.endsWith(")")) return null; |
| 60 | |
| 61 | let depth = 0; |
| 62 | let commaIndex = -1; |
| 63 | for (let i = 0; i < end.length; i++) { |
| 64 | const ch = end[i]; |
| 65 | if (ch === "(") depth++; |
| 66 | else if (ch === ")") depth--; |
| 67 | else if (ch === "," && depth === 1) { |
| 68 | commaIndex = i; |
| 69 | break; |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | if (commaIndex === -1) return null; |
| 74 | |
| 75 | const closeIndex = end.length - 1; // last ')' |
| 76 | return end.slice(commaIndex + 1, closeIndex).trim(); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Converts OKLCH color string to Figma RGB format |
| 81 | * |
| 82 | * @param oklchString - OKLCH color string (e.g., "oklch(21% 0.006 285.885)") |
| 83 | * @returns Figma RGB color object |
| 84 | */ |
| 85 | function convertOklchToFigma(oklchString: string): FigmaColor { |
| 86 | // Extract values from oklch(L C H / alpha) or oklch(L C H) |
| 87 | const match = oklchString.match( |
| 88 | /oklch\(\s*([0-9.]+%?)\s+([0-9.]+)\s+([0-9.]+)\s*(?:\/\s*([0-9.]+))?\s*\)/, |
| 89 | ); |
| 90 | |
| 91 | if (!match) { |
| 92 | throw new Error(`Invalid OKLCH format: ${oklchString}`); |
| 93 | } |
| 94 | |
| 95 | let [, lightnessStr, chromaStr, hueStr, alphaStr] = match; |
| 96 | |
| 97 | // Parse lightness (handle percentage) |
| 98 | let lightness = parseFloat(lightnessStr); |
| 99 | if (lightnessStr.includes("%")) { |
| 100 | lightness = lightness / 100; |
| 101 | } |
| 102 | |
| 103 | const chroma = parseFloat(chromaStr); |
| 104 | const hue = parseFloat(hueStr); |
| 105 | const alpha = alphaStr ? parseFloat(alphaStr) : undefined; |
| 106 | |
| 107 | // Convert OKLCH to RGB using culori |
| 108 | const oklchColor: Oklch = { mode: "oklch", l: lightness, c: chroma, h: hue }; |
| 109 | const rgbColor = rgb(oklchColor); |
| 110 | |
| 111 | if (!rgbColor) { |
| 112 | throw new Error(`Failed to convert OKLCH to RGB: ${oklchString}`); |
| 113 | } |
| 114 | |
| 115 | // Clamp RGB values to 0-1 range (OKLCH can produce out-of-gamut colors) |
| 116 | const result: FigmaColor = { |
| 117 | r: Math.max(0, Math.min(1, rgbColor.r)), |
| 118 | g: Math.max(0, Math.min(1, rgbColor.g)), |
| 119 | b: Math.max(0, Math.min(1, rgbColor.b)), |
| 120 | }; |
| 121 | |
| 122 | if (alpha !== undefined) { |
| 123 | result.a = alpha; |
| 124 | } |
| 125 | |
| 126 | return result; |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Converts hex color to Figma RGB format |
| 131 | * |
| 132 | * @param hex - Hex color string (e.g., "#fff", "#f6821f") |
| 133 | * @returns Figma RGB color object |
| 134 | */ |
| 135 | function convertHexToFigma(hex: string): FigmaColor { |
| 136 | // Expand shorthand hex (#fff -> #ffffff) |
| 137 | let normalized = hex.replace( |
| 138 | /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i, |
| 139 | "#$1$1$2$2$3$3", |
| 140 | ); |
| 141 | |
| 142 | const match = normalized.match(/^#([0-9a-f]{6})$/i); |
| 143 | if (!match) { |
| 144 | throw new Error(`Invalid hex color: ${hex}`); |
| 145 | } |
| 146 | |
| 147 | const hexValue = match[1]; |
| 148 | const r = parseInt(hexValue.substring(0, 2), 16) / 255; |
| 149 | const g = parseInt(hexValue.substring(2, 4), 16) / 255; |
| 150 | const b = parseInt(hexValue.substring(4, 6), 16) / 255; |
| 151 | |
| 152 | return { r, g, b }; |
| 153 | } |
| 154 | |