Merge branch 'master' into master

This commit is contained in:
Sadko 2026-07-17 18:37:59 -04:00 committed by GitHub
commit 86d4167cbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 382 additions and 166 deletions

View file

@ -9,9 +9,9 @@ Vue 3 + CanvasKit (Skia WASM) + Yoga WASM design editor. Tauri v2 desktop, also
Bun workspace packages:
- `packages/scene-graph``@open-pencil/scene-graph`: SceneGraph, node types, copy/snap/undo helpers, variables, instances, hit testing. Framework-agnostic.
- `packages/pen``@open-pencil/pen`: Pen/vector editing helpers shared by core/editor surfaces.
- `packages/pen``@open-pencil/pen`: Pencil.dev `.pen` document model, parser, and SceneGraph import adapter.
- `packages/kiwi``@open-pencil/kiwi`: pure Kiwi schema/runtime/protocol package. Owns low-level Figma Kiwi codec/container/parse helpers and stays SceneGraph-agnostic.
- `packages/fig``@open-pencil/fig`: publishable `.fig` package shell and low-level smoke/test boundary. Production SceneGraph `.fig` policy still lives mostly in core while this package grows.
- `packages/fig``@open-pencil/fig`: `.fig` archive/parser package and staged home for Figma-specific SceneGraph conversion policy. Core keeps format-neutral IO registration and runtime rendering integration.
- `packages/core``@open-pencil/core`: renderer, layout, editor core, Figma API, tools, clipboard, vector conversion, and app/CLI-facing document I/O. Depends on scene-graph/pen/kiwi but keeps browser DOM out of core.
- `packages/dom-css``@open-pencil/dom-css`: DOM/CSS projection layer for HTML/CSS/JSX/Tailwind compatibility. Owns DesignDOM types and browser/headless CSS runtime adapters; keeps DOM/CSS parser dependencies out of core.
- `packages/vue``@open-pencil/vue`: headless Vue 3 SDK (Reka UI-style) for building custom OpenPencil-powered editor shells and embedded editing surfaces. Renderless components and composables. The app is one consumer of the SDK.
@ -295,7 +295,7 @@ Self-review checklist:
## File format
- `.fig` files use Figma's Kiwi schema and `NodeChange[]` records. Low-level schema/runtime/codec/container/parse helpers live in `packages/kiwi/src/fig/**` and `packages/kiwi/src/schema-runtime/**`.
- `.fig` files use Figma's Kiwi schema and `NodeChange[]` records. Low-level schema/runtime/codec/container helpers live in `packages/kiwi/src/fig/**` and `packages/kiwi/src/schema-runtime/**`; complete `.fig` archive parsing lives in `packages/fig`.
- Core still owns SceneGraph `.fig` policy: import/export orchestration in `packages/core/src/io/formats/fig/**`, SceneGraph ⇄ NodeChange conversion in `packages/core/src/kiwi/fig/node-change/**`, and component/instance override interpretation in `packages/core/src/kiwi/fig/instance-overrides/**`.
- `packages/fig` is the publishable boundary for future `.fig` policy extraction; do not move behavior there without package-local tests and dist smoke.
- Vector data uses reverse-engineered `vectorNetworkBlob` binary format — encoder/decoder in `packages/core/src/vector/` and scene-graph vector-network types in `@open-pencil/scene-graph`.

View file

@ -136,6 +136,7 @@
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@open-pencil/fig": "workspace:*",
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/pen": "workspace:*",
"@open-pencil/scene-graph": "workspace:*",
@ -206,6 +207,9 @@
"packages/fig": {
"name": "@open-pencil/fig",
"version": "0.13.2",
"dependencies": {
"fflate": "^0.8.2",
},
"devDependencies": {
"@types/bun": "^1.3.14",
"tsdown": "^0.22.3",
@ -213,6 +217,7 @@
},
"peerDependencies": {
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/scene-graph": "workspace:*",
},
},
"packages/kiwi": {
@ -3261,6 +3266,8 @@
"@open-pencil/docs/@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="],
"@open-pencil/fig/fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"@open-pencil/kiwi/fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],

View file

@ -167,6 +167,7 @@
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@open-pencil/fig": "workspace:*",
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/pen": "workspace:*",
"@open-pencil/scene-graph": "workspace:*",

