openpencil/packages/fig/src/node-change/export-node.ts
xemc 783cdf9dbc fix(fig): component property definitions/references/assignments never survive a save/reload round trip
parseGuidOrNull requires the strict Figma GUID shape (sessionID:localID),
but every OpenPencil code path that creates a component property definition
uses `prop:<hex>` IDs, which never match. applyComponentMetadata silently
dropped componentPropDefs/componentPropRefs/componentPropAssignments/
variantPropSpecs whenever any entry failed that check, so any component
property created by the app (as opposed to imported straight from a real
Figma file) vanished on the very next save.

Mint a stable synthetic GUID for non-Figma-shaped property IDs instead of
dropping them, memoized per export so defs/refs/assignments/variantPropSpecs
pointing at the same property stay consistent. Also fix INSTANCE_SWAP
default/preferred/assignment values, which reference target node IDs and
had the same GUID-shape assumption, and were never being resolved back to
real node IDs on import in the first place (remapComponentIds only handled
instance.componentId, not these fields).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 08:10:11 +10:00

1005 lines
36 KiB
TypeScript

import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import { guidToString, stringToGuid } from '@open-pencil/kiwi/fig/guid'
import { DEFAULT_STROKE_MITER_LIMIT } from '@open-pencil/scene-graph'
import type {
ComponentPropertyDefinition,
ComponentPropertyReferenceField,
SceneGraph,
SceneNode
} from '@open-pencil/scene-graph'
import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../source-metadata'
/* eslint-disable max-lines */
import { bytesToHex } from './bytes'
import {
applyExportSettingsPluginData,
applyLibrarySourcePluginData,
applyTextPathBoxPluginData,
mergePluginData,
NODE_TYPE_PLUGIN_KEY,
serializePluginRelaunchData,
upsertPluginData
} from './plugin-data'
export type KiwiNodeChange = NodeChange & Record<string, unknown>
type KiwiBooleanOperation = NonNullable<NodeChange['booleanOperation']>
interface KiwiSymbolOverridePayload {
guidPath?: { guids?: GUID[] }
textData?: { characters?: string }
[key: string]: unknown
}
function toKiwiBooleanOperation(operation: SceneNode['booleanOperation']): KiwiBooleanOperation {
return operation === 'EXCLUDE' ? 'XOR' : (operation ?? 'UNION')
}
/** Resolve effect variable asset refs when the Kiwi effect schema requires GUID aliases. */
export function buildAssetRefToVarGuidMap(
graph: SceneGraph,
varIdToGuid: Map<string, GUID>
): Map<string, GUID> {
const map = new Map<string, GUID>()
for (const [varId, variable] of graph.variables) {
if (!variable.key) continue
const guid = varIdToGuid.get(varId) ?? stringToGuid(varId)
map.set(variable.key, guid)
if (variable.version) map.set(`${variable.key}@${variable.version}`, guid)
}
return map
}
interface SceneNodeToKiwiContext {
graph: SceneGraph
blobs: Uint8Array[]
blobIndexByHex?: Map<string, number>
nodeIdToGuid?: Map<string, GUID>
/** Reverse index of assigned GUID values ("sessionID:localID") for O(1)
* collision detection. Populated alongside every nodeIdToGuid.set() call. */
assignedGuidValues?: Set<string>
fontDigestMap?: Map<string, Uint8Array>
glyphBlobMap?: Map<string, number>
varIdToGuid?: Map<string, GUID>
modeIdToGuid?: Map<string, GUID>
/** Variable GUIDs used only where raw effect aliases cannot retain asset refs. */
assetRefToVarGuid?: Map<string, GUID>
/** GUIDs minted for component property IDs (e.g. "prop:abc123") that aren't
* already Figma-GUID-shaped, keyed by the original ID so refs/assignments/
* variantPropSpecs pointing at the same property reuse the same GUID. */
propertyIdToGuid?: Map<string, GUID>
componentPropertyDefinitionsById: ReadonlyMap<string, ComponentPropertyDefinition>
fractionalPosition: (index: number) => string
mapToFigmaType: (type: SceneNode['type']) => string
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
safeColor: (color: Color) => Color
computeExportTransform: (node: SceneNode) => Matrix
serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void
serializeTextProps: (
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[],
glyphBlobMap: Map<string, number> | undefined
) => void
serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void
serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void
serializeVariableBindings: (
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
varIdToGuid?: Map<string, GUID>
) => void
sceneNodeToKiwi: (
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
) => KiwiNodeChange[]
}
function applyColorVariableBinding(
context: SceneNodeToKiwiContext,
node: SceneNode,
paint: Paint,
field: string
): Paint {
const variableId = node.boundVariables[field]
if (!variableId) return paint
return {
...paint,
colorVariableBinding: {
variableID: context.varIdToGuid?.get(variableId) ?? stringToGuid(variableId)
}
}
}
function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] {
return node.strokes.map((stroke, index) =>
applyColorVariableBinding(
context,
node,
{
type: 'SOLID',
color: context.safeColor(stroke.color),
opacity: stroke.opacity,
visible: stroke.visible,
blendMode: 'NORMAL'
},
`strokes/${index}/color`
)
)
}
function componentPropertyTypeForKiwi(type: string) {
if (type === 'BOOLEAN') return 'BOOL'
return type
}
function componentPropertyValue(
type: string,
value: string,
context: SceneNodeToKiwiContext,
localIdCounter: { value: number }
) {
if (type === 'BOOLEAN') return { boolValue: value === 'true' }
if (type === 'INSTANCE_SWAP') {
const target = context.graph.getNode(value)
if (!target) return { textValue: { characters: value } }
const guid = getOrCreateNodeGuid(context, target.id, localIdCounter)
return guid ? { guidValue: guid } : { textValue: { characters: value } }
}
return { textValue: { characters: value } }
}
function parseGuidOrNull(value: string) {
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
}
function serializeVariableModes(
node: SceneNode,
variableIdToGuid?: Map<string, GUID>,
modeIdToGuid?: Map<string, GUID>
): NonNullable<KiwiNodeChange['variableModeBySetMap']> | undefined {
const entries = Object.entries(node.variableModes).flatMap(([collectionId, modeId]) => {
const collectionGuid = variableIdToGuid?.get(collectionId) ?? parseGuidOrNull(collectionId)
const modeGuid = modeIdToGuid?.get(modeId) ?? parseGuidOrNull(modeId)
if (!collectionGuid || !modeGuid) return []
return [{ variableSetID: { guid: collectionGuid }, variableModeID: modeGuid }]
})
return entries.length > 0 ? { entries } : undefined
}
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set([
'variableConsumptionMap',
'parameterConsumptionMap'
])
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
'BOOLEAN',
'FLOAT',
'STRING',
'ALIAS',
'COLOR',
'SYMBOL_ID',
'TEXT_DATA',
'PROP_REF'
])
interface FigmaPayloadVariableMap {
entries?: unknown[]
}
interface FigmaPayloadVariableMapEntry {
variableData?: { dataType?: string; value?: { propRefValue?: unknown } }
}
interface ColorVarCarrier {
colorVar?: {
value?: {
alias?: {
guid?: GUID
assetRef?: { key: string; version?: string }
}
}
}
}
function isFigmaPayloadVariableMap(value: unknown): value is FigmaPayloadVariableMap {
return !!value && typeof value === 'object' && !Array.isArray(value) && 'entries' in value
}
function isFigmaPayloadVariableMapEntry(value: unknown): value is FigmaPayloadVariableMapEntry {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isSupportedVariableMapEntry(value: unknown): boolean {
if (!isFigmaPayloadVariableMapEntry(value)) return false
const entry = value
const dataType = entry.variableData?.dataType
return (
(typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) ||
!!entry.variableData?.value?.propRefValue
)
}
function isPropRefVariableMapEntry(value: unknown): boolean {
if (!isFigmaPayloadVariableMapEntry(value)) return false
const entry = value
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
}
function materializeSafeVariableMap(
value: unknown,
blobs: Uint8Array[],
options: MaterializeFigmaPayloadOptions,
predicate: (value: unknown) => boolean
): unknown {
if (!isFigmaPayloadVariableMap(value)) return undefined
const entries = value.entries?.filter(predicate) ?? []
if (entries.length === 0) return undefined
return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs, options)) }
}
interface MaterializeFigmaPayloadOptions {
blobIndexByHex?: Map<string, number>
includePaintVariables?: boolean
includeVariableMaps?: boolean
}
function materializeFigmaBlob(
value: { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
blobs: Uint8Array[],
options: MaterializeFigmaPayloadOptions
): number {
const blob = value.__openPencilFigmaBlob
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob ?? {}))
const key = bytesToHex(bytes)
const existing = options.blobIndexByHex?.get(key)
if (existing !== undefined) return existing
const index = blobs.length
blobs.push(bytes)
options.blobIndexByHex?.set(key, index)
return index
}
function normalizeFigmaPayloadValue(key: string, value: unknown): unknown {
if (key === 'stackCounterAlignItems' && value === 'STRETCH') return 'MIN'
if (
(key === 'stackJustify' ||
key === 'stackPrimaryAlignItems' ||
key === 'stackCounterAlign' ||
key === 'stackCounterAlignItems') &&
value === 'SPACE_EVENLY'
) {
return 'SPACE_BETWEEN'
}
return value
}
function materializeFigmaPayload(
value: unknown,
blobs: Uint8Array[],
options: MaterializeFigmaPayloadOptions = {}
): unknown {
if (value instanceof Uint8Array) return value
if (Array.isArray(value))
return value.map((item) => materializeFigmaPayload(item, blobs, options))
if (!value || typeof value !== 'object') return value
if ('__openPencilFigmaBlob' in value) {
return materializeFigmaBlob(
value as { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
blobs,
options
)
}
const materialized: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
if (FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS.has(key) && !options.includePaintVariables) continue
if (FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) {
const variableMap = materializeSafeVariableMap(
child,
blobs,
options,
options.includeVariableMaps ? isSupportedVariableMapEntry : isPropRefVariableMapEntry
)
if (variableMap !== undefined) materialized[key] = variableMap
continue
}
materialized[key] = normalizeFigmaPayloadValue(
key,
materializeFigmaPayload(child, blobs, options)
)
}
return materialized
}
function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string {
const seen = new Set<string>()
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 {
const node = context.graph.getNode(nodeId)
if (!node) return undefined
const existing = context.nodeIdToGuid?.get(nodeId)
if (existing) return existing
const importedGuid = node.source.id ? parseGuidOrNull(node.source.id) : null
// When source.id maps to a GUID value that is already assigned to a
// different node (e.g. two nodes from different canvases with the same
// source.id "1:94"), fall back to the counter to avoid collisions.
if (importedGuid && context.assignedGuidValues) {
const key = `${importedGuid.sessionID}:${importedGuid.localID}`
if (context.assignedGuidValues.has(key)) {
const guid: GUID = { sessionID: 1, localID: localIdCounter.value++ }
context.nodeIdToGuid?.set(nodeId, guid)
context.assignedGuidValues.add(`${guid.sessionID}:${guid.localID}`)
return guid
}
}
const guid = importedGuid ?? { sessionID: 1, localID: localIdCounter.value++ }
context.nodeIdToGuid?.set(nodeId, guid)
context.assignedGuidValues?.add(`${guid.sessionID}:${guid.localID}`)
return guid
}
/**
* Component property IDs ("prop:abc123") never match the Figma GUID shape,
* so parseGuidOrNull always rejects them — mint a stable synthetic GUID from
* the shared node-id counter instead, memoized so every def/ref/assignment/
* variantPropSpec pointing at the same property ID round-trips consistently.
*/
function getOrCreatePropertyGuid(
context: SceneNodeToKiwiContext,
propertyId: string,
localIdCounter: { value: number }
): GUID {
const existing = context.propertyIdToGuid?.get(propertyId)
if (existing) return existing
const guid = parseGuidOrNull(propertyId) ?? { sessionID: 1, localID: localIdCounter.value++ }
context.propertyIdToGuid?.set(propertyId, guid)
return guid
}
function isDescendantOf(context: SceneNodeToKiwiContext, nodeId: string, ancestorId: string) {
let current = context.graph.getNode(nodeId)
while (current?.parentId) {
if (current.parentId === ancestorId) return true
current = context.graph.getNode(current.parentId)
}
return false
}
function serializeTextOverrides(
context: SceneNodeToKiwiContext,
instance: SceneNode,
localIdCounter: { value: number }
): KiwiSymbolOverridePayload[] {
const result: KiwiSymbolOverridePayload[] = []
for (const [key, value] of Object.entries(instance.overrides)) {
if (!key.endsWith(':text') || typeof value !== 'string') continue
const targetId = key.slice(0, -':text'.length)
const target = context.graph.getNode(targetId)
if (!target || !isDescendantOf(context, targetId, instance.id)) continue
const sourceId = target.componentId
if (!sourceId) continue
const source = context.graph.getNode(sourceId)
const overrideGuid = source?.overrideKey ? parseGuidOrNull(source.overrideKey) : null
const targetGuid = overrideGuid ?? getOrCreateNodeGuid(context, sourceId, localIdCounter)
if (!targetGuid) continue
result.push({
guidPath: { guids: [targetGuid] },
textData: { characters: value }
})
}
return result
}
function overridePathKey(payload: KiwiSymbolOverridePayload): string | null {
const guids = payload.guidPath?.guids
return guids?.length
? guids.map(({ sessionID, localID }) => `${sessionID}:${localID}`).join('/')
: null
}
function mergeTextOverrides(
symbolOverrides: KiwiSymbolOverridePayload[],
textOverrides: KiwiSymbolOverridePayload[]
): void {
for (const textOverride of textOverrides) {
const pathKey = overridePathKey(textOverride)
let existingIndex = -1
if (pathKey) {
for (let index = symbolOverrides.length - 1; index >= 0; index--) {
if (overridePathKey(symbolOverrides[index]) !== pathKey) continue
existingIndex = index
break
}
}
if (existingIndex < 0) symbolOverrides.push(textOverride)
else {
symbolOverrides[existingIndex] = {
...symbolOverrides[existingIndex],
textData: textOverride.textData
}
}
}
}
/**
* Fields that are ALWAYS set by explicit serialization and must NOT be
* overwritten by rawNodeFields (which may contain stale Figma defaults).
* rawNodeFields is a fallback for fields NOT covered by the explicit path.
*
* Additionally, applyRawFigmaNodeFields skips any key already present on `nc`,
* so conditionally-set fields (fontVariations, derivedTextData, strokeJoin,
* strokeWeight, miterLimit, etc.) are automatically protected when set.
*
* NOTE: fillGeometry, strokeGeometry, and vectorData are deliberately NOT
* listed here. When nodeForGeometryExport suppresses explicit serialization
* (because raw geometry exists), rawNodeFields must supply these fields.
*/
const RAW_FIELDS_OVERRIDE_BLOCKLIST = new Set([
// Fields that are structurally dangerous if overwritten by stale raw data:
'pageType',
'derivedSymbolData',
'derivedSymbolDataLayoutVersion',
'sourceLibraryKey',
'minSize',
'maxSize',
// Variable consumption maps: explicit serialization always sets these when
// bindings exist, and our VARIABLE_BINDING_FIELDS mapping may produce different
// kiwi field names than the original raw data for library variable references.
'variableConsumptionMap',
'parameterConsumptionMap'
])
/**
* Resize reflowed this path-text node (glyphs regenerated, strokeGeometry
* cleared). The raw strokeGeometry silhouettes are still at the pre-resize
* size, so exporting them would paint stale full-size outlines.
*/
function isReflowedStrokedPathText(node: SceneNode): boolean {
if (node.type !== 'TEXT' || node.textPathData === null) return false
if ((node.derivedTextGlyphs?.length ?? 0) === 0 || node.textPathBox === null) return false
if (node.strokeGeometry.length !== 0) return false
// Only when the node has stroke paint but its baked silhouettes were
// cleared by reflow (see resize.ts). Fill-only path text (no stroke paint,
// so strokeGeometry is always empty) is untouched and keeps its raw
// derivedTextData verbatim.
//
// This must key off live node.strokes, not rawNodeFields.strokeGeometry:
// clearResizedRawGeometry (resize.ts) deletes that raw field on every
// resize commit, so it's already gone by the time a reflowed node reaches
// export and can never be used to detect reflow here.
return node.strokes.length > 0
}
/**
* An imported path-text node that was edited after import — moving/rotating
* clears rawTransform (see clearEditedSourceMetadata), so the transform + size
* are recomputed from the node's post-expand box (exportNodeTransform /
* exportNodeSize). But the raw derivedTextData + baked silhouettes are still the
* PRE-expand (un-shifted) payload — exporting them against the shifted box
* re-triggers the import expand on reimport and drifts the node. Rebuild
* derivedTextData from the live (shifted) glyphs and re-derive silhouettes,
* exactly like the reflow path. rawTransform === null is the "edited" signal;
* pristine nodes keep rawTransform and their raw payload verbatim.
*/
function isEditedPathText(node: SceneNode): boolean {
return (
node.type === 'TEXT' &&
node.textPathData !== null &&
// "Edited" = the raw transform no longer backs the node. Editing does not
// clear source.fig.rawTransform directly; effectiveFigmaSourcePayload
// derives it from source.editedFields, so ask that, not the raw field.
effectiveFigmaSourcePayload(node).rawTransform === null &&
(node.derivedTextGlyphs?.length ?? 0) > 0
)
}
function applyRawFigmaNodeFields(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
let rawFields = effectiveFigmaRawNodeFields(node)
if (isReflowedStrokedPathText(node) || isEditedPathText(node)) {
// Strip before materializing so the stale blobs never enter the file:
// silhouettes are re-derived from glyphs by Figma/reimport, and
// derivedTextData was rebuilt from the reflowed glyphs by
// serializeTextProps (raw would clobber the new positions).
rawFields = { ...rawFields }
delete rawFields.strokeGeometry
delete rawFields.derivedTextData
}
const materialized = materializeFigmaPayload(rawFields, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}) as Partial<KiwiNodeChange>
for (const key of Object.keys(materialized) as (keyof KiwiNodeChange)[]) {
if (RAW_FIELDS_OVERRIDE_BLOCKLIST.has(String(key))) continue
// For paint arrays on imported nodes, the raw NC data preserves the
// original opacity/color.a split (e.g. opacity=0 for invisible strokes).
// The scene model may lose this distinction for instance children whose
// strokes are resolved from component overrides. Prefer the raw data.
if ((key === 'fillPaints' || key === 'strokePaints') && node.source.id) {
nc[key] = materialized[key]
continue
}
if (
key === 'effects' &&
node.source.id &&
context.assetRefToVarGuid &&
context.assetRefToVarGuid.size > 0
) {
nc[key] = convertColorVarAssetRefs(materialized[key], context.assetRefToVarGuid)
continue
}
if (key === 'derivedTextData' && node.source.id) {
nc.derivedTextData = materialized.derivedTextData
continue
}
if (key === 'textDecorationFillPaints' && node.source.id) {
nc.textDecorationFillPaints = materialized.textDecorationFillPaints
continue
}
// Skip any key already set on nc — explicit serialization takes priority
if (key in nc) continue
nc[key] = materialized[key]
}
}
/** Convert asset refs only for payloads whose Kiwi schema rejects asset-ref aliases. */
function convertColorVarAssetRefs<T>(values: T, assetRefToVarGuid: Map<string, GUID>): T {
if (!Array.isArray(values)) return values
const converted = values.map((value: ColorVarCarrier) => {
const colorVar = value.colorVar
const alias = colorVar?.value?.alias
if (!colorVar || !alias || alias.guid || !alias.assetRef?.key) return value
const assetRef = alias.assetRef
const lookupKey = assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
const guid = assetRefToVarGuid.get(lookupKey) ?? assetRefToVarGuid.get(assetRef.key)
if (!guid) return value
return {
...value,
colorVar: {
...colorVar,
value: { ...colorVar.value, alias: { guid } }
}
}
})
return converted.some((value, index) => value !== values[index]) ? (converted as T) : values
}
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<string, unknown> = { symbolID }
const symbolOverrides: KiwiSymbolOverridePayload[] = []
if (node.source.fig.symbolOverrides.length > 0) {
symbolOverrides.push(
...(materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}) as KiwiSymbolOverridePayload[])
)
}
mergeTextOverrides(symbolOverrides, serializeTextOverrides(context, node, localIdCounter))
if (symbolOverrides.length > 0) symbolData.symbolOverrides = symbolOverrides
if (node.source.fig.uniformScaleFactor != null) {
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
}
nc.symbolData = symbolData as KiwiNodeChange['symbolData']
}
if (node.source.fig.componentPropAssignments.length > 0) {
nc.componentPropAssignments = materializeFigmaPayload(
node.source.fig.componentPropAssignments,
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}
)
}
if (node.source.fig.derivedSymbolData.length > 0) {
nc.derivedSymbolData = materializeFigmaPayload(
node.source.fig.derivedSymbolData,
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}
)
}
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
}
}
function componentPropertyPreferredValues(
definition: ComponentPropertyDefinition,
context: SceneNodeToKiwiContext,
localIdCounter: { value: number }
) {
if (definition.type === 'INSTANCE_SWAP' && definition.preferredValues?.length) {
return {
instanceSwapValues: definition.preferredValues.map((nodeId) => {
const target = context.graph.getNode(nodeId)
const guid = target ? getOrCreateNodeGuid(context, target.id, localIdCounter) : undefined
return { type: 'COMPONENT', key: guid ? guidToString(guid) : nodeId }
})
}
}
if (definition.type === 'VARIANT' && definition.variantOptions?.length) {
return { stringValues: [...definition.variantOptions] }
}
return undefined
}
function componentPropertyNodeField(field: ComponentPropertyReferenceField): string {
if (field === 'TEXT') return 'TEXT_DATA'
if (field === 'INSTANCE_SWAP') return 'OVERRIDDEN_SYMBOL_ID'
return 'VISIBLE'
}
export function buildComponentPropIndex(
graph: SceneGraph
): ReadonlyMap<string, ComponentPropertyDefinition> {
const definitions = new Map<string, ComponentPropertyDefinition>()
for (const candidate of graph.getAllNodes()) {
for (const definition of candidate.componentPropertyDefinitions) {
if (!definitions.has(definition.id)) definitions.set(definition.id, definition)
}
}
return definitions
}
function shouldSerializeRawBackedField(
node: SceneNode,
rawField: string,
hasValue: boolean,
alreadySerialized = false
): boolean {
return hasValue && !(rawField in effectiveFigmaRawNodeFields(node)) && !alreadySerialized
}
function applyComponentMetadata(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange,
localIdCounter: { value: number }
): void {
if (node.componentKey) nc.componentKey = node.componentKey
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
const overrideKey = node.overrideKey ? parseGuidOrNull(node.overrideKey) : null
if (publishId) nc.publishID = publishId
if (overrideKey) nc.overrideKey = overrideKey
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
if (node.type === 'COMPONENT_SET' || node.isPublishable) nc.isPublishable = node.isPublishable
if (node.type === 'COMPONENT' || node.isSymbolPublishable) {
nc.isSymbolPublishable = node.isSymbolPublishable
}
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
const componentPropDefs = node.componentPropertyDefinitions.map((def) => ({
id: getOrCreatePropertyGuid(context, def.id, localIdCounter),
name: def.name,
type: componentPropertyTypeForKiwi(def.type),
initialValue: componentPropertyValue(def.type, def.defaultValue, context, localIdCounter),
preferredValues: componentPropertyPreferredValues(def, context, localIdCounter)
}))
if (shouldSerializeRawBackedField(node, 'componentPropDefs', componentPropDefs.length > 0)) {
nc.componentPropDefs = componentPropDefs
}
const componentPropRefs = node.componentPropertyReferences.map((ref) => ({
defID: getOrCreatePropertyGuid(context, ref.propertyId, localIdCounter),
componentPropNodeField: componentPropertyNodeField(ref.field)
}))
if (shouldSerializeRawBackedField(node, 'componentPropRefs', componentPropRefs.length > 0)) {
nc.componentPropRefs = componentPropRefs
}
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
.map(([propertyId, value]) => {
const definition = context.componentPropertyDefinitionsById.get(propertyId)
if (!definition) return null
return {
defID: getOrCreatePropertyGuid(context, propertyId, localIdCounter),
value: componentPropertyValue(definition.type, value, context, localIdCounter)
}
})
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null)
if (
shouldSerializeRawBackedField(
node,
'componentPropAssignments',
componentPropAssignments.length > 0,
Boolean(nc.componentPropAssignments)
)
) {
nc.componentPropAssignments = componentPropAssignments
}
const variantPropSpecs = node.variantPropSpecs.map((spec) => ({
propDefId: getOrCreatePropertyGuid(context, spec.propDefId, localIdCounter),
value: spec.value
}))
if (shouldSerializeRawBackedField(node, 'variantPropSpecs', variantPropSpecs.length > 0)) {
nc.variantPropSpecs = variantPropSpecs
}
}
function exportNodeSize(node: SceneNode): Vector {
// rawSize and rawTransform are a matched pair describing the ORIGINAL Figma
// box. Once the transform no longer backs the node, exportNodeTransform
// recomputes it from node dims via computeExportTransform; pairing that with
// an un-expanded rawSize disagrees about the box (for rotation, a different
// centre) and the node drifts on reimport. Use rawSize only while the
// transform still backs it.
const payload = effectiveFigmaSourcePayload(node)
return payload.rawSize && payload.rawTransform
? { ...payload.rawSize }
: { x: node.width, y: node.height }
}
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
const rawTransform = effectiveFigmaSourcePayload(node).rawTransform
return rawTransform ? { ...rawTransform } : context.computeExportTransform(node)
}
function hasRawGeometryPayload(node: SceneNode): boolean {
const rawNodeFields = effectiveFigmaRawNodeFields(node)
return 'fillGeometry' in rawNodeFields || 'strokeGeometry' in rawNodeFields
}
function hasRawVectorPayload(node: SceneNode): boolean {
return 'vectorData' in effectiveFigmaRawNodeFields(node)
}
const SUPPORTED_NORMALIZED_EFFECT_TYPES = new Set([
'DROP_SHADOW',
'INNER_SHADOW',
'LAYER_BLUR',
'BACKGROUND_BLUR',
'FOREGROUND_BLUR'
])
function hasRawUnsupportedEffects(node: SceneNode): boolean {
const effects = effectiveFigmaRawNodeFields(node).effects
return (
Array.isArray(effects) &&
effects.some(
(effect) =>
effect &&
typeof effect === 'object' &&
'type' in effect &&
!SUPPORTED_NORMALIZED_EFFECT_TYPES.has(String(effect.type))
)
)
}
function nodeForGeometryExport(node: SceneNode): SceneNode {
if (!hasRawGeometryPayload(node) && !hasRawVectorPayload(node)) return node
return {
...node,
fillGeometry: hasRawGeometryPayload(node) ? [] : node.fillGeometry,
strokeGeometry: hasRawGeometryPayload(node) ? [] : node.strokeGeometry,
vectorNetwork: hasRawVectorPayload(node) ? null : node.vectorNetwork
}
}
function applySharedStyleProps(node: SceneNode, nc: KiwiNodeChange): void {
if (node.fillStyleId) nc.styleIdForFill = { guid: stringToGuid(node.fillStyleId) }
if (node.strokeStyleId) nc.styleIdForStrokeFill = { guid: stringToGuid(node.strokeStyleId) }
if (node.textStyleId) nc.styleIdForText = { guid: stringToGuid(node.textStyleId) }
if (node.effectStyleId) nc.styleIdForEffect = { guid: stringToGuid(node.effectStyleId) }
if (node.gridStyleId) nc.styleIdForGrid = { guid: stringToGuid(node.gridStyleId) }
if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids)
}
function applyNodeVisualProps(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
if (node.independentStrokeWeights) {
nc.borderStrokeWeightsIndependent = true
nc.borderTopWeight = node.borderTopWeight
nc.borderRightWeight = node.borderRightWeight
nc.borderBottomWeight = node.borderBottomWeight
nc.borderLeftWeight = node.borderLeftWeight
}
if (node.fills.length > 0) {
nc.fillPaints = node.fills.map((fill, index) =>
applyColorVariableBinding(
context,
node,
context.fillToKiwiPaint(fill),
`fills/${index}/color`
)
)
}
context.serializeCornerRadii(node, nc)
if (node.effects.length > 0 && !hasRawUnsupportedEffects(node)) {
nc.effects = node.effects.map((effect) => ({
type: effect.type === 'LAYER_BLUR' ? 'FOREGROUND_BLUR' : effect.type,
color: context.safeColor(effect.color),
offset: effect.offset,
radius: effect.radius,
spread: effect.spread,
visible: effect.visible,
blendMode: effect.blendMode ?? 'NORMAL',
showShadowBehindNode: effect.showShadowBehindNode
}))
}
if (node.type === 'TEXT') {
context.serializeTextProps(
node,
nc,
context.graph,
context.fontDigestMap,
context.blobs,
context.glyphBlobMap
)
}
if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent
applySharedStyleProps(node, nc)
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
const rawNodeFields = effectiveFigmaRawNodeFields(node)
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in rawNodeFields) {
nc.strokeJoin = node.strokeJoin
}
if (node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT || 'miterLimit' in rawNodeFields) {
nc.miterLimit = node.strokeMiterLimit
}
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
if (node.arcData) {
nc.arcData = {
startingAngle: node.arcData.startingAngle,
endingAngle: node.arcData.endingAngle,
innerRadius: node.arcData.innerRadius
}
}
if (!node.autoRename) nc.autoRename = false
}
/**
* Import maps TEXT_PATH → TEXT. Re-emit Kiwi type 41 only while path fidelity
* remains (materialized path data + baked glyphs). After an edit that cannot
* reflow glyphs, invalidation clears the path data so export falls back to TEXT.
*/
function exportKiwiNodeType(node: SceneNode, context: SceneNodeToKiwiContext): string {
const isPathText =
node.textPathData !== null && node.type === 'TEXT' && (node.derivedTextGlyphs?.length ?? 0) > 0
return isPathText ? 'TEXT_PATH' : context.mapToFigmaType(node.type)
}
export function sceneNodeToKiwiWithContext(
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
): KiwiNodeChange[] {
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
sessionID: 1,
localID: localIdCounter.value++
}
const strokePaints = createStrokePaints(context, node)
const exportType = exportKiwiNodeType(node, context)
const nc: KiwiNodeChange = {
guid,
parentIndex: {
guid: parentGuid,
position: node.source.orderKey ?? context.fractionalPosition(childIndex)
},
type: exportType,
name: node.name,
visible: node.visible,
opacity: node.opacity,
phase: 'CREATED',
size: exportNodeSize(node),
transform: exportNodeTransform(context, node)
}
if (node.sharedStyleType) nc.styleType = node.sharedStyleType
if (node.type === 'GROUP') {
nc.resizeToFit = true
}
// Only set strokeWeight/strokeAlign when the node has strokes in the scene
// model. For imported nodes without strokes but with raw strokeWeight data
// (e.g. text nodes, instance children with scaled strokes), the raw value
// must be allowed to flow through via applyRawFigmaNodeFields.
if (node.strokes.length > 0) {
nc.strokeWeight = node.strokes[0].weight
nc.strokeAlign = node.strokes[0].align
}
if (node.locked) nc.locked = true
applyNodeVisualProps(context, node, nc)
applyComponentMetadata(context, node, nc, localIdCounter)
applyInstancePayload(context, node, nc, localIdCounter)
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
if (node.type === 'BOOLEAN_OPERATION')
nc.booleanOperation = toKiwiBooleanOperation(node.booleanOperation)
if (strokePaints.length > 0) nc.strokePaints = strokePaints
context.serializeLayoutProps(node, nc)
context.serializeGeometry(nodeForGeometryExport(node), nc, context.blobs)
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
applyRawFigmaNodeFields(context, node, nc)
const variableModeBySetMap = serializeVariableModes(
node,
context.varIdToGuid,
context.modeIdToGuid
)
if (variableModeBySetMap) nc.variableModeBySetMap = variableModeBySetMap
applyExportSettingsPluginData(node)
applyLibrarySourcePluginData(node)
applyTextPathBoxPluginData(node)
const pluginData = mergePluginData(node.pluginData)
if (pluginData.length > 0) nc.pluginData = pluginData
if (node.pluginRelaunchData.length > 0) {
nc.pluginRelaunchData = serializePluginRelaunchData(node.pluginRelaunchData)
}
const result: KiwiNodeChange[] = [nc]
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))
}
return result
}