From d9fbc6fb9240a82231510340a5a0e0841efe383d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 8 Mar 2026 21:44:01 +0300 Subject: [PATCH 1/3] Serialize variables, collections, and bindings to .fig files Add VARIABLE_SET node type, variable-related messages (VariableSetMode, VariableSetID, VariableDataValues, VariableScope, VariableDataMap, etc.) to the Kiwi schema. Export: emit VARIABLE_SET and VARIABLE nodeChanges on an internal-only canvas page, include variableConsumptionMap on nodes with boundVariables. Import: rewrite variable import to use variableSetModes, variableSetID, variableResolvedType, and variableDataValues (multi-mode COLOR/FLOAT/ BOOLEAN/STRING/ALIAS). Import variableConsumptionMap bindings. Closes #65 --- packages/core/src/fig-export.ts | 119 +++++++++++++++++++++- packages/core/src/kiwi-serialize.ts | 50 +++++++++- packages/core/src/kiwi/codec.ts | 8 ++ packages/core/src/kiwi/fig-import.ts | 132 +++++++++++++++++++------ packages/core/src/kiwi/kiwi-convert.ts | 5 + packages/core/src/kiwi/schema.ts | 96 ++++++++++++++++++ tests/engine/fig-roundtrip.test.ts | 78 +++++++++++++++ 7 files changed, 456 insertions(+), 32 deletions(-) diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index f61112724..2527f1a65 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -3,11 +3,12 @@ import { zipSync, deflateSync } from 'fflate' import { CANVAS_BG_COLOR, IS_TAURI } from './constants' import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi } from './kiwi-serialize' import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec' +import { stringToGuid } from './kiwi/kiwi-convert' import { renderThumbnail } from './render-image' import type { NodeChange } from './kiwi/codec' import type { SkiaRenderer } from './renderer' -import type { SceneGraph } from './scene-graph' +import type { SceneGraph, VariableValue } from './scene-graph' import type { CanvasKit } from 'canvaskit-wasm' const THUMBNAIL_1X1 = Uint8Array.from( @@ -19,6 +20,33 @@ const THUMBNAIL_1X1 = Uint8Array.from( type KiwiNodeChange = NodeChange & Record +function variableValueToKiwi( + value: VariableValue, + type: string +): { value: Record; dataType: string; resolvedDataType: string } { + if (typeof value === 'object' && value !== null && 'aliasId' in value) { + return { + value: { alias: { guid: stringToGuid(value.aliasId) } }, + dataType: 'ALIAS', + resolvedDataType: type === 'COLOR' ? 'COLOR' : type === 'BOOLEAN' ? 'BOOLEAN' : type === 'STRING' ? 'STRING' : 'FLOAT' + } + } + if (type === 'COLOR' && typeof value === 'object' && value !== null && 'r' in value) { + return { + value: { colorValue: { r: value.r, g: value.g, b: value.b, a: value.a } }, + dataType: 'COLOR', + resolvedDataType: 'COLOR' + } + } + if (type === 'BOOLEAN') { + return { value: { boolValue: !!value }, dataType: 'BOOLEAN', resolvedDataType: 'BOOLEAN' } + } + if (type === 'STRING') { + return { value: { textValue: String(value) }, dataType: 'STRING', resolvedDataType: 'STRING' } + } + return { value: { floatValue: Number(value) }, dataType: 'FLOAT', resolvedDataType: 'FLOAT' } +} + const THUMBNAIL_WIDTH = 400 const THUMBNAIL_HEIGHT = 225 @@ -53,6 +81,7 @@ export async function exportFigFile( const blobs: Uint8Array[] = [] const pages = graph.getPages(true) + const nodeIdToGuid = new Map() for (let p = 0; p < pages.length; p++) { const page = pages[p] @@ -80,7 +109,93 @@ export async function exportFigFile( const children = graph.getChildren(page.id) for (let i = 0; i < children.length; i++) { - nodeChanges.push(...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs)) + nodeChanges.push( + ...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs, nodeIdToGuid) + ) + } + } + + if (graph.variableCollections.size > 0) { + const hasInternalPage = pages.some((p) => p.internalOnly) + let internalCanvasGuid: { sessionID: number; localID: number } + + if (hasInternalPage) { + const internalPage = pages.find((p) => p.internalOnly)! + const idx = pages.indexOf(internalPage) + internalCanvasGuid = { sessionID: 0, localID: idx + 2 } + } else { + const internalLocalID = localIdCounter.value++ + internalCanvasGuid = { sessionID: 0, localID: internalLocalID } + nodeChanges.push({ + guid: internalCanvasGuid, + parentIndex: { guid: docGuid, position: fractionalPosition(pages.length) }, + type: 'CANVAS', + name: 'Internal Only Canvas', + visible: true, + opacity: 1, + phase: 'CREATED', + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + strokeWeight: 1, + strokeAlign: 'CENTER', + strokeJoin: 'MITER', + internalOnly: true + }) + } + + let collIdx = 0 + for (const [colId, col] of graph.variableCollections) { + const colGuid = stringToGuid(colId) + const colNc: KiwiNodeChange = { + guid: colGuid, + parentIndex: { guid: internalCanvasGuid, position: fractionalPosition(collIdx++) }, + type: 'VARIABLE_SET', + name: col.name, + phase: 'CREATED', + strokeAlign: 'CENTER', + strokeJoin: 'BEVEL', + variableSetModes: col.modes.map((m, i) => ({ + id: stringToGuid(m.modeId), + name: m.name, + sortPosition: fractionalPosition(i) + })) + } + nodeChanges.push(colNc) + + let varIdx = 0 + for (const varId of col.variableIds) { + const variable = graph.variables.get(varId) + if (!variable) continue + + const varGuid = stringToGuid(varId) + const resolvedType = + variable.type === 'COLOR' + ? 'COLOR' + : variable.type === 'BOOLEAN' + ? 'BOOLEAN' + : variable.type === 'STRING' + ? 'STRING' + : 'FLOAT' + + const entries = Object.entries(variable.valuesByMode).map(([modeId, value]) => ({ + modeID: stringToGuid(modeId), + variableData: variableValueToKiwi(value, variable.type) + })) + + const varNc: KiwiNodeChange = { + guid: varGuid, + parentIndex: { guid: internalCanvasGuid, position: fractionalPosition(varIdx++) }, + type: 'VARIABLE', + name: variable.name, + phase: 'CREATED', + strokeAlign: 'CENTER', + strokeJoin: 'BEVEL', + variableSetID: { guid: colGuid }, + variableResolvedType: resolvedType, + variableDataValues: { entries }, + variableScopes: ['ALL_SCOPES'] + } + nodeChanges.push(varNc) + } } } diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index a1a6ed733..75e93028a 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -4,6 +4,7 @@ import { deflateSync, inflateSync } from 'fflate' import { weightToStyle } from './fonts' import { encodeVectorNetworkBlob } from './vector' +import { stringToGuid } from './kiwi/kiwi-convert' import type { NodeChange, Paint } from './kiwi/codec' import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph' @@ -160,16 +161,40 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { } } +const BOUND_VARIABLE_FIELD_MAP: Record = { + cornerRadius: 'CORNER_RADIUS', + topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS', + topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS', + bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS', + bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS', + strokeWeight: 'STROKE_WEIGHT', + itemSpacing: 'STACK_SPACING', + paddingLeft: 'STACK_PADDING_LEFT', + paddingTop: 'STACK_PADDING_TOP', + paddingRight: 'STACK_PADDING_RIGHT', + paddingBottom: 'STACK_PADDING_BOTTOM', + counterAxisSpacing: 'STACK_COUNTER_SPACING', + visible: 'VISIBLE', + opacity: 'OPACITY', + width: 'WIDTH', + height: 'HEIGHT', + fontSize: 'FONT_SIZE', + letterSpacing: 'LETTER_SPACING', + lineHeight: 'LINE_HEIGHT' +} + export function sceneNodeToKiwi( node: SceneNode, parentGuid: { sessionID: number; localID: number }, childIndex: number, localIdCounter: { value: number }, graph: SceneGraph, - blobs: Uint8Array[] + blobs: Uint8Array[], + nodeIdToGuid?: Map ): KiwiNodeChange[] { const localID = localIdCounter.value++ const guid = { sessionID: 1, localID } + nodeIdToGuid?.set(node.id, guid) const sx = node.flipX ? -1 : 1 const cos = Math.cos((node.rotation * Math.PI) / 180) const sin = Math.sin((node.rotation * Math.PI) / 180) @@ -321,10 +346,31 @@ export function sceneNodeToKiwi( }) } + if (Object.keys(node.boundVariables).length > 0) { + const entries: Array<{ variableData: { value: { alias: { guid: { sessionID: number; localID: number } } }; dataType: string; resolvedDataType: string }; variableField: string }> = [] + for (const [field, varId] of Object.entries(node.boundVariables)) { + const kiwiField = BOUND_VARIABLE_FIELD_MAP[field] + if (!kiwiField) continue + const variable = graph.variables.get(varId) + if (!variable) continue + const varGuid = stringToGuid(varId) + const resolvedType = variable.type === 'COLOR' ? 'COLOR' : variable.type === 'BOOLEAN' ? 'BOOLEAN' : variable.type === 'STRING' ? 'STRING' : 'FLOAT' + entries.push({ + variableData: { + value: { alias: { guid: varGuid } }, + dataType: 'ALIAS', + resolvedDataType: resolvedType + }, + variableField: kiwiField + }) + } + if (entries.length > 0) nc.variableConsumptionMap = { entries } + } + const result: KiwiNodeChange[] = [nc] const children = graph.getChildren(node.id) for (let i = 0; i < children.length; i++) { - result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs)) + result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs, nodeIdToGuid)) } return result diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 6d3d241bb..647d74d30 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -289,6 +289,14 @@ export interface NodeChange { // Constraints horizontalConstraint?: string verticalConstraint?: string + // Variables + variableData?: { value?: { boolValue?: boolean; textValue?: string; floatValue?: number; colorValue?: { r: number; g: number; b: number; a: number }; alias?: { guid: GUID } }; dataType?: string; resolvedDataType?: string } + variableConsumptionMap?: { entries?: Array<{ nodeField?: number; variableData?: { value?: { alias?: { guid: GUID }; colorValue?: { r: number; g: number; b: number; a: number }; boolValue?: boolean; textValue?: string; floatValue?: number }; dataType?: string; resolvedDataType?: string }; variableField?: string }> } + variableSetModes?: Array<{ id: GUID; name: string; sortPosition?: string }> + variableSetID?: { guid: GUID } + variableResolvedType?: string + variableDataValues?: { entries?: Array<{ modeID: GUID; variableData: { value?: { boolValue?: boolean; textValue?: string; floatValue?: number; colorValue?: { r: number; g: number; b: number; a: number }; alias?: { guid: GUID } }; dataType?: string; resolvedDataType?: string } }> } + variableScopes?: string[] } export interface FigmaMessage { diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index 0bb7e34ab..9f9133cfa 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -67,7 +67,7 @@ export function importNodeChanges( if (!nc) return const { nodeType, ...props } = nodeChangeToProps(nc, blobs) - if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return + if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return const node = graph.createNode(nodeType, graphParentId, props) guidToNodeId.set(ncId, node.id) @@ -78,47 +78,82 @@ export function importNodeChanges( } function importVariables() { + const modeGuidToId = new Map() + + for (const [id, nc] of changeMap) { + if (nc.type !== 'VARIABLE_SET') continue + + const modes = (nc.variableSetModes ?? []).map( + (m: { id?: { sessionID: number; localID: number }; name?: string }) => { + const modeId = m.id ? guidToString(m.id) : 'default' + if (m.id) modeGuidToId.set(modeId, modeId) + return { modeId, name: m.name ?? 'Mode' } + } + ) + if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' }) + + graph.addCollection({ + id, + name: nc.name ?? 'Variables', + modes, + defaultModeId: modes[0].modeId, + variableIds: [] + }) + } + for (const [id, nc] of changeMap) { if (nc.type !== 'VARIABLE') continue - const varData = ( - nc as unknown as { - variableData?: { - value?: { boolValue?: boolean; textValue?: string; floatValue?: number } - dataType?: string - } - } - ).variableData - if (!varData) continue - const parentId = parentMap.get(id) ?? '' - const parentNc = changeMap.get(parentId) - const collectionName = parentNc?.name ?? 'Variables' - const collectionId = parentId + const setIdObj = nc.variableSetID as { guid?: { sessionID: number; localID: number } } | undefined + const collectionId = setIdObj?.guid ? guidToString(setIdObj.guid) : (parentMap.get(id) ?? '') if (!graph.variableCollections.has(collectionId)) { + const parentNc = changeMap.get(collectionId) graph.addCollection({ id: collectionId, - name: collectionName, + name: parentNc?.name ?? 'Variables', modes: [{ modeId: 'default', name: 'Default' }], defaultModeId: 'default', variableIds: [] }) } + const resolvedType = nc.variableResolvedType as string | undefined let type: VariableType = 'FLOAT' - let value: VariableValue = 0 - const dt = varData.dataType - const v = varData.value + if (resolvedType === 'COLOR') type = 'COLOR' + else if (resolvedType === 'BOOLEAN') type = 'BOOLEAN' + else if (resolvedType === 'STRING') type = 'STRING' - if (dt === 'BOOLEAN' || dt === '0') { - type = 'BOOLEAN' - value = v?.boolValue ?? false - } else if (dt === 'STRING' || dt === '2') { - type = 'STRING' - value = v?.textValue ?? '' - } else { - type = 'FLOAT' - value = v?.floatValue ?? 0 + const valuesByMode: Record = {} + const dataValues = nc.variableDataValues as { entries?: Array<{ modeID?: { sessionID: number; localID: number }; variableData?: { value?: Record; dataType?: string; resolvedDataType?: string } }> } | undefined + + if (dataValues?.entries) { + for (const entry of dataValues.entries) { + const modeId = entry.modeID ? guidToString(entry.modeID) : 'default' + const vd = entry.variableData + if (!vd?.value) continue + + const dt = vd.dataType ?? vd.resolvedDataType + if (dt === 'COLOR' && vd.value.colorValue) { + const c = vd.value.colorValue as { r: number; g: number; b: number; a: number } + valuesByMode[modeId] = { r: c.r, g: c.g, b: c.b, a: c.a } + } else if (dt === 'BOOLEAN') { + valuesByMode[modeId] = (vd.value.boolValue as boolean) ?? false + } else if (dt === 'STRING') { + valuesByMode[modeId] = (vd.value.textValue as string) ?? '' + } else if (dt === 'ALIAS' && vd.value.alias) { + const alias = vd.value.alias as { guid?: { sessionID: number; localID: number } } + if (alias.guid) valuesByMode[modeId] = { aliasId: guidToString(alias.guid) } + } else { + valuesByMode[modeId] = (vd.value.floatValue as number) ?? 0 + } + } + } + + if (Object.keys(valuesByMode).length === 0) { + const col = graph.variableCollections.get(collectionId) + const defaultMode = col?.defaultModeId ?? 'default' + valuesByMode[defaultMode] = type === 'BOOLEAN' ? false : type === 'STRING' ? '' : type === 'COLOR' ? { r: 0, g: 0, b: 0, a: 1 } : 0 } graph.addVariable({ @@ -126,13 +161,53 @@ export function importNodeChanges( name: nc.name ?? 'Variable', type, collectionId, - valuesByMode: { default: value }, + valuesByMode, description: '', hiddenFromPublishing: false }) } } + function importVariableBindings() { + const fieldMap: Record = { + CORNER_RADIUS: 'cornerRadius', + RECTANGLE_TOP_LEFT_CORNER_RADIUS: 'topLeftRadius', + RECTANGLE_TOP_RIGHT_CORNER_RADIUS: 'topRightRadius', + RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS: 'bottomLeftRadius', + RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS: 'bottomRightRadius', + STROKE_WEIGHT: 'strokeWeight', + STACK_SPACING: 'itemSpacing', + STACK_PADDING_LEFT: 'paddingLeft', + STACK_PADDING_TOP: 'paddingTop', + STACK_PADDING_RIGHT: 'paddingRight', + STACK_PADDING_BOTTOM: 'paddingBottom', + STACK_COUNTER_SPACING: 'counterAxisSpacing', + VISIBLE: 'visible', + OPACITY: 'opacity', + WIDTH: 'width', + HEIGHT: 'height', + FONT_SIZE: 'fontSize', + LETTER_SPACING: 'letterSpacing', + LINE_HEIGHT: 'lineHeight' + } + + for (const [ncId, nc] of changeMap) { + const consumption = nc.variableConsumptionMap as { entries?: Array<{ variableData?: { value?: { alias?: { guid?: { sessionID: number; localID: number } } } }; variableField?: string }> } | undefined + if (!consumption?.entries?.length) continue + + const nodeId = guidToNodeId.get(ncId) + if (!nodeId) continue + + for (const entry of consumption.entries) { + const alias = entry.variableData?.value?.alias + if (!alias?.guid) continue + const variableId = guidToString(alias.guid) + const field = fieldMap[entry.variableField ?? ''] + if (field) graph.bindVariable(nodeId, field, variableId) + } + } + } + // Find the document node (type=DOCUMENT or guid 0:0) let docId: string | null = null for (const [id, nc] of changeMap) { @@ -172,6 +247,7 @@ export function importNodeChanges( } importVariables() + importVariableBindings() // Remap componentId from original Figma GUIDs to imported node IDs for (const node of graph.getAllNodes()) { diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 519463920..f50a68941 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -39,6 +39,11 @@ export function guidToString(guid: GUID): string { return `${guid.sessionID}:${guid.localID}` } +export function stringToGuid(str: string): GUID { + const [session, local] = str.split(':') + return { sessionID: parseInt(session, 10), localID: parseInt(local, 10) } +} + const convertColor = normalizeColor function imageHashToString(hash: Record): string { diff --git a/packages/core/src/kiwi/schema.ts b/packages/core/src/kiwi/schema.ts index 84e807347..d1b9558c1 100644 --- a/packages/core/src/kiwi/schema.ts +++ b/packages/core/src/kiwi/schema.ts @@ -71,6 +71,7 @@ enum NodeType { SECTION_OVERLAY = 26; WASHI_TAPE = 27; VARIABLE = 28; + VARIABLE_SET = 29; } enum ShapeWithTextType { @@ -1480,6 +1481,12 @@ message NodeChange { string accessibleLabel = 304; bool propsAreBubbled = 305; VariableData variableData = 306; + VariableDataMap variableConsumptionMap = 307; + VariableSetMode[] variableSetModes = 312; + VariableSetID variableSetID = 313; + VariableResolvedDataType variableResolvedType = 314; + VariableDataValues variableDataValues = 315; + VariableScope[] variableScopes = 353; uint gridRowCount = 435; uint gridColumnCount = 436; float gridRowGap = 437; @@ -1997,17 +2004,106 @@ enum VariableDataType { BOOLEAN = 0; FLOAT = 1; STRING = 2; + ALIAS = 3; + COLOR = 4; +} + +enum VariableResolvedDataType { + BOOLEAN = 0; + FLOAT = 1; + STRING = 2; + COLOR = 4; +} + +message VariableID { + GUID guid = 1; +} + +message VariableSetID { + GUID guid = 1; } message VariableAnyValue { bool boolValue = 1; string textValue = 2; float floatValue = 3; + VariableID alias = 4; + Color colorValue = 5; } message VariableData { VariableAnyValue value = 1; VariableDataType dataType = 2; + VariableResolvedDataType resolvedDataType = 3; +} + +message VariableSetMode { + GUID id = 1; + string name = 2; + string sortPosition = 3; +} + +message VariableDataValuesEntry { + GUID modeID = 1; + VariableData variableData = 2; +} + +message VariableDataValues { + VariableDataValuesEntry[] entries = 1; +} + +enum VariableScope { + ALL_SCOPES = 0; + TEXT_CONTENT = 1; + CORNER_RADIUS = 2; + WIDTH_HEIGHT = 3; + GAP = 4; + ALL_FILLS = 5; + FRAME_FILL = 6; + SHAPE_FILL = 7; + TEXT_FILL = 8; + STROKE = 9; + STROKE_FLOAT = 10; + EFFECT_FLOAT = 11; + EFFECT_COLOR = 12; + OPACITY = 13; + FONT_FAMILY = 15; + FONT_SIZE = 16; + LINE_HEIGHT = 17; + LETTER_SPACING = 18; +} + +enum VariableField { + MISSING = 0; + CORNER_RADIUS = 1; + STROKE_WEIGHT = 4; + STACK_SPACING = 5; + STACK_PADDING_LEFT = 6; + STACK_PADDING_TOP = 7; + STACK_PADDING_RIGHT = 8; + STACK_PADDING_BOTTOM = 9; + VISIBLE = 10; + WIDTH = 12; + HEIGHT = 13; + RECTANGLE_TOP_LEFT_CORNER_RADIUS = 14; + RECTANGLE_TOP_RIGHT_CORNER_RADIUS = 15; + RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS = 16; + RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS = 17; + OPACITY = 31; + FONT_SIZE = 32; + LETTER_SPACING = 34; + LINE_HEIGHT = 36; + STACK_COUNTER_SPACING = 23; +} + +message VariableDataMapEntry { + uint nodeField = 1; + VariableData variableData = 2; + VariableField variableField = 3; +} + +message VariableDataMap { + VariableDataMapEntry[] entries = 1; } enum HTMLTag { diff --git a/tests/engine/fig-roundtrip.test.ts b/tests/engine/fig-roundtrip.test.ts index 02a11eccf..2e7c152c2 100644 --- a/tests/engine/fig-roundtrip.test.ts +++ b/tests/engine/fig-roundtrip.test.ts @@ -851,3 +851,81 @@ describe('edge cases', () => { expect(overflows).toBe(0) }) }) + +describe('variable roundtrip', () => { + test('variables and collections survive export → re-import', async () => { + await initCodec() + + const graph = new SceneGraph() + const col = graph.createCollection('Design Tokens') + graph.createVariable('color/primary', 'COLOR', col.id, { r: 0.23, g: 0.51, b: 0.96, a: 1 }) + graph.createVariable('spacing/base', 'FLOAT', col.id, 8) + graph.createVariable('visible', 'BOOLEAN', col.id, true) + graph.createVariable('label', 'STRING', col.id, 'Hello') + + const exported = await exportFigFile(graph) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + expect(reimported.variables.size).toBe(4) + expect(reimported.variableCollections.size).toBe(1) + + const reimportedCol = [...reimported.variableCollections.values()][0] + expect(reimportedCol.name).toBe('Design Tokens') + expect(reimportedCol.variableIds).toHaveLength(4) + + const vars = [...reimported.variables.values()] + const colorVar = vars.find((v) => v.name === 'color/primary')! + expect(colorVar.type).toBe('COLOR') + const colorVal = Object.values(colorVar.valuesByMode)[0] as { r: number; g: number; b: number; a: number } + expect(colorVal.r).toBeCloseTo(0.23, 1) + + const floatVar = vars.find((v) => v.name === 'spacing/base')! + expect(floatVar.type).toBe('FLOAT') + expect(Object.values(floatVar.valuesByMode)[0]).toBe(8) + + const boolVar = vars.find((v) => v.name === 'visible')! + expect(boolVar.type).toBe('BOOLEAN') + expect(Object.values(boolVar.valuesByMode)[0]).toBe(true) + + const strVar = vars.find((v) => v.name === 'label')! + expect(strVar.type).toBe('STRING') + expect(Object.values(strVar.valuesByMode)[0]).toBe('Hello') + }) + + test('variable bindings survive export → re-import', async () => { + await initCodec() + + const graph = new SceneGraph() + const col = graph.createCollection('Tokens') + const floatVar = graph.createVariable('radius', 'FLOAT', col.id, 12) + + const page = graph.getPages()[0] + const rect = graph.createNode('RECTANGLE', page.id, { + name: 'Bound Rect', + width: 100, + height: 100, + cornerRadius: 12, + }) + graph.bindVariable(rect.id, 'cornerRadius', floatVar.id) + + const exported = await exportFigFile(graph) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + const reimportedRect = [...reimported.getAllNodes()].find((n) => n.name === 'Bound Rect')! + expect(reimportedRect).toBeDefined() + expect(Object.keys(reimportedRect.boundVariables)).toContain('cornerRadius') + }) + + test('material3.fig variables survive round-trip', async () => { + const buf = readFileSync(resolve(FIXTURES, 'material3.fig')) + const original = await parseFigFile(buf.buffer as ArrayBuffer) + + const exported = await exportFigFile(original) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + expect(reimported.variables.size).toBe(original.variables.size) + expect(reimported.variableCollections.size).toBeGreaterThanOrEqual( + [...original.variableCollections.values()].filter((c) => c.variableIds.length > 0).length + ) + }) +}) From 5a3e68eadea9bf8a20c51d0c141c18daec3ebbe4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 8 Mar 2026 22:22:26 +0300 Subject: [PATCH 2/3] Extract named types, deduplicate field maps, fix internal canvas GUID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Define VariableAnyValue, VariableDataEntry, VariableConsumptionEntry, VariableDataValuesEntry in codec.ts — remove inline type monsters - Extract VARIABLE_BINDING_FIELDS / VARIABLE_BINDING_FIELDS_INVERSE to kiwi-convert.ts — single source of truth for export and import - Track internal canvas GUID during page loop instead of recalculating with incorrect idx+2 formula (broke when scene nodes existed) - Remove unused modeGuidToId map, unnecessary as-casts --- packages/core/src/fig-export.ts | 12 ++-- packages/core/src/kiwi-serialize.ts | 30 ++-------- packages/core/src/kiwi/codec.ts | 31 +++++++++- packages/core/src/kiwi/fig-import.ts | 83 +++++++++----------------- packages/core/src/kiwi/kiwi-convert.ts | 26 ++++++++ 5 files changed, 90 insertions(+), 92 deletions(-) diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index 2527f1a65..6b505c256 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -82,12 +82,15 @@ export async function exportFigFile( const blobs: Uint8Array[] = [] const pages = graph.getPages(true) const nodeIdToGuid = new Map() + let internalCanvasGuid: { sessionID: number; localID: number } | null = null for (let p = 0; p < pages.length; p++) { const page = pages[p] const canvasLocalID = localIdCounter.value++ const canvasGuid = { sessionID: 0, localID: canvasLocalID } + if (page.internalOnly) internalCanvasGuid = canvasGuid + const canvasNc: KiwiNodeChange = { guid: canvasGuid, parentIndex: { guid: docGuid, position: fractionalPosition(p) }, @@ -116,14 +119,7 @@ export async function exportFigFile( } if (graph.variableCollections.size > 0) { - const hasInternalPage = pages.some((p) => p.internalOnly) - let internalCanvasGuid: { sessionID: number; localID: number } - - if (hasInternalPage) { - const internalPage = pages.find((p) => p.internalOnly)! - const idx = pages.indexOf(internalPage) - internalCanvasGuid = { sessionID: 0, localID: idx + 2 } - } else { + if (!internalCanvasGuid) { const internalLocalID = localIdCounter.value++ internalCanvasGuid = { sessionID: 0, localID: internalLocalID } nodeChanges.push({ diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index 75e93028a..73ca0bec3 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -4,9 +4,9 @@ import { deflateSync, inflateSync } from 'fflate' import { weightToStyle } from './fonts' import { encodeVectorNetworkBlob } from './vector' -import { stringToGuid } from './kiwi/kiwi-convert' +import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert' -import type { NodeChange, Paint } from './kiwi/codec' +import type { NodeChange, Paint, VariableConsumptionEntry } from './kiwi/codec' import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph' type KiwiNodeChange = NodeChange & Record @@ -161,28 +161,6 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { } } -const BOUND_VARIABLE_FIELD_MAP: Record = { - cornerRadius: 'CORNER_RADIUS', - topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS', - topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS', - bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS', - bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS', - strokeWeight: 'STROKE_WEIGHT', - itemSpacing: 'STACK_SPACING', - paddingLeft: 'STACK_PADDING_LEFT', - paddingTop: 'STACK_PADDING_TOP', - paddingRight: 'STACK_PADDING_RIGHT', - paddingBottom: 'STACK_PADDING_BOTTOM', - counterAxisSpacing: 'STACK_COUNTER_SPACING', - visible: 'VISIBLE', - opacity: 'OPACITY', - width: 'WIDTH', - height: 'HEIGHT', - fontSize: 'FONT_SIZE', - letterSpacing: 'LETTER_SPACING', - lineHeight: 'LINE_HEIGHT' -} - export function sceneNodeToKiwi( node: SceneNode, parentGuid: { sessionID: number; localID: number }, @@ -347,9 +325,9 @@ export function sceneNodeToKiwi( } if (Object.keys(node.boundVariables).length > 0) { - const entries: Array<{ variableData: { value: { alias: { guid: { sessionID: number; localID: number } } }; dataType: string; resolvedDataType: string }; variableField: string }> = [] + const entries: VariableConsumptionEntry[] = [] for (const [field, varId] of Object.entries(node.boundVariables)) { - const kiwiField = BOUND_VARIABLE_FIELD_MAP[field] + const kiwiField = VARIABLE_BINDING_FIELDS[field] if (!kiwiField) continue const variable = graph.variables.get(varId) if (!variable) continue diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 647d74d30..55a9c8c90 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -209,6 +209,31 @@ export interface Effect { blendMode?: string } +export interface VariableAnyValue { + boolValue?: boolean + textValue?: string + floatValue?: number + colorValue?: Color + alias?: { guid: GUID } +} + +export interface VariableDataEntry { + value?: VariableAnyValue + dataType?: string + resolvedDataType?: string +} + +export interface VariableConsumptionEntry { + nodeField?: number + variableData?: VariableDataEntry + variableField?: string +} + +export interface VariableDataValuesEntry { + modeID: GUID + variableData: VariableDataEntry +} + export interface NodeChange { [key: string]: unknown guid: GUID @@ -290,12 +315,12 @@ export interface NodeChange { horizontalConstraint?: string verticalConstraint?: string // Variables - variableData?: { value?: { boolValue?: boolean; textValue?: string; floatValue?: number; colorValue?: { r: number; g: number; b: number; a: number }; alias?: { guid: GUID } }; dataType?: string; resolvedDataType?: string } - variableConsumptionMap?: { entries?: Array<{ nodeField?: number; variableData?: { value?: { alias?: { guid: GUID }; colorValue?: { r: number; g: number; b: number; a: number }; boolValue?: boolean; textValue?: string; floatValue?: number }; dataType?: string; resolvedDataType?: string }; variableField?: string }> } + variableData?: VariableDataEntry + variableConsumptionMap?: { entries?: VariableConsumptionEntry[] } variableSetModes?: Array<{ id: GUID; name: string; sortPosition?: string }> variableSetID?: { guid: GUID } variableResolvedType?: string - variableDataValues?: { entries?: Array<{ modeID: GUID; variableData: { value?: { boolValue?: boolean; textValue?: string; floatValue?: number; colorValue?: { r: number; g: number; b: number; a: number }; alias?: { guid: GUID } }; dataType?: string; resolvedDataType?: string } }> } + variableDataValues?: { entries?: VariableDataValuesEntry[] } variableScopes?: string[] } diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index 9f9133cfa..5055f4891 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -1,7 +1,12 @@ import type { VariableType, VariableValue } from '../scene-graph' import { SceneGraph } from '../scene-graph' -import { guidToString, nodeChangeToProps, sortChildren } from './kiwi-convert' +import { + guidToString, + nodeChangeToProps, + sortChildren, + VARIABLE_BINDING_FIELDS_INVERSE +} from './kiwi-convert' import { populateAndApplyOverrides } from './instance-overrides' import type { InstanceNodeChange } from './instance-overrides' @@ -78,18 +83,13 @@ export function importNodeChanges( } function importVariables() { - const modeGuidToId = new Map() - for (const [id, nc] of changeMap) { if (nc.type !== 'VARIABLE_SET') continue - const modes = (nc.variableSetModes ?? []).map( - (m: { id?: { sessionID: number; localID: number }; name?: string }) => { - const modeId = m.id ? guidToString(m.id) : 'default' - if (m.id) modeGuidToId.set(modeId, modeId) - return { modeId, name: m.name ?? 'Mode' } - } - ) + const modes = (nc.variableSetModes ?? []).map((m) => { + const modeId = m.id ? guidToString(m.id) : 'default' + return { modeId, name: m.name ?? 'Mode' } + }) if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' }) graph.addCollection({ @@ -104,8 +104,7 @@ export function importNodeChanges( for (const [id, nc] of changeMap) { if (nc.type !== 'VARIABLE') continue - const setIdObj = nc.variableSetID as { guid?: { sessionID: number; localID: number } } | undefined - const collectionId = setIdObj?.guid ? guidToString(setIdObj.guid) : (parentMap.get(id) ?? '') + const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '') if (!graph.variableCollections.has(collectionId)) { const parentNc = changeMap.get(collectionId) @@ -118,34 +117,32 @@ export function importNodeChanges( }) } - const resolvedType = nc.variableResolvedType as string | undefined let type: VariableType = 'FLOAT' + const resolvedType = nc.variableResolvedType if (resolvedType === 'COLOR') type = 'COLOR' else if (resolvedType === 'BOOLEAN') type = 'BOOLEAN' else if (resolvedType === 'STRING') type = 'STRING' const valuesByMode: Record = {} - const dataValues = nc.variableDataValues as { entries?: Array<{ modeID?: { sessionID: number; localID: number }; variableData?: { value?: Record; dataType?: string; resolvedDataType?: string } }> } | undefined - if (dataValues?.entries) { - for (const entry of dataValues.entries) { - const modeId = entry.modeID ? guidToString(entry.modeID) : 'default' + if (nc.variableDataValues?.entries) { + for (const entry of nc.variableDataValues.entries) { + const modeId = guidToString(entry.modeID) const vd = entry.variableData - if (!vd?.value) continue + if (!vd.value) continue const dt = vd.dataType ?? vd.resolvedDataType if (dt === 'COLOR' && vd.value.colorValue) { - const c = vd.value.colorValue as { r: number; g: number; b: number; a: number } + const c = vd.value.colorValue valuesByMode[modeId] = { r: c.r, g: c.g, b: c.b, a: c.a } } else if (dt === 'BOOLEAN') { - valuesByMode[modeId] = (vd.value.boolValue as boolean) ?? false + valuesByMode[modeId] = vd.value.boolValue ?? false } else if (dt === 'STRING') { - valuesByMode[modeId] = (vd.value.textValue as string) ?? '' - } else if (dt === 'ALIAS' && vd.value.alias) { - const alias = vd.value.alias as { guid?: { sessionID: number; localID: number } } - if (alias.guid) valuesByMode[modeId] = { aliasId: guidToString(alias.guid) } + valuesByMode[modeId] = vd.value.textValue ?? '' + } else if (dt === 'ALIAS' && vd.value.alias?.guid) { + valuesByMode[modeId] = { aliasId: guidToString(vd.value.alias.guid) } } else { - valuesByMode[modeId] = (vd.value.floatValue as number) ?? 0 + valuesByMode[modeId] = vd.value.floatValue ?? 0 } } } @@ -169,41 +166,17 @@ export function importNodeChanges( } function importVariableBindings() { - const fieldMap: Record = { - CORNER_RADIUS: 'cornerRadius', - RECTANGLE_TOP_LEFT_CORNER_RADIUS: 'topLeftRadius', - RECTANGLE_TOP_RIGHT_CORNER_RADIUS: 'topRightRadius', - RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS: 'bottomLeftRadius', - RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS: 'bottomRightRadius', - STROKE_WEIGHT: 'strokeWeight', - STACK_SPACING: 'itemSpacing', - STACK_PADDING_LEFT: 'paddingLeft', - STACK_PADDING_TOP: 'paddingTop', - STACK_PADDING_RIGHT: 'paddingRight', - STACK_PADDING_BOTTOM: 'paddingBottom', - STACK_COUNTER_SPACING: 'counterAxisSpacing', - VISIBLE: 'visible', - OPACITY: 'opacity', - WIDTH: 'width', - HEIGHT: 'height', - FONT_SIZE: 'fontSize', - LETTER_SPACING: 'letterSpacing', - LINE_HEIGHT: 'lineHeight' - } - for (const [ncId, nc] of changeMap) { - const consumption = nc.variableConsumptionMap as { entries?: Array<{ variableData?: { value?: { alias?: { guid?: { sessionID: number; localID: number } } } }; variableField?: string }> } | undefined - if (!consumption?.entries?.length) continue + if (!nc.variableConsumptionMap?.entries?.length) continue const nodeId = guidToNodeId.get(ncId) if (!nodeId) continue - for (const entry of consumption.entries) { - const alias = entry.variableData?.value?.alias - if (!alias?.guid) continue - const variableId = guidToString(alias.guid) - const field = fieldMap[entry.variableField ?? ''] - if (field) graph.bindVariable(nodeId, field, variableId) + for (const entry of nc.variableConsumptionMap.entries) { + const varGuid = entry.variableData?.value?.alias?.guid + if (!varGuid) continue + const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? ''] + if (field) graph.bindVariable(nodeId, field, guidToString(varGuid)) } } } diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index f50a68941..bb312c9c8 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -44,6 +44,32 @@ export function stringToGuid(str: string): GUID { return { sessionID: parseInt(session, 10), localID: parseInt(local, 10) } } +export const VARIABLE_BINDING_FIELDS: Record = { + cornerRadius: 'CORNER_RADIUS', + topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS', + topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS', + bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS', + bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS', + strokeWeight: 'STROKE_WEIGHT', + itemSpacing: 'STACK_SPACING', + paddingLeft: 'STACK_PADDING_LEFT', + paddingTop: 'STACK_PADDING_TOP', + paddingRight: 'STACK_PADDING_RIGHT', + paddingBottom: 'STACK_PADDING_BOTTOM', + counterAxisSpacing: 'STACK_COUNTER_SPACING', + visible: 'VISIBLE', + opacity: 'OPACITY', + width: 'WIDTH', + height: 'HEIGHT', + fontSize: 'FONT_SIZE', + letterSpacing: 'LETTER_SPACING', + lineHeight: 'LINE_HEIGHT' +} + +export const VARIABLE_BINDING_FIELDS_INVERSE: Record = Object.fromEntries( + Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k]) +) + const convertColor = normalizeColor function imageHashToString(hash: Record): string { From cfea96a9836213bcbb6c86a4880cdde760a2d7f3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 8 Mar 2026 22:23:16 +0300 Subject: [PATCH 3/3] Add variable serialization fix to changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 920ea8c1e..0865b3a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - CSS Grid layout mode — select a frame, click the grid icon in the auto layout toolbar to switch from flex to grid. Configure column/row tracks (fr, fixed px, auto), column and row gaps, and per-side padding. Powered by a [Yoga fork](https://github.com/open-pencil/yoga/tree/grid) with cherry-picked CSS Grid PRs from upstream - JSX and Tailwind CSS export for grid layouts — `grid grid-cols-N`, `gap-x-*`/`gap-y-*`, child `col-start-*`/`row-start-*`/`col-span-*`/`row-span-*` +- Multi-provider AI support — connect to Anthropic, OpenAI, Google AI, or any OpenAI-compatible endpoint directly, in addition to OpenRouter. Per-provider API key storage, provider settings popover, automatic migration from single OpenRouter key + +### Fixes + +- Serialize variables, collections, and bindings to `.fig` files — previously lost on save (#65) ### Improvements