| 1 | import type { Rgba } from './ir' |
| 2 | |
| 3 | /** |
| 4 | * Somewhere to record a color string no parser understood. A parameter rather |
| 5 | * than module state, so calls made outside an export run cannot accumulate |
| 6 | * into the next one. |
| 7 | */ |
| 8 | export type UnparsedColors = Set<string> | undefined |
| 9 | |
| 10 | function numbers(body: string): number[] { |
| 11 | return body.split(/[\s,/]+/).filter(Boolean).map((token) => { |
| 12 | const n = Number.parseFloat(token) |
| 13 | return token.endsWith('%') ? n / 100 : n |
| 14 | }) |
| 15 | } |
| 16 | |
| 17 | function srgbChannel(v: number): number { |
| 18 | // The sRGB transfer function, for converting linear light back to 0-255. |
| 19 | const c = v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055 |
| 20 | return Math.max(0, Math.min(255, Math.round(c * 255))) |
| 21 | } |
| 22 | |
| 23 | /** Oklab to sRGB, per the CSS Color 4 conversion. */ |
| 24 | function oklabToRgb(L: number, a: number, b: number): [number, number, number] { |
| 25 | const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3 |
| 26 | const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3 |
| 27 | const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3 |
| 28 | return [ |
| 29 | srgbChannel(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), |
| 30 | srgbChannel(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), |
| 31 | srgbChannel(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), |
| 32 | ] |
| 33 | } |
| 34 | |
| 35 | function hueToRgb(h: number, s: number, l: number): [number, number, number] { |
| 36 | const c = (1 - Math.abs(2 * l - 1)) * s |
| 37 | const hp = (((h % 360) + 360) % 360) / 60 |
| 38 | const x = c * (1 - Math.abs((hp % 2) - 1)) |
| 39 | const [r, g, b] |
| 40 | = hp < 1 |
| 41 | ? [c, x, 0] |
| 42 | : hp < 2 |
| 43 | ? [x, c, 0] |
| 44 | : hp < 3 |
| 45 | ? [0, c, x] |
| 46 | : hp < 4 |
| 47 | ? [0, x, c] |
| 48 | : hp < 5 |
| 49 | ? [x, 0, c] |
| 50 | : [c, 0, x] |
| 51 | const m = l - c / 2 |
| 52 | const to255 = (v: number) => Math.max(0, Math.min(255, Math.round((v + m) * 255))) |
| 53 | return [to255(r), to255(g), to255(b)] |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * A computed CSS color, in any syntax Chromium actually serializes: a theme |
| 58 | * authored in modern syntax keeps `oklch()`, `color()` or `hsl()` in the |
| 59 | * computed value, and dropping those loses every fill on the slide. |
| 60 | */ |
| 61 | export function parseColor(value: string | undefined, unparsed?: UnparsedColors): Rgba | undefined { |
| 62 | if (!value) |
| 63 | return undefined |
| 64 | const text = value.trim() |
| 65 | if (text === 'transparent') |
| 66 | return { r: 0, g: 0, b: 0, a: 0 } |
| 67 | |
| 68 | const fn = text.match(/^([a-z-]+)\(([^)]+)\)$/i) |
| 69 | if (!fn) { |
| 70 | unparsed?.add(text) |
| 71 | return undefined |
| 72 | } |
| 73 | const name = fn[1].toLowerCase() |
| 74 | |
| 75 | if (name === 'color') { |
| 76 | // Before the numeric guard: the first token is a color space name, so |
| 77 | // parsing the whole body as numbers would reject every `color()` value. |
| 78 | const tokens = fn[2].trim().split(/[\s,/]+/).filter(Boolean) |
| 79 | const channels = numbers(tokens.slice(1).join(' ')) |
| 80 | if (tokens[0] === 'srgb' && channels.length >= 3 && !channels.slice(0, 3).some(Number.isNaN)) { |
| 81 | return { |
| 82 | r: Math.round(channels[0] * 255), |
| 83 | g: Math.round(channels[1] * 255), |
| 84 | b: Math.round(channels[2] * 255), |
| 85 | a: channels.length > 3 && !Number.isNaN(channels[3]) ? channels[3] : 1, |
| 86 | } |
| 87 | } |
| 88 | unparsed?.add(text) |
| 89 | return undefined |
| 90 | } |
| 91 | |
| 92 | const parts = numbers(fn[2]) |
| 93 | if (parts.some(Number.isNaN)) { |
| 94 | unparsed?.add(text) |
| 95 | return undefined |
| 96 | } |
| 97 | const alpha = (index: number) => (parts.length > index ? parts[index] : 1) |
| 98 | |
| 99 | if (name === 'rgb' || name === 'rgba') { |
| 100 | if (parts.length < 3) |
| 101 | return undefined |
| 102 | // Percentage channels were divided by 100 above, so scale them back. |
| 103 | const channel = (v: number, raw: string) => (raw.includes('%') ? v * 255 : v) |
| 104 | const raw = fn[2].split(/[\s,/]+/).filter(Boolean) |
| 105 | return { |
| 106 | r: channel(parts[0], raw[0] ?? ''), |
| 107 | g: channel(parts[1], raw[1] ?? ''), |
| 108 | b: channel(parts[2], raw[2] ?? ''), |
| 109 | a: alpha(3), |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | if (name === 'hsl' || name === 'hsla') { |
| 114 | if (parts.length < 3) |
| 115 | return undefined |
| 116 | const [r, g, b] = hueToRgb(parts[0], parts[1], parts[2]) |
| 117 | return { r, g, b, a: alpha(3) } |
| 118 | } |
| 119 | |
| 120 | if (name === 'oklch' || name === 'oklab') { |
| 121 | if (parts.length < 3) |
| 122 | return undefined |
| 123 | const L = parts[0] |
| 124 | const [a, b] = name === 'oklch' |
| 125 | ? [parts[1] * Math.cos((parts[2] * Math.PI) / 180), parts[1] * Math.sin((parts[2] * Math.PI) / 180)] |
| 126 | : [parts[1], parts[2]] |
| 127 | const [r, g, bb] = oklabToRgb(L, a, b) |
| 128 | return { r, g, b: bb, a: alpha(3) } |
| 129 | } |
| 130 | |
| 131 | unparsed?.add(text) |
| 132 | return undefined |
| 133 | } |
| 134 | |
| 135 | /** Whether a color contributes any ink at all. */ |
| 136 | export function isVisible(color: Rgba | undefined): boolean { |
| 137 | return !!color && color.a > 0 |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * Fold an element's effective opacity into a color's own alpha: DrawingML has |
| 142 | * no element-level or group opacity, only per-fill alpha. The walker compounds |
| 143 | * the value down the tree, since CSS `opacity` does not inherit. |
| 144 | */ |
| 145 | export function withOpacity(color: Rgba | undefined, opacity: number | undefined): Rgba | undefined { |
| 146 | if (!color) |
| 147 | return undefined |
| 148 | if (opacity === undefined || !Number.isFinite(opacity) || opacity >= 1) |
| 149 | return color |
| 150 | return { ...color, a: color.a * Math.max(0, opacity) } |
| 151 | } |
| 152 |