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 type KiwiBooleanOperation = NonNullable 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 ): Map { const map = new Map() 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 nodeIdToGuid?: Map /** Reverse index of assigned GUID values ("sessionID:localID") for O(1) * collision detection. Populated alongside every nodeIdToGuid.set() call. */ assignedGuidValues?: Set fontDigestMap?: Map glyphBlobMap?: Map varIdToGuid?: Map modeIdToGuid?: Map /** Variable GUIDs used only where raw effect aliases cannot retain asset refs. */ assetRefToVarGuid?: Map /** 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 componentPropertyDefinitionsById: ReadonlyMap 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 | undefined, blobs: Uint8Array[], glyphBlobMap: Map | 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 ) => 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, modeIdToGuid?: Map ): NonNullable | 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 includePaintVariables?: boolean includeVariableMaps?: boolean } function materializeFigmaBlob( value: { __openPencilFigmaBlob?: Uint8Array | Record }, 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 }, blobs, options ) } const materialized: Record = {} 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() 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 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(values: T, assetRefToVarGuid: Map): 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 = { 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 { const definitions = new Map() 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 => 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 }