Skip to main content

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