From 8542c1db6536f7d06133736291256f39e5cfea0b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 16:34:03 +0300 Subject: [PATCH 01/44] fix(vue): ignore click jitter before moving selection - Start selection moves only after a 4px screen-space threshold - Keep click selection from previewing or committing 1px geometry changes - Cover below-threshold and intentional move behavior --- packages/vue/src/canvas/tool-input/use.ts | 6 ++- packages/vue/src/canvas/useCanvasInput.ts | 6 ++- .../vue/src/shared/input/duplicate-drag.ts | 5 ++ packages/vue/src/shared/input/move.ts | 28 ++++++++++- packages/vue/src/shared/input/select.ts | 4 +- packages/vue/src/shared/input/select/move.ts | 7 ++- packages/vue/src/shared/input/types.ts | 3 ++ .../engine/text/rtl-auto-layout-input.test.ts | 3 ++ tests/engine/vue/input/move-threshold.test.ts | 49 +++++++++++++++++++ 9 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 tests/engine/vue/input/move-threshold.test.ts diff --git a/packages/vue/src/canvas/tool-input/use.ts b/packages/vue/src/canvas/tool-input/use.ts index d751da840..815ecdac1 100644 --- a/packages/vue/src/canvas/tool-input/use.ts +++ b/packages/vue/src/canvas/tool-input/use.ts @@ -13,6 +13,8 @@ type ToolMouseDownOptions = { event: MouseEvent cx: number cy: number + sx: number + sy: number editor: Editor hitFns: HitTestFns cursorOverride: Ref @@ -27,6 +29,8 @@ export function handleToolMouseDown({ event, cx, cy, + sx, + sy, editor, hitFns, cursorOverride, @@ -42,7 +46,7 @@ export function handleToolMouseDown({ } if (tool === 'SELECT') { - handleSelectDown(event, cx, cy, editor, hitFns, tryStartRotation, handleTextEditClick, setDrag) + handleSelectDown(event, cx, cy, sx, sy, editor, hitFns, tryStartRotation, handleTextEditClick, setDrag) return } diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index e287a2c75..32ec36164 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -163,6 +163,8 @@ export function useCanvasInput( event: e, cx, cy, + sx, + sy, editor, hitFns, cursorOverride, @@ -202,14 +204,14 @@ export function useCanvasInput( return } - const { cx, cy } = getCoords(e) + const { sx, sy, cx, cy } = getCoords(e) if (d.type === 'rotate') { handleRotateMove(d, cx, cy, e.shiftKey) return } if (d.type === 'move') { - handleMoveMove(d, cx, cy, editor) + handleMoveMove(d, cx, cy, sx, sy, editor) return } if (d.type === 'text-select') { diff --git a/packages/vue/src/shared/input/duplicate-drag.ts b/packages/vue/src/shared/input/duplicate-drag.ts index ca2fe0137..391b84925 100644 --- a/packages/vue/src/shared/input/duplicate-drag.ts +++ b/packages/vue/src/shared/input/duplicate-drag.ts @@ -7,6 +7,8 @@ type DragOriginal = { x: number; y: number; parentId: string } export function duplicateAndDrag( cx: number, cy: number, + sx: number, + sy: number, editor: Editor ): { originals: Map; drag: DragState } { const previousSelection = new Set(editor.state.selectedIds) @@ -35,6 +37,9 @@ export function duplicateAndDrag( startY: cy, currentX: cx, currentY: cy, + startScreenX: sx, + startScreenY: sy, + dragStarted: true, originals: newOriginals, duplicated: true, duplicatedPreviousSelection: previousSelection diff --git a/packages/vue/src/shared/input/move.ts b/packages/vue/src/shared/input/move.ts index 31535566f..0621828c9 100644 --- a/packages/vue/src/shared/input/move.ts +++ b/packages/vue/src/shared/input/move.ts @@ -12,6 +12,7 @@ import type { DragMove } from '#vue/shared/input/types' const AUTO_LAYOUT_REORDER_CLICK_SLOP = 3 const AUTO_LAYOUT_CROSS_AXIS_DRAG_TOLERANCE = 96 +export const MOVE_DRAG_START_THRESHOLD_PX = 3 function isInsideAutoLayoutDragBounds(parentId: string, cx: number, cy: number, editor: Editor) { const parent = editor.graph.getNode(parentId) @@ -44,10 +45,28 @@ export function detectAutoLayoutParent(editor: Editor): string | undefined { return undefined } -export function handleMoveMove(d: DragMove, cx: number, cy: number, editor: Editor) { +function isPastDragStartThreshold(d: DragMove, sx: number, sy: number) { + const dx = sx - d.startScreenX + const dy = sy - d.startScreenY + return dx * dx + dy * dy >= MOVE_DRAG_START_THRESHOLD_PX * MOVE_DRAG_START_THRESHOLD_PX +} + +export function handleMoveMove( + d: DragMove, + cx: number, + cy: number, + sx: number, + sy: number, + editor: Editor +) { d.currentX = cx d.currentY = cy + if (!d.dragStarted) { + if (!isPastDragStartThreshold(d, sx, sy)) return + d.dragStarted = true + } + let dx = cx - d.startX let dy = cy - d.startY @@ -113,6 +132,13 @@ function applyFinalPositions(d: DragMove, editor: Editor) { } export function handleMoveUp(d: DragMove, editor: Editor) { + if (!d.dragStarted) { + editor.setLayoutInsertIndicator(null) + editor.setSnapGuides([]) + editor.setDropTarget(null) + return + } + const indicator = editor.state.layoutInsertIndicator editor.setLayoutInsertIndicator(null) editor.setSnapGuides([]) diff --git a/packages/vue/src/shared/input/select.ts b/packages/vue/src/shared/input/select.ts index fe4eef436..15df49372 100644 --- a/packages/vue/src/shared/input/select.ts +++ b/packages/vue/src/shared/input/select.ts @@ -21,6 +21,8 @@ export function handleSelectDown( e: MouseEvent, cx: number, cy: number, + sx: number, + sy: number, editor: Editor, fns: HitTestFns, tryStartRotation: (cx: number, cy: number) => boolean, @@ -62,5 +64,5 @@ export function handleSelectDown( if (selectionIsLocked(editor)) return - setDrag(createSelectionMoveDrag(cx, cy, editor, e.altKey)) + setDrag(createSelectionMoveDrag(cx, cy, sx, sy, editor, e.altKey)) } diff --git a/packages/vue/src/shared/input/select/move.ts b/packages/vue/src/shared/input/select/move.ts index beeabdb63..4e932e5bd 100644 --- a/packages/vue/src/shared/input/select/move.ts +++ b/packages/vue/src/shared/input/select/move.ts @@ -59,10 +59,12 @@ function detectDragAutoLayoutParent(originals: Map, editor export function createSelectionMoveDrag( cx: number, cy: number, + sx: number, + sy: number, editor: Editor, duplicate: boolean ): DragState { - if (duplicate && editor.state.selectedIds.size > 0) return duplicateAndDrag(cx, cy, editor).drag + if (duplicate && editor.state.selectedIds.size > 0) return duplicateAndDrag(cx, cy, sx, sy, editor).drag const originals = collectMoveOriginals(editor) @@ -72,6 +74,9 @@ export function createSelectionMoveDrag( startY: cy, currentX: cx, currentY: cy, + startScreenX: sx, + startScreenY: sy, + dragStarted: false, originals, autoLayoutParentId: detectDragAutoLayoutParent(originals, editor) } diff --git a/packages/vue/src/shared/input/types.ts b/packages/vue/src/shared/input/types.ts index 6f615d895..c063a1bf2 100644 --- a/packages/vue/src/shared/input/types.ts +++ b/packages/vue/src/shared/input/types.ts @@ -19,6 +19,9 @@ export interface DragMove { startY: number currentX: number currentY: number + startScreenX: number + startScreenY: number + dragStarted: boolean originals: Map duplicated?: boolean duplicatedPreviousSelection?: Set diff --git a/tests/engine/text/rtl-auto-layout-input.test.ts b/tests/engine/text/rtl-auto-layout-input.test.ts index 29444c2ac..0b23d85a8 100644 --- a/tests/engine/text/rtl-auto-layout-input.test.ts +++ b/tests/engine/text/rtl-auto-layout-input.test.ts @@ -74,6 +74,9 @@ describe('RTL auto-layout input', () => { startY: 40, currentX: 100, currentY: 40, + startScreenX: 100, + startScreenY: 40, + dragStarted: true, originals: new Map([[second.id, { x: second.x, y: second.y, parentId: frame.id }]]), autoLayoutParentId: frame.id } diff --git a/tests/engine/vue/input/move-threshold.test.ts b/tests/engine/vue/input/move-threshold.test.ts new file mode 100644 index 000000000..4dbf0c953 --- /dev/null +++ b/tests/engine/vue/input/move-threshold.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' + +import { createEditor } from '@open-pencil/core/editor' + +import { handleMoveMove, handleMoveUp, MOVE_DRAG_START_THRESHOLD_PX } from '#vue/shared/input/move' +import { createSelectionMoveDrag } from '#vue/shared/input/select/move' +import type { DragMove } from '#vue/shared/input/types' + +function setupMoveDrag(): { editor: ReturnType; drag: DragMove; nodeId: string } { + const editor = createEditor() + const pageId = editor.state.currentPageId + const node = editor.graph.createNode('RECTANGLE', pageId, { + name: 'Box', + x: 10, + y: 20, + width: 100, + height: 80 + }) + editor.select([node.id]) + const drag = createSelectionMoveDrag(10, 20, 100, 200, editor, false) + if (drag.type !== 'move') throw new Error('Expected move drag') + return { editor, drag, nodeId: node.id } +} + +describe('selection move drag threshold', () => { + test('does not move selected nodes for click jitter below threshold', () => { + const { editor, drag, nodeId } = setupMoveDrag() + + handleMoveMove(drag, 11, 21, 100 + MOVE_DRAG_START_THRESHOLD_PX - 1, 200, editor) + handleMoveUp(drag, editor) + + const node = editor.graph.getNode(nodeId) + expect(node?.x).toBe(10) + expect(node?.y).toBe(20) + expect(drag.dragStarted).toBe(false) + }) + + test('moves selected nodes once pointer movement exceeds threshold', () => { + const { editor, drag, nodeId } = setupMoveDrag() + + handleMoveMove(drag, 16, 27, 100 + MOVE_DRAG_START_THRESHOLD_PX + 1, 200, editor) + handleMoveUp(drag, editor) + + const node = editor.graph.getNode(nodeId) + expect(node?.x).toBe(16) + expect(node?.y).toBe(27) + expect(drag.dragStarted).toBe(true) + }) +}) From c1ddbf7039bb1662c15045a2e6d6b69e12886435 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 16:59:31 +0300 Subject: [PATCH 02/44] fix(fig): deduplicate exported text glyph blobs - Preserve imported Figma glyph outlines for fallback rendering - Reuse identical glyph command blobs across exported text nodes - Cover real fixture preservation and generated glyph deduplication --- packages/core/src/io/formats/fig/export.ts | 4 +- .../core/src/kiwi/node-change/export-node.ts | 13 ++- .../core/src/kiwi/node-change/serialize.ts | 72 +++++++++++---- .../io/fig/roundtrip/glyph-blob.test.ts | 89 +++++++++++++++++++ 4 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 tests/engine/io/fig/roundtrip/glyph-blob.test.ts diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index a3259a9e8..71a23f617 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -192,6 +192,7 @@ export async function exportFigFile( const varIdToGuid = new Map() const modeIdToGuid = new Map() const fontDigestMap = await buildFontDigestMap(graph) + const glyphBlobMap = new Map() let internalCanvasGuid: GUID | null = null assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid) @@ -223,7 +224,8 @@ export async function exportFigFile( blobs, nodeIdToGuid, fontDigestMap, - varIdToGuid + varIdToGuid, + glyphBlobMap ) ) } diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 1e617484e..26d2dfce5 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -17,6 +17,7 @@ interface SceneNodeToKiwiContext { blobs: Uint8Array[] nodeIdToGuid?: Map fontDigestMap?: Map + glyphBlobMap?: Map varIdToGuid?: Map fractionalPosition: (index: number) => string mapToFigmaType: (type: SceneNode['type']) => string @@ -29,7 +30,8 @@ interface SceneNodeToKiwiContext { nc: KiwiNodeChange, graph: SceneGraph, fontDigestMap: Map | undefined, - blobs: Uint8Array[] + blobs: Uint8Array[], + glyphBlobMap: Map | undefined ) => void serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void @@ -173,7 +175,14 @@ function applyNodeVisualProps( } if (node.type === 'TEXT') { - context.serializeTextProps(node, nc, context.graph, context.fontDigestMap, context.blobs) + context.serializeTextProps( + node, + nc, + context.graph, + context.fontDigestMap, + context.blobs, + context.glyphBlobMap + ) } nc.frameMaskDisabled = !node.clipsContent diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index 867dd58a2..9d130593b 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -1,4 +1,4 @@ -import { hexToBytes } from '#core/bytes/hex' +import { bytesToHex, hexToBytes } from '#core/bytes/hex' import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands' import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text/data' import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' @@ -74,10 +74,24 @@ function textLines(text: string): NonNullable['lines'] { return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' })) } +function appendGlyphBlob( + blobs: Uint8Array[], + glyphBlobMap: Map, + blob: Uint8Array +): number { + const key = bytesToHex(blob) + const existing = glyphBlobMap.get(key) + if (existing !== undefined) return existing + const index = blobs.push(blob) - 1 + glyphBlobMap.set(key, index) + return index +} + function buildDerivedTextData( node: SceneNode, digestMap: Map, - blobs: Uint8Array[] + blobs: Uint8Array[], + glyphBlobMap: Map ): NodeChange['derivedTextData'] { const fontMeta: NonNullable['fontMetaData'] = [] const seen = new Set() @@ -106,20 +120,41 @@ function buildDerivedTextData( ) } - const style = weightToStyle(node.fontWeight, node.italic) - const glyphMetrics = - getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2) const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0 - const glyphs = glyphMetrics.map((glyph, index) => ({ - commandsBlob: blobs.push(encodePathCommandsBlob(glyph.commands, node.fontSize)) - 1, - position: { x: glyph.x || index * glyphAdvance, y: lineHeight }, - fontSize: node.fontSize, - firstCharacter: index, - advance: glyph.advance || glyphAdvance, - rotation: 0 - })) + const derivedGlyphs = node.figmaDerivedTextGlyphs ?? [] + const glyphs = + derivedGlyphs.length > 0 + ? derivedGlyphs.map((glyph, index) => ({ + commandsBlob: appendGlyphBlob(blobs, glyphBlobMap, glyph.commandsBlob), + position: { x: glyph.x, y: glyph.y }, + fontSize: glyph.fontSize, + firstCharacter: index, + advance: + index + 1 < derivedGlyphs.length + ? Math.max(derivedGlyphs[index + 1].x - glyph.x, 0) + : glyphAdvance, + rotation: 0 + })) + : (getGlyphOutlineMetricsSync( + node.fontFamily, + weightToStyle(node.fontWeight, node.italic), + node.text, + node.fontSize + ) ?? [] + ).map((glyph, index) => ({ + commandsBlob: appendGlyphBlob( + blobs, + glyphBlobMap, + encodePathCommandsBlob(glyph.commands, node.fontSize) + ), + position: { x: glyph.x || index * glyphAdvance, y: lineHeight }, + fontSize: node.fontSize, + firstCharacter: index, + advance: glyph.advance || glyphAdvance, + rotation: 0 + })) const logicalIndexToCharacterOffsetMap = Array.from( { length: node.text.length + 1 }, @@ -254,7 +289,8 @@ function serializeTextProps( nc: KiwiNodeChange, graph: SceneGraph, fontDigestMap: Map | undefined, - blobs: Uint8Array[] + blobs: Uint8Array[], + glyphBlobMap: Map | undefined ): void { upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection) nc.fontSize = node.fontSize @@ -276,7 +312,9 @@ function serializeTextProps( nc.fontVariantContextualLigatures = true nc.fontVersion = '' nc.emojiImageSet = 'APPLE' - if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs) + if (fontDigestMap) { + nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs, glyphBlobMap ?? new Map()) + } if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' } nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' } if (node.textDecoration !== 'NONE') { @@ -421,13 +459,15 @@ export function sceneNodeToKiwi( blobs: Uint8Array[], nodeIdToGuid?: Map, fontDigestMap?: Map, - varIdToGuid?: Map + varIdToGuid?: Map, + glyphBlobMap = new Map() ): KiwiNodeChange[] { return sceneNodeToKiwiWithContext(node, parentGuid, childIndex, localIdCounter, { graph, blobs, nodeIdToGuid, fontDigestMap, + glyphBlobMap, varIdToGuid, fractionalPosition, mapToFigmaType, diff --git a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts new file mode 100644 index 000000000..0bd61573c --- /dev/null +++ b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core' +import { parseFigBuffer } from '#core/kiwi/fig/parse/core' +import { fontManager } from '@open-pencil/core/text' + +const FIXTURES = resolve(import.meta.dir, '../../../../fixtures') +const INTER_ASSETS = resolve(import.meta.dir, '../../../../../packages/core/assets') + +function countGlyphBlobs(bytes: Uint8Array) { + const parsed = parseFigBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) + let glyphs = 0 + let glyphsWithBlob = 0 + const uniqueGlyphBlobs = new Set() + + for (const nc of parsed.nodeChanges) { + if (nc.type !== 'TEXT') continue + for (const glyph of nc.derivedTextData?.glyphs ?? []) { + glyphs++ + if (glyph.commandsBlob !== undefined) { + glyphsWithBlob++ + uniqueGlyphBlobs.add(glyph.commandsBlob) + } + } + } + + return { glyphs, glyphsWithBlob, uniqueGlyphBlobs: uniqueGlyphBlobs.size } +} + +function loadInterFonts() { + for (const style of ['Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold']) { + const bytes = readFileSync(resolve(INTER_ASSETS, `Inter-${style}.ttf`)) + fontManager.markLoaded( + 'Inter', + style, + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + ) + } +} + +describe('roundtrip: text glyph blobs', () => { + beforeAll(async () => { + await initCodec() + }) + + test('preserves imported Figma glyph blobs for fallback rendering', async () => { + const fixtureBytes = new Uint8Array(readFileSync(resolve(FIXTURES, 'gold-preview.fig'))) + const input = countGlyphBlobs(fixtureBytes) + + const graph = await parseFigFile( + fixtureBytes.buffer.slice(fixtureBytes.byteOffset, fixtureBytes.byteOffset + fixtureBytes.byteLength) + ) + const exported = await exportFigFile(graph) + const output = countGlyphBlobs(exported) + + expect(input.glyphsWithBlob).toBeGreaterThan(0) + expect(output.glyphsWithBlob).toBeGreaterThan(0) + expect(output.uniqueGlyphBlobs).toBeLessThan(output.glyphsWithBlob / 100) + expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(input.uniqueGlyphBlobs + 20) + }) + + test('deduplicates generated glyph blobs across repeated text', async () => { + loadInterFonts() + + const graph = new SceneGraph() + const page = graph.getPages()[0] + for (let i = 0; i < 100; i++) { + graph.createNode('TEXT', page.id, { + name: `Label ${i}`, + text: 'Hello', + x: 0, + y: i * 20, + width: 80, + height: 20, + fontFamily: 'Inter', + fontWeight: 400, + fontSize: 14 + }) + } + + const exported = await exportFigFile(graph) + const output = countGlyphBlobs(exported) + + expect(output.glyphsWithBlob).toBe(500) + expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(4) + }) +}) From 4fe2b75dc81a3b15ea7c3cc2a9ed6992adeade57 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 18:32:01 +0300 Subject: [PATCH 03/44] fix(io): keep page descendants in fig exports - Preserve the source document root when extracting export subgraphs - Include page descendants when exporting a single page to .fig - Cover page subgraph extraction so CLI .fig exports are not empty --- packages/core/src/io/subgraph.ts | 49 +++++++++-- .../core/src/kiwi/node-change/export-node.ts | 40 ++++++++- tests/engine/io/subgraph.test.ts | 83 +++++++++++++++++++ 3 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 tests/engine/io/subgraph.test.ts diff --git a/packages/core/src/io/subgraph.ts b/packages/core/src/io/subgraph.ts index 3f4e299e9..8bceb431e 100644 --- a/packages/core/src/io/subgraph.ts +++ b/packages/core/src/io/subgraph.ts @@ -10,14 +10,8 @@ export interface ExtractedGraph { function cloneIntoGraph(source: SceneGraph, ids: Set): SceneGraph { const graph = new SceneGraph() - const root = graph.getNode(graph.rootId) - if (root) { - root.childIds = [] - root.width = 0 - root.height = 0 - } + graph.rootId = source.rootId graph.nodes = new Map() - if (root) graph.nodes.set(root.id, root) graph.images = new Map(source.images) graph.variables = new Map() graph.variableCollections = new Map() @@ -116,6 +110,43 @@ function collectDescendants(source: SceneGraph, id: string, out: Set) { } } +function collectAncestors(source: SceneGraph, id: string, out: Set) { + let current = source.getNode(id) + while (current?.parentId) { + out.add(current.parentId) + current = source.getNode(current.parentId) + } +} + +function resolveInstanceComponentId(source: SceneGraph, componentId: string): string { + const seen = new Set() + let currentId = componentId + while (!seen.has(currentId)) { + seen.add(currentId) + const node = source.getNode(currentId) + if (node?.type !== 'INSTANCE' || !node.componentId) return currentId + currentId = node.componentId + } + return componentId +} + +function collectComponentDependencies(source: SceneGraph, ids: Set) { + let changed = true + while (changed) { + changed = false + for (const id of Array.from(ids)) { + const node = source.getNode(id) + if (node?.type !== 'INSTANCE' || !node.componentId) continue + const componentId = resolveInstanceComponentId(source, node.componentId) + if (ids.has(componentId)) continue + const before = ids.size + collectAncestors(source, componentId, ids) + collectDescendants(source, componentId, ids) + changed ||= ids.size !== before + } + } +} + export function findPageId(source: SceneGraph, nodeId: string): string | null { let current = source.getNode(nodeId) while (current?.parentId) { @@ -156,12 +187,14 @@ function collectSelectionIds(source: SceneGraph, nodeIds: string[]): Set ids.add(pageId) } + collectComponentDependencies(source, ids) return ids } function pageNodeIds(source: SceneGraph, pageId: string): Set { - const ids = new Set([source.rootId, pageId]) + const ids = new Set([source.rootId]) collectDescendants(source, pageId, ids) + collectComponentDependencies(source, ids) return ids } diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 26d2dfce5..a00e7b992 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -99,6 +99,31 @@ function parseGuidOrNull(value: string) { return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null } +function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string { + const seen = new Set() + let currentId = componentId + while (!seen.has(currentId)) { + seen.add(currentId) + const node = context.graph.getNode(currentId) + if (node?.type !== 'INSTANCE' || !node.componentId) return currentId + currentId = node.componentId + } + return componentId +} + +function getOrCreateNodeGuid( + context: SceneNodeToKiwiContext, + nodeId: string, + localIdCounter: { value: number } +): GUID | undefined { + if (!context.graph.getNode(nodeId)) return undefined + const existing = context.nodeIdToGuid?.get(nodeId) + if (existing) return existing + const guid = { sessionID: 1, localID: localIdCounter.value++ } + context.nodeIdToGuid?.set(nodeId, guid) + return guid +} + function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void { if (node.componentKey) nc.componentKey = node.componentKey if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey @@ -209,9 +234,10 @@ export function sceneNodeToKiwiWithContext( localIdCounter: { value: number }, context: SceneNodeToKiwiContext ): KiwiNodeChange[] { - const localID = localIdCounter.value++ - const guid = { sessionID: 1, localID } - context.nodeIdToGuid?.set(node.id, guid) + const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? { + sessionID: 1, + localID: localIdCounter.value++ + } const strokePaints = createStrokePaints(context, node) @@ -231,6 +257,14 @@ export function sceneNodeToKiwiWithContext( applyNodeVisualProps(context, node, nc) applyComponentMetadata(node, nc) + if (node.type === 'INSTANCE' && node.componentId) { + const symbolID = getOrCreateNodeGuid( + context, + resolveInstanceComponentId(context, node.componentId), + localIdCounter + ) + if (symbolID) nc.symbolData = { symbolID } + } if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) if (strokePaints.length > 0) nc.strokePaints = strokePaints diff --git a/tests/engine/io/subgraph.test.ts b/tests/engine/io/subgraph.test.ts new file mode 100644 index 000000000..af635f73f --- /dev/null +++ b/tests/engine/io/subgraph.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' + +import { exportFigFile, extractExportGraph, parseFigFile } from '@open-pencil/core/io' +import { initCodec } from '@open-pencil/core/kiwi' +import { SceneGraph } from '@open-pencil/core/scene-graph' + +describe('export subgraph extraction', () => { + test('page extraction keeps the source root and page descendants', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + name: 'Frame', + x: 10, + y: 20, + width: 100, + height: 100 + }) + const text = graph.createNode('TEXT', frame.id, { + name: 'Label', + text: 'Hello', + width: 50, + height: 20 + }) + + const extracted = extractExportGraph(graph, { scope: 'page', pageId: page.id }) + const extractedPages = extracted.graph.getPages(true) + + expect(extracted.graph.rootId).toBe(graph.rootId) + expect(extractedPages.map((node) => node.id)).toEqual([page.id]) + expect(extracted.graph.getNode(page.id)?.childIds).toEqual([frame.id]) + expect(extracted.graph.getNode(frame.id)?.childIds).toEqual([text.id]) + expect(extracted.graph.getNode(text.id)?.text).toBe('Hello') + }) + + test('page extraction includes component dependencies for instances', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const libraryPage = graph.addPage('Library') + const component = graph.createNode('COMPONENT', libraryPage.id, { + name: 'Button', + width: 100, + height: 40 + }) + graph.createNode('TEXT', component.id, { + name: 'Label', + text: 'Button', + width: 80, + height: 20 + }) + const instance = graph.createInstance(component.id, page.id) + + const extracted = extractExportGraph(graph, { scope: 'page', pageId: page.id }) + + expect(extracted.graph.getNode(instance.id)?.type).toBe('INSTANCE') + expect(extracted.graph.getNode(component.id)?.type).toBe('COMPONENT') + expect(extracted.graph.getNode(libraryPage.id)?.type).toBe('CANVAS') + }) + + test('fig page export preserves valid instance component references', async () => { + await initCodec() + const graph = new SceneGraph() + const page = graph.getPages()[0] + const libraryPage = graph.addPage('Library') + const component = graph.createNode('COMPONENT', libraryPage.id, { + name: 'Button', + width: 100, + height: 40 + }) + const instance = graph.createInstance(component.id, page.id) + if (!instance) throw new Error('Expected instance') + + const extracted = extractExportGraph(graph, { scope: 'page', pageId: page.id }) + const exported = await exportFigFile(extracted.graph) + const parsed = await parseFigFile(exported.buffer as ArrayBuffer) + const parsedInstance = [...parsed.getAllNodes()].find( + (node) => node.type === 'INSTANCE' && node.name === instance.name + ) + + expect(parsedInstance?.type).toBe('INSTANCE') + expect(parsedInstance?.componentId).toBeTruthy() + expect(parsed.getNode(parsedInstance?.componentId ?? '')?.type).toBe('COMPONENT') + }) +}) From 1c655f34cea0e5bb0e9bdae07b07ef79b13a9ea0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 18:50:00 +0300 Subject: [PATCH 04/44] fix(fig): preserve auto-layout child transforms - Stop zeroing transforms for auto-layout children during .fig export - Match real Figma files, which keep child transform offsets alongside layout data - Update transform regression coverage to prevent collapsed roundtrips --- packages/core/src/kiwi/node-change/export-node.ts | 4 ++-- packages/core/src/kiwi/node-change/serialize.ts | 15 +++------------ .../auto-layout/transforms.test.ts | 12 ++++++------ 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index a00e7b992..3868da63b 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -23,7 +23,7 @@ interface SceneNodeToKiwiContext { mapToFigmaType: (type: SceneNode['type']) => string fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint safeColor: (color: Color) => Color - computeExportTransform: (node: SceneNode, graph: SceneGraph) => Matrix + computeExportTransform: (node: SceneNode) => Matrix serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void serializeTextProps: ( node: SceneNode, @@ -250,7 +250,7 @@ export function sceneNodeToKiwiWithContext( opacity: node.opacity, phase: 'CREATED', size: { x: node.width, y: node.height }, - transform: context.computeExportTransform(node, context.graph), + transform: context.computeExportTransform(node), strokeWeight: node.strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT, strokeAlign: node.strokes[0]?.align ?? 'INSIDE' } diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index 9d130593b..41da0e276 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -410,20 +410,11 @@ function serializeVariableBindings( if (entries.length > 0) nc.variableConsumptionMap = { entries } } -function computeExportTransform(node: SceneNode, graph: SceneGraph): Matrix { +function computeExportTransform(node: SceneNode): Matrix { const sx = node.flipX ? -1 : 1 const cos = Math.cos((node.rotation * Math.PI) / 180) const sin = Math.sin((node.rotation * Math.PI) / 180) - // Auto-layout children should have (0,0) transform — Figma computes - // their positions from the layout engine at render time. - const parent = node.parentId ? graph.getNode(node.parentId) : undefined - const isAutoLayoutChild = - parent && - parent.layoutMode !== 'NONE' && - parent.layoutMode !== 'GRID' && - node.layoutPositioning !== 'ABSOLUTE' - const m00 = cos * sx const m01 = -sin const m10 = sin * sx @@ -443,10 +434,10 @@ function computeExportTransform(node: SceneNode, graph: SceneGraph): Matrix { return { m00, m01, - m02: isAutoLayoutChild ? 0 : node.x - offsetX, + m02: node.x - offsetX, m10, m11, - m12: isAutoLayoutChild ? 0 : node.y - offsetY + m12: node.y - offsetY } } diff --git a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts index e38b36c6e..2552405f5 100644 --- a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts +++ b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts @@ -7,7 +7,7 @@ import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert' import { ROOT_GUID, pageId } from '../helpers' describe('Fix 1: auto-layout child transforms', () => { - test('auto-layout child gets zero transform regardless of its x/y', () => { + test('auto-layout child preserves its stored transform offsets', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), { name: 'AutoLayout', @@ -48,8 +48,8 @@ describe('Fix 1: auto-layout child transforms', () => { 'child node change' ) expect(childNc).toBeDefined() - expect(childNc.transform.m02).toBe(0) - expect(childNc.transform.m12).toBe(0) + expect(childNc.transform.m02).toBe(50) + expect(childNc.transform.m12).toBe(100) }) test('absolute-positioned child inside auto-layout keeps its real x/y', () => { @@ -130,7 +130,7 @@ describe('Fix 1: auto-layout child transforms', () => { expect(childNc.transform.m12).toBe(45) }) - test('horizontal auto-layout child also gets zero transform', () => { + test('horizontal auto-layout child also preserves its stored transform offsets', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), { name: 'HorizontalLayout', @@ -164,8 +164,8 @@ describe('Fix 1: auto-layout child transforms', () => { changes.find((nc) => nc.name === 'Item'), 'item node change' ) - expect(itemNc.transform.m02).toBe(0) - expect(itemNc.transform.m12).toBe(0) + expect(itemNc.transform.m02).toBe(200) + expect(itemNc.transform.m12).toBe(50) }) }) From 68ebef5f9bb1d10f764dc6b558d6a553cbbbd336 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 19:10:23 +0300 Subject: [PATCH 05/44] fix(fig): omit populated instance children on export - Match Figma files where INSTANCE node changes carry symbol refs, not child nodes - Prevent exported roundtrips from expanding every populated instance subtree - Tighten glyph blob preservation now that instance children are omitted --- packages/core/src/kiwi/node-change/export-node.ts | 5 ++++- tests/engine/io/fig/roundtrip/glyph-blob.test.ts | 5 ++--- tests/engine/io/subgraph.test.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 3868da63b..cc9aaa5d0 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -279,7 +279,10 @@ export function sceneNodeToKiwiWithContext( } const result: KiwiNodeChange[] = [nc] - const children = context.graph.getChildren(node.id).filter((child) => !child.internalOnly) + const children = + node.type === 'INSTANCE' + ? [] + : context.graph.getChildren(node.id).filter((child) => !child.internalOnly) for (let i = 0; i < children.length; i++) { result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context)) } diff --git a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts index 0bd61573c..6900976d5 100644 --- a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts +++ b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts @@ -56,9 +56,8 @@ describe('roundtrip: text glyph blobs', () => { const output = countGlyphBlobs(exported) expect(input.glyphsWithBlob).toBeGreaterThan(0) - expect(output.glyphsWithBlob).toBeGreaterThan(0) - expect(output.uniqueGlyphBlobs).toBeLessThan(output.glyphsWithBlob / 100) - expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(input.uniqueGlyphBlobs + 20) + expect(output.glyphsWithBlob).toBe(input.glyphsWithBlob) + expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(input.uniqueGlyphBlobs) }) test('deduplicates generated glyph blobs across repeated text', async () => { diff --git a/tests/engine/io/subgraph.test.ts b/tests/engine/io/subgraph.test.ts index af635f73f..1a44c95f6 100644 --- a/tests/engine/io/subgraph.test.ts +++ b/tests/engine/io/subgraph.test.ts @@ -56,7 +56,7 @@ describe('export subgraph extraction', () => { expect(extracted.graph.getNode(libraryPage.id)?.type).toBe('CANVAS') }) - test('fig page export preserves valid instance component references', async () => { + test('fig page export preserves valid instance component references without serializing instance children', async () => { await initCodec() const graph = new SceneGraph() const page = graph.getPages()[0] @@ -79,5 +79,12 @@ describe('export subgraph extraction', () => { expect(parsedInstance?.type).toBe('INSTANCE') expect(parsedInstance?.componentId).toBeTruthy() expect(parsed.getNode(parsedInstance?.componentId ?? '')?.type).toBe('COMPONENT') + + const exportedAgain = await exportFigFile(extracted.graph) + const reparsed = await parseFigFile(exportedAgain.buffer as ArrayBuffer) + const reparsedInstance = [...reparsed.getAllNodes()].find( + (node) => node.type === 'INSTANCE' && node.name === instance.name + ) + expect(reparsedInstance?.childIds).toEqual([]) }) }) From 9a6e29cf6ff82ca06d2b252d88cb66f84fa67699 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 19:24:20 +0300 Subject: [PATCH 06/44] fix(fig): preserve imported layout metadata - Keep Figma stack sizing fields such as RESIZE_TO_FIT_WITH_IMPLICIT_SIZE - Avoid writing default auto-layout fields onto imported instances when Figma omitted them - Cover instance layout metadata roundtrips --- packages/core/src/kiwi/node-change/convert.ts | 22 +++++++ .../core/src/kiwi/node-change/serialize.ts | 45 +++++++++++++++ .../core/src/scene-graph/node-defaults.ts | 1 + packages/core/src/scene-graph/types.ts | 29 ++++++++++ .../auto-layout/transforms.test.ts | 57 +++++++++++++++++++ 5 files changed, 154 insertions(+) diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index 4f6c17241..e1bd63e20 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -355,6 +355,7 @@ function convertLayoutProps( ): Pick< SceneNode, | 'layoutMode' + | 'figmaLayout' | 'itemSpacing' | 'paddingTop' | 'paddingBottom' @@ -387,6 +388,27 @@ function convertLayoutProps( return { layoutMode, + figmaLayout: { + stackMode: nc.stackMode, + stackSpacing: nc.stackSpacing, + stackPadding: nc.stackPadding, + stackPaddingRight: nc.stackPaddingRight, + stackPaddingBottom: nc.stackPaddingBottom, + stackCounterAlign: nc.stackCounterAlign, + stackJustify: nc.stackJustify, + stackCounterAlignItems: nc.stackCounterAlignItems, + stackPrimaryAlignItems: nc.stackPrimaryAlignItems, + stackPrimarySizing: nc.stackPrimarySizing, + stackCounterSizing: nc.stackCounterSizing, + stackVerticalPadding: nc.stackVerticalPadding, + stackHorizontalPadding: nc.stackHorizontalPadding, + stackWrap: nc.stackWrap, + stackPositioning: nc.stackPositioning, + stackChildPrimaryGrow: nc.stackChildPrimaryGrow, + stackChildAlignSelf: nc.stackChildAlignSelf, + stackCounterSpacing: nc.stackCounterSpacing, + bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined + }, itemSpacing: nc.stackSpacing ?? 0, ...convertLayoutPadding(nc), primaryAxisSizing, diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index 41da0e276..ae953e23e 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -322,8 +322,53 @@ function serializeTextProps( } } +function normalizeStackMode( + value: string | undefined +): KiwiNodeChange['stackMode'] { + return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined +} + +function normalizeStackSizing( + value: string | undefined +): KiwiNodeChange['stackPrimarySizing'] { + return value === 'FIXED' || value === 'RESIZE_TO_FIT' || value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE' + ? value + : undefined +} + +function normalizeStackJustify(value: string | undefined): string | undefined { + return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value +} + +function normalizeStackCounterAlign(value: string | undefined): string | undefined { + return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value +} + function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection) + const figmaLayout = node.figmaLayout + if (figmaLayout) { + nc.stackMode = normalizeStackMode(figmaLayout.stackMode) + nc.stackSpacing = figmaLayout.stackSpacing + nc.stackPadding = figmaLayout.stackPadding + nc.stackPaddingRight = figmaLayout.stackPaddingRight + nc.stackPaddingBottom = figmaLayout.stackPaddingBottom + nc.stackCounterAlign = normalizeStackCounterAlign(figmaLayout.stackCounterAlign) + nc.stackJustify = normalizeStackJustify(figmaLayout.stackJustify) + nc.stackCounterAlignItems = normalizeStackCounterAlign(figmaLayout.stackCounterAlignItems) + nc.stackPrimaryAlignItems = normalizeStackJustify(figmaLayout.stackPrimaryAlignItems) + nc.stackPrimarySizing = normalizeStackSizing(figmaLayout.stackPrimarySizing) + nc.stackCounterSizing = normalizeStackSizing(figmaLayout.stackCounterSizing) + nc.stackVerticalPadding = figmaLayout.stackVerticalPadding + nc.stackHorizontalPadding = figmaLayout.stackHorizontalPadding + nc.stackWrap = figmaLayout.stackWrap + nc.stackPositioning = figmaLayout.stackPositioning + nc.stackChildPrimaryGrow = figmaLayout.stackChildPrimaryGrow + nc.stackChildAlignSelf = figmaLayout.stackChildAlignSelf + nc.stackCounterSpacing = figmaLayout.stackCounterSpacing + nc.bordersTakeSpace = figmaLayout.bordersTakeSpace + return + } if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') { nc.stackMode = node.layoutMode nc.stackSpacing = node.itemSpacing diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index ea8a5a480..e3d0597a9 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -46,6 +46,7 @@ export function createDefaultNode( lineHeight: null, letterSpacing: 0, layoutMode: 'NONE', + figmaLayout: null, layoutDirection: 'AUTO', layoutWrap: 'NO_WRAP', primaryAxisAlign: 'MIN', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 7859f153e..9584d0b51 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -229,6 +229,34 @@ export interface VariantPropSpec { value: string } +export type FigmaLayoutMetadata = Partial< + Record< + | 'stackMode' + | 'stackCounterAlign' + | 'stackJustify' + | 'stackCounterAlignItems' + | 'stackPrimaryAlignItems' + | 'stackPrimarySizing' + | 'stackCounterSizing' + | 'stackWrap' + | 'stackPositioning' + | 'stackChildAlignSelf', + string + > & + Record< + | 'stackSpacing' + | 'stackPadding' + | 'stackPaddingRight' + | 'stackPaddingBottom' + | 'stackVerticalPadding' + | 'stackHorizontalPadding' + | 'stackChildPrimaryGrow' + | 'stackCounterSpacing', + number + > & + Record<'bordersTakeSpace', boolean> +> + export interface SceneNode { id: string type: NodeType @@ -283,6 +311,7 @@ export interface SceneNode { verticalConstraint: ConstraintType layoutMode: LayoutMode + figmaLayout: FigmaLayoutMetadata | null layoutDirection: LayoutDirection layoutWrap: LayoutWrap primaryAxisAlign: LayoutAlign diff --git a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts index 2552405f5..031c1f129 100644 --- a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts +++ b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts @@ -130,6 +130,63 @@ describe('Fix 1: auto-layout child transforms', () => { expect(childNc.transform.m12).toBe(45) }) + test('preserves imported Figma layout metadata for instance roundtrips', () => { + const graph = new SceneGraph() + const parent = graph.createNode('FRAME', pageId(graph), { + name: 'Checked List', + width: 878, + height: 24, + layoutMode: 'HORIZONTAL', + counterAxisSizing: 'HUG', + layoutAlignSelf: 'STRETCH', + figmaLayout: { + stackMode: 'HORIZONTAL', + stackPrimarySizing: 'FIXED', + stackCounterSizing: 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE', + stackChildAlignSelf: 'STRETCH' + } + }) + + graph.createNode('INSTANCE', parent.id, { + name: 'Lists', + width: 282.6666564941406, + height: 24, + layoutMode: 'VERTICAL', + layoutGrow: 1, + figmaLayout: { + stackMode: 'VERTICAL', + stackChildPrimaryGrow: 1 + } + }) + + const blobs: Uint8Array[] = [] + const changes = sceneNodeToKiwi( + getNodeOrThrow(graph, parent.id), + ROOT_GUID, + 0, + { value: 100 }, + graph, + blobs + ) as Record[] + + const parentNc = expectDefined( + changes.find((nc) => nc.name === 'Checked List'), + 'parent node change' + ) + expect(parentNc.stackCounterSizing).toBe('RESIZE_TO_FIT_WITH_IMPLICIT_SIZE') + + const instanceNc = expectDefined( + changes.find((nc) => nc.name === 'Lists'), + 'instance node change' + ) + expect(instanceNc.stackMode).toBe('VERTICAL') + expect(instanceNc.stackChildPrimaryGrow).toBe(1) + expect(instanceNc.stackPrimarySizing).toBeUndefined() + expect(instanceNc.stackCounterSizing).toBeUndefined() + expect(instanceNc.stackPrimaryAlignItems).toBeUndefined() + expect(instanceNc.stackCounterAlignItems).toBeUndefined() + }) + test('horizontal auto-layout child also preserves its stored transform offsets', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), { From 40ee1b83d42383605e17d13f2730487e96db5e39 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 19:31:44 +0300 Subject: [PATCH 07/44] fix(fig): preserve instance override payloads - Keep imported Figma node guids so override paths and symbol references remain stable - Serialize imported symbolOverrides and uniform scale factors for instances - Strip unsupported variable consumption maps from preserved overrides before encoding --- packages/core/src/kiwi/node-change/convert.ts | 14 ++++++++++ .../core/src/kiwi/node-change/export-node.ts | 26 +++++++++++++++++-- .../core/src/scene-graph/node-defaults.ts | 3 +++ packages/core/src/scene-graph/types.ts | 3 +++ tests/engine/io/subgraph.test.ts | 25 ++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index e1bd63e20..fd35886d6 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -482,6 +482,8 @@ export function nodeChangeToProps( return { nodeType, name: nc.name ?? nodeType, + figmaGuid: nc.guid ? guidToString(nc.guid) : null, + ...extractFigmaSymbolMetadata(nc), ...convertTransformProps(nc), opacity: nc.opacity ?? 1, visible: nc.visible ?? true, @@ -674,6 +676,18 @@ export function sortChildren( } } +function extractFigmaSymbolMetadata( + nc: NodeChange +): Pick { + const sd = nc.symbolData as + | { symbolOverrides?: unknown[]; uniformScaleFactor?: number } + | undefined + return { + figmaSymbolOverrides: structuredClone(sd?.symbolOverrides ?? []), + figmaUniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null + } +} + function extractSymbolId(nc: NodeChange): string { const sd = nc.symbolData as { symbolID?: GUID } | undefined if (!sd?.symbolID) return '' diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index cc9aaa5d0..2e6b91eed 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -99,6 +99,17 @@ function parseGuidOrNull(value: string) { return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null } +function sanitizeSymbolOverride(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeSymbolOverride) + if (!value || typeof value !== 'object') return value + const sanitized: Record = {} + for (const [key, child] of Object.entries(value)) { + if (key === 'variableConsumptionMap' || key === 'parameterConsumptionMap') continue + sanitized[key] = sanitizeSymbolOverride(child) + } + return sanitized +} + function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string { const seen = new Set() let currentId = componentId @@ -119,7 +130,9 @@ function getOrCreateNodeGuid( if (!context.graph.getNode(nodeId)) return undefined const existing = context.nodeIdToGuid?.get(nodeId) if (existing) return existing - const guid = { sessionID: 1, localID: localIdCounter.value++ } + const node = context.graph.getNode(nodeId) + const importedGuid = node?.figmaGuid ? parseGuidOrNull(node.figmaGuid) : null + const guid = importedGuid ?? { sessionID: 1, localID: localIdCounter.value++ } context.nodeIdToGuid?.set(nodeId, guid) return guid } @@ -263,7 +276,16 @@ export function sceneNodeToKiwiWithContext( resolveInstanceComponentId(context, node.componentId), localIdCounter ) - if (symbolID) nc.symbolData = { symbolID } + if (symbolID) { + const symbolData: Record = { symbolID } + if (node.figmaSymbolOverrides.length > 0) { + symbolData.symbolOverrides = sanitizeSymbolOverride(node.figmaSymbolOverrides) + } + if (node.figmaUniformScaleFactor != null) { + symbolData.uniformScaleFactor = node.figmaUniformScaleFactor + } + nc.symbolData = symbolData as KiwiNodeChange['symbolData'] + } } if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) if (strokePaints.length > 0) nc.strokePaints = strokePaints diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index e3d0597a9..2b365a3ce 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -46,7 +46,10 @@ export function createDefaultNode( lineHeight: null, letterSpacing: 0, layoutMode: 'NONE', + figmaGuid: null, figmaLayout: null, + figmaSymbolOverrides: [], + figmaUniformScaleFactor: null, layoutDirection: 'AUTO', layoutWrap: 'NO_WRAP', primaryAxisAlign: 'MIN', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 9584d0b51..36a83230a 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -311,7 +311,10 @@ export interface SceneNode { verticalConstraint: ConstraintType layoutMode: LayoutMode + figmaGuid: string | null figmaLayout: FigmaLayoutMetadata | null + figmaSymbolOverrides: unknown[] + figmaUniformScaleFactor: number | null layoutDirection: LayoutDirection layoutWrap: LayoutWrap primaryAxisAlign: LayoutAlign diff --git a/tests/engine/io/subgraph.test.ts b/tests/engine/io/subgraph.test.ts index 1a44c95f6..a9f508b23 100644 --- a/tests/engine/io/subgraph.test.ts +++ b/tests/engine/io/subgraph.test.ts @@ -1,7 +1,11 @@ +import { readFileSync } from 'node:fs' + import { describe, expect, test } from 'bun:test' import { exportFigFile, extractExportGraph, parseFigFile } from '@open-pencil/core/io' import { initCodec } from '@open-pencil/core/kiwi' +import { parseFigBuffer } from '@open-pencil/core/kiwi/fig/parse/core' +import { guidToString } from '@open-pencil/core/kiwi/node-change/guid' import { SceneGraph } from '@open-pencil/core/scene-graph' describe('export subgraph extraction', () => { @@ -87,4 +91,25 @@ describe('export subgraph extraction', () => { ) expect(reparsedInstance?.childIds).toEqual([]) }) + + test('fig export preserves imported instance symbol overrides and guids', async () => { + await initCodec() + const fixture = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig')) + const graph = await parseFigFile( + fixture.buffer.slice(fixture.byteOffset, fixture.byteOffset + fixture.byteLength) as ArrayBuffer + ) + const exported = await exportFigFile(graph) + const parsed = parseFigBuffer(exported.buffer as ArrayBuffer) + const input = parsed.nodeChanges.find( + (node) => node.guid && guidToString(node.guid) === '1:3503' + ) + const lists = parsed.nodeChanges.find( + (node) => node.guid && guidToString(node.guid) === '1:3491' + ) + + expect(input?.type).toBe('INSTANCE') + expect(input?.symbolData?.symbolOverrides?.length).toBe(9) + expect(input?.symbolData?.uniformScaleFactor).toBeCloseTo(0.8908441662788391) + expect(lists?.symbolData?.symbolOverrides?.length).toBe(5) + }) }) From 1c0122b4854b5b1a1dbf87c05bcfb72d08500c9d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 20:18:18 +0300 Subject: [PATCH 08/44] fix(fig): preserve derived instance payloads - Store imported derivedSymbolData on scene nodes with blob bytes remapped for export - Re-emit derivedSymbolData and layout versions for Figma instances - Skip unsupported metadata fields that the current Kiwi encoder cannot write --- packages/core/src/kiwi/node-change/convert.ts | 40 +++++++++++++++++-- .../core/src/kiwi/node-change/export-node.ts | 33 +++++++++++---- .../core/src/scene-graph/node-defaults.ts | 2 + packages/core/src/scene-graph/types.ts | 2 + 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index fd35886d6..dd7b01e7b 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -483,7 +483,7 @@ export function nodeChangeToProps( nodeType, name: nc.name ?? nodeType, figmaGuid: nc.guid ? guidToString(nc.guid) : null, - ...extractFigmaSymbolMetadata(nc), + ...extractFigmaSymbolMetadata(nc, blobs), ...convertTransformProps(nc), opacity: nc.opacity ?? 1, visible: nc.visible ?? true, @@ -676,14 +676,46 @@ export function sortChildren( } } +interface PreservedFigmaBlob { + __openPencilFigmaBlob: Uint8Array +} + +function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown { + if (Array.isArray(value)) return value.map((item) => preserveFigmaPayloadBlobs(item, blobs)) + if (!value || typeof value !== 'object') return value + const result: Record = {} + for (const [key, child] of Object.entries(value)) { + if (key === 'commandsBlob' && typeof child === 'number') { + const blob: unknown = blobs[child] + result[key] = { + __openPencilFigmaBlob: + blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob as Record)) + } satisfies PreservedFigmaBlob + } else { + result[key] = preserveFigmaPayloadBlobs(child, blobs) + } + } + return result +} + function extractFigmaSymbolMetadata( - nc: NodeChange -): Pick { + nc: NodeChange, + blobs: Uint8Array[] +): Pick< + SceneNode, + | 'figmaSymbolOverrides' + | 'figmaDerivedSymbolData' + | 'figmaDerivedSymbolDataLayoutVersion' + | 'figmaUniformScaleFactor' +> { const sd = nc.symbolData as | { symbolOverrides?: unknown[]; uniformScaleFactor?: number } | undefined return { - figmaSymbolOverrides: structuredClone(sd?.symbolOverrides ?? []), + figmaSymbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[], + figmaDerivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[], + figmaDerivedSymbolDataLayoutVersion: + typeof nc.derivedSymbolDataLayoutVersion === 'number' ? nc.derivedSymbolDataLayoutVersion : null, figmaUniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null } } diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 2e6b91eed..9bb7737a5 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -99,15 +99,26 @@ function parseGuidOrNull(value: string) { return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null } -function sanitizeSymbolOverride(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sanitizeSymbolOverride) +function materializeFigmaPayload(value: unknown, blobs: Uint8Array[]): unknown { + if (Array.isArray(value)) return value.map((item) => materializeFigmaPayload(item, blobs)) if (!value || typeof value !== 'object') return value - const sanitized: Record = {} - for (const [key, child] of Object.entries(value)) { - if (key === 'variableConsumptionMap' || key === 'parameterConsumptionMap') continue - sanitized[key] = sanitizeSymbolOverride(child) + if ('__openPencilFigmaBlob' in value) { + const blob = (value as { __openPencilFigmaBlob?: Uint8Array | Record }) + .__openPencilFigmaBlob + const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob ?? {})) + const index = blobs.length + blobs.push(bytes) + return index } - return sanitized + + const materialized: Record = {} + for (const [key, child] of Object.entries(value)) { + if (key === 'variableConsumptionMap' || key === 'parameterConsumptionMap' || key === 'fontMetaData') { + continue + } + materialized[key] = materializeFigmaPayload(child, blobs) + } + return materialized } function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string { @@ -279,13 +290,19 @@ export function sceneNodeToKiwiWithContext( if (symbolID) { const symbolData: Record = { symbolID } if (node.figmaSymbolOverrides.length > 0) { - symbolData.symbolOverrides = sanitizeSymbolOverride(node.figmaSymbolOverrides) + symbolData.symbolOverrides = materializeFigmaPayload(node.figmaSymbolOverrides, context.blobs) } if (node.figmaUniformScaleFactor != null) { symbolData.uniformScaleFactor = node.figmaUniformScaleFactor } nc.symbolData = symbolData as KiwiNodeChange['symbolData'] } + if (node.figmaDerivedSymbolData.length > 0) { + nc.derivedSymbolData = materializeFigmaPayload(node.figmaDerivedSymbolData, context.blobs) + } + if (node.figmaDerivedSymbolDataLayoutVersion != null) { + nc.derivedSymbolDataLayoutVersion = node.figmaDerivedSymbolDataLayoutVersion + } } if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) if (strokePaints.length > 0) nc.strokePaints = strokePaints diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 2b365a3ce..69161db54 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -49,6 +49,8 @@ export function createDefaultNode( figmaGuid: null, figmaLayout: null, figmaSymbolOverrides: [], + figmaDerivedSymbolData: [], + figmaDerivedSymbolDataLayoutVersion: null, figmaUniformScaleFactor: null, layoutDirection: 'AUTO', layoutWrap: 'NO_WRAP', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 36a83230a..a164d7c5e 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -314,6 +314,8 @@ export interface SceneNode { figmaGuid: string | null figmaLayout: FigmaLayoutMetadata | null figmaSymbolOverrides: unknown[] + figmaDerivedSymbolData: unknown[] + figmaDerivedSymbolDataLayoutVersion: number | null figmaUniformScaleFactor: number | null layoutDirection: LayoutDirection layoutWrap: LayoutWrap From f4ad80d789abdf91ce2d5630b021cbae2612dc1b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 20:56:13 +0300 Subject: [PATCH 09/44] fix(fig): preserve imported geometry and prop values - Keep raw Figma size and transform values for imported nodes during export - Add current Kiwi schema fields for component prop varValue and symbol id variable values - Preserve component source geometry used by Figma to reconstruct instances --- packages/core/src/kiwi/binary/codec.ts | 1 + packages/core/src/kiwi/binary/schema.ts | 10 +++ packages/core/src/kiwi/node-change/convert.ts | 10 +++ .../core/src/kiwi/node-change/export-node.ts | 68 ++++++++++++------- .../core/src/scene-graph/node-defaults.ts | 2 + packages/core/src/scene-graph/types.ts | 2 + 6 files changed, 67 insertions(+), 26 deletions(-) diff --git a/packages/core/src/kiwi/binary/codec.ts b/packages/core/src/kiwi/binary/codec.ts index db282c185..14a28325d 100644 --- a/packages/core/src/kiwi/binary/codec.ts +++ b/packages/core/src/kiwi/binary/codec.ts @@ -227,6 +227,7 @@ export interface VariableAnyValue { floatValue?: number colorValue?: Color alias?: { guid?: GUID; assetRef?: { key: string; version?: string } } + symbolIdValue?: { guid?: GUID } } export interface VariableDataEntry { diff --git a/packages/core/src/kiwi/binary/schema.ts b/packages/core/src/kiwi/binary/schema.ts index 66f0e6476..d811dd51c 100644 --- a/packages/core/src/kiwi/binary/schema.ts +++ b/packages/core/src/kiwi/binary/schema.ts @@ -1580,6 +1580,7 @@ enum ComponentPropNodeField { message ComponentPropAssignment { GUID defID = 1; ComponentPropValue value = 2; + VariableData varValue = 3; } message ComponentPropDef { @@ -2044,6 +2045,8 @@ enum VariableDataType { STRING = 2; ALIAS = 3; COLOR = 4; + SYMBOL_ID = 7; + TEXT_DATA = 9; } enum VariableResolvedDataType { @@ -2051,12 +2054,18 @@ enum VariableResolvedDataType { FLOAT = 1; STRING = 2; COLOR = 4; + SYMBOL_ID = 6; + TEXT_DATA = 8; } message VariableID { GUID guid = 1; } +message SymbolId { + GUID guid = 1; +} + message VariableSetID { GUID guid = 1; } @@ -2067,6 +2076,7 @@ message VariableAnyValue { float floatValue = 3; VariableID alias = 4; Color colorValue = 5; + SymbolId symbolIdValue = 8; } message VariableData { diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index dd7b01e7b..609143a6a 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -483,6 +483,7 @@ export function nodeChangeToProps( nodeType, name: nc.name ?? nodeType, figmaGuid: nc.guid ? guidToString(nc.guid) : null, + ...extractFigmaRawGeometry(nc), ...extractFigmaSymbolMetadata(nc, blobs), ...convertTransformProps(nc), opacity: nc.opacity ?? 1, @@ -698,6 +699,15 @@ function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown return result } +function extractFigmaRawGeometry( + nc: NodeChange +): Pick { + return { + figmaRawSize: nc.size ? { ...nc.size } : null, + figmaRawTransform: nc.transform ? { ...nc.transform } : null + } +} + function extractFigmaSymbolMetadata( nc: NodeChange, blobs: Uint8Array[] diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 9bb7737a5..edf2802b1 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -1,6 +1,6 @@ import type { NodeChange, Paint } from '#core/kiwi/binary/codec' import type { SceneGraph, SceneNode } from '#core/scene-graph' -import type { Color, GUID, Matrix } from '#core/types' +import type { Color, GUID, Matrix, Vector } from '#core/types' import { stringToGuid } from './guid' import { @@ -148,6 +148,36 @@ function getOrCreateNodeGuid( return guid } +function applyInstancePayload( + context: SceneNodeToKiwiContext, + node: SceneNode, + nc: KiwiNodeChange, + localIdCounter: { value: number } +): void { + if (node.type !== 'INSTANCE' || !node.componentId) return + const symbolID = getOrCreateNodeGuid( + context, + resolveInstanceComponentId(context, node.componentId), + localIdCounter + ) + if (symbolID) { + const symbolData: Record = { symbolID } + if (node.figmaSymbolOverrides.length > 0) { + symbolData.symbolOverrides = materializeFigmaPayload(node.figmaSymbolOverrides, context.blobs) + } + if (node.figmaUniformScaleFactor != null) { + symbolData.uniformScaleFactor = node.figmaUniformScaleFactor + } + nc.symbolData = symbolData as KiwiNodeChange['symbolData'] + } + if (node.figmaDerivedSymbolData.length > 0) { + nc.derivedSymbolData = materializeFigmaPayload(node.figmaDerivedSymbolData, context.blobs) + } + if (node.figmaDerivedSymbolDataLayoutVersion != null) { + nc.derivedSymbolDataLayoutVersion = node.figmaDerivedSymbolDataLayoutVersion + } +} + function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void { if (node.componentKey) nc.componentKey = node.componentKey if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey @@ -185,6 +215,14 @@ function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void { if (variantPropSpecs.length > 0) nc.variantPropSpecs = variantPropSpecs } +function exportNodeSize(node: SceneNode): Vector { + return node.figmaRawSize ? { ...node.figmaRawSize } : { x: node.width, y: node.height } +} + +function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix { + return node.figmaRawTransform ? { ...node.figmaRawTransform } : context.computeExportTransform(node) +} + function applyNodeVisualProps( context: SceneNodeToKiwiContext, node: SceneNode, @@ -273,37 +311,15 @@ export function sceneNodeToKiwiWithContext( visible: node.visible, opacity: node.opacity, phase: 'CREATED', - size: { x: node.width, y: node.height }, - transform: context.computeExportTransform(node), + size: exportNodeSize(node), + transform: exportNodeTransform(context, node), strokeWeight: node.strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT, strokeAlign: node.strokes[0]?.align ?? 'INSIDE' } applyNodeVisualProps(context, node, nc) applyComponentMetadata(node, nc) - if (node.type === 'INSTANCE' && node.componentId) { - const symbolID = getOrCreateNodeGuid( - context, - resolveInstanceComponentId(context, node.componentId), - localIdCounter - ) - if (symbolID) { - const symbolData: Record = { symbolID } - if (node.figmaSymbolOverrides.length > 0) { - symbolData.symbolOverrides = materializeFigmaPayload(node.figmaSymbolOverrides, context.blobs) - } - if (node.figmaUniformScaleFactor != null) { - symbolData.uniformScaleFactor = node.figmaUniformScaleFactor - } - nc.symbolData = symbolData as KiwiNodeChange['symbolData'] - } - if (node.figmaDerivedSymbolData.length > 0) { - nc.derivedSymbolData = materializeFigmaPayload(node.figmaDerivedSymbolData, context.blobs) - } - if (node.figmaDerivedSymbolDataLayoutVersion != null) { - nc.derivedSymbolDataLayoutVersion = node.figmaDerivedSymbolDataLayoutVersion - } - } + applyInstancePayload(context, node, nc, localIdCounter) if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) if (strokePaints.length > 0) nc.strokePaints = strokePaints diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 69161db54..c033ce9d6 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -18,6 +18,8 @@ export function createDefaultNode( width: 100, height: 100, rotation: 0, + figmaRawSize: null, + figmaRawTransform: null, figmaDerivedLayout: null, fills: type === 'TEXT' diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index a164d7c5e..afff3f2ce 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -269,6 +269,8 @@ export interface SceneNode { width: number height: number rotation: number + figmaRawSize: Vector | null + figmaRawTransform: Matrix | null figmaDerivedLayout: Partial | null fills: Fill[] From 844b90484430a23e1a0a4f69904bce317a60e331 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 21:07:29 +0300 Subject: [PATCH 10/44] fix(fig): preserve instance component assignments - Keep top-level componentPropAssignments from imported Figma instances - Re-emit those assignments with preserved variable values for Figma import - Fix datepicker instances losing configured component property values --- packages/core/src/kiwi/node-change/convert.ts | 5 +++++ packages/core/src/kiwi/node-change/export-node.ts | 6 ++++++ packages/core/src/scene-graph/node-defaults.ts | 1 + packages/core/src/scene-graph/types.ts | 1 + 4 files changed, 13 insertions(+) diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index 609143a6a..a64543c6d 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -714,6 +714,7 @@ function extractFigmaSymbolMetadata( ): Pick< SceneNode, | 'figmaSymbolOverrides' + | 'figmaComponentPropAssignments' | 'figmaDerivedSymbolData' | 'figmaDerivedSymbolDataLayoutVersion' | 'figmaUniformScaleFactor' @@ -723,6 +724,10 @@ function extractFigmaSymbolMetadata( | undefined return { figmaSymbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[], + figmaComponentPropAssignments: preserveFigmaPayloadBlobs( + nc.componentPropAssignments ?? [], + blobs + ) as unknown[], figmaDerivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[], figmaDerivedSymbolDataLayoutVersion: typeof nc.derivedSymbolDataLayoutVersion === 'number' ? nc.derivedSymbolDataLayoutVersion : null, diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index edf2802b1..f75f4d3c7 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -170,6 +170,12 @@ function applyInstancePayload( } nc.symbolData = symbolData as KiwiNodeChange['symbolData'] } + if (node.figmaComponentPropAssignments.length > 0) { + nc.componentPropAssignments = materializeFigmaPayload( + node.figmaComponentPropAssignments, + context.blobs + ) + } if (node.figmaDerivedSymbolData.length > 0) { nc.derivedSymbolData = materializeFigmaPayload(node.figmaDerivedSymbolData, context.blobs) } diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index c033ce9d6..8753c646e 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -51,6 +51,7 @@ export function createDefaultNode( figmaGuid: null, figmaLayout: null, figmaSymbolOverrides: [], + figmaComponentPropAssignments: [], figmaDerivedSymbolData: [], figmaDerivedSymbolDataLayoutVersion: null, figmaUniformScaleFactor: null, diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index afff3f2ce..4f95d65ae 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -316,6 +316,7 @@ export interface SceneNode { figmaGuid: string | null figmaLayout: FigmaLayoutMetadata | null figmaSymbolOverrides: unknown[] + figmaComponentPropAssignments: unknown[] figmaDerivedSymbolData: unknown[] figmaDerivedSymbolDataLayoutVersion: number | null figmaUniformScaleFactor: number | null From 513d6306537c32c804a767b52aeac68532ab8fd4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 21:13:56 +0300 Subject: [PATCH 11/44] fix(fig): preserve variable-backed override fields - Add missing style and variable map fields to the Kiwi schema - Preserve TEXT_DATA consumption maps and style ids inside instance overrides - Keep Figma's component override payloads closer to clipboard output --- packages/core/src/kiwi/binary/schema.ts | 17 +++++++++++++++++ .../core/src/kiwi/node-change/export-node.ts | 4 +--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/core/src/kiwi/binary/schema.ts b/packages/core/src/kiwi/binary/schema.ts index d811dd51c..9e82bdb64 100644 --- a/packages/core/src/kiwi/binary/schema.ts +++ b/packages/core/src/kiwi/binary/schema.ts @@ -1369,6 +1369,11 @@ message NodeChange { GUID inheritEffectStyleID = 169; GUID inheritGridStyleID = 170; GUID inheritFillStyleIDForStroke = 185; + StyleId styleIdForFill = 332; + StyleId styleIdForStrokeFill = 333; + StyleId styleIdForText = 334; + StyleId styleIdForEffect = 335; + StyleId styleIdForGrid = 336; bool isFillStyle = 157 [deprecated]; bool isStrokeStyle = 161 [deprecated]; StyleType styleType = 163; @@ -1519,6 +1524,7 @@ message NodeChange { DerivedTextData derivedTextData = 359; EmojiImageSet emojiImageSet = 391; uint textExplicitLayoutVersion = 396; + VariableDataMap parameterConsumptionMap = 445; Paint[] textDecorationFillPaints = 411; bool textDecorationSkipInk = 412; Number textUnderlineOffset = 413; @@ -2058,6 +2064,16 @@ enum VariableResolvedDataType { TEXT_DATA = 8; } +message AssetRef { + string key = 1; + string version = 2; +} + +message StyleId { + GUID guid = 1; + AssetRef assetRef = 2; +} + message VariableID { GUID guid = 1; } @@ -2131,6 +2147,7 @@ enum VariableField { STACK_PADDING_RIGHT = 8; STACK_PADDING_BOTTOM = 9; VISIBLE = 10; + TEXT_DATA = 11; WIDTH = 12; HEIGHT = 13; RECTANGLE_TOP_LEFT_CORNER_RADIUS = 14; diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index f75f4d3c7..2b3294348 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -113,9 +113,7 @@ function materializeFigmaPayload(value: unknown, blobs: Uint8Array[]): unknown { const materialized: Record = {} for (const [key, child] of Object.entries(value)) { - if (key === 'variableConsumptionMap' || key === 'parameterConsumptionMap' || key === 'fontMetaData') { - continue - } + if (key === 'fontMetaData') continue materialized[key] = materializeFigmaPayload(child, blobs) } return materialized From 81fa52933260c23d1e1bddd082ca4d4587316111 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 21:24:58 +0300 Subject: [PATCH 12/44] fix(fig): preserve raw Figma override metadata - Preserve raw paint variables, style ids, text line metadata, and variable maps - Keep raw node paint/geometry/effect fields for imported Figma nodes - Extend the Kiwi schema for current Figma override and variable fields --- packages/core/src/kiwi/binary/schema.ts | 65 ++++++++++++++++++- packages/core/src/kiwi/node-change/convert.ts | 35 ++++++++-- .../core/src/kiwi/node-change/export-node.ts | 11 +++- .../core/src/scene-graph/node-defaults.ts | 1 + packages/core/src/scene-graph/types.ts | 1 + 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/core/src/kiwi/binary/schema.ts b/packages/core/src/kiwi/binary/schema.ts index 9e82bdb64..7095d4b21 100644 --- a/packages/core/src/kiwi/binary/schema.ts +++ b/packages/core/src/kiwi/binary/schema.ts @@ -846,7 +846,8 @@ message Paint { Video video = 18; uint originalImageWidth = 19; uint originalImageHeight = 20; - PaintVariableBinding variableBinding = 21; // Not in .fig files. Discovered via WS sniffing 2026-01 + VariableData colorVar = 21; + VariableData opacityVar = 38; } message FontMetaData { @@ -904,6 +905,7 @@ message Glyph { float fontSize = 4; uint firstCharacter = 5; float advance = 6; + float rotation = 7; } message Decoration { @@ -1705,6 +1707,12 @@ message TextLineData { int indentationLevel = 2; Directionality directionality = 3; DirectionalityIntent directionalityIntent = 4; + int downgradeStyleId = 5; + int consistencyStyleId = 6; + int listStartOffset = 7; + bool isFirstLineOfList = 8; + SourceDirectionality sourceDirectionality = 9; + int styleId = 10; } enum BulletType { @@ -1725,6 +1733,12 @@ enum Directionality { RTL = 1; } +enum SourceDirectionality { + AUTO = 0; + LTR = 1; + RTL = 2; +} + enum DirectionalityIntent { IMPLICIT = 0; EXPLICIT = 1; @@ -2053,6 +2067,7 @@ enum VariableDataType { COLOR = 4; SYMBOL_ID = 7; TEXT_DATA = 9; + PROP_REF = 13; } enum VariableResolvedDataType { @@ -2076,6 +2091,7 @@ message StyleId { message VariableID { GUID guid = 1; + AssetRef assetRef = 2; } message SymbolId { @@ -2140,6 +2156,8 @@ enum VariableScope { enum VariableField { MISSING = 0; CORNER_RADIUS = 1; + PARAGRAPH_SPACING = 2; + PARAGRAPH_INDENT = 3; STROKE_WEIGHT = 4; STACK_SPACING = 5; STACK_PADDING_LEFT = 6; @@ -2154,11 +2172,54 @@ enum VariableField { RECTANGLE_TOP_RIGHT_CORNER_RADIUS = 15; RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS = 16; RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS = 17; + BORDER_TOP_WEIGHT = 18; + BORDER_BOTTOM_WEIGHT = 19; + BORDER_LEFT_WEIGHT = 20; + BORDER_RIGHT_WEIGHT = 21; + VARIANT_PROPERTIES = 22; + STACK_COUNTER_SPACING = 23; + MIN_WIDTH = 24; + MAX_WIDTH = 25; + MIN_HEIGHT = 26; + MAX_HEIGHT = 27; + FONT_FAMILY = 28; + FONT_STYLE = 29; + FONT_VARIATIONS = 30; OPACITY = 31; FONT_SIZE = 32; LETTER_SPACING = 34; LINE_HEIGHT = 36; - STACK_COUNTER_SPACING = 23; + OVERRIDDEN_SYMBOL_ID = 37; + HYPERLINK = 38; + CMS_SERIALIZED_RICH_TEXT_DATA = 39; + SLOT_CONTENT_ID = 40; + GRID_ROW_GAP = 41; + GRID_COLUMN_GAP = 42; + X_POSITION = 43; + Y_POSITION = 44; + ROTATION = 45; + MOTION_TRANSLATION_X = 46; + MOTION_TRANSLATION_Y = 47; + MOTION_ROTATION = 48; + MOTION_SCALE_X = 49; + MOTION_SCALE_Y = 50; + MOTION_SHEAR = 51; + SCROLL_OFFSET_X = 52; + SCROLL_OFFSET_Y = 53; + PATH_TRIM_START = 54; + PATH_TRIM_END = 55; + DISSOLVE_PROGRESS = 56; + EASING_DATA = 57; + MEDIA_CURRENT_TIME = 58; + TRANSFORM_3D_PERSPECTIVE = 59; + TRANSFORM_3D_TRANSLATION_Z = 60; + TRANSFORM_3D_ROTATION_X = 61; + TRANSFORM_3D_ROTATION_Y = 62; + TRANSFORM_3D_ROTATION_Z = 63; + POLYGON_COUNT = 64; + ARC_DATA_STARTING_ANGLE = 65; + ARC_DATA_ENDING_ANGLE = 66; + ARC_DATA_INNER_RADIUS = 67; } message VariableDataMapEntry { diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index a64543c6d..ef4a33221 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -483,7 +483,7 @@ export function nodeChangeToProps( nodeType, name: nc.name ?? nodeType, figmaGuid: nc.guid ? guidToString(nc.guid) : null, - ...extractFigmaRawGeometry(nc), + ...extractFigmaRawGeometry(nc, blobs), ...extractFigmaSymbolMetadata(nc, blobs), ...convertTransformProps(nc), opacity: nc.opacity ?? 1, @@ -682,6 +682,7 @@ interface PreservedFigmaBlob { } function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown { + if (value instanceof Uint8Array) return value if (Array.isArray(value)) return value.map((item) => preserveFigmaPayloadBlobs(item, blobs)) if (!value || typeof value !== 'object') return value const result: Record = {} @@ -699,12 +700,38 @@ function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown return result } +const FIGMA_RAW_NODE_FIELD_KEYS = [ + 'styleIdForFill', + 'styleIdForStrokeFill', + 'styleIdForText', + 'styleIdForEffect', + 'styleIdForGrid', + 'variableConsumptionMap', + 'parameterConsumptionMap', + 'editInfo', + 'miterLimit', + 'strokeWeight', + 'strokeJoin', + 'fillPaints', + 'strokePaints', + 'effects', + 'fillGeometry', + 'strokeGeometry' +] + function extractFigmaRawGeometry( - nc: NodeChange -): Pick { + nc: NodeChange, + blobs: Uint8Array[] +): Pick { + const figmaRawNodeFields: Record = {} + for (const key of FIGMA_RAW_NODE_FIELD_KEYS) { + const value = (nc as Record)[key] + if (value !== undefined) figmaRawNodeFields[key] = preserveFigmaPayloadBlobs(value, blobs) + } return { figmaRawSize: nc.size ? { ...nc.size } : null, - figmaRawTransform: nc.transform ? { ...nc.transform } : null + figmaRawTransform: nc.transform ? { ...nc.transform } : null, + figmaRawNodeFields } } diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 2b3294348..38f483897 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -100,6 +100,7 @@ function parseGuidOrNull(value: string) { } function materializeFigmaPayload(value: unknown, blobs: Uint8Array[]): unknown { + if (value instanceof Uint8Array) return value if (Array.isArray(value)) return value.map((item) => materializeFigmaPayload(item, blobs)) if (!value || typeof value !== 'object') return value if ('__openPencilFigmaBlob' in value) { @@ -113,7 +114,6 @@ function materializeFigmaPayload(value: unknown, blobs: Uint8Array[]): unknown { const materialized: Record = {} for (const [key, child] of Object.entries(value)) { - if (key === 'fontMetaData') continue materialized[key] = materializeFigmaPayload(child, blobs) } return materialized @@ -146,6 +146,14 @@ function getOrCreateNodeGuid( return guid } +function applyRawFigmaNodeFields( + context: SceneNodeToKiwiContext, + node: SceneNode, + nc: KiwiNodeChange +): void { + Object.assign(nc, materializeFigmaPayload(node.figmaRawNodeFields, context.blobs)) +} + function applyInstancePayload( context: SceneNodeToKiwiContext, node: SceneNode, @@ -330,6 +338,7 @@ export function sceneNodeToKiwiWithContext( context.serializeLayoutProps(node, nc) context.serializeGeometry(node, nc, context.blobs) context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid) + applyRawFigmaNodeFields(context, node, nc) const pluginData = mergePluginData(node.pluginData) if (pluginData.length > 0) nc.pluginData = pluginData diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 8753c646e..bca734bdc 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -20,6 +20,7 @@ export function createDefaultNode( rotation: 0, figmaRawSize: null, figmaRawTransform: null, + figmaRawNodeFields: {}, figmaDerivedLayout: null, fills: type === 'TEXT' diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 4f95d65ae..19bdff387 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -271,6 +271,7 @@ export interface SceneNode { rotation: number figmaRawSize: Vector | null figmaRawTransform: Matrix | null + figmaRawNodeFields: Record figmaDerivedLayout: Partial | null fills: Fill[] From 54310308dc6f4395b3603aeea5fc7677e3f583ef Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 21:54:16 +0300 Subject: [PATCH 13/44] refactor(kiwi): split schema text from runtime - Move the Kiwi runtime under schema-runtime to match folder naming - Load the Figma schema from a .kiwi text asset with static validation - Assert Figma clipboard-derived field numbers for fragile layout/text fields --- AGENTS.md | 2 +- oxlint.json | 2 +- packages/core/src/clipboard.ts | 2 +- packages/core/src/global.d.ts | 5 ++ packages/core/src/kiwi/binary/codec.ts | 2 +- .../binary/{schema.ts => schema/fig.kiwi} | 15 ++-- packages/core/src/kiwi/binary/schema/index.ts | 8 ++ packages/core/src/kiwi/fig/parse/core.ts | 2 +- .../{kiwi-schema => schema-runtime}/bb.ts | 0 .../{kiwi-schema => schema-runtime}/binary.ts | 0 .../{kiwi-schema => schema-runtime}/index.ts | 1 + .../{kiwi-schema => schema-runtime}/js.ts | 0 .../{kiwi-schema => schema-runtime}/parser.ts | 0 .../{kiwi-schema => schema-runtime}/schema.ts | 0 .../{kiwi-schema => schema-runtime}/util.ts | 0 .../core/src/kiwi/schema-runtime/validate.ts | 86 +++++++++++++++++++ packages/core/tsconfig.json | 1 + scripts/steiger-rules.ts | 7 +- tests/engine/io/fig/export/text.test.ts | 8 +- tests/engine/kiwi/schema-runtime.test.ts | 24 ++++++ .../render/canvas/silhouette-autopsy.test.ts | 2 +- tsconfig.json | 1 + vite/raw-markdown.ts | 4 +- 23 files changed, 147 insertions(+), 25 deletions(-) rename packages/core/src/kiwi/binary/{schema.ts => schema/fig.kiwi} (99%) create mode 100644 packages/core/src/kiwi/binary/schema/index.ts rename packages/core/src/kiwi/{kiwi-schema => schema-runtime}/bb.ts (100%) rename packages/core/src/kiwi/{kiwi-schema => schema-runtime}/binary.ts (100%) rename packages/core/src/kiwi/{kiwi-schema => schema-runtime}/index.ts (68%) rename packages/core/src/kiwi/{kiwi-schema => schema-runtime}/js.ts (100%) rename packages/core/src/kiwi/{kiwi-schema => schema-runtime}/parser.ts (100%) rename packages/core/src/kiwi/{kiwi-schema => schema-runtime}/schema.ts (100%) rename packages/core/src/kiwi/{kiwi-schema => schema-runtime}/util.ts (100%) create mode 100644 packages/core/src/kiwi/schema-runtime/validate.ts create mode 100644 tests/engine/kiwi/schema-runtime.test.ts diff --git a/AGENTS.md b/AGENTS.md index 94314f781..0a1aed874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,7 +268,7 @@ OpenPencil follows a Reka UI-inspired component namespace structure: - No module-level mutable state in components — use the editor store - Prefer `tw-animate-css` for animations — don't hand-write `