View file

@ -1,23 +1 @@
import { zipSync, type Zippable } from 'fflate'
import { buildFigKiwi } from '#core/kiwi/fig/node-change/serialize'
export function compressFigDataSync(
schemaDeflated: Uint8Array,
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: Array<{ name: string; data: Uint8Array }>,
figKiwiVersion?: number
): Uint8Array {
const canvasData = buildFigKiwi(schemaDeflated, kiwiData, figKiwiVersion)
const zipEntries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(metaJson)
}
for (const entry of imageEntries) {
zipEntries[entry.name] = [entry.data, { level: 0 }]
}
return zipSync(zipEntries)
}
export { compressFigDataSync } from '@open-pencil/fig'

View file

@ -1,4 +1,4 @@
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { IS_BROWSER } from '#core/constants'

View file

@ -1,5 +1,6 @@
import { isNotNil } from 'es-toolkit/predicate'
import { applyStyleRefsToFields } from '@open-pencil/fig/node-change'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '@open-pencil/kiwi/fig/codec'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { VariableType, VariableValue } from '@open-pencil/scene-graph'
@ -16,7 +17,6 @@ import {
setVariableColorResolver,
VARIABLE_BINDING_FIELDS_INVERSE
} from '#core/kiwi/fig/node-change/convert'
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
type AssetRef = { key: string; version?: string }
type AliasRef = { guid?: GUID; assetRef?: AssetRef }

View file

