perf(fig): remove large fixture bottlenecks

- Index component property definitions and variable asset references once per export or override context\n- Skip converged instance fill and text propagation and use native binary hex encoding\n- Add a non-populating parse mode for raw fixture validation\n- Reduce the Material 3 round trip from minutes to about twenty seconds
This commit is contained in:
Danila Poyarkov 2026-07-18 03:37:58 +03:00
parent 6c9ef9d103
commit 8b9be17a56
20 changed files with 162 additions and 57 deletions

View file

@ -7,6 +7,7 @@
- Move complete `.fig` archive parsing, bidirectional SceneGraph/NodeChange conversion, and component/instance interpretation into `@open-pencil/fig`, keeping runtime font access and format-neutral IO orchestration in core and Kiwi schema/container mechanics in `@open-pencil/kiwi`.
- Remove internal cross-package forwarding modules; import `@open-pencil/fig`, `@open-pencil/pen`, and `@open-pencil/scene-graph` from their owning public exports.
- Track normalized source edits in SceneGraph while preserving original `.fig` provenance and filtering stale raw fields through Fig-owned metadata policy.
- Eliminate quadratic component-property scans and redundant instance propagation during large `.fig` round trips, reducing the Material 3 fixture regression from minutes to seconds.
- Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering.
- Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK.
- Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI.

View file

@ -207,6 +207,7 @@
"name": "@open-pencil/fig",
"version": "0.13.2",
"dependencies": {
"es-toolkit": "^1.46.1",
"fflate": "^0.8.2",
},
"devDependencies": {

View file

@ -2,7 +2,7 @@ import type { CanvasKit } from 'canvaskit-wasm'
import { deflateSync, inflateSync } from 'fflate'
import { compressFigDataSync } from '@open-pencil/fig'
import { stringToGuid } from '@open-pencil/fig/node-change'
import { buildComponentPropIndex, stringToGuid } from '@open-pencil/fig/node-change'
import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
@ -312,6 +312,7 @@ interface InternalResourceContext {
glyphBlobMap: Map<string, number>
blobIndexByHex: Map<string, number>
assignedGuidValues: Set<string>
componentPropertyDefinitionsById: ReturnType<typeof buildComponentPropIndex>
}
function appendInternalResources(context: InternalResourceContext): void {
@ -332,7 +333,8 @@ function appendInternalResources(context: InternalResourceContext): void {
context.varIdToGuid,
context.glyphBlobMap,
context.blobIndexByHex,
context.assignedGuidValues
context.assignedGuidValues,
context.componentPropertyDefinitionsById
)
)
}
@ -395,6 +397,7 @@ export async function exportFigFile(
const fontDigestMap = await buildFontDigestMap(graph)
const glyphBlobMap = new Map<string, number>()
const blobIndexByHex = new Map<string, number>()
const componentPropertyDefinitionsById = buildComponentPropIndex(graph)
// Scan ALL imported source.ids BEFORE any new GUID assignment to find
// max sessionID:0 and sessionID:1 localID values. This guarantees the
@ -450,7 +453,8 @@ export async function exportFigFile(
varIdToGuid,
glyphBlobMap,
blobIndexByHex,
assignedGuidValues
assignedGuidValues,
componentPropertyDefinitionsById
)
)
}
@ -468,7 +472,8 @@ export async function exportFigFile(
modeIdToGuid,
glyphBlobMap,
blobIndexByHex,
assignedGuidValues
assignedGuidValues,
componentPropertyDefinitionsById
})
const msg: Record<string, unknown> = {

View file

@ -1,2 +1,2 @@
export { readFigFile, parseFigFile } from './read'
export { readFigFile, parseFigFile, type ParseFigFileOptions } from './read'
export { exportFigFile, compressFigData, compressFigDataSync } from './write'

View file

@ -7,7 +7,7 @@ import { deserializeSceneGraph } from '#core/kiwi/fig/parse/transfer'
import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer'
export interface ParseFigFileOptions {
populate?: 'all' | 'first-page'
populate?: 'all' | 'first-page' | 'none'
}
function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph {

View file

@ -10,7 +10,7 @@ export {
svgFormat,
jsxFormat
} from './formats'
export { exportFigFile, parseFigFile, readFigFile } from './formats/fig'
export { exportFigFile, parseFigFile, readFigFile, type ParseFigFileOptions } from './formats/fig'
export { parsePenFile, readPenFile } from '@open-pencil/pen'
export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from './formats/jsx'
export {

View file

@ -390,7 +390,7 @@ function applyStyleRefs(changeMap: Map<string, NodeChange>): void {
}
export interface FigImportOptions {
populate?: 'all' | 'first-page'
populate?: 'all' | 'first-page' | 'none'
}
function rememberLazyFigImportContext(
@ -408,6 +408,19 @@ function rememberLazyFigImportContext(
})
}
function componentPageIdsForLazyPopulation(graph: SceneGraph): Set<string> {
const pageIds = new Set<string>()
for (const node of graph.getAllNodes()) {
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') continue
let current = node.parentId ? graph.getNode(node.parentId) : undefined
while (current?.parentId && current.type !== 'CANVAS') {
current = graph.getNode(current.parentId)
}
if (current?.type === 'CANVAS') pageIds.add(current.id)
}
return pageIds
}
export function importNodeChanges(
nodeChanges: NodeChange[],
blobs: Uint8Array[] = [],
@ -469,27 +482,24 @@ export function importNodeChanges(
applyVariantPropSpecs(graph)
const firstPageId = graph.getPages()[0]?.id
const componentPageIds = new Set<string>()
for (const node of graph.getAllNodes()) {
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') continue
let current = node.parentId ? graph.getNode(node.parentId) : undefined
while (current?.parentId && current.type !== 'CANVAS') current = graph.getNode(current.parentId)
if (current?.type === 'CANVAS') componentPageIds.add(current.id)
}
const componentPageIds =
options.populate === 'first-page' ? componentPageIdsForLazyPopulation(graph) : new Set<string>()
const activeRootIds =
options.populate === 'first-page'
? [firstPageId, ...componentPageIds].filter(isNotNil)
: undefined
graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides(
graph,
changeMap as Map<string, InstanceNodeChange>,
guidToNodeId,
blobs,
activeRootIds
)
})
if (options.populate !== 'none') {
graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides(
graph,
changeMap as Map<string, InstanceNodeChange>,
guidToNodeId,
blobs,
activeRootIds
)
})
}
if (activeRootIds)
rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)

