Documentation Index
Fetch the complete documentation index at: https://mintlify.com/open-pencil/open-pencil/llms.txt
Use this file to discover all available pages before exploring further.
OpenPencil reads and writes native Figma .fig files using reverse-engineered binary formats. Files are 100% compatible with Figma — you can open OpenPencil files in Figma and vice versa.
File Structure
A .fig file is a ZIP archive containing:
design.fig
├── document.kiwi # Scene graph (Kiwi binary)
├── canvas.kiwi # Canvas metadata
├── images/ # Embedded images
│ ├── 0.png
│ ├── 1.jpg
│ └── ...
└── thumbnails/ # Preview thumbnails
└── 0.png
Kiwi Binary Format
Figma uses Kiwi — a compact binary protocol created by Evan Wallace (Figma co-founder). Similar to Protocol Buffers but optimized for JavaScript.
Schema compilation:
import { compileSchema } from '@open-pencil/core/kiwi'
import figmaSchema from '@open-pencil/core/kiwi/schema'
const compiled = compileSchema(figmaSchema)
From packages/core/src/kiwi/codec.ts:30-33:
export async function initCodec(): Promise<void> {
if (compiledSchema) return
compiledSchema = compileSchema(figmaSchema as Schema) as CompiledSchema
}
Compression
.fig files use Zstandard (Zstd) compression for Kiwi data:
import { decompress } from 'fzstd'
function isZstdCompressed(data: Uint8Array): boolean {
return data.length >= 4 &&
data[0] === 0x28 &&
data[1] === 0xB5 &&
data[2] === 0x2F &&
data[3] === 0xFD
}
const decompressed = decompress(data)
From packages/core/src/kiwi/codec.ts:61-64
NodeChange Format
NodeChange is the central type for encoding/decoding design nodes. It represents a delta change to a node.
Core Structure
export interface NodeChange {
guid: string // Node ID
parentIndex?: ParentIndex // Parent + position in tree
// Geometry
size?: { x: number; y: number }
transform?: Matrix // 2D transform matrix
// Visual properties
fillPaints?: Paint[]
strokePaints?: Paint[]
effects?: Effect[]
opacity?: number
blendMode?: string
// Shape properties
rectangleCornerRadii?: number[]
rectangleTopLeftCornerRadius?: number
rectangleTopRightCornerRadius?: number
rectangleBottomRightCornerRadius?: number
rectangleBottomLeftCornerRadius?: number
// Vector data
vectorData?: Uint8Array // Binary vectorNetworkBlob
// Text
characters?: string
fontSize?: number
fontName?: { family: string; style: string }
textAlignHorizontal?: string
textAlignVertical?: string
// Layout
layoutMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL'
itemSpacing?: number
counterAxisSpacing?: number
layoutAlign?: string
layoutGrow?: number
// Variables
boundVariables?: Record<string, string>
// Metadata
name?: string
visible?: boolean
locked?: boolean
}
From packages/core/src/kiwi/codec.ts:174-260
Paint Encoding
export interface Paint {
type: 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND' | 'IMAGE'
color?: Color
opacity?: number
visible?: boolean
blendMode?: string
stops?: { color: Color; position: number }[] // Gradient stops
transform?: Matrix // Gradient/image transform
image?: { hash: string } // Image reference
imageScaleMode?: 'FILL' | 'FIT' | 'CROP' | 'TILE'
colorVariableBinding?: { variableID: string } // Variable binding
}
From packages/core/src/kiwi/codec.ts:183-200
Variable bindings require custom encoding:
function encodeMessage(message: FigmaMessage): Uint8Array {
// Check if any nodeChange has variable bindings
const hasVariables = message.nodeChanges?.some(
(nc) =>
nc.fillPaints?.some((p) => p.colorVariableBinding) ||
nc.strokePaints?.some((p) => p.colorVariableBinding)
)
if (!hasVariables) {
// Standard encoding
const encoded = compiledSchema.encodeMessage(message)
return compress(encoded)
}
// Custom encoding for variable bindings...
}
From packages/core/src/kiwi/codec.ts:70-86
Vector Data Format
Vector paths use a proprietary binary format called vectorNetworkBlob. OpenPencil includes a full encoder/decoder in packages/core/src/vector.ts.
VectorNetwork Structure
export interface VectorNetwork {
vertices: VectorVertex[] // Points
segments: VectorSegment[] // Bezier curves
regions: VectorRegion[] // Filled areas
}
export interface VectorVertex {
x: number
y: number
strokeCap?: 'NONE' | 'ROUND' | 'SQUARE'
strokeJoin?: 'MITER' | 'BEVEL' | 'ROUND'
cornerRadius?: number
handleMirroring?: 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
}
export interface VectorSegment {
start: number // Vertex index
end: number // Vertex index
tangentStart: { x: number; y: number } // Bezier handle
tangentEnd: { x: number; y: number } // Bezier handle
}
export interface VectorRegion {
windingRule: 'NONZERO' | 'EVENODD'
loops: number[][] // Segment indices forming loops
}
From packages/core/src/scene-graph.ts:8-33
Encoding Example
import { encodeVectorNetwork, decodeVectorNetwork } from '@open-pencil/core'
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 50, y: 100 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [
{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }
]
}
const blob = encodeVectorNetwork(network)
const decoded = decodeVectorNetwork(blob)
File Operations
Opening Files
OpenPencil supports multiple file loading methods:
Browser (File System Access API):
const [fileHandle] = await window.showOpenFilePicker({
types: [{
description: 'Figma Files',
accept: { 'application/fig': ['.fig'] }
}]
})
const file = await fileHandle.getFile()
const buffer = await file.arrayBuffer()
const uint8 = new Uint8Array(buffer)
// Unzip and decode
const zip = await unzip(uint8)
const documentKiwi = zip['document.kiwi']
const decoded = decodeMessage(documentKiwi)
Tauri Desktop:
import { open } from '@tauri-apps/plugin-dialog'
import { readFile } from '@tauri-apps/plugin-fs'
const filePath = await open({
filters: [{ name: 'Figma', extensions: ['fig'] }]
})
if (filePath) {
const contents = await readFile(filePath)
// Process file...
}
Headless CLI:
bunx @open-pencil/cli info design.fig
bunx @open-pencil/cli tree design.fig
bunx @open-pencil/cli export design.fig --format png --scale 2
Saving Files
Browser:
const fileHandle = await window.showSaveFilePicker({
suggestedName: 'design.fig',
types: [{
description: 'Figma Files',
accept: { 'application/fig': ['.fig'] }
}]
})
const writable = await fileHandle.createWritable()
await writable.write(zipBuffer)
await writable.close()
Tauri:
import { save } from '@tauri-apps/plugin-dialog'
import { writeFile } from '@tauri-apps/plugin-fs'
const filePath = await save({
filters: [{ name: 'Figma', extensions: ['fig'] }]
})
if (filePath) {
await writeFile(filePath, zipBuffer)
}
Clipboard Format
Copy & paste between OpenPencil and Figma uses the same Kiwi binary format.
Copy Implementation
const nodeChanges = selectedNodes.map(node => encodeNodeChange(node))
const message = {
type: MessageType.CLIPBOARD,
nodeChanges
}
const encoded = encodeMessage(message)
// Write to clipboard
await navigator.clipboard.write([
new ClipboardItem({
'application/x-figma': new Blob([encoded])
})
])
Paste Implementation
const items = await navigator.clipboard.read()
for (const item of items) {
if (item.types.includes('application/x-figma')) {
const blob = await item.getType('application/x-figma')
const buffer = await blob.arrayBuffer()
const decoded = decodeMessage(new Uint8Array(buffer))
// Create nodes from decoded changes
for (const change of decoded.nodeChanges) {
applyNodeChange(change)
}
}
}
Testing File Compatibility
Always test .fig export by round-tripping through Figma:
# 1. Create design in OpenPencil
# 2. Export as .fig
bunx @open-pencil/cli export design.fig
# 3. Open in Figma
open design.fig # macOS
# 4. Verify all properties:
# - Node positions
# - Fills and strokes
# - Text formatting
# - Auto-layout
# - Components and instances
# - Variables and bindings
# 5. Export from Figma
# 6. Re-open in OpenPencil
Test fixtures in tests/fixtures/*.fig use Git LFS. Use git push --no-verify to skip the slow LFS pre-push hook during development.
Implementation Reference
From AGENTS.md:172-181:
- .fig files use Kiwi binary codec — schema in
packages/core/src/kiwi/codec.ts
NodeChange is the central type for Kiwi encode/decode
- Vector data uses reverse-engineered
vectorNetworkBlob binary format — encoder/decoder in packages/core/src/vector.ts
- showOpenFilePicker/showSaveFilePicker are File System Access API (Chrome/Edge), not Tauri-only — code has fallbacks
- Tauri detection:
IS_TAURI constant from packages/core/src/constants.ts — don’t use '__TAURI_INTERNALS__' in window inline
- .fig export: compression with fflate (browser) or Tauri Rust commands
- Test .fig round-trip by exporting and reimporting in Figma
File Format Specifications
Key types and interfaces:
// From packages/core/src/kiwi/codec.ts
export interface ParentIndex {
guid: GUID
position: string
}
export interface VariableBinding {
variableID: GUID
}
export type BlendMode =
| 'NORMAL' | 'DARKEN' | 'MULTIPLY' | 'COLOR_BURN'
| 'LIGHTEN' | 'SCREEN' | 'COLOR_DODGE'
| 'OVERLAY' | 'SOFT_LIGHT' | 'HARD_LIGHT'
| 'DIFFERENCE' | 'EXCLUSION'
| 'HUE' | 'SATURATION' | 'COLOR' | 'LUMINOSITY'
export interface Effect {
type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR'
color?: Color
offset?: { x: number; y: number }
radius?: number
spread?: number
visible?: boolean
blendMode?: BlendMode
}
See Also
- MCP Server - Programmatic .fig file access
- CLI - Headless file operations
- Components - Component encoding in .fig files