@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
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @zseven-w/pen-figma
|
|
2
|
+
|
|
3
|
+
Figma `.fig` file parser and converter for [OpenPencil](https://github.com/nicepkg/openpencil). Import Figma designs directly into the OpenPencil document model.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @zseven-w/pen-figma
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Parse binary `.fig` files (Kiwi schema + zstd/zip compression)
|
|
14
|
+
- Convert Figma node trees to `PenDocument`
|
|
15
|
+
- Multi-page support — import all pages or a single page
|
|
16
|
+
- Clipboard paste — detect and convert Figma clipboard HTML
|
|
17
|
+
- Image blob resolution
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Parse a `.fig` file
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { parseFigFile, figmaAllPagesToPenDocument } from '@zseven-w/pen-figma'
|
|
25
|
+
|
|
26
|
+
const figFile = parseFigFile(buffer)
|
|
27
|
+
const document = figmaAllPagesToPenDocument(figFile)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Single page import
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { parseFigFile, getFigmaPages, figmaToPenDocument } from '@zseven-w/pen-figma'
|
|
34
|
+
|
|
35
|
+
const figFile = parseFigFile(buffer)
|
|
36
|
+
const pages = getFigmaPages(figFile)
|
|
37
|
+
const document = figmaToPenDocument(figFile, pages[0])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Clipboard paste
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { isFigmaClipboardHtml, extractFigmaClipboardData, figmaClipboardToNodes } from '@zseven-w/pen-figma'
|
|
44
|
+
|
|
45
|
+
if (isFigmaClipboardHtml(html)) {
|
|
46
|
+
const data = extractFigmaClipboardData(html)
|
|
47
|
+
const nodes = figmaClipboardToNodes(data)
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zseven-w/pen-figma",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Figma .fig file parser and converter for OpenPencil",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@zseven-w/pen-types": "0.0.1",
|
|
20
|
+
"kiwi-schema": "^0.5.0",
|
|
21
|
+
"uzip": "^0.20201231.0",
|
|
22
|
+
"fzstd": "^0.1.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/uzip": "^0.20201231.2",
|
|
26
|
+
"typescript": "^5.7.2"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { ByteBuffer, compileSchema, decodeBinarySchema } from 'kiwi-schema'
|
|
2
|
+
import * as UZIP from 'uzip'
|
|
3
|
+
import { decompress as zstdDecompress } from 'fzstd'
|
|
4
|
+
import type { FigmaDecodedFile } from './figma-types'
|
|
5
|
+
|
|
6
|
+
// Magic bytes for "fig-kiwi"
|
|
7
|
+
const FIG_KIWI_MAGIC = [102, 105, 103, 45, 107, 105, 119, 105]
|
|
8
|
+
// Zstandard magic bytes: 0x28 0xB5 0x2F 0xFD
|
|
9
|
+
const ZSTD_MAGIC = [0x28, 0xB5, 0x2F, 0xFD]
|
|
10
|
+
// PNG magic bytes
|
|
11
|
+
const PNG_MAGIC_0 = 137
|
|
12
|
+
const PNG_MAGIC_1 = 80
|
|
13
|
+
|
|
14
|
+
const MAX_COMPRESSED_SIZE = 150 * 1024 * 1024 // 150MB compressed input
|
|
15
|
+
const MAX_UNZIPPED_SIZE = 300 * 1024 * 1024 // 300MB total decompressed
|
|
16
|
+
const MAX_IMAGE_SIZE = 150 * 1024 * 1024 // 150MB per image
|
|
17
|
+
const MAX_ZIP_ENTRIES = 10_000 // guard against zip bombs with many small entries
|
|
18
|
+
|
|
19
|
+
const int32 = new Int32Array(1)
|
|
20
|
+
const uint8 = new Uint8Array(int32.buffer)
|
|
21
|
+
const uint32 = new Uint32Array(int32.buffer)
|
|
22
|
+
|
|
23
|
+
function transfer8to32(fileByte: Uint8Array, start: number): void {
|
|
24
|
+
uint8[0] = fileByte[start]
|
|
25
|
+
uint8[1] = fileByte[start + 1]
|
|
26
|
+
uint8[2] = fileByte[start + 2]
|
|
27
|
+
uint8[3] = fileByte[start + 3]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readUint32(fileByte: Uint8Array, start: number): number {
|
|
31
|
+
transfer8to32(fileByte, start)
|
|
32
|
+
return uint32[0]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hasFigKiwiMagic(bytes: Uint8Array): boolean {
|
|
36
|
+
for (let i = 0; i < FIG_KIWI_MAGIC.length; i++) {
|
|
37
|
+
if (bytes[i] !== FIG_KIWI_MAGIC[i]) return false
|
|
38
|
+
}
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isZstd(bytes: Uint8Array): boolean {
|
|
43
|
+
return (
|
|
44
|
+
bytes.length >= 4 &&
|
|
45
|
+
bytes[0] === ZSTD_MAGIC[0] &&
|
|
46
|
+
bytes[1] === ZSTD_MAGIC[1] &&
|
|
47
|
+
bytes[2] === ZSTD_MAGIC[2] &&
|
|
48
|
+
bytes[3] === ZSTD_MAGIC[3]
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPng(bytes: Uint8Array): boolean {
|
|
53
|
+
return bytes.length >= 2 && bytes[0] === PNG_MAGIC_0 && bytes[1] === PNG_MAGIC_1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decompress a chunk using the appropriate algorithm.
|
|
58
|
+
* Figma uses deflate for the schema chunk and may use zstd for the data chunk.
|
|
59
|
+
*/
|
|
60
|
+
function decompressChunk(bytes: Uint8Array): Uint8Array {
|
|
61
|
+
// Don't decompress PNG image data
|
|
62
|
+
if (isPng(bytes)) return bytes
|
|
63
|
+
|
|
64
|
+
// Try zstd first if magic matches
|
|
65
|
+
if (isZstd(bytes)) {
|
|
66
|
+
return zstdDecompress(bytes)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Try deflate (inflateRaw)
|
|
70
|
+
try {
|
|
71
|
+
return UZIP.inflateRaw(bytes) as Uint8Array<ArrayBuffer>
|
|
72
|
+
} catch {
|
|
73
|
+
// If deflate fails, try zstd as fallback (some files may not have magic)
|
|
74
|
+
try {
|
|
75
|
+
return zstdDecompress(bytes)
|
|
76
|
+
} catch {
|
|
77
|
+
// Return raw bytes if neither works
|
|
78
|
+
return bytes
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface FigBinaryResult {
|
|
84
|
+
parts: Uint8Array[]
|
|
85
|
+
imageFiles: Map<string, Uint8Array>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split a .fig file buffer into schema and data binary parts.
|
|
90
|
+
* Also extracts image files from the ZIP archive if present.
|
|
91
|
+
*/
|
|
92
|
+
function figToBinaryParts(fileBuffer: ArrayBuffer): FigBinaryResult {
|
|
93
|
+
let fileByte = new Uint8Array(fileBuffer)
|
|
94
|
+
const imageFiles = new Map<string, Uint8Array>()
|
|
95
|
+
|
|
96
|
+
// If not starting with "fig-kiwi", it's a ZIP archive containing canvas.fig
|
|
97
|
+
if (!hasFigKiwiMagic(fileByte)) {
|
|
98
|
+
// Pre-decompression size check: reject oversized compressed input before
|
|
99
|
+
// UZIP.parse loads the full archive into memory (mitigates zip bombs).
|
|
100
|
+
if (fileBuffer.byteLength > MAX_COMPRESSED_SIZE) {
|
|
101
|
+
throw new Error('Compressed .fig file exceeds maximum size limit (150MB)')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let unzipped: Record<string, Uint8Array>
|
|
105
|
+
try {
|
|
106
|
+
unzipped = UZIP.parse(fileBuffer)
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Invalid .fig file: could not unzip (${e instanceof Error ? e.message : 'unknown error'})`
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const entryCount = Object.keys(unzipped).length
|
|
114
|
+
if (entryCount > MAX_ZIP_ENTRIES) {
|
|
115
|
+
throw new Error(`ZIP archive contains too many entries (${entryCount})`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Extract image files stored under images/ directory (keyed by hex hash)
|
|
119
|
+
let totalSize = 0
|
|
120
|
+
for (const [path, bytes] of Object.entries(unzipped)) {
|
|
121
|
+
totalSize += bytes.length
|
|
122
|
+
if (totalSize > MAX_UNZIPPED_SIZE) {
|
|
123
|
+
throw new Error('Decompressed file exceeds maximum size limit (300MB)')
|
|
124
|
+
}
|
|
125
|
+
if (path.startsWith('images/') && bytes.length > 0) {
|
|
126
|
+
if (bytes.length > MAX_IMAGE_SIZE) {
|
|
127
|
+
throw new Error('Image exceeds maximum size limit (150MB)')
|
|
128
|
+
}
|
|
129
|
+
const key = path.slice(7) // Remove "images/" prefix
|
|
130
|
+
imageFiles.set(key, bytes)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const canvasFile = unzipped['canvas.fig']
|
|
135
|
+
if (!canvasFile) {
|
|
136
|
+
const keys = Object.keys(unzipped)
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Invalid .fig file: no canvas.fig found in archive. Contents: [${keys.join(', ')}]`
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
fileBuffer = canvasFile.buffer as ArrayBuffer
|
|
142
|
+
fileByte = new Uint8Array(fileBuffer)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!hasFigKiwiMagic(fileByte)) {
|
|
146
|
+
throw new Error('Invalid .fig file: missing fig-kiwi header after extraction')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Skip 8 bytes magic + 4 bytes delimiter
|
|
150
|
+
let start = 8
|
|
151
|
+
readUint32(fileByte, start)
|
|
152
|
+
start += 4
|
|
153
|
+
|
|
154
|
+
const parts: Uint8Array[] = []
|
|
155
|
+
while (start < fileByte.length) {
|
|
156
|
+
const chunkSize = readUint32(fileByte, start)
|
|
157
|
+
start += 4
|
|
158
|
+
|
|
159
|
+
if (chunkSize === 0 || start + chunkSize > fileByte.length) break
|
|
160
|
+
|
|
161
|
+
const rawChunk = fileByte.slice(start, start + chunkSize)
|
|
162
|
+
const decompressed = decompressChunk(rawChunk)
|
|
163
|
+
parts.push(decompressed)
|
|
164
|
+
start += chunkSize
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { parts, imageFiles }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Find the decode function for the root message type in the compiled schema.
|
|
174
|
+
* Figma's .fig files use a root type called "Message", so we look for
|
|
175
|
+
* `decodeMessage` first, then fall back to any `decode*` method.
|
|
176
|
+
*/
|
|
177
|
+
function findDecoder(
|
|
178
|
+
schemaHelper: Record<string, any>
|
|
179
|
+
): (bb: any) => any {
|
|
180
|
+
// Primary: Figma uses "Message" as root type
|
|
181
|
+
if (typeof schemaHelper.decodeMessage === 'function') {
|
|
182
|
+
return schemaHelper.decodeMessage.bind(schemaHelper)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Fallback: find any decode* method
|
|
186
|
+
for (const key of Object.keys(schemaHelper)) {
|
|
187
|
+
if (key.startsWith('decode') && typeof schemaHelper[key] === 'function') {
|
|
188
|
+
return schemaHelper[key].bind(schemaHelper)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
throw new Error(
|
|
193
|
+
`No decode method found in schema. Available keys: [${Object.keys(schemaHelper).join(', ')}]`
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parse a .fig file ArrayBuffer into decoded JSON.
|
|
199
|
+
*/
|
|
200
|
+
export function parseFigFile(fileBuffer: ArrayBuffer): FigmaDecodedFile {
|
|
201
|
+
const { parts, imageFiles } = figToBinaryParts(fileBuffer)
|
|
202
|
+
|
|
203
|
+
if (parts.length < 2) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Invalid .fig file: expected at least 2 binary parts, got ${parts.length}`
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const [schemaByte, dataByte] = parts
|
|
210
|
+
|
|
211
|
+
let schema: any
|
|
212
|
+
try {
|
|
213
|
+
const schemaBB = new ByteBuffer(schemaByte)
|
|
214
|
+
schema = decodeBinarySchema(schemaBB)
|
|
215
|
+
} catch (e) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Failed to decode .fig schema: ${e instanceof Error ? e.message : 'unknown error'}`
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let schemaHelper: Record<string, any>
|
|
222
|
+
try {
|
|
223
|
+
schemaHelper = compileSchema(schema) as Record<string, any>
|
|
224
|
+
} catch (e) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Failed to compile .fig schema: ${e instanceof Error ? e.message : 'unknown error'}`
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const decoder = findDecoder(schemaHelper)
|
|
231
|
+
|
|
232
|
+
let raw: any
|
|
233
|
+
try {
|
|
234
|
+
const dataBB = new ByteBuffer(dataByte)
|
|
235
|
+
raw = decoder(dataBB)
|
|
236
|
+
} catch (e) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Failed to decode .fig data: ${e instanceof Error ? e.message : 'unknown error'}`
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!raw || typeof raw !== 'object') {
|
|
243
|
+
throw new Error('Decoded .fig data is empty or invalid')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Extract nodeChanges
|
|
247
|
+
const nodeChanges = raw.nodeChanges ?? []
|
|
248
|
+
if (nodeChanges.length === 0) {
|
|
249
|
+
// Some schemas may use a different field name — search for arrays with guid objects
|
|
250
|
+
for (const key of Object.keys(raw)) {
|
|
251
|
+
if (Array.isArray(raw[key]) && raw[key].length > 0 && raw[key][0]?.guid) {
|
|
252
|
+
return {
|
|
253
|
+
nodeChanges: raw[key],
|
|
254
|
+
blobs: extractBlobs(raw),
|
|
255
|
+
imageFiles,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
nodeChanges,
|
|
263
|
+
blobs: extractBlobs(raw),
|
|
264
|
+
imageFiles,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function extractBlobs(raw: any): (Uint8Array | string)[] {
|
|
269
|
+
const blobs: (Uint8Array | string)[] = []
|
|
270
|
+
if (!raw.blobs) return blobs
|
|
271
|
+
|
|
272
|
+
for (const blob of raw.blobs) {
|
|
273
|
+
if (blob?.bytes instanceof Uint8Array) {
|
|
274
|
+
blobs.push(blob.bytes)
|
|
275
|
+
} else if (typeof blob === 'string') {
|
|
276
|
+
blobs.push(blob)
|
|
277
|
+
} else {
|
|
278
|
+
blobs.push(new Uint8Array(0))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return blobs
|
|
282
|
+
}
|