@zseven-w/pen-figma 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import type { PenNode } from '@zseven-w/pen-types'
2
+ import { parseFigFile } from './fig-parser'
3
+ import { figmaNodeChangesToPenNodes } from './figma-node-mapper'
4
+ import { resolveImageBlobs } from './figma-image-resolver'
5
+
6
+ /**
7
+ * Quick check: does this HTML string contain Figma clipboard markers?
8
+ * Figma wraps its data in `<!--(figmeta)-->` comment blocks or uses
9
+ * `data-metadata` / `data-buffer` attributes.
10
+ */
11
+ export function isFigmaClipboardHtml(html: string): boolean {
12
+ return html.includes('figmeta') || html.includes('data-buffer')
13
+ }
14
+
15
+ // Standard base64 lookup table
16
+ const B64_LOOKUP = new Uint8Array(256)
17
+ {
18
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
19
+ for (let i = 0; i < chars.length; i++) B64_LOOKUP[chars.charCodeAt(i)] = i
20
+ // URL-safe variants
21
+ B64_LOOKUP['-'.charCodeAt(0)] = 62
22
+ B64_LOOKUP['_'.charCodeAt(0)] = 63
23
+ }
24
+
25
+ /**
26
+ * Decode a base64 string to Uint8Array without relying on atob.
27
+ * Handles URL-safe alphabet, whitespace, missing padding, and stray characters.
28
+ */
29
+ function decodeBase64ToBytes(input: string): Uint8Array {
30
+ // Strip everything except valid base64 characters
31
+ const b64 = input.replace(/[^A-Za-z0-9+/\-_=]/g, '')
32
+
33
+ const len = b64.length
34
+ // Compute output byte length (ignoring padding)
35
+ const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0
36
+ const byteLen = Math.floor(len * 3 / 4) - padding
37
+
38
+ const bytes = new Uint8Array(byteLen)
39
+ let p = 0
40
+
41
+ for (let i = 0; i < len; i += 4) {
42
+ const a = B64_LOOKUP[b64.charCodeAt(i)]
43
+ const b = B64_LOOKUP[b64.charCodeAt(i + 1)]
44
+ const c = B64_LOOKUP[b64.charCodeAt(i + 2)]
45
+ const d = B64_LOOKUP[b64.charCodeAt(i + 3)]
46
+
47
+ if (p < byteLen) bytes[p++] = (a << 2) | (b >> 4)
48
+ if (p < byteLen) bytes[p++] = ((b & 0x0F) << 4) | (c >> 2)
49
+ if (p < byteLen) bytes[p++] = ((c & 0x03) << 6) | d
50
+ }
51
+
52
+ return bytes
53
+ }
54
+
55
+ /**
56
+ * Decode a base64 string to a UTF-8 string.
57
+ */
58
+ function decodeBase64(input: string): string {
59
+ const bytes = decodeBase64ToBytes(input)
60
+ return new TextDecoder().decode(bytes)
61
+ }
62
+
63
+ interface FigmaClipboardData {
64
+ meta: Record<string, unknown>
65
+ buffer: ArrayBuffer
66
+ }
67
+
68
+ /**
69
+ * Extract and decode Figma clipboard data from the HTML payload.
70
+ *
71
+ * Figma writes two comment-wrapped, base64-encoded blocks in various formats:
72
+ * Format A (in HTML comments):
73
+ * <!--(figmeta)-->BASE64_JSON<!--(figmeta)-->
74
+ * <!--(figma)-->BASE64_BINARY<!--(figma)-->
75
+ * Format B (in data attributes):
76
+ * <span data-metadata="BASE64_JSON"></span>
77
+ * <span data-buffer="BASE64_BINARY"></span>
78
+ */
79
+ export function extractFigmaClipboardData(html: string): FigmaClipboardData | null {
80
+ let metaB64: string | null = null
81
+ let bufferB64: string | null = null
82
+
83
+ // Strategy 1: comment-wrapped format
84
+ // Figma uses <!--(figmeta)BASE64<!--(figmeta)--> (opening lacks -->)
85
+ // or <!--(figmeta)-->BASE64<!--(figmeta)--> (both have -->)
86
+ const metaCommentMatch = html.match(/<!--\(figmeta\)(?:-->)?([\s\S]*?)<!--\(figmeta\)-->/)
87
+ const bufferCommentMatch = html.match(/<!--\(figma\)(?:-->)?([\s\S]*?)<!--\(figma\)-->/)
88
+
89
+ if (metaCommentMatch && bufferCommentMatch) {
90
+ metaB64 = metaCommentMatch[1].trim()
91
+ bufferB64 = bufferCommentMatch[1].trim()
92
+ }
93
+
94
+ // Strategy 2: data-attribute format (the comments may be inside attribute values)
95
+ if (!metaB64 || !bufferB64) {
96
+ const attrMetaMatch = html.match(/data-metadata="([^"]*)"/)
97
+ const attrBufferMatch = html.match(/data-buffer="([^"]*)"/)
98
+
99
+ if (attrMetaMatch && attrBufferMatch) {
100
+ // Strip comment wrappers from attribute values if present.
101
+ // Opening marker may lack --> (e.g. "<!--(figmeta)BASE64<!--(figmeta)-->")
102
+ metaB64 = attrMetaMatch[1]
103
+ .replace(/<!--\(figmeta\)(-->)?/g, '')
104
+ .trim()
105
+ bufferB64 = attrBufferMatch[1]
106
+ .replace(/<!--\(figma\)(-->)?/g, '')
107
+ .trim()
108
+ }
109
+ }
110
+
111
+ // Strategy 3: HTML-encoded comment markers inside attributes
112
+ if (!metaB64 || !bufferB64) {
113
+ const encodedMetaMatch = html.match(/&lt;!--\(figmeta\)--&gt;([\s\S]*?)&lt;!--\(figmeta\)--&gt;/)
114
+ const encodedBufferMatch = html.match(/&lt;!--\(figma\)--&gt;([\s\S]*?)&lt;!--\(figma\)--&gt;/)
115
+
116
+ if (encodedMetaMatch && encodedBufferMatch) {
117
+ metaB64 = encodedMetaMatch[1].trim()
118
+ bufferB64 = encodedBufferMatch[1].trim()
119
+ }
120
+ }
121
+
122
+ if (!metaB64 || !bufferB64) return null
123
+
124
+ try {
125
+ const metaRaw = decodeBase64(metaB64)
126
+ // Trim trailing junk bytes from base64 padding — extract only the JSON object
127
+ const jsonEnd = metaRaw.lastIndexOf('}')
128
+ const metaJson = jsonEnd >= 0 ? metaRaw.slice(0, jsonEnd + 1) : metaRaw
129
+ const meta = JSON.parse(metaJson)
130
+ const bytes = decodeBase64ToBytes(bufferB64)
131
+ return { meta, buffer: bytes.buffer as ArrayBuffer }
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Convert a Figma clipboard buffer into PenNodes.
139
+ * The buffer uses the same fig-kiwi binary format as .fig files.
140
+ *
141
+ * @param buffer The decoded binary buffer from the Figma clipboard.
142
+ * @param html Optional full clipboard HTML — when provided, styled content
143
+ * outside the binary comments is parsed to supplement missing
144
+ * style properties (colors, fonts) on the binary-parsed nodes.
145
+ */
146
+ export function figmaClipboardToNodes(
147
+ buffer: ArrayBuffer,
148
+ html?: string,
149
+ ): { nodes: PenNode[]; warnings: string[] } {
150
+ const decoded = parseFigFile(buffer)
151
+ // Use 'preserve' layout mode (same as .fig file import) so that:
152
+ // 1. Auto-layout children are reversed to correct flow order
153
+ // 2. Image nodes get numeric pixel dimensions instead of sizing strings
154
+ const { nodes, warnings, imageBlobs } = figmaNodeChangesToPenNodes(decoded, 'preserve')
155
+
156
+ // Resolve embedded image blobs to data URLs
157
+ if (imageBlobs.size > 0 || decoded.imageFiles.size > 0) {
158
+ resolveImageBlobs(nodes, imageBlobs, decoded.imageFiles)
159
+ }
160
+
161
+ // Handle unresolved image references — clipboard data often lacks image
162
+ // binary data. Convert unresolvable image nodes to placeholder rectangles.
163
+ fixUnresolvedImages(nodes)
164
+
165
+ // Enrich nodes with style hints extracted from the clipboard HTML.
166
+ // Figma clipboard HTML contains styled elements (with inline CSS) that
167
+ // may carry color/font information lost during binary parsing (e.g. when
168
+ // shared style nodes are not included in the clipboard data).
169
+ if (html) {
170
+ const hints = parseClipboardHtmlStyles(html)
171
+ if (hints.size > 0) {
172
+ enrichNodesFromHtmlHints(nodes, hints)
173
+ }
174
+ }
175
+
176
+ return { nodes, warnings }
177
+ }
178
+
179
+ /**
180
+ * Walk the node tree and convert image nodes with unresolved __blob:/__hash:
181
+ * references into placeholder rectangles. Clipboard data often lacks the
182
+ * actual image binary, so leaving these as image nodes with broken src would
183
+ * render as invisible/broken elements.
184
+ */
185
+ function fixUnresolvedImages(nodes: PenNode[]): void {
186
+ for (let i = 0; i < nodes.length; i++) {
187
+ const node = nodes[i]
188
+ // Convert standalone image nodes with unresolved references to rectangles
189
+ if (node.type === 'image' && node.src && (node.src.startsWith('__blob:') || node.src.startsWith('__hash:'))) {
190
+ const rect: PenNode = {
191
+ type: 'rectangle',
192
+ id: node.id,
193
+ name: node.name,
194
+ x: node.x,
195
+ y: node.y,
196
+ width: node.width,
197
+ height: node.height,
198
+ cornerRadius: node.cornerRadius,
199
+ opacity: node.opacity,
200
+ fill: [{ type: 'solid', color: '#E5E7EB' }],
201
+ }
202
+ nodes[i] = rect
203
+ }
204
+ // Fix unresolved image fills on rectangles/ellipses/frames —
205
+ // __blob: and __hash: are internal references that the image loader
206
+ // cannot fetch; replace with a placeholder solid fill.
207
+ if ('fill' in node && Array.isArray(node.fill)) {
208
+ for (let j = node.fill.length - 1; j >= 0; j--) {
209
+ const fill = node.fill[j]
210
+ if (fill.type === 'image' && 'url' in fill) {
211
+ const url = (fill as any).url as string
212
+ if (url?.startsWith('__blob:') || url?.startsWith('__hash:')) {
213
+ node.fill[j] = { type: 'solid', color: '#E5E7EB' }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ // Recurse into children
219
+ if ('children' in node && Array.isArray(node.children)) {
220
+ fixUnresolvedImages(node.children)
221
+ }
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // HTML style extraction — parse the styled portion of Figma clipboard HTML
227
+ // to recover color/font information that may be missing from the binary data.
228
+ // ---------------------------------------------------------------------------
229
+
230
+ interface HtmlStyleHint {
231
+ color?: string
232
+ fontFamily?: string
233
+ fontSize?: number
234
+ fontWeight?: number
235
+ backgroundColor?: string
236
+ }
237
+
238
+ /**
239
+ * Convert a CSS color value (hex, rgb, rgba) to a #RRGGBB(AA) hex string.
240
+ */
241
+ function cssColorToHex(css: string): string | undefined {
242
+ const c = css.trim()
243
+ if (c.startsWith('#')) {
244
+ // Normalize 3-digit to 6-digit hex
245
+ if (c.length === 4) {
246
+ return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`
247
+ }
248
+ return c
249
+ }
250
+ const rgbaMatch = c.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/)
251
+ if (rgbaMatch) {
252
+ const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0')
253
+ const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0')
254
+ const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0')
255
+ if (rgbaMatch[4] !== undefined) {
256
+ const a = Math.round(parseFloat(rgbaMatch[4]) * 255)
257
+ if (a < 255) return `#${r}${g}${b}${a.toString(16).padStart(2, '0')}`
258
+ }
259
+ return `#${r}${g}${b}`
260
+ }
261
+ return undefined
262
+ }
263
+
264
+ /**
265
+ * Decode HTML entities (&#NN; and &amp;/&lt;/etc.) in text content.
266
+ */
267
+ function decodeHtmlEntities(text: string): string {
268
+ return text
269
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code)))
270
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
271
+ .replace(/&amp;/g, '&')
272
+ .replace(/&lt;/g, '<')
273
+ .replace(/&gt;/g, '>')
274
+ .replace(/&quot;/g, '"')
275
+ .replace(/&nbsp;/g, ' ')
276
+ }
277
+
278
+ /**
279
+ * Parse the styled HTML portion of a Figma clipboard to extract text style hints.
280
+ * Figma clipboard HTML contains styled elements (p, span, div) with inline CSS
281
+ * in addition to the binary data in comment blocks. These inline styles carry
282
+ * resolved color/font values that are sometimes missing from the binary format
283
+ * (e.g. when a node references a shared style that is not included in the
284
+ * clipboard data).
285
+ *
286
+ * Returns a map keyed by normalized text content → style properties.
287
+ */
288
+ function parseClipboardHtmlStyles(html: string): Map<string, HtmlStyleHint> {
289
+ // Remove the binary data comment blocks to isolate the styled HTML content
290
+ const cleanHtml = html
291
+ .replace(/<!--\(figmeta\)[\s\S]*?<!--\(figmeta\)-->/g, '')
292
+ .replace(/<!--\(figma\)[\s\S]*?<!--\(figma\)-->/g, '')
293
+
294
+ const hints = new Map<string, HtmlStyleHint>()
295
+
296
+ // Match elements with style attributes and text content.
297
+ // Captures: style attribute value, text content between tags.
298
+ const elemRegex = /style="([^"]*)"[^>]*>([^<]+)</gi
299
+ let match
300
+ while ((match = elemRegex.exec(cleanHtml)) !== null) {
301
+ const styleAttr = match[1]
302
+ const rawText = decodeHtmlEntities(match[2]).trim()
303
+ if (!rawText || rawText.length > 200) continue
304
+
305
+ const hint: HtmlStyleHint = {}
306
+
307
+ // color (text color) — avoid matching background-color
308
+ const colorMatch = styleAttr.match(/(?:^|;\s*)color:\s*((?:rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}))/)
309
+ if (colorMatch) hint.color = cssColorToHex(colorMatch[1])
310
+
311
+ // font-family
312
+ const fontMatch = styleAttr.match(/font-family:\s*([^;]+)/)
313
+ if (fontMatch) {
314
+ const family = fontMatch[1].trim().replace(/['"]/g, '').split(',')[0].trim()
315
+ if (family) hint.fontFamily = family
316
+ }
317
+
318
+ // font-size
319
+ const sizeMatch = styleAttr.match(/font-size:\s*(\d+(?:\.\d+)?)px/)
320
+ if (sizeMatch) hint.fontSize = parseFloat(sizeMatch[1])
321
+
322
+ // font-weight
323
+ const weightMatch = styleAttr.match(/font-weight:\s*(\d+)/)
324
+ if (weightMatch) hint.fontWeight = parseInt(weightMatch[1])
325
+
326
+ // background-color (for div/frame enrichment)
327
+ const bgMatch = styleAttr.match(/background-color:\s*((?:rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}))/)
328
+ if (bgMatch) hint.backgroundColor = cssColorToHex(bgMatch[1])
329
+
330
+ if (Object.keys(hint).length > 0) {
331
+ // Use first occurrence — later duplicates may be nested/overridden
332
+ if (!hints.has(rawText)) {
333
+ hints.set(rawText, hint)
334
+ }
335
+ }
336
+ }
337
+
338
+ return hints
339
+ }
340
+
341
+ /**
342
+ * Walk the PenNode tree and fill in missing style properties using hints
343
+ * extracted from the clipboard HTML. Only fills in properties that are
344
+ * undefined/missing — explicit values from the binary parser are never
345
+ * overwritten.
346
+ */
347
+ function enrichNodesFromHtmlHints(
348
+ nodes: PenNode[],
349
+ hints: Map<string, HtmlStyleHint>,
350
+ ): void {
351
+ for (const node of nodes) {
352
+ if (node.type === 'text') {
353
+ // Build plain text content for lookup
354
+ const content = typeof node.content === 'string'
355
+ ? node.content
356
+ : Array.isArray(node.content)
357
+ ? node.content.map(s => s.text).join('')
358
+ : ''
359
+ const trimmed = content.trim()
360
+ if (!trimmed) continue
361
+
362
+ // Try exact match first, then try individual lines
363
+ const hint = hints.get(trimmed) ?? findPartialHint(trimmed, hints)
364
+ if (hint) {
365
+ // Fill in missing text color
366
+ if (!node.fill && hint.color) {
367
+ node.fill = [{ type: 'solid', color: hint.color }]
368
+ }
369
+ // Fill in missing font properties
370
+ if (!node.fontFamily && hint.fontFamily) {
371
+ node.fontFamily = hint.fontFamily
372
+ }
373
+ if (!node.fontSize && hint.fontSize) {
374
+ node.fontSize = hint.fontSize
375
+ }
376
+ if (!node.fontWeight && hint.fontWeight) {
377
+ node.fontWeight = hint.fontWeight
378
+ }
379
+ }
380
+ }
381
+
382
+ // For frames/rectangles without fill, check if HTML has background-color
383
+ if ((node.type === 'frame' || node.type === 'rectangle') && !node.fill) {
384
+ const name = node.name?.trim()
385
+ if (name) {
386
+ const hint = hints.get(name)
387
+ if (hint?.backgroundColor) {
388
+ node.fill = [{ type: 'solid', color: hint.backgroundColor }]
389
+ }
390
+ }
391
+ }
392
+
393
+ // Recurse into children
394
+ if ('children' in node && Array.isArray(node.children)) {
395
+ enrichNodesFromHtmlHints(node.children, hints)
396
+ }
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Try to find a matching hint for text that may span multiple lines or
402
+ * may be a subset of a longer HTML text.
403
+ */
404
+ function findPartialHint(
405
+ text: string,
406
+ hints: Map<string, HtmlStyleHint>,
407
+ ): HtmlStyleHint | undefined {
408
+ // Check if text starts with any hint key
409
+ for (const [key, hint] of hints) {
410
+ if (text.startsWith(key) || key.startsWith(text)) {
411
+ return hint
412
+ }
413
+ }
414
+ return undefined
415
+ }
@@ -0,0 +1,28 @@
1
+ import type { FigmaColor } from './figma-types'
2
+
3
+ /**
4
+ * Convert Figma {r, g, b, a} (0-1 floats) to #RRGGBB or #RRGGBBAA hex string.
5
+ */
6
+ export function figmaColorToHex(color: FigmaColor): string {
7
+ const r = Math.round(color.r * 255)
8
+ const g = Math.round(color.g * 255)
9
+ const b = Math.round(color.b * 255)
10
+ const hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`
11
+
12
+ if (color.a !== undefined && color.a < 1) {
13
+ const a = Math.round(color.a * 255)
14
+ return `${hex}${toHex(a)}`
15
+ }
16
+ return hex
17
+ }
18
+
19
+ function toHex(n: number): string {
20
+ return n.toString(16).padStart(2, '0')
21
+ }
22
+
23
+ /**
24
+ * Extract opacity from a Figma color's alpha channel (0-1).
25
+ */
26
+ export function figmaColorOpacity(color: FigmaColor): number {
27
+ return color.a ?? 1
28
+ }
@@ -0,0 +1,57 @@
1
+ import type { FigmaEffect } from './figma-types'
2
+ import type { PenEffect } from '@zseven-w/pen-types'
3
+ import { figmaColorToHex } from './figma-color-utils'
4
+
5
+ /**
6
+ * Convert Figma effects[] (internal format) to PenEffect[].
7
+ */
8
+ export function mapFigmaEffects(
9
+ effects: FigmaEffect[] | undefined
10
+ ): PenEffect[] | undefined {
11
+ if (!effects || effects.length === 0) return undefined
12
+ const mapped: PenEffect[] = []
13
+
14
+ for (const effect of effects) {
15
+ if (effect.visible === false) continue
16
+ const pen = mapSingleEffect(effect)
17
+ if (pen) mapped.push(pen)
18
+ }
19
+
20
+ return mapped.length > 0 ? mapped : undefined
21
+ }
22
+
23
+ function mapSingleEffect(effect: FigmaEffect): PenEffect | null {
24
+ switch (effect.type) {
25
+ case 'DROP_SHADOW':
26
+ case 'INNER_SHADOW': {
27
+ return {
28
+ type: 'shadow',
29
+ inner: effect.type === 'INNER_SHADOW',
30
+ offsetX: effect.offset?.x ?? 0,
31
+ offsetY: effect.offset?.y ?? 0,
32
+ blur: effect.radius ?? 0,
33
+ spread: effect.spread ?? 0,
34
+ color: effect.color
35
+ ? figmaColorToHex(effect.color)
36
+ : '#00000040',
37
+ }
38
+ }
39
+
40
+ case 'FOREGROUND_BLUR': {
41
+ return {
42
+ type: 'blur',
43
+ radius: effect.radius ?? 0,
44
+ }
45
+ }
46
+
47
+ case 'BACKGROUND_BLUR': {
48
+ return {
49
+ type: 'background_blur',
50
+ radius: effect.radius ?? 0,
51
+ }
52
+ }
53
+
54
+ default:
55
+ return null
56
+ }
57
+ }
@@ -0,0 +1,100 @@
1
+ import type { FigmaPaint, FigmaMatrix } from './figma-types'
2
+ import type { PenFill } from '@zseven-w/pen-types'
3
+ import { figmaColorToHex } from './figma-color-utils'
4
+
5
+ /**
6
+ * Convert Figma fillPaints (internal format) to PenFill[].
7
+ */
8
+ export function mapFigmaFills(paints: FigmaPaint[] | undefined): PenFill[] | undefined {
9
+ if (!paints || paints.length === 0) return undefined
10
+ const fills: PenFill[] = []
11
+
12
+ for (const paint of paints) {
13
+ if (paint.visible === false) continue
14
+ const mapped = mapSingleFill(paint)
15
+ if (mapped) fills.push(mapped)
16
+ }
17
+
18
+ return fills.length > 0 ? fills : undefined
19
+ }
20
+
21
+ function mapSingleFill(paint: FigmaPaint): PenFill | null {
22
+ switch (paint.type) {
23
+ case 'SOLID': {
24
+ if (!paint.color) return null
25
+ return {
26
+ type: 'solid',
27
+ color: figmaColorToHex(paint.color),
28
+ opacity: paint.opacity,
29
+ }
30
+ }
31
+
32
+ case 'GRADIENT_LINEAR': {
33
+ if (!paint.stops) return null
34
+ const angle = paint.transform
35
+ ? gradientAngleFromTransform(paint.transform)
36
+ : 0
37
+ return {
38
+ type: 'linear_gradient',
39
+ angle,
40
+ stops: paint.stops.map((s) => ({
41
+ offset: s.position,
42
+ color: figmaColorToHex(s.color),
43
+ })),
44
+ opacity: paint.opacity,
45
+ }
46
+ }
47
+
48
+ case 'GRADIENT_RADIAL':
49
+ case 'GRADIENT_ANGULAR':
50
+ case 'GRADIENT_DIAMOND': {
51
+ if (!paint.stops) return null
52
+ return {
53
+ type: 'radial_gradient',
54
+ cx: 0.5,
55
+ cy: 0.5,
56
+ radius: 0.5,
57
+ stops: paint.stops.map((s) => ({
58
+ offset: s.position,
59
+ color: figmaColorToHex(s.color),
60
+ })),
61
+ opacity: paint.opacity,
62
+ }
63
+ }
64
+
65
+ case 'IMAGE': {
66
+ // Image fills reference blobs or ZIP image files; we'll resolve them later
67
+ let url = ''
68
+ if (paint.image?.hash && paint.image.hash.length > 0) {
69
+ url = `__hash:${Array.from(paint.image.hash).map(b => b.toString(16).padStart(2, '0')).join('')}`
70
+ } else if (paint.image?.dataBlob !== undefined) {
71
+ url = `__blob:${paint.image.dataBlob}`
72
+ }
73
+ return {
74
+ type: 'image',
75
+ url,
76
+ mode: mapScaleMode(paint.imageScaleMode),
77
+ opacity: paint.opacity,
78
+ }
79
+ }
80
+
81
+ default:
82
+ return null
83
+ }
84
+ }
85
+
86
+ function gradientAngleFromTransform(m: FigmaMatrix): number {
87
+ // Figma gradient direction is (m00, m10) in object space (default = horizontal).
88
+ // atan2 gives the math-convention angle (0° = right, CCW).
89
+ // Convert to CSS gradient convention (0° = bottom-to-top, 90° = left-to-right).
90
+ const mathAngle = Math.atan2(m.m10, m.m00) * (180 / Math.PI)
91
+ return Math.round(90 - mathAngle)
92
+ }
93
+
94
+ function mapScaleMode(mode?: string): 'stretch' | 'fill' | 'fit' {
95
+ switch (mode) {
96
+ case 'FIT': return 'fit'
97
+ case 'STRETCH': return 'stretch'
98
+ default: return 'fill'
99
+ }
100
+ }