openpencil/packages/fig/src/node-change/export/node.ts
Danila Poyarkov 69dba7002f
refactor: define OpenPencil plugin data in one typed registry (#878)
* fix: validate parsed JSON at untrusted boundaries with Valibot

Clipboard HTML, library revisions from shared storage, MCP and automation
WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool
arguments were JSON.parse'd and cast to their expected types, so a
malformed payload reached the document or crashed paste. They now go
through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON
and a wrong shape as the same validation failure.

The path_set tool rejects an invalid VectorNetwork and shares its parser
with create_vector. The CLI library catalog validates its files and runs
revisions through the same size, identity and content-hash checks as the
app; reading image bytes as index-keyed records also stops them coming
back empty. Hand-rolled typeof readers for plugin data, document metadata,
caches and preferences become schemas with their behaviour preserved, and
readCacheJSON takes a schema for its payload.

open-pencil/no-unvalidated-json-parse rejects type assertions on
JSON.parse results other than `as unknown` in src and packages/*/src.

* refactor: validate parsed JSON in tests and tooling

Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures.

* refactor: define OpenPencil plugin data in one typed registry

Every plugin-data key OpenPencil writes is now a field of OPEN_PENCIL_PLUGIN_DATA in scene-graph, with the Valibot schema that reads it; readPluginData and withPluginData replace per-key constants, JSON.parse and hand-matched pluginId/key filters across fig, core, and vue. Moving OkHCL onto it fixes picking a colour rewriting the layer's other plugin data as OkHCL entries.
2026-10-04 17:32:18 +00:00

976 lines
35 KiB
TypeScript

import type { DerivedSymbolOverride } from '#fig/instance-overrides/types'
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '#fig/source-metadata'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
import type {
ComponentPropertyDefinition,
ComponentPropertyReferenceField,
SceneGraph,
SceneNode
} from '@open-pencil/scene-graph'
import {
DEFAULT_STROKE_MITER_LIMIT,
OPEN_PENCIL_PLUGIN_DATA,
withPluginData
} from '@open-pencil/scene-graph'
import type { GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
/* eslint-disable max-lines */
import { siblingOrderKeys } from '../basics'
import { bytesToHex } from '../bytes'
import { exportCanvasGuides } from '../canvas-guides'
import { snapshotInstanceGeometry } from '../instance/geometry'
import {
applyExportSettingsPluginData,
applyLibrarySourcePluginData,
applyTextPathBoxPluginData,
mergePluginData,
serializePluginRelaunchData
} from '../plugin-data'
import {
applyColorVariableBinding,
createFillPaints,
createStrokePaints,
getOrCreateNodeGuid,
instanceGuidResolver,
getOrCreatePropertyGuid,
parseGuidOrNull,
resolveInstanceComponentId,
type KiwiNodeChange,
type KiwiSymbolOverridePayload,
type SceneNodeToKiwiContext
} from './context'
import { mergeOverrides, serializeRuntimePropertyOverrides } from './override-claims'
import { nodeWithResolvedBindings } from './resolved-bindings'
import { slotContentAssignment, slotDefinitionFields } from './slots'
export type { KiwiNodeChange, SceneNodeToKiwiContext } from './context'
const siblingOrderKeyCache = new WeakMap<object, Map<string, { keyById: Map<string, string> }>>()
/**
* A layer's `parentIndex.position`: its imported key where that still orders it after the
* previous sibling, otherwise a key between its neighbours, so a round trip keeps the keys
* Figma wrote instead of renumbering every sibling.
*
* Canvas children are excluded. Shared styles and variable records are written to the
* internal canvas by other passes that continue the keys already there, and an imported
* key would collide with them, so those keep the caller's running index.
*/
function exportOrderKey(
context: SceneNodeToKiwiContext,
node: SceneNode,
childIndex: number
): string {
const parentId = node.parentId
if (!parentId) return context.fractionalPosition(childIndex)
const parent = context.graph.getNode(parentId)
if (!parent || parent.type === 'CANVAS') return context.fractionalPosition(childIndex)
let cache = siblingOrderKeyCache.get(context)
if (!cache) {
cache = new Map()
siblingOrderKeyCache.set(context, cache)
}
let entry = cache.get(parentId)
if (!entry) {
const siblings = context.graph.getChildren(parentId).filter((child) => !child.internalOnly)
const keys = siblingOrderKeys(siblings.map((sibling) => sibling.source.orderKey))
// Keyed by id: this runs once per child, so a scan per child would cost the parent O(n²).
entry = { keyById: new Map(siblings.map((sibling, index) => [sibling.id, keys[index]])) }
cache.set(parentId, entry)
}
return entry.keyById.get(node.id) ?? context.fractionalPosition(childIndex)
}
type KiwiBooleanOperation = NonNullable<NodeChange['booleanOperation']>
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
}
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)
const guid = target
? getOrCreateNodeGuid(context, target.id, localIdCounter)
: parseGuidOrNull(value)
return guid ? { guidValue: guid } : { textValue: { characters: value } }
}
return { textValue: { characters: value } }
}
function componentPropertyVariableValue(
type: string,
value: string,
context: SceneNodeToKiwiContext,
localIdCounter: { value: number }
) {
const legacy = componentPropertyValue(type, value, context, localIdCounter)
if (type === 'BOOLEAN')
return {
value: { boolValue: value === 'true' },
dataType: 'BOOLEAN',
resolvedDataType: 'BOOLEAN'
}
if (type === 'INSTANCE_SWAP' && 'guidValue' in legacy) {
return {
value: { symbolIdValue: { guid: legacy.guidValue } },
dataType: 'SYMBOL_ID',
resolvedDataType: 'SYMBOL_ID'
}
}
return { value: { textValue: value }, dataType: 'STRING', resolvedDataType: 'STRING' }
}
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
}
/**
* 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) {
const paints = materialized[key]
nc[key] = paints?.map((paint, index) =>
applyColorVariableBinding(
context,
node,
paint,
`${key === 'fillPaints' ? 'fills' : 'strokes'}/${index}/color`
)
)
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[])
)
}
mergeOverrides(
symbolOverrides,
serializeRuntimePropertyOverrides(context, node, localIdCounter)
)
if (symbolOverrides.length > 0) symbolData.symbolOverrides = symbolOverrides
if (!Number.isFinite(node.componentScale) || node.componentScale <= 0)
throw new Error('Invalid instance uniform scale')
if (node.componentScale !== 1 || node.source.fig.uniformScaleFactor != null) {
symbolData.uniformScaleFactor = node.componentScale
}
nc.symbolData = symbolData as KiwiNodeChange['symbolData']
}
if (
node.source.fig.componentPropAssignments.length > 0 &&
!node.source.editedFields.includes('componentPropertyAssignments')
) {
nc.componentPropAssignments = materializeFigmaPayload(
node.source.fig.componentPropAssignments,
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}
)
}
const retainedGeometry = materializeFigmaPayload(
node.source.fig.derivedSymbolData,
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}
) as DerivedSymbolOverride[]
nc.derivedSymbolData = snapshotInstanceGeometry(
context.graph,
node,
instanceGuidResolver(context, localIdCounter),
retainedGeometry,
(target) => ({ size: exportNodeSize(target), transform: exportNodeTransform(context, target) })
)
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
}
}
function componentPropertyPreferredValues(
definition: ComponentPropertyDefinition,
context: SceneNodeToKiwiContext
) {
if (
(definition.type === 'INSTANCE_SWAP' || definition.type === 'SLOT') &&
definition.preferredValues?.length
) {
return {
instanceSwapValues: definition.preferredValues.map((value) => {
const target = context.graph.getNode(value)
const key = target?.componentKey || target?.sourceLibraryKey || value
return { type: 'COMPONENT', key }
})
}
}
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'
if (field === 'SLOT_CONTENT') return 'SLOT_CONTENT_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
}
interface ExportedPropertyReference {
defID: GUID
componentPropNodeField: string
}
interface ExportedParameterEntry {
variableField?: string
variableData?: {
value: { propRefValue: { defId: GUID } }
dataType: string
resolvedDataType: string
}
}
function mergeParameterBindings(nc: KiwiNodeChange): void {
const parameters = nc.parameterConsumptionMap as
| { entries?: Array<{ variableField?: string }> }
| undefined
const entries = parameters?.entries ?? []
const fields = new Set(entries.map((entry) => entry.variableField))
const variables = nc.variableConsumptionMap?.entries ?? []
if (!variables.length) return
nc.parameterConsumptionMap = {
entries: [...variables.filter((entry) => !fields.has(entry.variableField)), ...entries]
}
}
function applyParameterReferences(nc: KiwiNodeChange, refs: ExportedPropertyReference[]): void {
if (!refs.length) return
const existing = nc.parameterConsumptionMap as { entries?: ExportedParameterEntry[] } | undefined
const fields = new Set(refs.map((ref) => ref.componentPropNodeField))
const entries = (existing?.entries ?? []).filter(
(entry) => !fields.has(entry.variableField ?? '')
)
const types: Record<string, string> = {
VISIBLE: 'BOOLEAN',
TEXT_DATA: 'STRING',
OVERRIDDEN_SYMBOL_ID: 'SYMBOL_ID',
SLOT_CONTENT_ID: 'SLOT_CONTENT_ID'
}
for (const ref of refs)
entries.push({
variableField: ref.componentPropNodeField,
variableData: {
value: { propRefValue: { defId: ref.defID } },
dataType: 'PROP_REF',
resolvedDataType: types[ref.componentPropNodeField]
}
})
nc.parameterConsumptionMap = { entries }
}
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) => {
const record: Record<string, unknown> = {
id: getOrCreatePropertyGuid(context, def.id, localIdCounter),
name: def.name,
type: componentPropertyTypeForKiwi(def.type),
preferredValues: componentPropertyPreferredValues(def, context)
}
if (def.type === 'SLOT') Object.assign(record, slotDefinitionFields(def))
else {
record.initialValue = componentPropertyValue(
def.type,
def.defaultValue,
context,
localIdCounter
)
record.varValue = componentPropertyVariableValue(
def.type,
def.defaultValue,
context,
localIdCounter
)
}
if (def.description) record.description = def.description
return record
})
if (shouldSerializeRawBackedField(node, 'componentPropDefs', componentPropDefs.length > 0)) {
nc.componentPropDefs = componentPropDefs
}
const parameterRefs = node.componentPropertyReferences.map((ref) => ({
defID: getOrCreatePropertyGuid(context, ref.propertyId, localIdCounter),
componentPropNodeField: componentPropertyNodeField(ref.field)
}))
// Figma binds a slot frame only through its parameter map, never a legacy property ref.
const componentPropRefs = parameterRefs.filter(
(ref) => ref.componentPropNodeField !== 'SLOT_CONTENT_ID'
)
if (shouldSerializeRawBackedField(node, 'componentPropRefs', componentPropRefs.length > 0)) {
nc.componentPropRefs = componentPropRefs
}
applyParameterReferences(nc, parameterRefs)
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
.map(([propertyId, value]) => {
const definition = context.componentPropertyDefinitionsById.get(propertyId)
if (!definition) return null
if (definition.type === 'SLOT')
return slotContentAssignment(
context,
node,
propertyId,
getOrCreatePropertyGuid(context, propertyId, localIdCounter),
localIdCounter
)
return {
defID: getOrCreatePropertyGuid(context, propertyId, localIdCounter),
value: componentPropertyValue(definition.type, value, context, localIdCounter),
varValue: componentPropertyVariableValue(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(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
const reference = (id: string) => ({ guid: context.nodeIdToGuid?.get(id) ?? stringToGuid(id) })
if (node.fillStyleId) nc.styleIdForFill = reference(node.fillStyleId)
if (node.strokeStyleId) nc.styleIdForStrokeFill = reference(node.strokeStyleId)
if (node.textStyleId) nc.styleIdForText = reference(node.textStyleId)
if (node.effectStyleId) nc.styleIdForEffect = reference(node.effectStyleId)
if (node.gridStyleId) nc.styleIdForGrid = reference(node.gridStyleId)
if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids)
if (node.guides.length > 0) nc.guides = exportCanvasGuides(node.guides)
}
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 = createFillPaints(context, node)
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(context, 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(
source: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
): KiwiNodeChange[] {
const node = nodeWithResolvedBindings(context.graph, source)
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: exportOrderKey(context, node, 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')
node.pluginData = withPluginData(node.pluginData, OPEN_PENCIL_PLUGIN_DATA.nodeType, 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)
mergeParameterBindings(nc)
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 && child.sharedStyleType === null)
for (let i = 0; i < children.length; i++) {
result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context))
}
return result
}