@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.
- package/README.md +53 -0
- package/package.json +28 -0
- package/src/fig-parser.ts +282 -0
- package/src/figma-clipboard.ts +415 -0
- package/src/figma-color-utils.ts +28 -0
- package/src/figma-effect-mapper.ts +57 -0
- package/src/figma-fill-mapper.ts +100 -0
- package/src/figma-image-resolver.ts +113 -0
- package/src/figma-layout-mapper.ts +145 -0
- package/src/figma-node-converters.ts +1101 -0
- package/src/figma-node-mapper.ts +325 -0
- package/src/figma-stroke-mapper.ts +65 -0
- package/src/figma-text-mapper.ts +217 -0
- package/src/figma-tree-builder.ts +137 -0
- package/src/figma-types.ts +275 -0
- package/src/figma-vector-decoder.ts +321 -0
- package/src/index.ts +26 -0
|
@@ -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(/<!--\(figmeta\)-->([\s\S]*?)<!--\(figmeta\)-->/)
|
|
114
|
+
const encodedBufferMatch = html.match(/<!--\(figma\)-->([\s\S]*?)<!--\(figma\)-->/)
|
|
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 &/</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(/&/g, '&')
|
|
272
|
+
.replace(/</g, '<')
|
|
273
|
+
.replace(/>/g, '>')
|
|
274
|
+
.replace(/"/g, '"')
|
|
275
|
+
.replace(/ /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
|
+
}
|