View file

@ -2,7 +2,7 @@ import {
sceneNodeToKiwi as sceneNodeToKiwiWithRuntime,
type KiwiNodeChange
} from '@open-pencil/fig/node-change'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { ComponentPropertyDefinition, SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { GUID } from '@open-pencil/scene-graph/primitives'
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
@ -36,7 +36,8 @@ export function sceneNodeToKiwi(
varIdToGuid?: Map<string, GUID>,
glyphBlobMap = new Map<string, number>(),
blobIndexByHex?: Map<string, number>,
assignedGuidValues?: Set<string>
assignedGuidValues?: Set<string>,
componentPropertyDefinitionsById?: ReadonlyMap<string, ComponentPropertyDefinition>
): KiwiNodeChange[] {
return sceneNodeToKiwiWithRuntime(
node,
@ -51,6 +52,7 @@ export function sceneNodeToKiwi(
glyphBlobMap,
blobIndexByHex,
assignedGuidValues,
coreFigExportRuntime
coreFigExportRuntime,
componentPropertyDefinitionsById
)
}

View file

@ -52,6 +52,7 @@
"@open-pencil/scene-graph": "workspace:*"
},
"dependencies": {
"es-toolkit": "^1.46.1",
"fflate": "^0.8.2"
},
"devDependencies": {

View file

@ -14,6 +14,8 @@ export type {
SymbolOverride
} from './types'
import { isEqual } from 'es-toolkit/predicate'
import { guidToString } from '@open-pencil/fig/node-change'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { copyFills, copyStyleRuns } from '@open-pencil/scene-graph/copy'
@ -85,7 +87,7 @@ function propagateResolvedFills(
if (activeNodeIds && !activeNodeIds.has(node.id)) continue
if (!node.componentId) continue
const source = graph.getNode(node.componentId)
if (!source || source.fills === node.fills) continue
if (!source || isEqual(source.fills, node.fills)) continue
if (protectedNodes.has(node.id) && !protectedNodes.has(source.id)) continue
graph.updateNode(node.id, { fills: copyFills(source.fills) })
changed = true
@ -132,6 +134,15 @@ function propagateResolvedTextClones(graph: SceneGraph): void {
if (node.type !== 'TEXT' || !node.componentId) continue
const source = graph.getNode(node.componentId)
if (source?.type !== 'TEXT' || source.text !== node.text) continue
if (
source.width === node.width &&
source.height === node.height &&
isEqual(source.fills, node.fills) &&
isEqual(source.styleRuns, node.styleRuns) &&
isEqual(source.figmaDerivedTextGlyphs, node.figmaDerivedTextGlyphs)
) {
continue
}
graph.updateNode(node.id, {
width: source.width,
height: source.height,
@ -155,8 +166,12 @@ function buildOverrideContext(
activeNodeIds?: Set<string>
): OverrideContext {
const overrideKeyToGuid = new Map<string, string>()
const assetRefToGuid = new Map<string, string>()
for (const [id, nc] of changeMap) {
if (nc.overrideKey) overrideKeyToGuid.set(guidToString(nc.overrideKey), id)
if (typeof nc.key !== 'string') continue
assetRefToGuid.set(nc.key, id)
if (typeof nc.version === 'string') assetRefToGuid.set(`${nc.key}@${nc.version}`, id)
}
const propDefaults = new Map<string, ComponentPropValue>()
@ -185,6 +200,7 @@ function buildOverrideContext(
guidToNodeId,
blobs,
overrideKeyToGuid,
assetRefToGuid,
nodeIdToGuid,
propDefaults,
propNames,

View file

@ -25,18 +25,6 @@ function assetRefKey(assetRef: { key: string; version?: string }): string {
return assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
}
function buildAssetRefMap(ctx: OverrideContext): Map<string, string> {
const refs = new Map<string, string>()
for (const [id, nc] of ctx.changeMap) {
const key = typeof nc.key === 'string' ? nc.key : undefined
if (!key) continue
refs.set(key, id)
const version = typeof nc.version === 'string' ? nc.version : undefined
if (version) refs.set(assetRefKey({ key, version }), id)
}
return refs
}
function resolveAliasId(alias: AliasRef, assetRefs: Map<string, string>): string | undefined {
if (alias.guid) return guidToString(alias.guid)
const assetRef = alias.assetRef
@ -69,7 +57,7 @@ function applyVariableRadiusOverrides(
): void {
const entries = fields.variableConsumptionMap?.entries
if (!entries?.length) return
const assetRefs = buildAssetRefMap(ctx)
const assetRefs = ctx.assetRefToGuid
for (const entry of entries) {
const variableField = entry.variableField
if (!variableField || !VARIABLE_RADIUS_FIELDS.has(variableField)) continue

View file

@ -106,6 +106,7 @@ export interface OverrideContext {
blobs: Uint8Array[]
overrideKeyToGuid: Map<string, string>
assetRefToGuid: Map<string, string>
nodeIdToGuid: Map<string, string>
propDefaults: Map<string, ComponentPropValue>
propNames: Map<string, string>

View file

@ -12,10 +12,12 @@ export function hexToBytes(hex: string): Uint8Array {
return bytes
}
const HEX_BYTES = Array.from({ length: 256 }, (_, byte) => byte.toString(16).padStart(2, '0'))
export function bytesToHex(bytes: Uint8Array): string {
let hex = ''
for (const byte of bytes) {
hex += byte.toString(16).padStart(2, '0')
}
return hex
if (typeof bytes.toHex === 'function') return bytes.toHex()
const chunks = Array.from({ length: bytes.length }, () => '')
for (let index = 0; index < bytes.length; index++) chunks[index] = HEX_BYTES[bytes[index]]
return chunks.join('')
}

View file

@ -63,6 +63,7 @@ interface SceneNodeToKiwiContext {
/** Maps "key@version" or "key" (from variable.key/version) variable GUID.
* Used to convert colorVar.assetRef references in raw paints to guid references. */
assetRefToVarGuid?: Map<string, GUID>
componentPropertyDefinitionsById: ReadonlyMap<string, ComponentPropertyDefinition>
fractionalPosition: (index: number) => string
mapToFigmaType: (type: SceneNode['type']) => string
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
@ -530,12 +531,16 @@ function componentPropertyNodeField(field: ComponentPropertyReferenceField): str
return 'VISIBLE'
}
function findComponentPropertyDefinition(graph: SceneGraph, id: string) {
export function buildComponentPropIndex(
graph: SceneGraph
): ReadonlyMap<string, ComponentPropertyDefinition> {
const definitions = new Map<string, ComponentPropertyDefinition>()
for (const candidate of graph.getAllNodes()) {
const definition = candidate.componentPropertyDefinitions.find((item) => item.id === id)
if (definition) return definition
for (const definition of candidate.componentPropertyDefinitions) {
if (!definitions.has(definition.id)) definitions.set(definition.id, definition)
}
}
return undefined
return definitions
}
function shouldSerializeRawBackedField(
@ -598,7 +603,7 @@ function applyComponentMetadata(
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
.map(([propertyId, value]) => {
const defID = parseGuidOrNull(propertyId)
const definition = findComponentPropertyDefinition(context.graph, propertyId)
const definition = context.componentPropertyDefinitionsById.get(propertyId)
return defID && definition
? {
defID,

View file

@ -30,6 +30,7 @@ import type { Color, GUID, JsonObject, Matrix } from '@open-pencil/scene-graph/p
import {
buildAssetRefToVarGuidMap,
buildComponentPropIndex,
sceneNodeToKiwiWithContext,
type KiwiNodeChange
} from './export-node'
@ -470,7 +471,8 @@ export function sceneNodeToKiwi(
glyphBlobMap = new Map<string, number>(),
blobIndexByHex?: Map<string, number>,
assignedGuidValues?: Set<string>,
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME,
componentPropertyDefinitionsById = buildComponentPropIndex(graph)
): KiwiNodeChange[] {
// Build assetRef to guid mapping for converting colorVar references in raw paints
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
@ -484,6 +486,7 @@ export function sceneNodeToKiwi(
glyphBlobMap,
varIdToGuid,
assetRefToVarGuid,
componentPropertyDefinitionsById,
fractionalPosition,
mapToFigmaType,
fillToKiwiPaint,

View file

@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import {
buildComponentPropIndex,
fractionalPosition,
mapToFigmaType,
sceneNodeToKiwi,
@ -15,6 +16,41 @@ describe('@open-pencil/fig SceneGraph export policy', () => {
expect([0, 93, 94, 188].map(fractionalPosition)).toEqual(['!', '~', '~!', '~~!'])
})
test('reuses an export-scoped component property definition index', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const component = graph.createNode('COMPONENT', page.id, {
componentPropertyDefinitions: [
{ id: '1:100', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
]
})
const instance = graph.createNode('INSTANCE', page.id, {
componentId: component.id,
componentPropertyAssignments: { '1:100': 'Override' }
})
const serialize = (definitions?: ReturnType<typeof buildComponentPropIndex>) =>
sceneNodeToKiwi(
instance,
{ sessionID: 1, localID: 1 },
0,
{ value: 2 },
graph,
[],
new Map(),
undefined,
undefined,
undefined,
undefined,
new Set(),
undefined,
definitions
)[0].componentPropAssignments
const definitions = buildComponentPropIndex(graph)
expect(definitions.get('1:100')).toBe(component.componentPropertyDefinitions[0])
expect(serialize(definitions)).toEqual(serialize())
})
test('injects runtime glyph outlines into derived text data', () => {
const graph = new SceneGraph()
const text = graph.createNode('TEXT', graph.getPages()[0].id, {

View file

@ -27,6 +27,31 @@ describe('@open-pencil/fig instance interpretation', () => {
expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label')
})
test('resolves text clone chains to their source values', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const source = graph.createNode('TEXT', pageId, {
text: 'Label',
width: 80,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const middle = graph.createNode('TEXT', pageId, {
componentId: source.id,
text: 'Label',
width: 120
})
const leaf = graph.createNode('TEXT', pageId, {
componentId: middle.id,
text: 'Label',
width: 160
})
populateAndApplyOverrides(graph, new Map(), new Map())
expect(graph.getNode(middle.id)?.width).toBe(80)
expect(graph.getNode(leaf.id)).toMatchObject({ width: 80, fills: source.fills })
})
test('preserves protected text while synchronizing other fields', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id

View file

@ -15,8 +15,8 @@ heavy('parse heavy .fig files', () => {
let nuxtUiNodes: SceneNode[]
beforeAll(async () => {
material3 = await parseFixture('material3.fig')
nuxtui = await parseFixture('nuxtui.fig')
material3 = await parseFixture('material3.fig', { populate: 'none' })
nuxtui = await parseFixture('nuxtui.fig', { populate: 'none' })
material3Nodes = collectAllNodes(material3)
nuxtUiNodes = collectAllNodes(nuxtui)
})

View file

@ -97,6 +97,12 @@ describe('Figma component property import', () => {
(node) => node.name === 'Menu item instance'
)
expect(instance?.componentPropertyAssignments).toEqual({ '3:1': 'Profile Item' })
const unpopulated = importNodeChanges(nodeChanges, [], undefined, { populate: 'none' })
const unpopulatedInstance = Array.from(unpopulated.getAllNodes()).find(
(node) => node.name === 'Menu item instance'
)
expect(unpopulatedInstance?.childIds).toEqual([])
})
test('propagates nested instance swaps through clone chains', () => {

View file

@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { parseFigFile } from '@open-pencil/core'
import type { SceneGraph, SceneNode } from '@open-pencil/core'
import type { ParseFigFileOptions, SceneGraph, SceneNode } from '@open-pencil/core'
import { collectAllNodes } from './fig-traversal'
@ -33,9 +33,12 @@ export function readFixtureBytes(name: string): Uint8Array {
return readFileSync(resolve(FIXTURES, name))
}
export async function parseFixture(name: string): Promise<SceneGraph> {
export async function parseFixture(
name: string,
options?: ParseFigFileOptions
): Promise<SceneGraph> {
const bytes = readFixtureBytes(name)
return parseFigFile(bytes.buffer as ArrayBuffer)
return parseFigFile(bytes.buffer as ArrayBuffer, options)
}
export async function parseGoldPreviewFixture(): Promise<{