返回 slidev
color.ts
根目录 / packages / slidev / node / commands / pptx / color.ts
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
152 lines TYPESCRIPT