openpencil/tests/engine/fig-roundtrip.test.ts
Danila Poyarkov 4a6dd703c8 Emit derivedTextData and textUserLayoutVersion for text nodes
Figma needs derivedTextData (layoutSize, fontMetaData with font digest)
and textUserLayoutVersion to render text in .fig files. Without these
fields, text nodes appear as empty boxes when opened in Figma.

- Add DerivedTextData message to kiwi schema (field 359)
- Add derivedTextData and textUserLayoutVersion to NodeChange codec
- Compute SHA-1 font digests from loaded font binaries at export time
- Emit textData.lines with lineType: PLAIN for each paragraph
- Expose getLoadedFontData() from fonts module for digest computation

Closes #64
2026-03-08 23:28:54 +03:00

1102 lines
36 KiB
TypeScript

import { describe, test, expect, beforeAll, setDefaultTimeout } from 'bun:test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import {
parseFigFile,
exportFigFile,
importNodeChanges,
initCodec,
SceneGraph,
type SceneNode,
type NodeType,
type Fill,
} from '@open-pencil/core'
import { heavy } from '../helpers/test-utils'
setDefaultTimeout(60_000)
const FIXTURES = resolve(import.meta.dir, '../fixtures')
const VALID_NODE_TYPES = new Set<string>([
'CANVAS',
'FRAME',
'RECTANGLE',
'ROUNDED_RECTANGLE',
'ELLIPSE',
'TEXT',
'LINE',
'STAR',
'POLYGON',
'VECTOR',
'GROUP',
'SECTION',
'COMPONENT',
'COMPONENT_SET',
'INSTANCE',
'CONNECTOR',
'SHAPE_WITH_TEXT',
])
function collectAllNodes(graph: SceneGraph): SceneNode[] {
const all: SceneNode[] = []
function walk(id: string) {
const node = graph.getNode(id)
if (!node) return
all.push(node)
for (const child of graph.getChildren(id)) {
walk(child.id)
}
}
for (const page of graph.getPages()) {
for (const child of graph.getChildren(page.id)) {
walk(child.id)
}
}
return all
}
function countByType(nodes: SceneNode[]): Map<NodeType, number> {
const counts = new Map<NodeType, number>()
for (const n of nodes) {
counts.set(n.type, (counts.get(n.type) ?? 0) + 1)
}
return counts
}
let parsed: SceneGraph
let allNodes: SceneNode[]
beforeAll(async () => {
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
parsed = await parseFigFile(buf.buffer as ArrayBuffer)
allNodes = collectAllNodes(parsed)
})
describe('parse real .fig files', () => {
test('parses without error', () => {
expect(parsed).toBeInstanceOf(SceneGraph)
})
test('has pages', () => {
expect(parsed.getPages().length).toBeGreaterThan(0)
})
test('has nodes', () => {
expect(allNodes.length).toBeGreaterThan(0)
})
})
describe('node type coverage', () => {
test('contains FRAME nodes', () => {
expect(allNodes.some((n) => n.type === 'FRAME')).toBe(true)
})
test('contains TEXT nodes with content', () => {
const textNodes = allNodes.filter((n) => n.type === 'TEXT')
expect(textNodes.length).toBeGreaterThan(0)
expect(textNodes.some((n) => n.text.length > 0)).toBe(true)
})
test('contains INSTANCE nodes referencing components', () => {
const instances = allNodes.filter((n) => n.type === 'INSTANCE')
expect(instances.length).toBeGreaterThan(0)
expect(instances.some((n) => n.componentId)).toBe(true)
})
test('no unmapped node types', () => {
const invalid = allNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
})
describe('property integrity', () => {
test('all nodes have finite dimensions', () => {
for (const n of allNodes) {
expect(Number.isFinite(n.width)).toBe(true)
expect(Number.isFinite(n.height)).toBe(true)
expect(n.width).toBeGreaterThanOrEqual(0)
expect(n.height).toBeGreaterThanOrEqual(0)
}
})
test('all nodes have finite positions', () => {
for (const n of allNodes) {
expect(Number.isFinite(n.x)).toBe(true)
expect(Number.isFinite(n.y)).toBe(true)
}
})
test('all nodes have valid opacity', () => {
for (const n of allNodes) {
expect(n.opacity).toBeGreaterThanOrEqual(0)
expect(n.opacity).toBeLessThanOrEqual(1)
}
})
test('TEXT nodes have fontFamily', () => {
for (const n of allNodes) {
if (n.type === 'TEXT') {
expect(typeof n.fontFamily).toBe('string')
expect(n.fontFamily.length).toBeGreaterThan(0)
}
}
})
test('TEXT nodes have valid fontSize', () => {
for (const n of allNodes) {
if (n.type === 'TEXT') {
expect(n.fontSize).toBeGreaterThan(0)
}
}
})
test('fills have valid colors', () => {
function checkFill(fill: Fill, nodeName: string) {
if (fill.type === 'SOLID') {
const { r, g, b, a } = fill.color
expect(r).toBeGreaterThanOrEqual(0)
expect(r).toBeLessThanOrEqual(1)
expect(g).toBeGreaterThanOrEqual(0)
expect(g).toBeLessThanOrEqual(1)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThanOrEqual(1)
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThanOrEqual(1)
}
}
for (const n of allNodes) {
for (const fill of n.fills) {
checkFill(fill, n.name)
}
}
})
test('effects have valid radius', () => {
for (const n of allNodes) {
for (const e of n.effects) {
expect(e.radius).toBeGreaterThanOrEqual(0)
}
}
})
test('layout nodes have valid spacing', () => {
for (const n of allNodes) {
if (n.layoutMode !== 'NONE') {
expect(Number.isFinite(n.itemSpacing)).toBe(true)
expect(n.paddingTop).toBeGreaterThanOrEqual(0)
expect(n.paddingRight).toBeGreaterThanOrEqual(0)
expect(n.paddingBottom).toBeGreaterThanOrEqual(0)
expect(n.paddingLeft).toBeGreaterThanOrEqual(0)
}
}
})
})
heavy('parse heavy .fig files', () => {
let material3: SceneGraph
let nuxtui: SceneGraph
let material3Nodes: SceneNode[]
let nuxtUiNodes: SceneNode[]
beforeAll(async () => {
const m3Buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const nuBuf = readFileSync(resolve(FIXTURES, 'nuxtui.fig'))
material3 = await parseFigFile(m3Buf.buffer as ArrayBuffer)
nuxtui = await parseFigFile(nuBuf.buffer as ArrayBuffer)
material3Nodes = collectAllNodes(material3)
nuxtUiNodes = collectAllNodes(nuxtui)
})
test('material3.fig parses with pages and nodes', () => {
expect(material3).toBeInstanceOf(SceneGraph)
expect(material3.getPages().length).toBeGreaterThan(0)
expect(material3Nodes.length).toBeGreaterThan(0)
})
test('nuxtui.fig parses with pages and nodes', () => {
expect(nuxtui).toBeInstanceOf(SceneGraph)
expect(nuxtui.getPages().length).toBeGreaterThan(0)
expect(nuxtUiNodes.length).toBeGreaterThan(0)
})
test('material3: contains COMPONENT nodes', () => {
expect(material3Nodes.some((n) => n.type === 'COMPONENT')).toBe(true)
})
test('material3: no unmapped node types', () => {
const invalid = material3Nodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('nuxtui: no unmapped node types', () => {
const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('material3: fills have valid colors', () => {
for (const n of material3Nodes) {
for (const fill of n.fills) {
if (fill.type === 'SOLID') {
const { r, g, b, a } = fill.color
expect(r).toBeGreaterThanOrEqual(0)
expect(r).toBeLessThanOrEqual(1)
expect(g).toBeGreaterThanOrEqual(0)
expect(g).toBeLessThanOrEqual(1)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThanOrEqual(1)
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThanOrEqual(1)
}
}
}
})
test('nuxtui: fills have valid colors', () => {
for (const n of nuxtUiNodes) {
for (const fill of n.fills) {
if (fill.type === 'SOLID') {
expect(fill.color.r).toBeGreaterThanOrEqual(0)
expect(fill.color.r).toBeLessThanOrEqual(1)
}
}
}
})
})
describe('roundtrip: export → re-import', () => {
let reImported: SceneGraph
let reImportedNodes: SceneNode[]
beforeAll(async () => {
await initCodec()
const graph = new SceneGraph()
const page1 = graph.getPages()[0]
const page2 = graph.addPage('Second Page')
const internalPage = graph.addPage('Internal Only Canvas')
internalPage.internalOnly = true
graph.createNode('RECTANGLE', internalPage.id, {
name: 'Internal Rect',
width: 50,
height: 50,
})
graph.createNode('FRAME', page1.id, {
name: 'Container',
x: 0,
y: 0,
width: 400,
height: 300,
layoutMode: 'VERTICAL',
itemSpacing: 16,
paddingTop: 24,
paddingRight: 24,
paddingBottom: 24,
paddingLeft: 24,
cornerRadius: 12,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
})
const container = graph.getChildren(page1.id)[0]
graph.createNode('RECTANGLE', container.id, {
name: 'Header BG',
x: 0,
y: 0,
width: 400,
height: 80,
cornerRadius: 8,
topLeftRadius: 8,
topRightRadius: 8,
bottomRightRadius: 0,
bottomLeftRadius: 0,
independentCorners: true,
fills: [
{ type: 'SOLID', color: { r: 0.2, g: 0.4, b: 0.8, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' },
{
type: 'GRADIENT_LINEAR',
color: { r: 0, g: 0, b: 0, a: 0 },
opacity: 0.5,
visible: true,
blendMode: 'NORMAL',
gradientStops: [
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 },
{ color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 },
],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
},
],
effects: [
{ type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.25 }, offset: { x: 0, y: 4 }, radius: 8, spread: 0, visible: true, blendMode: 'NORMAL' },
],
})
graph.createNode('TEXT', container.id, {
name: 'Title',
x: 24,
y: 100,
width: 352,
height: 24,
text: 'Hello World',
fontSize: 18,
fontFamily: 'Inter',
fontWeight: 700,
textAlignHorizontal: 'CENTER',
})
graph.createNode('ELLIPSE', container.id, {
name: 'Circle',
x: 176,
y: 140,
width: 48,
height: 48,
fills: [{ type: 'SOLID', color: { r: 0.9, g: 0.1, b: 0.3, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
})
graph.createNode('RECTANGLE', page2.id, {
name: 'Page2 Rect',
x: 50,
y: 50,
width: 100,
height: 100,
})
const figBytes = await exportFigFile(graph)
reImported = await parseFigFile(figBytes.buffer as ArrayBuffer)
reImportedNodes = collectAllNodes(reImported)
})
test('preserves page count', () => {
expect(reImported.getPages().length).toBe(2)
})
test('preserves page names', () => {
const names = reImported.getPages().map((p) => p.name)
expect(names).toContain('Page 1')
expect(names).toContain('Second Page')
})
test('preserves internal pages', () => {
const allPages = reImported.getPages(true)
const publicPages = reImported.getPages(false)
expect(allPages.length).toBe(3)
expect(publicPages.length).toBe(2)
const internal = allPages.find((p) => p.internalOnly)
expect(internal).toBeDefined()
expect(internal!.name).toBe('Internal Only Canvas')
expect(reImported.getChildren(internal!.id).length).toBe(1)
})
test('preserves node count', () => {
// 1 frame + 1 rect + 1 text + 1 ellipse + 1 rect on page 2 = 5
expect(reImportedNodes.length).toBe(5)
})
test('preserves node types', () => {
const types = countByType(reImportedNodes)
expect(types.get('FRAME')).toBe(1)
expect(types.get('RECTANGLE')).toBe(2)
expect(types.get('TEXT')).toBe(1)
expect(types.get('ELLIPSE')).toBe(1)
})
test('preserves node names', () => {
const names = new Set(reImportedNodes.map((n) => n.name))
expect(names.has('Container')).toBe(true)
expect(names.has('Header BG')).toBe(true)
expect(names.has('Title')).toBe(true)
expect(names.has('Circle')).toBe(true)
expect(names.has('Page2 Rect')).toBe(true)
})
test('preserves fills', () => {
const headerBg = reImportedNodes.find((n) => n.name === 'Header BG')!
expect(headerBg.fills).toHaveLength(2)
expect(headerBg.fills[0].type).toBe('SOLID')
expect(headerBg.fills[0].color.r).toBeCloseTo(0.2, 1)
expect(headerBg.fills[1].type).toBe('GRADIENT_LINEAR')
expect(headerBg.fills[1].opacity).toBeCloseTo(0.5, 1)
expect(headerBg.fills[1].gradientStops).toHaveLength(2)
})
test('preserves text content', () => {
const title = reImportedNodes.find((n) => n.name === 'Title')!
expect(title.text).toBe('Hello World')
})
test('preserves text properties', () => {
const title = reImportedNodes.find((n) => n.name === 'Title')!
expect(title.fontSize).toBe(18)
expect(title.fontFamily).toBe('Inter')
expect(title.fontWeight).toBe(700)
expect(title.textAlignHorizontal).toBe('CENTER')
})
test('preserves layout mode', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')!
expect(container.layoutMode).toBe('VERTICAL')
})
test('preserves layout spacing', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')!
expect(container.itemSpacing).toBe(16)
expect(container.paddingTop).toBe(24)
expect(container.paddingRight).toBe(24)
expect(container.paddingBottom).toBe(24)
expect(container.paddingLeft).toBe(24)
})
test('preserves corner radius', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')!
expect(container.cornerRadius).toBe(12)
})
test('preserves independent corner radii', () => {
const headerBg = reImportedNodes.find((n) => n.name === 'Header BG')!
expect(headerBg.independentCorners).toBe(true)
expect(headerBg.topLeftRadius).toBe(8)
expect(headerBg.topRightRadius).toBe(8)
expect(headerBg.bottomRightRadius).toBe(0)
expect(headerBg.bottomLeftRadius).toBe(0)
})
test('preserves effects', () => {
const headerBg = reImportedNodes.find((n) => n.name === 'Header BG')!
expect(headerBg.effects).toHaveLength(1)
expect(headerBg.effects[0].type).toBe('DROP_SHADOW')
expect(headerBg.effects[0].radius).toBe(8)
expect(headerBg.effects[0].offset.y).toBe(4)
})
test('preserves dimensions', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')!
expect(container.width).toBe(400)
expect(container.height).toBe(300)
})
})
describe('edge cases', () => {
test('non-fig-kiwi bytes throw meaningful error', async () => {
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
await expect(parseFigFile(garbage.buffer as ArrayBuffer)).rejects.toThrow()
})
test('invalid zip data throws', async () => {
const garbage = new Uint8Array(50).fill(0xff)
await expect(parseFigFile(garbage.buffer as ArrayBuffer)).rejects.toThrow()
})
test('REMOVED nodes are skipped', async () => {
const { importNodeChanges } = await import('../../packages/core/src/kiwi/fig-import')
const graph = importNodeChanges([
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
},
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
},
{
guid: { sessionID: 1, localID: 10 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'RECTANGLE',
name: 'Visible',
visible: true,
opacity: 1,
phase: 'CREATED',
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
},
{
guid: { sessionID: 1, localID: 11 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'RECTANGLE',
name: 'Deleted',
visible: true,
opacity: 1,
phase: 'REMOVED',
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
},
])
const children = graph.getChildren(graph.getPages()[0].id)
expect(children).toHaveLength(1)
expect(children[0].name).toBe('Visible')
})
test('symbolOverrides propagate through nested instances', () => {
// Structure:
// DOCUMENT (0:0)
// └─ CANVAS "Page" (0:1)
// ├─ COMPONENT "Inner" (1:1) ← inner component
// │ └─ TEXT "Label" (1:2) ← default text "Default"
// ├─ COMPONENT "Outer" (1:3) ← outer component
// │ └─ INSTANCE "InnerUse" (1:4) symId=1:1, override: Label→"Changed"
// └─ INSTANCE "OuterUse" (1:5) ← instance of Outer
//
// After import, OuterUse should contain a clone of InnerUse,
// which should contain a Label text with "Changed" (not "Default")
const graph = importNodeChanges([
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
phase: 'CREATED',
} as NodeChange,
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
phase: 'CREATED',
} as NodeChange,
// Inner component
{
guid: { sessionID: 1, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'SYMBOL',
name: 'Inner',
phase: 'CREATED',
size: { x: 100, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
// TEXT child of Inner
{
guid: { sessionID: 1, localID: 2 },
overrideKey: { sessionID: 99, localID: 2 },
parentIndex: { guid: { sessionID: 1, localID: 1 }, position: '!' },
type: 'TEXT',
name: 'Label',
textData: { characters: 'Default' },
phase: 'CREATED',
size: { x: 80, y: 20 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
// Outer component
{
guid: { sessionID: 1, localID: 3 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'SYMBOL',
name: 'Outer',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
// INSTANCE of Inner inside Outer, with symbolOverride on Label
{
guid: { sessionID: 1, localID: 4 },
parentIndex: { guid: { sessionID: 1, localID: 3 }, position: '!' },
type: 'INSTANCE',
name: 'InnerUse',
phase: 'CREATED',
size: { x: 100, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 1 },
symbolOverrides: [
{
guidPath: { guids: [{ sessionID: 99, localID: 2 }] },
textData: { characters: 'Changed' },
},
],
},
} as NodeChange,
// Top-level INSTANCE of Outer on the page
{
guid: { sessionID: 1, localID: 5 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' },
type: 'INSTANCE',
name: 'OuterUse',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 3 },
},
} as NodeChange,
])
const page = graph.getPages()[0]
const outerUse = graph.getChildren(page.id).find((n) => n.name === 'OuterUse')
expect(outerUse).toBeDefined()
expect(outerUse!.type).toBe('INSTANCE')
// Walk down: OuterUse > InnerUse clone > Label clone
const innerClone = graph.getChildren(outerUse!.id)[0]
expect(innerClone).toBeDefined()
expect(innerClone.name).toBe('InnerUse')
const labelClone = graph.getChildren(innerClone.id)[0]
expect(labelClone).toBeDefined()
expect(labelClone.name).toBe('Label')
expect(labelClone.text).toBe('Changed')
})
test('overriddenSymbolID swaps instance component through nested levels', () => {
// Structure:
// COMPONENT "IconA" (1:1) → VECTOR "PathA"
// COMPONENT "IconB" (1:3) → VECTOR "PathB1", VECTOR "PathB2"
// COMPONENT "Button" (1:5) → INSTANCE "icon" of IconA
// COMPONENT "Toolbar" (1:7) → INSTANCE "btn" of Button, with override swapping icon to IconB
// INSTANCE "ToolbarUse" (1:9) of Toolbar on the page
//
// After import, ToolbarUse > btn clone > icon clone should have IconB's children
const graph = importNodeChanges([
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
phase: 'CREATED',
} as NodeChange,
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
phase: 'CREATED',
} as NodeChange,
// IconA component with 1 child
{
guid: { sessionID: 1, localID: 1 },
overrideKey: { sessionID: 90, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'SYMBOL',
name: 'IconA',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
{
guid: { sessionID: 1, localID: 2 },
overrideKey: { sessionID: 90, localID: 2 },
parentIndex: { guid: { sessionID: 1, localID: 1 }, position: '!' },
type: 'VECTOR',
name: 'PathA',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
// IconB component with 2 children
{
guid: { sessionID: 1, localID: 3 },
overrideKey: { sessionID: 90, localID: 3 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'SYMBOL',
name: 'IconB',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
{
guid: { sessionID: 1, localID: 31 },
overrideKey: { sessionID: 90, localID: 31 },
parentIndex: { guid: { sessionID: 1, localID: 3 }, position: '!' },
type: 'VECTOR',
name: 'PathB1',
phase: 'CREATED',
size: { x: 24, y: 12 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
{
guid: { sessionID: 1, localID: 32 },
overrideKey: { sessionID: 90, localID: 32 },
parentIndex: { guid: { sessionID: 1, localID: 3 }, position: '"' },
type: 'VECTOR',
name: 'PathB2',
phase: 'CREATED',
size: { x: 24, y: 12 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
// Button component with instance of IconA
{
guid: { sessionID: 1, localID: 5 },
overrideKey: { sessionID: 90, localID: 5 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' },
type: 'SYMBOL',
name: 'Button',
phase: 'CREATED',
size: { x: 40, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
{
guid: { sessionID: 1, localID: 6 },
overrideKey: { sessionID: 90, localID: 6 },
parentIndex: { guid: { sessionID: 1, localID: 5 }, position: '!' },
type: 'INSTANCE',
name: 'icon',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: { symbolID: { sessionID: 1, localID: 1 } },
} as NodeChange,
// Toolbar component with instance of Button, swapping icon to IconB
{
guid: { sessionID: 1, localID: 7 },
overrideKey: { sessionID: 90, localID: 7 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '$' },
type: 'SYMBOL',
name: 'Toolbar',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
} as NodeChange,
{
guid: { sessionID: 1, localID: 8 },
overrideKey: { sessionID: 90, localID: 8 },
parentIndex: { guid: { sessionID: 1, localID: 7 }, position: '!' },
type: 'INSTANCE',
name: 'btn',
phase: 'CREATED',
size: { x: 40, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 5 },
symbolOverrides: [
{
guidPath: { guids: [{ sessionID: 90, localID: 6 }] },
overriddenSymbolID: { sessionID: 1, localID: 3 },
},
],
},
} as NodeChange,
// Page-level instance of Toolbar
{
guid: { sessionID: 1, localID: 9 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '%' },
type: 'INSTANCE',
name: 'ToolbarUse',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 7 },
},
} as NodeChange,
])
const page = graph.getPages()[0]
const toolbarUse = graph.getChildren(page.id).find((n) => n.name === 'ToolbarUse')
expect(toolbarUse).toBeDefined()
// ToolbarUse > btn clone > icon clone
const btnClone = graph.getChildren(toolbarUse?.id ?? '')[0]
expect(btnClone).toBeDefined()
expect(btnClone.name).toBe('btn')
const iconClone = graph.getChildren(btnClone.id)[0]
expect(iconClone).toBeDefined()
expect(iconClone.name).toBe('icon')
// Icon should have IconB's 2 children, not IconA's 1 child
const iconChildren = graph.getChildren(iconClone.id)
expect(iconChildren).toHaveLength(2)
expect(iconChildren.map((c) => c.name).sort()).toEqual(['PathB1', 'PathB2'])
})
test('DSD propagates through intermediate clones that are also DSD-targeted', async () => {
const graph = await parseFigFile(readFileSync(resolve(__dirname, '../fixtures/gold-preview.fig')).buffer)
const thumb = [...graph.getAllNodes()].find((n) => n.name === 'Preview Thumbnail')
expect(thumb).toBeDefined()
let overflows = 0
function walk(id: string) {
const node = graph.getNode(id)
if (!node) return
if (node.type === 'VECTOR') {
const parent = graph.getNode(node.parentId)
if (parent?.type === 'INSTANCE' && parent.width > 0 && parent.height > 0) {
// Check visibility
let vis = true
let cur: typeof node | null = node
while (cur) {
if (!cur.visible) {
vis = false
break
}
cur = cur.parentId ? graph.getNode(cur.parentId) ?? null : null
}
// Check clipping
let clipped = false
cur = graph.getNode(parent.parentId)
while (cur) {
if (cur.clipsContent) {
clipped = true
break
}
cur = cur.parentId ? graph.getNode(cur.parentId) ?? null : null
}
if (vis && !clipped && node.width > parent.width * 1.2) {
overflows++
}
}
}
for (const cid of node.childIds) walk(cid)
}
walk(thumb?.id ?? '')
expect(overflows).toBe(0)
})
})
describe('text node export', () => {
test('text nodes have derivedTextData and textUserLayoutVersion', async () => {
await initCodec()
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Greeting',
text: 'Hello World',
width: 120,
height: 24,
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 16
})
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Greeting')!
expect(textNode).toBeDefined()
expect(textNode.type).toBe('TEXT')
expect(textNode.text).toBe('Hello World')
expect(textNode.fontFamily).toBe('Inter')
expect(textNode.fontSize).toBe(16)
})
test('text node has lines in textData', async () => {
await initCodec()
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Multiline',
text: 'Line 1\nLine 2\nLine 3',
width: 100,
height: 60,
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 14
})
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Multiline')!
expect(textNode).toBeDefined()
expect(textNode.text).toBe('Line 1\nLine 2\nLine 3')
})
test('derivedTextData fields present in raw binary', async () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import(
'../../packages/core/src/kiwi/kiwi-schema'
)
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Raw Test',
text: 'Check binary',
width: 80,
height: 18,
fontFamily: 'Roboto',
fontWeight: 700,
fontSize: 12
})
const exported = await exportFigFile(graph)
const zip = unzipSync(new Uint8Array(exported))
const canvasData = zip['canvas.fig'] ?? zip['canvas']
expect(canvasData).toBeDefined()
const chunks = parseFigKiwiChunks(canvasData)
expect(chunks).not.toBeNull()
expect(chunks!.length).toBeGreaterThanOrEqual(2)
const schemaBytes = inflateSync(chunks![0])
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): any }
const dataRaw = inflateSync(chunks![1])
const message = compiled.decodeMessage(dataRaw)
const textNc = message.nodeChanges.find((nc: any) => nc.type === 'TEXT')
expect(textNc).toBeDefined()
expect(textNc.textData.characters).toBe('Check binary')
expect(textNc.textData.lines).toBeDefined()
expect(textNc.textData.lines.length).toBeGreaterThanOrEqual(1)
expect(textNc.textData.lines[0].lineType).toBe('PLAIN')
expect(textNc.textUserLayoutVersion).toBe(3)
expect(textNc.derivedTextData).toBeDefined()
expect(textNc.derivedTextData.layoutSize).toBeDefined()
expect(textNc.derivedTextData.layoutSize.x).toBe(80)
expect(textNc.derivedTextData.layoutSize.y).toBe(18)
expect(textNc.derivedTextData.fontMetaData).toBeDefined()
expect(textNc.derivedTextData.fontMetaData.length).toBe(1)
expect(textNc.derivedTextData.fontMetaData[0].key.family).toBe('Roboto')
expect(textNc.derivedTextData.fontMetaData[0].fontWeight).toBe(700)
expect(textNc.derivedTextData.fontMetaData[0].fontStyle).toBe('NORMAL')
})
test('style runs produce multiple fontMetaData entries', async () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import(
'../../packages/core/src/kiwi/kiwi-schema'
)
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Styled',
text: 'Bold and Normal',
width: 150,
height: 20,
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 16,
styleRuns: [
{ start: 0, length: 4, style: { fontWeight: 700 } },
{ start: 5, length: 10, style: {} }
]
})
const exported = await exportFigFile(graph)
const zip = unzipSync(new Uint8Array(exported))
const canvasData = zip['canvas.fig'] ?? zip['canvas']
const chunks = parseFigKiwiChunks(canvasData)!
const schemaBytes = inflateSync(chunks[0])
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): any }
const dataRaw = inflateSync(chunks[1])
const message = compiled.decodeMessage(dataRaw)
const textNc = message.nodeChanges.find((nc: any) => nc.type === 'TEXT')
expect(textNc.derivedTextData.fontMetaData.length).toBe(2)
const families = textNc.derivedTextData.fontMetaData.map((m: any) => m.key.style)
expect(families).toContain('Bold')
expect(families).toContain('Medium')
})
test('material3.fig text nodes have derivedTextData after round-trip', async () => {
const buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const original = await parseFigFile(buf.buffer as ArrayBuffer)
const textNodes = [...original.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(textNodes.length).toBeGreaterThan(0)
const exported = await exportFigFile(original)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const reimportedText = [...reimported.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(reimportedText.length).toBe(textNodes.length)
for (const node of reimportedText.slice(0, 10)) {
expect(node.text.length).toBeGreaterThan(0)
}
})
})
describe('variable roundtrip', () => {
test('variables and collections survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
const col = graph.createCollection('Design Tokens')
graph.createVariable('color/primary', 'COLOR', col.id, { r: 0.23, g: 0.51, b: 0.96, a: 1 })
graph.createVariable('spacing/base', 'FLOAT', col.id, 8)
graph.createVariable('visible', 'BOOLEAN', col.id, true)
graph.createVariable('label', 'STRING', col.id, 'Hello')
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
expect(reimported.variables.size).toBe(4)
expect(reimported.variableCollections.size).toBe(1)
const reimportedCol = [...reimported.variableCollections.values()][0]
expect(reimportedCol.name).toBe('Design Tokens')
expect(reimportedCol.variableIds).toHaveLength(4)
const vars = [...reimported.variables.values()]
const colorVar = vars.find((v) => v.name === 'color/primary')!
expect(colorVar.type).toBe('COLOR')
const colorVal = Object.values(colorVar.valuesByMode)[0] as { r: number; g: number; b: number; a: number }
expect(colorVal.r).toBeCloseTo(0.23, 1)
const floatVar = vars.find((v) => v.name === 'spacing/base')!
expect(floatVar.type).toBe('FLOAT')
expect(Object.values(floatVar.valuesByMode)[0]).toBe(8)
const boolVar = vars.find((v) => v.name === 'visible')!
expect(boolVar.type).toBe('BOOLEAN')
expect(Object.values(boolVar.valuesByMode)[0]).toBe(true)
const strVar = vars.find((v) => v.name === 'label')!
expect(strVar.type).toBe('STRING')
expect(Object.values(strVar.valuesByMode)[0]).toBe('Hello')
})
test('variable bindings survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
const col = graph.createCollection('Tokens')
const floatVar = graph.createVariable('radius', 'FLOAT', col.id, 12)
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, {
name: 'Bound Rect',
width: 100,
height: 100,
cornerRadius: 12,
})
graph.bindVariable(rect.id, 'cornerRadius', floatVar.id)
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const reimportedRect = [...reimported.getAllNodes()].find((n) => n.name === 'Bound Rect')!
expect(reimportedRect).toBeDefined()
expect(Object.keys(reimportedRect.boundVariables)).toContain('cornerRadius')
})
test('material3.fig variables survive round-trip', async () => {
const buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const original = await parseFigFile(buf.buffer as ArrayBuffer)
const exported = await exportFigFile(original)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
expect(reimported.variables.size).toBe(original.variables.size)
expect(reimported.variableCollections.size).toBeGreaterThanOrEqual(
[...original.variableCollections.values()].filter((c) => c.variableIds.length > 0).length
)
})
})