openpencil/tests/engine/fig-roundtrip.test.ts
Danila Poyarkov 6545f20c53
Refactor architecture boundaries across core, app, and packages (#234)
* refactor(core): decompose editor factory and action modules

Split the monolithic editor factory and large action modules into focused
domain helpers:

- create.ts assembles context through bridge modules (clipboard,
  components, structure, undo) and delegates to graph-reads, graph-events,
  layout-runner, component-sync, and state factory
- structure.ts delegates to group, container-wrap, auto-layout-wrap,
  reorder, and state-toggle helpers
- selection.ts delegates to hit-test, overlays, container navigation,
  and read helpers
- clipboard.ts delegates to subtree-history, images, export, copy,
  fonts, and placement helpers
- shapes.ts delegates to pen actions and section-adopt
- components.ts delegates to focus and instances helpers
- alignment.ts delegates to flip-rotate helper
- text.ts uses explicit TextEditSession for snapshot comparison

New focused modules: nudge, variable-bindings, layout-mode,
page-viewports, tool-registry, color-space

Undo: history/position and history/snapshot helpers, hardened
batch/rollback with nested batch support and configurable limit

* refactor(core): split tool definitions by domain

Split the monolithic tool registry into domain-specific modules:

- read/ — selection, find, pages, fonts, components, nodes, query, jsx
- create/ — basic shapes, components, vector, JSX render
- modify/ — paint, effects, geometry, layout, state, text, update
- structure/ — basic, arrange, batch, hierarchy, replace, tree
- variables/ — bindings, collections, read, values
- vector/ — boolean, path, export, viewport
- analyze/ — colors, typography, spacing, clusters, diff, eval
- describe/ — summaries, tree, roles, layout-issues
- stock-photo/ — providers, requests, apply
- codegen/ — component-map, tokens

Split registry into core/extended tiers; refine schema and AI adapter

* refactor(core): restructure kiwi codec and instance overrides

Reorganize the Kiwi .fig codec into domain subdirectories:

- binary/ — codec, schema, protocol
- fig/ — file, import, parse (core, worker, transfer)
- node-change/ — convert, export-node, serialize, plugin-data
- instance-overrides/ — constraints, dsd, populate, props, resolve,
  symbol-overrides, symbol-props, sync, types

Vendored kiwi-schema/ left isolated

* refactor(core): split profiler, icons, IO, and add subpath exports

Profiler: speedscope-export, capture-session, hud-controller
Icons: api, svg, types, render, create-icons tool
IO: format registry and subpath exports
Canvas/color/text/vector: targeted cleanup

Add deliberate subpath exports: random, xpath, vector, color, canvas,
scene-graph, kiwi, design-jsx, io, tools, editor, layout, canvaskit,
profiler, text, lint, rpc, figma-api, constants

* refactor(vue): decompose canvas input, surface lifecycle, and controls

Canvas surface: gl-surface, kit-loader, render-loop, resize-observer

Canvas input handlers:
- move: drop-target, move-snap, duplicate-drag
- select: select-move, select-hover, select-hit
- resize: resize-rect, resize-vector, resize-start
- transform: rotation, marquee, pan, text-selection
- text-edit: navigation, clipboard, textarea lifecycle
- Shared: click-count, space-key, pan, pan-zoom, draw, raf-scheduler

Editor composition:
- commands split: actions, context, metadata, edit, selection, view
- menu-model split: command-groups, builders, types
- Gradient stop composable reuse in primitive root

Controls: fill, layout, typography, appearance, effects, stroke,
okhcl, prop-scrub, node-props, undo-batch, color-variable-binding
Variables/i18n/document/export helpers

Organize canvas, primitives, controls, editor, and variables into
cohesive module directories with package-local import aliases

Expose MenuActionNode/MenuSeparatorNode from public API

* refactor(app): split document IO, editor session, and automation bridge

Document IO: source-state, naming, writer, reload-source, reload-state,
imported-document, watch-targets, save-targets

Editor session: create, modules, types, accessors, computed, refs
Editor canvas: loader-overlay, collaboration-awareness,
context-selection, menu-actions, menu-model

Automation bridge: eval, tools, exports, files, selection, RPC fallback

AI/ACP: transport, map-update, permission, debug, chat effects/storage
Collab: awareness, graph-bindings, yjs-sync, follow, session, types
Shell keyboard: actions, bindings, clipboard, focus, nudging,
raw-events, registry, reserved, shortcuts, space-tool
Shell menu: app-menu, document-name, entry, files

Demo: colors, effects, helpers, section builders (components,
app-preview, effects, standalone, variables) — document.ts reduced
from 981 to 32 lines as pure orchestrator

Move app modules under src/app/ with organized domain structure:
editor, document, ai, collab, shell, automation, demo, tabs

* refactor(app): decompose UI components with provide/inject context

Split monolithic components using Reka UI-inspired namespace folders
with scoped provide/inject context — no prop drilling:

- CollabPanel/ — context, avatars, share, connected, join
- ColorPickerPanel/ — context, area, format, field groups, sliders
- MobileHud/ — context, action toast, tool badge, file menu, presence
- ProviderSettings/ — context, API key/type, endpoint, tokens, photos
- Toolbar/ — actions, types, desktop, mobile, tool button, flyout
- LayoutSection/ — types, auto-layout, flex, grid, padding, size, clip

Properties helpers: fill-okhcl adapter, fill-label, color-style-row
Menu: entry helpers, document-name rename, stale type removal

* refactor(mcp): split server into focused modules

- browser-rpc — WebSocket client management
- mcp-sessions — session lifecycle
- tool-output — response formatting
- tool-schema — Zod schema generation from ToolDefs
- jsx-preprocess — JSX source transformation
- result — result helpers
- tool-registration — MCP tool wiring
- auth — API key validation
- http-options — CORS/request handling
- stdio-bridge — stdio transport adapter

* refactor(cli): split analyze subcommands and shared helpers

- Analyze subcommands: clusters, colors, spacing, typography
- RPC data loading helper
- Migrate imports to targeted core subpath exports

* refactor(docs): split VitePress config and shared table component

Config helpers: sdk-sidebar, seo, labels, sidebars, locale-theme,
root-theme, locales

Shared SdkDataTable component replaces duplicated table markup in
SdkPropsTable, SdkEventsTable, and SdkSlotsTable

Update contributing and testing docs

* refactor(tauri): decompose desktop entrypoint

Split lib.rs into focused service modules:

- fig_container.rs — .fig archive/compression commands
- fonts.rs — font cache and system font enumeration
- menu.rs — native menu construction
- menu_events.rs — menu event dispatch and devtools toggle
- window.rs — main window show/focus lifecycle

* test: share domain test factories and migrate fixtures

New shared helpers:
- tests/helpers/scene.ts — makeSceneGraph factory
- tests/helpers/vector-network.ts — vertex/segment/network builders
- tests/helpers/fig-traversal.ts — all-node collection, type counts
- tests/helpers/undo.ts — undo test utilities
- tests/helpers/editor-history.ts — editor history test helpers

Migrate render, vector, fig-roundtrip, and undo tests to use shared
factories instead of inline fixture construction

* build: add structural lint rules, split vite config, update docs

Structural lint (oxlint.structure.json + lint/plugin.js):
- 20+ custom rules enforcing package boundaries, lifecycle patterns,
  naming conventions, and import discipline

Vite config split: raw-markdown, canvaskit-assets, pwa, server,
aliases, automation plugins

Remove legacy shims and utils superseded by SDK/core modules
Update AGENTS.md, CONTRIBUTING.md, eval-command docs, tsconfig

* fix(vue): normalize canvas directory casing and remove duplicate export

- Rename Canvas/ to canvas/ in git index to match #vue/canvas/* imports
  (PascalCase was correct for component primitives but canvas/ is a
  non-component domain directory)
- Remove duplicate ./random subpath export in core package.json

* fix: add #vue and #core Vite resolve aliases for dev server

* refactor(core): reduce remaining large modules

Split the remaining large core hotspots into cohesive domain modules while preserving public facades and behavior.

- Extract scene graph types, variables, node defaults, and vector-network helpers
- Decompose canvas renderer orchestration, state, paints, colors, lifecycle, labels, and delegated domain methods into renderer/ and labels/ subfolders
- Split Kiwi node-change, binary variable binding, layout, RPC, vector, JSX export, clipboard, design JSX, and Figma proxy helpers
- Replace collision-driven *Fn import aliases with namespace imports and enforce the pattern in lint

Validation:
- bun run check
- bun --filter @open-pencil/vue build
- bun run test:dupes

* fix(app): forward color input attrs

* fix(app): cover section drawing errors

* fix(editor): undo option-drag duplicates

* docs: document domain subfolder convention

* fix(app): handle undo redo on keydown

* refactor(app): dispatch shortcuts from keydown

* refactor: group prefixed domain modules

* refactor(app): use tinykeys for shortcuts

* refactor(core): group symbol override modules

* refactor(core): group fig kiwi container helper

* refactor(canvas): split overlay rendering modules

* refactor(vue): remove unused internal barrels

* fix(app): lay out demo components before instancing

* fix(app): restore demo badge spacing

* perf(canvas): split scene and overlay rendering

* refactor(vue): wrap wheel gesture lifecycle

* fix(canvas): wait for fonts before hiding loader

* docs: update unreleased changelog
2026-04-30 15:14:19 +03:00

1222 lines
42 KiB
TypeScript

import { describe, test, expect, beforeAll, setDefaultTimeout } from 'bun:test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { computeContentBounds } from '#core/io/formats/raster/render'
import {
parseFigFile,
computeAllLayouts,
exportFigFile,
importNodeChanges,
initCodec,
SceneGraph,
type SceneNode,
type Fill,
} from '@open-pencil/core'
import {
childMatching,
childNamed,
collectAllNodes,
countByType,
previewChild
} from '../helpers/fig-traversal'
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',
])
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('derived instance layout regressions', () => {
let layoutGraph: SceneGraph
let layoutNodes: SceneNode[]
beforeAll(async () => {
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
layoutGraph = await parseFigFile(buf.buffer as ArrayBuffer)
computeAllLayouts(layoutGraph)
layoutNodes = collectAllNodes(layoutGraph)
})
test('preserves repeated badge overrides without moving sibling component wrappers', () => {
const input = previewChild(layoutGraph, layoutNodes, 'Input')
const inputRoot = childNamed(layoutGraph, input, '_input')
const inputFrame = childNamed(layoutGraph, inputRoot, 'Input')
const content = childNamed(layoutGraph, inputFrame, 'Content')
const tags = childNamed(layoutGraph, content, 'Tags')
const firstBadge = childNamed(layoutGraph, tags, 'Badge')
const firstBadgeContent = childNamed(layoutGraph, firstBadge, '_badge-and-tag')
const placeholderFrame = childNamed(layoutGraph, content, 'Placeholder')
const placeholderText = childNamed(layoutGraph, placeholderFrame, 'Placeholder')
expect(inputFrame).toMatchObject({ x: 0, y: 0 })
expect(inputFrame?.width).toBeCloseTo(375.7498, 3)
expect(inputFrame?.height).toBeCloseTo(39.3803, 3)
expect(content).toMatchObject({ x: 0, y: 0 })
expect(firstBadge?.x).toBeCloseTo(7.1268, 3)
expect(firstBadge?.y).toBeCloseTo(5.3451, 3)
expect(firstBadge?.width).toBeCloseTo(85.3239, 3)
expect(firstBadge?.height).toBeCloseTo(28.6901, 3)
expect(firstBadgeContent).toMatchObject({ x: 0, y: 0 })
expect(placeholderFrame?.x).toBeCloseTo(277.352, 3)
expect(placeholderFrame?.y).toBeCloseTo(0, 3)
expect(placeholderText?.x).toBeCloseTo(14.2535, 3)
expect(placeholderText?.y).toBeCloseTo(10.6901, 3)
})
test('does not collapse unrelated datepicker instances to the page origin', () => {
const datepicker = previewChild(layoutGraph, layoutNodes, '_datepicker')
expect(datepicker?.x).toBeCloseTo(765.2428, 3)
expect(datepicker?.y).toBeCloseTo(518, 3)
expect(datepicker?.width).toBeCloseTo(362, 3)
expect(datepicker?.height).toBeCloseTo(422, 3)
})
test('keeps checked-list icons aligned with labels', () => {
const title = previewChild(layoutGraph, layoutNodes, 'Title + Description')
const checkedList = childNamed(layoutGraph, title, 'Checked List')
const listItems = checkedList ? layoutGraph.getChildren(checkedList.id) : []
expect(listItems).toHaveLength(3)
for (const item of listItems) {
const list = childNamed(layoutGraph, item, '_list')
const inline = childNamed(layoutGraph, list, 'Inline')
const icon = childNamed(layoutGraph, inline, 'Static Icon')
const content = childNamed(layoutGraph, inline, 'Content')
const label = content ? layoutGraph.getChildren(content.id).find((node) => node.text) : undefined
expect(icon?.y).toBeCloseTo(0, 3)
expect(label?.y).toBeCloseTo(0, 3)
expect(icon?.height).toBeCloseTo(label?.height ?? 0, 3)
}
})
test('preserves WYSIWYG toolbar padding', () => {
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
const toolbarRoot = childNamed(layoutGraph, wysiwyg, '_on-text-WYSIWYG-toolbar')
const toolbar = childMatching(
layoutGraph,
toolbarRoot,
(node) => node.name === 'Toolbar' && node.width === 286 && node.height === 48
)
const buttons = childMatching(
layoutGraph,
toolbar,
(node) => node.name === 'Toolbar' && node.width === 270 && node.height === 32
)
expect(toolbar?.x).toBeCloseTo(0, 3)
expect(toolbar?.y).toBeCloseTo(37, 3)
expect(buttons?.x).toBeCloseTo(8, 3)
expect(buttons?.y).toBeCloseTo(8, 3)
expect(buttons?.width).toBeCloseTo(270, 3)
expect(buttons?.height).toBeCloseTo(32, 3)
})
test('scales WYSIWYG icon vector geometry with nested instances', () => {
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
const toolbarRoot = childNamed(layoutGraph, wysiwyg, '_on-text-WYSIWYG-toolbar')
const toolbar = childMatching(
layoutGraph,
toolbarRoot,
(node) => node.name === 'Toolbar' && node.width === 286 && node.height === 48
)
const buttons = childMatching(
layoutGraph,
toolbar,
(node) => node.name === 'Toolbar' && node.width === 270 && node.height === 32
)
const linkButton = layoutGraph.getChildren(buttons?.id ?? '').find((node) => node.x === 136)
const linkIcon = childNamed(layoutGraph, linkButton, 'link')
const linkVectors = linkIcon ? layoutGraph.getChildren(linkIcon.id) : []
expect(linkIcon?.width).toBeCloseTo(18, 3)
expect(linkIcon?.height).toBeCloseTo(18, 3)
for (const vector of linkVectors) {
const xs = vector.vectorNetwork?.vertices.map((vertex) => vertex.x) ?? []
const ys = vector.vectorNetwork?.vertices.map((vertex) => vertex.y) ?? []
expect(Math.max(...xs)).toBeLessThanOrEqual(vector.width + 0.001)
expect(Math.max(...ys)).toBeLessThanOrEqual(vector.height + 0.001)
}
})
test('exports WYSIWYG and logo visual overflow instead of clipping to node boxes', () => {
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
expect(wysiwyg).toBeDefined()
const wysiwygBounds = wysiwyg ? computeContentBounds(layoutGraph, [wysiwyg.id]) : null
expect(wysiwygBounds?.minX).toBeCloseTo(5, 3)
expect(wysiwygBounds?.minY).toBeCloseTo(577, 3)
expect(wysiwygBounds ? wysiwygBounds.maxX - wysiwygBounds.minX : 0).toBeCloseTo(294, 3)
expect(wysiwygBounds ? wysiwygBounds.maxY - wysiwygBounds.minY : 0).toBeCloseTo(93, 3)
const title = previewChild(layoutGraph, layoutNodes, 'Title + Description')
const logoGroup = childNamed(
layoutGraph,
childNamed(layoutGraph, title, 'Logo'),
'logo-short-6'
)
expect(logoGroup).toBeDefined()
const logoBounds = logoGroup ? computeContentBounds(layoutGraph, [logoGroup.id]) : null
const logoAbs = logoGroup ? layoutGraph.getAbsolutePosition(logoGroup.id) : { x: 0, y: 0 }
const logo = logoGroup
expect(logoBounds?.minX).toBeLessThan(logoAbs.x)
expect(logoBounds?.minY).toBeLessThan(logoAbs.y)
expect(logoBounds?.maxX).toBeGreaterThan(logoAbs.x + (logo?.width ?? 0))
expect(logoBounds?.maxY).toBeGreaterThan(logoAbs.y + (logo?.height ?? 0))
})
})
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('#core/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(4)
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('Regular')
})
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
)
})
})