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`. - 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. - 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. - 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 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 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. - Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI.

View file

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

View file

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

View file

@ -10,7 +10,7 @@ export {
svgFormat, svgFormat,
jsxFormat jsxFormat
} from './formats' } 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 { parsePenFile, readPenFile } from '@open-pencil/pen'
export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from './formats/jsx' export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from './formats/jsx'
export { export {

View file

@ -390,7 +390,7 @@ function applyStyleRefs(changeMap: Map<string, NodeChange>): void {
} }
export interface FigImportOptions { export interface FigImportOptions {
populate?: 'all' | 'first-page' populate?: 'all' | 'first-page' | 'none'
} }
function rememberLazyFigImportContext( 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( export function importNodeChanges(
nodeChanges: NodeChange[], nodeChanges: NodeChange[],
blobs: Uint8Array[] = [], blobs: Uint8Array[] = [],
@ -469,18 +482,14 @@ export function importNodeChanges(
applyVariantPropSpecs(graph) applyVariantPropSpecs(graph)
const firstPageId = graph.getPages()[0]?.id const firstPageId = graph.getPages()[0]?.id
const componentPageIds = new Set<string>() const componentPageIds =
for (const node of graph.getAllNodes()) { options.populate === 'first-page' ? componentPageIdsForLazyPopulation(graph) : new Set<string>()
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 activeRootIds = const activeRootIds =
options.populate === 'first-page' options.populate === 'first-page'
? [firstPageId, ...componentPageIds].filter(isNotNil) ? [firstPageId, ...componentPageIds].filter(isNotNil)
: undefined : undefined
if (options.populate !== 'none') {
graph.preserveSourceMetadataDuring(() => { graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides( populateAndApplyOverrides(
graph, graph,
@ -490,6 +499,7 @@ export function importNodeChanges(
activeRootIds activeRootIds
) )
}) })
}
if (activeRootIds) if (activeRootIds)
rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds) rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph' import { SceneGraph } from '@open-pencil/scene-graph'
import { import {
buildComponentPropIndex,
fractionalPosition, fractionalPosition,
mapToFigmaType, mapToFigmaType,
sceneNodeToKiwi, sceneNodeToKiwi,
@ -15,6 +16,41 @@ describe('@open-pencil/fig SceneGraph export policy', () => {
expect([0, 93, 94, 188].map(fractionalPosition)).toEqual(['!', '~', '~!', '~~!']) 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', () => { test('injects runtime glyph outlines into derived text data', () => {
const graph = new SceneGraph() const graph = new SceneGraph()
const text = graph.createNode('TEXT', graph.getPages()[0].id, { 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') 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', () => { test('preserves protected text while synchronizing other fields', () => {
const graph = new SceneGraph() const graph = new SceneGraph()
const pageId = graph.getPages()[0].id const pageId = graph.getPages()[0].id

View file

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

View file

@ -97,6 +97,12 @@ describe('Figma component property import', () => {
(node) => node.name === 'Menu item instance' (node) => node.name === 'Menu item instance'
) )
expect(instance?.componentPropertyAssignments).toEqual({ '3:1': 'Profile Item' }) 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', () => { 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 { resolve } from 'node:path'
import { parseFigFile } from '@open-pencil/core' 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' import { collectAllNodes } from './fig-traversal'
@ -33,9 +33,12 @@ export function readFixtureBytes(name: string): Uint8Array {
return readFileSync(resolve(FIXTURES, name)) 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) const bytes = readFixtureBytes(name)
return parseFigFile(bytes.buffer as ArrayBuffer) return parseFigFile(bytes.buffer as ArrayBuffer, options)
} }
export async function parseGoldPreviewFixture(): Promise<{ export async function parseGoldPreviewFixture(): Promise<{