@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 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
+ }