* 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.
976 lines
35 KiB
TypeScript
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
|
|
}
|