@ -1,3 +1,4 @@
import { convertFigmaDerivedTextGlyphs } from '@open-pencil/fig/node-change'
import type { SceneNode } from '@open-pencil/scene-graph'
import type {
@ -5,7 +6,6 @@ import type {
OverrideContext
} from '#core/kiwi/fig/instance-overrides/types'
import { convertLetterSpacing, convertLineHeight } from '#core/kiwi/fig/node-change/convert'
import { convertFigmaDerivedTextGlyphs } from '#core/kiwi/fig/node-change/derived-text-glyphs'
import { resolveDsdGeometry } from './geometry'

View file

@ -1,3 +1,4 @@
import { applyStyleRefsToFields } from '@open-pencil/fig/node-change'
import type { GUID } from '@open-pencil/kiwi/fig/codec'
import type { OverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
@ -7,7 +8,6 @@ import type {
SymbolOverrideFields
} from '#core/kiwi/fig/instance-overrides/types'
import { guidToString, VARIABLE_BINDING_FIELDS_INVERSE } from '#core/kiwi/fig/node-change/convert'
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
import { convertOverrideToProps } from './props'

View file

@ -5,16 +5,17 @@ import { parseVariantName } from '@open-pencil/scene-graph/variant-name'
import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '#core/constants'
import { styleToWeight } from '#core/text/fonts'
import { convertEffects, convertFills, convertStrokes } from './paint'
import { importStyleRuns } from './style-runs'
export { importStyleRuns } from './style-runs'
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
import { convertFontFeatures } from './font/features'
import { convertFontVariations } from './font/variations'
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint'
export { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
import {
convertEffects,
convertFigmaDerivedTextGlyphs,
convertFills,
convertFontFeatures,
convertFontVariations,
convertLetterSpacing,
convertLineHeight,
convertStrokes,
extractBoundVariables,
extractExportSettings,
extractPluginData,
@ -22,8 +23,19 @@ import {
getOpenPencilPluginValue,
LAYOUT_DIRECTION_PLUGIN_KEY,
NODE_TYPE_PLUGIN_KEY,
TEXT_DIRECTION_PLUGIN_KEY
} from './plugin-data'
TEXT_DIRECTION_PLUGIN_KEY,
mapTextDecoration
} from '@open-pencil/fig/node-change'
export {
convertEffects,
convertFills,
convertLetterSpacing,
convertLineHeight,
convertStrokes,
mapTextDecoration,
setVariableColorResolver
} from '@open-pencil/fig/node-change'
import { resolveGeometryPaths, resolveVectorNetwork } from './vector-geometry'
export { resolveGeometryPaths } from './vector-geometry'

View file

@ -1,3 +1,10 @@
import {
applyExportSettingsPluginData,
mergePluginData,
NODE_TYPE_PLUGIN_KEY,
serializePluginRelaunchData,
upsertPluginData
} from '@open-pencil/fig/node-change'
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
import type {
@ -12,14 +19,6 @@ import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primi
import { bytesToHex } from '#core/bytes/hex'
import { DEFAULT_STROKE_MITER_LIMIT } from '#core/constants'
import {
applyExportSettingsPluginData,
mergePluginData,
NODE_TYPE_PLUGIN_KEY,
serializePluginRelaunchData,
upsertPluginData
} from './plugin-data'
export type KiwiNodeChange = NodeChange & Record<string, unknown>
type KiwiBooleanOperation = NonNullable<NodeChange['booleanOperation']>

View file

@ -1,3 +1,8 @@
export { convertFontFeatures, applyFontFeaturesToKiwi } from './features'
export { figmaAxisTagToString, stringToFigmaAxisTag, convertFontVariations } from './variations'
export {
applyFontFeaturesToKiwi,
convertFontFeatures,
convertFontVariations,
figmaAxisTagToString,
stringToFigmaAxisTag
} from '@open-pencil/fig/node-change'
export { buildFontDigestMap } from './digests'

View file

@ -13,6 +13,13 @@ export {
} from '@open-pencil/kiwi/fig/container'
export { buildFontDigestMap } from './font/digests'
import {
applyFontFeaturesToKiwi,
BOUND_VARIABLES_PLUGIN_KEY,
LAYOUT_DIRECTION_PLUGIN_KEY,
TEXT_DIRECTION_PLUGIN_KEY,
upsertPluginData
} from '@open-pencil/fig/node-change'
import type { NodeChange, Paint, VariableConsumptionEntry } from '@open-pencil/kiwi/fig/codec'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { Color, GUID, JsonObject, Matrix } from '@open-pencil/scene-graph/primitives'
@ -23,13 +30,6 @@ import {
sceneNodeToKiwiWithContext,
type KiwiNodeChange
} from './export-node'
import { applyFontFeaturesToKiwi } from './font/features'
import {
BOUND_VARIABLES_PLUGIN_KEY,
LAYOUT_DIRECTION_PLUGIN_KEY,
TEXT_DIRECTION_PLUGIN_KEY,
upsertPluginData
} from './plugin-data'
import { exportTextData, fontVariationToKiwi } from './text-data-export'
export function mapToFigmaType(type: SceneNode['type']): string {

View file

@ -1,13 +1,16 @@
import {
convertFontFeatures,
convertFontVariations,
convertFills,
convertLetterSpacing,
convertLineHeight,
mapTextDecoration
} from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type { CharacterStyleOverride, StyleRun } from '@open-pencil/scene-graph'
import { styleToWeight } from '#core/text/fonts'
import { convertFontFeatures } from './font/features'
import { convertFontVariations } from './font/variations'
import { convertFills } from './paint'
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
function applyTextDecorationOverride(style: CharacterStyleOverride, override: NodeChange): void {
const deco = override.textDecoration
if (deco) style.textDecoration = mapTextDecoration(deco)

View file

@ -1,11 +1,9 @@
import { applyFontFeaturesToKiwi, stringToFigmaAxisTag } from '@open-pencil/fig/node-change'
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import type { CharacterStyleOverride, SceneNode } from '@open-pencil/scene-graph'
import { normalizeFontFamily, weightToFigmaStyle } from '#core/text/fonts'
import { applyFontFeaturesToKiwi } from './font/features'
import { stringToFigmaAxisTag } from './font/variations'
export function fontVariationToKiwi(variation: SceneNode['fontVariations'][number]) {
const axisTag = stringToFigmaAxisTag(variation.axis)
return axisTag === undefined

View file

@ -1,4 +1,4 @@
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
import { importNodeChanges } from '#core/kiwi/fig/import'
import {

View file

@ -1,28 +1,36 @@
# @open-pencil/fig
`.fig` document policy package for OpenPencil.
`.fig` file-format package for OpenPencil.
This package currently exposes low-level `fig-kiwi` container read/write helpers and remains the staging area for the next package-split stage. Use `@open-pencil/core` for production SceneGraph `.fig` read/write APIs until higher-level behavior moves here.
The package owns the outer `.fig` archive boundary and is the staged home for Figma-specific
SceneGraph conversion policy. Production SceneGraph read/write remains available through
`@open-pencil/core/io` while conversion modules move behind this package's public API.
Current ownership:
- `readFigContainer()` / `writeFigContainer()` wrappers over `@open-pencil/kiwi` container helpers
- `.fig` document source typing
- Complete `.fig` archive parsing through `parseFigBuffer()`
- `.fig` archive assembly through `writeFigArchive()`
- Canvas payload and image resource handling
- `readFigContainer()` / `writeFigContainer()` helpers for raw `fig-kiwi` payloads
- `.fig` source and archive result types
- Dependency-free NodeChange policy helpers for styles, plugin metadata, text values, and font axes/features through `@open-pencil/fig/node-change`
Planned ownership:
- `.fig` read/write orchestration
- SceneGraph ⇄ Figma NodeChange conversion policy
- raw Figma metadata preservation and invalidation
- component/instance interpretation
- oracle-backed `.fig` fixtures and package-local tests
- SceneGraph ⇄ Figma `NodeChange` conversion
- Raw Figma metadata precedence and invalidation policy
- Component and instance interpretation
- Oracle-backed `.fig` fixtures and package-local tests
Non-goals:
- low-level Kiwi schema/runtime/codec internals — use `@open-pencil/kiwi`
- editor actions, renderer behavior, Vue/app UI, CLI formatting, or MCP transport
- Generic Kiwi schema/runtime internals — use `@open-pencil/kiwi`
- Format-neutral IO registration, export targeting, CanvasKit thumbnails, or browser workers — use
`@open-pencil/core/io`
- Editor actions, renderer behavior, Vue/app UI, CLI formatting, or MCP transport
See `packages/docs/development/fig-package-plan.md` for the staged migration plan.
This follows the existing `@open-pencil/pen` pattern: a format package owns its source model/parser
and SceneGraph policy, while core registers it in the shared IO system.
## Checks

View file

@ -9,6 +9,12 @@
"bun": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./node-change": {
"types": "./dist/node-change.d.ts",
"bun": "./src/node-change/index.ts",
"import": "./dist/node-change.js",
"default": "./dist/node-change.js"
}
},
"main": "./dist/index.js",
@ -36,7 +42,11 @@
"provenance": true
},
"peerDependencies": {
"@open-pencil/kiwi": "workspace:*"
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/scene-graph": "workspace:*"
},
"dependencies": {
"fflate": "^0.8.2"
},
"devDependencies": {
"@types/bun": "^1.3.14",

View file

@ -1,9 +1,15 @@
export {}
const mod = await import('../dist/index.js')
const nodeChange = await import('../dist/node-change.js')
if (mod.FIG_PACKAGE_STATUS !== 'container-api') {
throw new Error('Expected @open-pencil/fig container API export')
if (
mod.FIG_PACKAGE_STATUS !== 'archive-api' ||
typeof mod.parseFigBuffer !== 'function' ||
typeof mod.writeFigArchive !== 'function' ||
typeof nodeChange.convertLineHeight !== 'function'
) {
throw new Error('Expected @open-pencil/fig archive API exports')
}
const bytes = mod.writeFigContainer({

View file

@ -0,0 +1,99 @@
import { unzipSync, zipSync, type Zippable } from 'fflate'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { buildFigKiwi } from '@open-pencil/kiwi/fig/container'
import { decodeFigKiwiCanvas } from '@open-pencil/kiwi/fig/parse'
export interface FigImageEntry {
name: string
data: Uint8Array
}
export interface WriteFigArchiveInput {
schemaDeflated: Uint8Array
kiwiData: Uint8Array
thumbnailPng: Uint8Array
metaJson: string
images?: FigImageEntry[]
figKiwiVersion?: number
}
export interface FigParseResult {
nodeChanges: NodeChange[]
blobs: Uint8Array[]
images: Array<[string, Uint8Array]>
figKiwiVersion: number
/** Deflated Kiwi schema bytes from the original file, retained for round-trip fidelity. */
figSchemaDeflated: Uint8Array
}
function isLikelyAsset(name: string): boolean {
const lower = name.toLowerCase()
return lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.json')
}
function findCanvasData(entries: Partial<Record<string, Uint8Array>>): Uint8Array | null {
const canonical = entries['canvas.fig'] ?? entries.canvas
if (canonical) return canonical
let largest: Uint8Array | null = null
for (const [name, data] of Object.entries(entries)) {
if (!data || isLikelyAsset(name)) continue
if (!largest || data.byteLength > largest.byteLength) largest = data
}
return largest
}
/** Parse a complete zipped `.fig` file into its Figma protocol payload and binary resources. */
export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult {
const archive = unzipSync(new Uint8Array(buffer), {
filter: (file) =>
file.name === 'canvas.fig' ||
file.name === 'canvas' ||
(file.name.startsWith('images/') && file.name !== 'images/')
})
const canvasData = findCanvasData(archive)
if (!canvasData) {
throw new Error(
`No canvas data found in .fig file. Entries: ${Object.keys(archive).join(', ')}`
)
}
const decoded = decodeFigKiwiCanvas(canvasData)
const images = Object.entries(archive)
.filter(([name]) => name.startsWith('images/') && name !== 'images/')
.map(([name, data]) => [name.slice('images/'.length), data] as [string, Uint8Array])
return { ...decoded, images }
}
/** Assemble a complete zipped `.fig` archive from an encoded Kiwi message and resources. */
export function writeFigArchive(input: WriteFigArchiveInput): Uint8Array {
const canvasData = buildFigKiwi(input.schemaDeflated, input.kiwiData, input.figKiwiVersion)
const entries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [input.thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(input.metaJson)
}
for (const image of input.images ?? []) entries[image.name] = [image.data, { level: 0 }]
return zipSync(entries)
}
/** Compatibility signature used by core while archive assembly migrates to this package. */
export function compressFigDataSync(
schemaDeflated: Uint8Array,
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: FigImageEntry[],
figKiwiVersion?: number
): Uint8Array {
return writeFigArchive({
schemaDeflated,
kiwiData,
thumbnailPng,
metaJson,
images: imageEntries,
figKiwiVersion
})
}

View file

@ -1,3 +1,12 @@
export {
compressFigDataSync,
parseFigBuffer,
writeFigArchive,
type FigImageEntry,
type FigParseResult,
type WriteFigArchiveInput
} from './archive'
import {
FIG_KIWI_DEFAULT_VERSION,
buildFigKiwi,
@ -37,7 +46,7 @@ export interface WriteFigContainerOptions {
readonly version?: number
}
export const FIG_PACKAGE_STATUS = 'container-api' as const
export const FIG_PACKAGE_STATUS = 'archive-api' as const
export function readFigContainer(
bytes: Uint8Array,
@ -66,6 +75,6 @@ export function writeFigContainer(
export function assertFigPackageReady(): void {
throw new Error(
'@open-pencil/fig currently exposes low-level container APIs; use @open-pencil/core for SceneGraph .fig read/write APIs for now.'
'@open-pencil/fig currently exposes archive/container APIs; use @open-pencil/core for SceneGraph .fig read/write APIs for now.'
)
}

View file

@ -0,0 +1,7 @@
export * from './derived-text-glyphs'
export * from './font/features'
export * from './font/variations'
export * from './paint'
export * from './plugin-data'
export * from './style-refs'
export * from './text-values'

View file

@ -11,11 +11,13 @@ import type {
StrokeCap,
StrokeJoin
} from '@open-pencil/scene-graph'
import { BLACK } from '@open-pencil/scene-graph/constants'
import type { Color, Matrix } from '@open-pencil/scene-graph/primitives'
import { normalizeColor } from '#core/color'
const convertColor = normalizeColor
function convertColor(color?: Partial<Color>): Color {
if (!color) return { ...BLACK }
return { r: color.r ?? 0, g: color.g ?? 0, b: color.b ?? 0, a: color.a ?? 1 }
}
function imageHashToString(hash: Record<string, number>): string {
const bytes = Object.keys(hash)

View file

@ -1,15 +1,30 @@
import { describe, expect, it } from 'bun:test'
import { beforeAll, describe, expect, it } from 'bun:test'
import { deflateSync } from 'fflate'
import {
createNodeChangesMessage,
encodeMessage,
getSchemaBytes,
initCodec
} from '@open-pencil/kiwi/fig/codec'
import {
FIG_PACKAGE_STATUS,
assertFigPackageReady,
parseFigBuffer,
readFigContainer,
writeFigArchive,
writeFigContainer
} from '../src/index'
describe('@open-pencil/fig package API', () => {
it('exports container API status', () => {
expect(FIG_PACKAGE_STATUS).toBe('container-api')
beforeAll(async () => {
await initCodec()
})
it('exports archive API status', () => {
expect(FIG_PACKAGE_STATUS).toBe('archive-api')
})
it('round-trips fig-kiwi container bytes', () => {
@ -25,11 +40,35 @@ describe('@open-pencil/fig package API', () => {
expect(document.source?.fileName).toBe('fixture.fig')
})
it('parses complete .fig archives and image resources', () => {
const bytes = writeFigArchive({
schemaDeflated: deflateSync(getSchemaBytes()),
kiwiData: encodeMessage(
createNodeChangesMessage(0, 0, [
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
phase: 'CREATED',
name: 'Document'
}
])
),
thumbnailPng: new Uint8Array([1]),
metaJson: '{}',
images: [{ name: 'images/hash', data: new Uint8Array([9, 8, 7]) }]
})
const parsed = parseFigBuffer(bytes.buffer as ArrayBuffer)
expect(parsed.nodeChanges).toHaveLength(1)
expect(parsed.nodeChanges[0]?.type).toBe('DOCUMENT')
expect(parsed.images).toEqual([['hash', new Uint8Array([9, 8, 7])]])
})
it('rejects invalid fig-kiwi containers', () => {
expect(() => readFigContainer(new Uint8Array([1, 2, 3]))).toThrow('Invalid fig-kiwi')
})
it('directs consumers to core for SceneGraph read/write', () => {
expect(() => assertFigPackageReady()).toThrow('low-level container APIs')
expect(() => assertFigPackageReady()).toThrow('archive/container APIs')
})
})

View file

@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test'
import {
applyStyleRefsToFields,
convertEffects,
convertFills,
convertFontFeatures,
convertLetterSpacing,
convertLineHeight,
mapTextDecoration
} from '../src/node-change'
describe('@open-pencil/fig NodeChange policy', () => {
test('converts normalized text values', () => {
expect(convertLineHeight({ value: 120, units: 'PERCENT' }, 20)).toBe(24)
expect(convertLetterSpacing({ value: 10, units: 'PERCENT' }, 20)).toBe(2)
expect(mapTextDecoration('UNDERLINE')).toBe('UNDERLINE')
})
test('converts Figma OpenType feature toggles', () => {
expect(
convertFontFeatures({
toggledOnOTFeatures: ['DLIG'],
toggledOffOTFeatures: ['LIGA']
})
).toEqual([
{ tag: 'DLIG', enabled: true },
{ tag: 'LIGA', enabled: false }
])
})
test('normalizes imported paints and effects', () => {
expect(convertFills([{ type: 'SOLID' }])[0]).toMatchObject({
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true
})
expect(convertEffects([{ type: 'DROP_SHADOW' }])[0]).toMatchObject({
type: 'DROP_SHADOW',
radius: 0,
visible: true
})
})
test('resolves imported style references before SceneGraph conversion', () => {
const fields: Record<string, unknown> = {
styleIdForFill: { guid: { sessionID: 2, localID: 3 } }
}
applyStyleRefsToFields(
new Map([
[
'2:3',
{
type: 'RECTANGLE',
styleType: 'FILL',
fillPaints: [{ type: 'SOLID', visible: true }]
}
]
]),
fields
)
expect(fields.fillPaints).toEqual([{ type: 'SOLID', visible: true }])
})
})

View file

@ -1,14 +1,23 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"target": "ES2022",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"noEmit": false,
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
"types": ["bun"]
},
"include": ["src"]
"outDir": "./dist",
"rootDir": ".",
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["bun"],
"paths": {
"@open-pencil/fig": ["./src/index.ts"],
"@open-pencil/fig/node-change": ["./src/node-change/index.ts"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -2,7 +2,8 @@ import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: './src/index.ts'
index: './src/index.ts',
'node-change': './src/node-change/index.ts'
},
platform: 'neutral',
format: ['esm'],

View file

@ -2,7 +2,7 @@
Scene-graph-agnostic Kiwi runtime utilities for OpenPencil.
This package owns pure Kiwi schema parsing, Figma Kiwi schema data, low-level Figma message encode/decode, FIG Kiwi container helpers, GUID formatting, and raw `.fig` parse helpers. `.fig` import/export policy stays in `@open-pencil/core`: SceneGraph conversion, raw metadata invalidation, component/instance interpretation, and app/CLI document I/O are not part of this package.
This package owns pure Kiwi schema parsing, Figma Kiwi schema data, low-level Figma message encode/decode, raw `fig-kiwi` container helpers, and GUID formatting. Complete `.fig` archive parsing lives in `@open-pencil/fig`; SceneGraph integration remains outside this package.
## Installation
@ -74,19 +74,16 @@ const container = buildFigKiwi(new Uint8Array([1, 2, 3]))
const chunks = parseFigKiwiChunks(container)
```
## Raw `.fig` parsing
## Raw `fig-kiwi` payload decoding
```ts
import { parseFigBytes } from '@open-pencil/kiwi/fig/parse'
import { decodeFigKiwiCanvas } from '@open-pencil/kiwi/fig/parse'
const parsed = await parseFigBytes(await Bun.file('design.fig').arrayBuffer())
for (const canvas of parsed.canvases) {
console.log(canvas.name, canvas.nodeChanges.length)
}
const decoded = decodeFigKiwiCanvas(canvasBytes)
console.log(decoded.nodeChanges.length, decoded.blobs.length)
```
`parseFigBytes()` returns structural `NodeChange` data, blobs, images, schema bytes, and container metadata. Use `@open-pencil/core` for converting that data into an editable `SceneGraph`.
Use `parseFigBuffer()` from `@open-pencil/fig` for complete zipped `.fig` files, including image resources. Use `@open-pencil/core/io` for conversion into an editable `SceneGraph`.
## GUID helpers

View file

@ -1,4 +1,4 @@
import { unzipSync, inflateSync } from 'fflate'
import { inflateSync } from 'fflate'
import { decompress as zstdDecompress } from 'fzstd'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '../schema-runtime'
@ -84,48 +84,17 @@ export function parseFigKiwiContainer(data: Uint8Array): FigKiwiPayload | null {
return { schemaDeflated: chunks[0], dataRaw, version }
}
export interface FigParseResult {
export interface FigKiwiDecodeResult {
nodeChanges: NodeChange[]
blobs: Uint8Array[]
images: Array<[string, Uint8Array]>
figKiwiVersion: number
/** Deflated kiwi schema bytes from the original file (for roundtrip fidelity). */
figSchemaDeflated: Uint8Array
}
export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult {
const zip = unzipSync(new Uint8Array(buffer), {
filter: (file) =>
file.name === 'canvas.fig' ||
file.name === 'canvas' ||
(file.name.startsWith('images/') && file.name !== 'images/')
})
const entries = Object.keys(zip)
let canvasData: Uint8Array | null = null
for (const name of entries) {
if (name === 'canvas.fig' || name === 'canvas') {
canvasData = zip[name]
break
}
}
if (!canvasData) {
let maxSize = 0
for (const name of entries) {
const lower = name.toLowerCase()
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.json')) continue
if (zip[name].byteLength > maxSize) {
maxSize = zip[name].byteLength
canvasData = zip[name]
}
}
}
if (!canvasData) {
throw new Error(`No canvas data found in .fig file. Entries: ${entries.join(', ')}`)
}
const payload = parseFigKiwiContainer(canvasData)
/** Decode one raw `fig-kiwi` canvas payload. Outer `.fig` archive handling lives in `@open-pencil/fig`. */
export function decodeFigKiwiCanvas(data: Uint8Array): FigKiwiDecodeResult {
const payload = parseFigKiwiContainer(data)
if (!payload) throw new Error('Invalid fig-kiwi container')
const schemaBytes = inflateSync(payload.schemaDeflated)
@ -138,25 +107,15 @@ export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult {
throw new Error('No nodes found in .fig file')
}
// Deduplicate before returning — critical for worker path where raw
// nodeChanges are serialized via postMessage before extractPluginData runs
deduplicateNodeChangePluginData(nodeChanges)
const blobs: Uint8Array[] = (message.blobs ?? []).map((b) =>
b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes))
const blobs: Uint8Array[] = (message.blobs ?? []).map((blob) =>
blob.bytes instanceof Uint8Array ? blob.bytes : new Uint8Array(Object.values(blob.bytes))
)
const images: Array<[string, Uint8Array]> = []
for (const name of entries) {
if (name.startsWith('images/') && name !== 'images/') {
images.push([name.replace('images/', ''), zip[name]])
}
}
return {
nodeChanges,
blobs,
images,
figKiwiVersion: payload.version,
figSchemaDeflated: payload.schemaDeflated
}

View file

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec } from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
import { SceneGraph } from '@open-pencil/scene-graph'
import { importNodeChanges } from '#core/kiwi/fig/import'

View file

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec } from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
import { SceneGraph } from '@open-pencil/scene-graph'
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'

View file

@ -1,7 +1,7 @@
import { expect, setDefaultTimeout, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
import { importNodeChanges } from '#core/kiwi'

View file

@ -1,9 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { applyStyleRefsToFields } from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
describe('fig import style refs', () => {
test('effect and grid styles replace stale direct payloads', () => {
const effectGuid = { sessionID: 4, localID: 5000 }

View file

@ -8,7 +8,7 @@ import {
SceneGraph,
type NodeChange
} from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
import { MAX_EXPORT_SCALE } from '@open-pencil/scene-graph'
function decodeExport(bytes: Uint8Array) {

View file

@ -4,7 +4,7 @@ import { resolve } from 'node:path'
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
import { fontManager } from '@open-pencil/core/text'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils'

View file

@ -2,7 +2,7 @@ import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
import { guidToString } from '@open-pencil/core/kiwi/fig/node-change/convert'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { parseFigBuffer } from '@open-pencil/fig'
function decodeExport(bytes: Uint8Array) {
return parseFigBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))

View file

@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs'
import { exportFigFile, extractExportGraph, parseFigFile } from '@open-pencil/core/io'
import { initCodec } from '@open-pencil/core/kiwi'
import { parseFigBuffer } from '@open-pencil/fig'
import { guidToString } from '@open-pencil/kiwi/fig/guid'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { SceneGraph } from '@open-pencil/scene-graph'
describe('export subgraph extraction', () => {

View file

@ -39,7 +39,7 @@ const scenePath = coreSourcePath('canvas/scene.ts')
const rendererPath = coreSourcePath('canvas/renderer.ts')
const sgTypesPath = repoPath('packages/scene-graph/src/types.ts')
const nodeExportPath = coreSourcePath('kiwi/fig/node-change/export-node.ts')
const convertPath = coreSourcePath('kiwi/fig/node-change/paint.ts')
const convertPath = repoPath('packages/fig/src/node-change/paint.ts')
const schemaPath = repoPath('packages/kiwi/src/fig/schema/fig.kiwi')
const codecPath = repoPath('packages/kiwi/src/fig/codec.ts')
const lifecyclePath = coreSourcePath('canvas/renderer/lifecycle.ts')

View file

@ -90,7 +90,11 @@ try {
tempDir
)
nodeEval(
"const { FIG_PACKAGE_STATUS, readFigContainer, writeFigContainer } = await import('@open-pencil/fig'); if (FIG_PACKAGE_STATUS !== 'container-api') throw new Error('Fig package status smoke failed'); const document = readFigContainer(writeFigContainer({ schemaDeflated: new Uint8Array([1]), dataRaw: new Uint8Array([2]) })); if (document.dataRaw[0] !== 2) throw new Error('Fig container smoke failed')",
"const { FIG_PACKAGE_STATUS, parseFigBuffer, writeFigArchive, readFigContainer, writeFigContainer } = await import('@open-pencil/fig'); if (FIG_PACKAGE_STATUS !== 'archive-api' || typeof parseFigBuffer !== 'function' || typeof writeFigArchive !== 'function') throw new Error('Fig package status smoke failed'); const document = readFigContainer(writeFigContainer({ schemaDeflated: new Uint8Array([1]), dataRaw: new Uint8Array([2]) })); if (document.dataRaw[0] !== 2) throw new Error('Fig container smoke failed')",
tempDir
)
nodeEval(
"const { convertLineHeight } = await import('@open-pencil/fig/node-change'); if (convertLineHeight({ value: 120, units: 'PERCENT' }, 20) !== 24) throw new Error('Fig NodeChange subpath failed')",
tempDir
)
nodeEval(