openpencil/src/engine/fig-file.ts
Danila Poyarkov 859afdce45 Add .fig file import (Kiwi codec → scene graph)
File parser:
- .fig files are ZIP archives with Kiwi-encoded canvas data
- Detects canvas blob by name (canvas.fig) or largest binary entry
- Decodes Kiwi Message → NodeChange[] → SceneGraph

Node conversion:
- Maps Figma types to OpenPencil types (COMPONENT → FRAME, etc.)
- Extracts position from transform matrix (m02, m12)
- Extracts rotation from transform matrix (atan2)
- Converts fillPaints/strokePaints to Fill[]/Stroke[]
- Converts effects (shadows, blurs)
- Handles independent corner radii
- Preserves text properties (font, size, alignment, spacing)
- Builds parent-child tree from parentIndex GUIDs
- Sorts children by parentIndex.position

Browser compatibility:
- fzstd for Zstd decompression (replaces Bun.zstd*)
- fflate for ZIP extraction
- Cmd+O opens file dialog for .fig files
- UndoManager.clear() for resetting on file open
2026-02-27 23:01:35 +03:00

70 lines
2 KiB
TypeScript

import { unzipSync } from 'fflate'
import { initCodec, decodeMessage } from '../kiwi/codec'
import { importNodeChanges } from './fig-import'
import type { SceneGraph } from './scene-graph'
/**
* Parse a .fig file into a SceneGraph.
*
* A .fig file is a ZIP archive containing:
* - `canvas.fig` or similar blob: Kiwi-encoded document state
* - `thumbnail.png`: preview image
* - `meta.json`: file metadata
*
* The Kiwi payload is a serialized Message with nodeChanges.
*/
export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
await initCodec()
const zip = unzipSync(new Uint8Array(buffer))
const entries = Object.keys(zip)
// Find the Kiwi-encoded canvas data
// .fig files may have various structures; the binary blob is typically the largest non-image file
let canvasData: Uint8Array | null = null
// Try known names first
for (const name of entries) {
if (name === 'canvas.fig' || name === 'canvas') {
canvasData = zip[name]
break
}
}
// Fallback: find the largest binary file that's not an image/json
if (!canvasData) {
let maxSize = 0
for (const name of entries) {
const lower = name.toLowerCase()
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.json')) continue
if (zip[name].byteLength > maxSize) {
maxSize = zip[name].byteLength
canvasData = zip[name]
}
}
}
if (!canvasData) {
throw new Error(`No canvas data found in .fig file. Entries: ${entries.join(', ')}`)
}
// Decode the Kiwi message
const message = decodeMessage(canvasData)
const nodeChanges = message.nodeChanges
if (!nodeChanges || nodeChanges.length === 0) {
throw new Error('No nodes found in .fig file')
}
return importNodeChanges(nodeChanges)
}
/**
* Read a .fig File object and parse it
*/
export async function readFigFile(file: File): Promise<SceneGraph> {
const buffer = await file.arrayBuffer()
return parseFigFile(buffer)
}