2026-06-06 10:58:22 +00:00
|
|
|
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
|
2026-06-06 13:37:04 +00:00
|
|
|
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
|
2026-07-17 23:41:35 +00:00
|
|
|
import { DEFAULT_STROKE_MITER_LIMIT } from '@open-pencil/scene-graph'
|
2026-07-17 18:59:49 +00:00
|
|
|
import type {
|
|
|
|
|
ComponentPropertyDefinition,
|
|
|
|
|
ComponentPropertyReferenceField,
|
|
|
|
|
SceneGraph,
|
|
|
|
|
SceneNode
|
|
|
|
|
} from '@open-pencil/scene-graph'
|
2026-06-06 19:24:31 +00:00
|
|
|
import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
|
2026-06-06 10:58:22 +00:00
|
|
|
|
2026-07-17 23:41:35 +00:00
|
|
|
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../source-metadata'
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
/* eslint-disable max-lines */
|
2026-07-17 23:41:35 +00:00
|
|
|
import { bytesToHex } from './bytes'
|
|
|
|
|
import {
|
|
|
|
|
applyExportSettingsPluginData,
|
|
|
|
|
mergePluginData,
|
|
|
|
|
NODE_TYPE_PLUGIN_KEY,
|
|
|
|
|
serializePluginRelaunchData,
|
|
|
|
|
upsertPluginData
|
|
|
|
|
} from './plugin-data'
|
2026-03-28 14:07:21 +00:00
|
|
|
|
|
|
|
|
export type KiwiNodeChange = NodeChange & Record<string, unknown>
|
|
|
|
|
|
2026-06-06 13:37:04 +00:00
|
|
|
type KiwiBooleanOperation = NonNullable<NodeChange['booleanOperation']>
|
|
|
|
|
|
|
|
|
|
function toKiwiBooleanOperation(operation: SceneNode['booleanOperation']): KiwiBooleanOperation {
|
|
|
|
|
return operation === 'EXCLUDE' ? 'XOR' : (operation ?? 'UNION')
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 22:16:37 +00:00
|
|
|
/** Resolve effect variable asset refs when the Kiwi effect schema requires GUID aliases. */
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
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)
|
2026-07-20 22:16:37 +00:00
|
|
|
if (variable.version) map.set(`${variable.key}@${variable.version}`, guid)
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
}
|
|
|
|
|
return map
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 14:07:21 +00:00
|
|
|
interface SceneNodeToKiwiContext {
|
|
|
|
|
graph: SceneGraph
|
|
|
|
|
blobs: Uint8Array[]
|
2026-05-20 22:49:04 +00:00
|
|
|
blobIndexByHex?: Map<string, number>
|
2026-03-28 14:07:21 +00:00
|
|
|
nodeIdToGuid?: Map<string, GUID>
|
fix(export): prevent GUID collisions and file corruption on .fig round-trip (#333)
* fix(export): prevent GUID collisions and file corruption on .fig round-trip
Nodes sharing the same imported source.id (component instance children,
cloned subtrees) silently overwrote each other on export because the
GUID assignment reused imported GUID values without checking for
duplicates. This caused data loss on reimport — only the last node
with a given GUID survived.
- Track all assigned GUID values in a Set for O(1) collision detection.
- Scan imported source.ids for both sessionID 0 and 1 before assigning
any new GUIDs, so the counter starts past every imported value.
- Fall back to counter-based GUIDs when source.id collides with an
already-assigned value.
Additional fixes in the same change set:
- cloneTree now deep-copies source.fig via structuredClone, preventing
mutations on a clone from corrupting the original node's kiwi payload.
- Removed decompressFigKiwiData sync wrapper (zero callers) and the
silent try/catch fallback in parseFigKiwiContainer that masked
corrupt data as raw bytes.
- buildFigKiwi uses Bun.zstdCompressSync when available, matching the
zstd decompression path already used on import.
- Fixed setSavedVersion ordering in read.ts — must run after
requestRender to capture the post-bump version, preventing spurious
dirty-state immediately after file reload.
Tests: GUID collision (2 and 3 node), clone isolation, parse failure,
text export zstd compatibility, gold-preview round-trip.
* fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type
The internal representation uses EXCLUDE for exclude boolean operations,
but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so
round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to
VALID_NODE_TYPES and increase timeout for heavy material3 fixture test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style: format export-node.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: add type guard after null assertion in guid-collision test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): detect zstd-compressed data before zlib inflate
fflate's inflateSync silently accepts zstd-compressed data and returns
garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f
fd) before attempting zlib decompression. Also revert the EXCLUDE enum
addition to the kiwi schema since the export-node.ts mapping is
sufficient.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): add length guard before zstd magic byte check
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(scene-graph): group clone regression coverage
* ci: retrigger CI checks
* test: increase timeouts for heavy .fig fixture tests on slow CI runners
Gold-preview.fig and material3.fig parsing/export tests consistently
exceed the 5s bun:test default timeout on GitHub Actions runners.
Increase to 30s for: beforeAll codec init, clipboard roundtrip,
glyph blob roundtrip, group reclassification, and text measurement.
* test: add individual test timeouts for heavy .fig fixture tests
The beforeAll timeout helped but individual test() calls also need
explicit 30s timeouts since bun:test applies the 5s default per-test.
Fixes remaining CI flakes in glyph-blob roundtrip and clipboard
roundtrip tests.
* test: increase beforeAll timeout for render cache test
The canvas/render cache test loads gold-preview.fig AND initializes
CanvasKit (Skia WASM), which is much slower than the other fixture
tests. Use 60s timeout to account for slow CI runners.
* fix(export): reserve document GUID to prevent 0:0 namespace collision
- Add docGuid (0:0) to assignedGuidValues before processing imported
node source.ids, preventing an imported node with source.id "0:0"
from reusing the document's GUID slot
- Add regression test using session-0 source.ids to verify nodes
survive roundtrip without document GUID collision
- Remove unnecessary async keyword from synchronous component
metadata test
* fix(export): guard canvas GUID reuse with assignedGuidValues check
- Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries():
if an imported page's source.id maps to a GUID already in
assignedGuidValues, generate a fresh counter-based GUID instead
- Prevents canvas-level last-write-wins when multiple pages share
the same source.id or a page uses 0:0
* fix(test): use explicit little-endian writes and fix misleading test title
- Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly
- Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify
* test(io): use file-level timeout for heavy fixture
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-06-23 15:05:23 +00:00
|
|
|
/** Reverse index of assigned GUID values ("sessionID:localID") for O(1)
|
|
|
|
|
* collision detection. Populated alongside every nodeIdToGuid.set() call. */
|
|
|
|
|
assignedGuidValues?: Set<string>
|
2026-03-28 14:07:21 +00:00
|
|
|
fontDigestMap?: Map<string, Uint8Array>
|
2026-05-20 13:59:31 +00:00
|
|
|
glyphBlobMap?: Map<string, number>
|
2026-03-28 14:07:21 +00:00
|
|
|
varIdToGuid?: Map<string, GUID>
|
2026-07-20 21:23:00 +00:00
|
|
|
modeIdToGuid?: Map<string, GUID>
|
2026-07-20 22:16:37 +00:00
|
|
|
/** Variable GUIDs used only where raw effect aliases cannot retain asset refs. */
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
assetRefToVarGuid?: Map<string, GUID>
|
2026-07-18 00:37:58 +00:00
|
|
|
componentPropertyDefinitionsById: ReadonlyMap<string, ComponentPropertyDefinition>
|
2026-03-28 14:07:21 +00:00
|
|
|
fractionalPosition: (index: number) => string
|
|
|
|
|
mapToFigmaType: (type: SceneNode['type']) => string
|
|
|
|
|
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
|
2026-05-17 21:12:46 +00:00
|
|
|
safeColor: (color: Color) => Color
|
2026-05-20 15:50:00 +00:00
|
|
|
computeExportTransform: (node: SceneNode) => Matrix
|
2026-03-28 14:07:21 +00:00
|
|
|
serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void
|
|
|
|
|
serializeTextProps: (
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
nc: KiwiNodeChange,
|
|
|
|
|
graph: SceneGraph,
|
2026-05-16 08:46:14 +00:00
|
|
|
fontDigestMap: Map<string, Uint8Array> | undefined,
|
2026-05-20 13:59:31 +00:00
|
|
|
blobs: Uint8Array[],
|
|
|
|
|
glyphBlobMap: Map<string, number> | undefined
|
2026-03-28 14:07:21 +00:00
|
|
|
) => void
|
|
|
|
|
serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void
|
|
|
|
|
serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void
|
|
|
|
|
serializeVariableBindings: (
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
nc: KiwiNodeChange,
|
|
|
|
|
graph: SceneGraph,
|
|
|
|
|
varIdToGuid?: Map<string, GUID>
|
|
|
|
|
) => void
|
|
|
|
|
sceneNodeToKiwi: (
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
parentGuid: GUID,
|
|
|
|
|
childIndex: number,
|
|
|
|
|
localIdCounter: { value: number },
|
|
|
|
|
context: SceneNodeToKiwiContext
|
|
|
|
|
) => KiwiNodeChange[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 21:23:23 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 14:07:21 +00:00
|
|
|
function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] {
|
2026-05-18 21:23:23 +00:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-03-28 14:07:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 10:09:48 +00:00
|
|
|
function componentPropertyTypeForKiwi(type: string) {
|
|
|
|
|
if (type === 'BOOLEAN') return 'BOOL'
|
|
|
|
|
return type
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:59:49 +00:00
|
|
|
function componentPropertyValue(type: string, value: string, graph: SceneGraph) {
|
|
|
|
|
if (type === 'BOOLEAN') return { boolValue: value === 'true' }
|
|
|
|
|
if (type === 'INSTANCE_SWAP') {
|
|
|
|
|
const target = graph.getNode(value)
|
|
|
|
|
const guid = parseGuidOrNull(target?.source.id ?? value)
|
|
|
|
|
return guid ? { guidValue: guid } : { textValue: { characters: value } }
|
|
|
|
|
}
|
|
|
|
|
return { textValue: { characters: value } }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 18:48:42 +00:00
|
|
|
function parseGuidOrNull(value: string) {
|
|
|
|
|
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 21:23:00 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 21:18:33 +00:00
|
|
|
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set([
|
|
|
|
|
'variableConsumptionMap',
|
|
|
|
|
'parameterConsumptionMap'
|
|
|
|
|
])
|
2026-05-20 19:57:13 +00:00
|
|
|
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
|
|
|
|
|
|
2026-05-20 23:28:26 +00:00
|
|
|
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
|
|
|
|
'BOOLEAN',
|
|
|
|
|
'FLOAT',
|
|
|
|
|
'STRING',
|
|
|
|
|
'ALIAS',
|
|
|
|
|
'COLOR',
|
|
|
|
|
'SYMBOL_ID',
|
|
|
|
|
'TEXT_DATA',
|
|
|
|
|
'PROP_REF'
|
|
|
|
|
])
|
|
|
|
|
|
2026-05-24 21:26:02 +00:00
|
|
|
interface FigmaPayloadVariableMap {
|
|
|
|
|
entries?: unknown[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FigmaPayloadVariableMapEntry {
|
|
|
|
|
variableData?: { dataType?: string; value?: { propRefValue?: unknown } }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ColorVarCarrier {
|
|
|
|
|
colorVar?: {
|
|
|
|
|
value?: {
|
|
|
|
|
alias?: {
|
|
|
|
|
guid?: GUID
|
|
|
|
|
assetRef?: { key: string; version?: string }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-22 21:18:33 +00:00
|
|
|
}
|
2026-05-24 21:26:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-20 23:28:26 +00:00
|
|
|
const dataType = entry.variableData?.dataType
|
2026-05-22 21:18:33 +00:00
|
|
|
return (
|
|
|
|
|
(typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) ||
|
|
|
|
|
!!entry.variableData?.value?.propRefValue
|
|
|
|
|
)
|
2026-05-20 23:28:26 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 21:09:12 +00:00
|
|
|
function isPropRefVariableMapEntry(value: unknown): boolean {
|
2026-05-24 21:26:02 +00:00
|
|
|
if (!isFigmaPayloadVariableMapEntry(value)) return false
|
|
|
|
|
const entry = value
|
2026-05-20 21:09:12 +00:00
|
|
|
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 23:28:26 +00:00
|
|
|
function materializeSafeVariableMap(
|
|
|
|
|
value: unknown,
|
|
|
|
|
blobs: Uint8Array[],
|
|
|
|
|
options: MaterializeFigmaPayloadOptions,
|
|
|
|
|
predicate: (value: unknown) => boolean
|
|
|
|
|
): unknown {
|
2026-05-24 21:26:02 +00:00
|
|
|
if (!isFigmaPayloadVariableMap(value)) return undefined
|
|
|
|
|
const entries = value.entries?.filter(predicate) ?? []
|
2026-05-20 21:09:12 +00:00
|
|
|
if (entries.length === 0) return undefined
|
2026-05-20 23:28:26 +00:00
|
|
|
return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs, options)) }
|
2026-05-20 21:09:12 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 22:49:04 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 23:28:26 +00:00
|
|
|
function normalizeFigmaPayloadValue(key: string, value: unknown): unknown {
|
2026-07-27 23:52:24 +00:00
|
|
|
if (key === 'stackCounterAlignItems' && value === 'STRETCH') return 'MIN'
|
2026-05-20 23:28:26 +00:00
|
|
|
if (
|
|
|
|
|
(key === 'stackJustify' ||
|
|
|
|
|
key === 'stackPrimaryAlignItems' ||
|
|
|
|
|
key === 'stackCounterAlign' ||
|
|
|
|
|
key === 'stackCounterAlignItems') &&
|
|
|
|
|
value === 'SPACE_EVENLY'
|
|
|
|
|
) {
|
|
|
|
|
return 'SPACE_BETWEEN'
|
|
|
|
|
}
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:57:13 +00:00
|
|
|
function materializeFigmaPayload(
|
|
|
|
|
value: unknown,
|
|
|
|
|
blobs: Uint8Array[],
|
2026-05-20 22:49:04 +00:00
|
|
|
options: MaterializeFigmaPayloadOptions = {}
|
2026-05-20 19:57:13 +00:00
|
|
|
): unknown {
|
2026-05-20 18:24:58 +00:00
|
|
|
if (value instanceof Uint8Array) return value
|
2026-05-22 21:18:33 +00:00
|
|
|
if (Array.isArray(value))
|
|
|
|
|
return value.map((item) => materializeFigmaPayload(item, blobs, options))
|
2026-05-20 16:31:44 +00:00
|
|
|
if (!value || typeof value !== 'object') return value
|
2026-05-20 17:18:18 +00:00
|
|
|
if ('__openPencilFigmaBlob' in value) {
|
2026-05-20 22:49:04 +00:00
|
|
|
return materializeFigmaBlob(
|
|
|
|
|
value as { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
|
|
|
|
|
blobs,
|
|
|
|
|
options
|
|
|
|
|
)
|
2026-05-20 17:18:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const materialized: Record<string, unknown> = {}
|
2026-05-20 16:31:44 +00:00
|
|
|
for (const [key, child] of Object.entries(value)) {
|
2026-05-20 21:48:37 +00:00
|
|
|
if (FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS.has(key) && !options.includePaintVariables) continue
|
2026-05-20 23:28:26 +00:00
|
|
|
if (FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) {
|
|
|
|
|
const variableMap = materializeSafeVariableMap(
|
|
|
|
|
child,
|
|
|
|
|
blobs,
|
|
|
|
|
options,
|
|
|
|
|
options.includeVariableMaps ? isSupportedVariableMapEntry : isPropRefVariableMapEntry
|
|
|
|
|
)
|
2026-05-20 21:09:12 +00:00
|
|
|
if (variableMap !== undefined) materialized[key] = variableMap
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-05-20 23:28:26 +00:00
|
|
|
materialized[key] = normalizeFigmaPayloadValue(
|
|
|
|
|
key,
|
|
|
|
|
materializeFigmaPayload(child, blobs, options)
|
|
|
|
|
)
|
2026-05-20 16:31:44 +00:00
|
|
|
}
|
2026-05-20 17:18:18 +00:00
|
|
|
return materialized
|
2026-05-20 16:31:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 15:32:01 +00:00
|
|
|
function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string {
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
let currentId = componentId
|
|
|
|
|
while (!seen.has(currentId)) {
|
|
|
|
|
seen.add(currentId)
|
|
|
|
|
const node = context.graph.getNode(currentId)
|
|
|
|
|
if (node?.type !== 'INSTANCE' || !node.componentId) return currentId
|
|
|
|
|
currentId = node.componentId
|
|
|
|
|
}
|
|
|
|
|
return componentId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getOrCreateNodeGuid(
|
|
|
|
|
context: SceneNodeToKiwiContext,
|
|
|
|
|
nodeId: string,
|
|
|
|
|
localIdCounter: { value: number }
|
|
|
|
|
): GUID | undefined {
|
fix(export): prevent GUID collisions and file corruption on .fig round-trip (#333)
* fix(export): prevent GUID collisions and file corruption on .fig round-trip
Nodes sharing the same imported source.id (component instance children,
cloned subtrees) silently overwrote each other on export because the
GUID assignment reused imported GUID values without checking for
duplicates. This caused data loss on reimport — only the last node
with a given GUID survived.
- Track all assigned GUID values in a Set for O(1) collision detection.
- Scan imported source.ids for both sessionID 0 and 1 before assigning
any new GUIDs, so the counter starts past every imported value.
- Fall back to counter-based GUIDs when source.id collides with an
already-assigned value.
Additional fixes in the same change set:
- cloneTree now deep-copies source.fig via structuredClone, preventing
mutations on a clone from corrupting the original node's kiwi payload.
- Removed decompressFigKiwiData sync wrapper (zero callers) and the
silent try/catch fallback in parseFigKiwiContainer that masked
corrupt data as raw bytes.
- buildFigKiwi uses Bun.zstdCompressSync when available, matching the
zstd decompression path already used on import.
- Fixed setSavedVersion ordering in read.ts — must run after
requestRender to capture the post-bump version, preventing spurious
dirty-state immediately after file reload.
Tests: GUID collision (2 and 3 node), clone isolation, parse failure,
text export zstd compatibility, gold-preview round-trip.
* fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type
The internal representation uses EXCLUDE for exclude boolean operations,
but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so
round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to
VALID_NODE_TYPES and increase timeout for heavy material3 fixture test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style: format export-node.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: add type guard after null assertion in guid-collision test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): detect zstd-compressed data before zlib inflate
fflate's inflateSync silently accepts zstd-compressed data and returns
garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f
fd) before attempting zlib decompression. Also revert the EXCLUDE enum
addition to the kiwi schema since the export-node.ts mapping is
sufficient.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): add length guard before zstd magic byte check
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(scene-graph): group clone regression coverage
* ci: retrigger CI checks
* test: increase timeouts for heavy .fig fixture tests on slow CI runners
Gold-preview.fig and material3.fig parsing/export tests consistently
exceed the 5s bun:test default timeout on GitHub Actions runners.
Increase to 30s for: beforeAll codec init, clipboard roundtrip,
glyph blob roundtrip, group reclassification, and text measurement.
* test: add individual test timeouts for heavy .fig fixture tests
The beforeAll timeout helped but individual test() calls also need
explicit 30s timeouts since bun:test applies the 5s default per-test.
Fixes remaining CI flakes in glyph-blob roundtrip and clipboard
roundtrip tests.
* test: increase beforeAll timeout for render cache test
The canvas/render cache test loads gold-preview.fig AND initializes
CanvasKit (Skia WASM), which is much slower than the other fixture
tests. Use 60s timeout to account for slow CI runners.
* fix(export): reserve document GUID to prevent 0:0 namespace collision
- Add docGuid (0:0) to assignedGuidValues before processing imported
node source.ids, preventing an imported node with source.id "0:0"
from reusing the document's GUID slot
- Add regression test using session-0 source.ids to verify nodes
survive roundtrip without document GUID collision
- Remove unnecessary async keyword from synchronous component
metadata test
* fix(export): guard canvas GUID reuse with assignedGuidValues check
- Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries():
if an imported page's source.id maps to a GUID already in
assignedGuidValues, generate a fresh counter-based GUID instead
- Prevents canvas-level last-write-wins when multiple pages share
the same source.id or a page uses 0:0
* fix(test): use explicit little-endian writes and fix misleading test title
- Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly
- Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify
* test(io): use file-level timeout for heavy fixture
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-06-23 15:05:23 +00:00
|
|
|
const node = context.graph.getNode(nodeId)
|
|
|
|
|
if (!node) return undefined
|
2026-05-20 15:32:01 +00:00
|
|
|
const existing = context.nodeIdToGuid?.get(nodeId)
|
|
|
|
|
if (existing) return existing
|
fix(export): prevent GUID collisions and file corruption on .fig round-trip (#333)
* fix(export): prevent GUID collisions and file corruption on .fig round-trip
Nodes sharing the same imported source.id (component instance children,
cloned subtrees) silently overwrote each other on export because the
GUID assignment reused imported GUID values without checking for
duplicates. This caused data loss on reimport — only the last node
with a given GUID survived.
- Track all assigned GUID values in a Set for O(1) collision detection.
- Scan imported source.ids for both sessionID 0 and 1 before assigning
any new GUIDs, so the counter starts past every imported value.
- Fall back to counter-based GUIDs when source.id collides with an
already-assigned value.
Additional fixes in the same change set:
- cloneTree now deep-copies source.fig via structuredClone, preventing
mutations on a clone from corrupting the original node's kiwi payload.
- Removed decompressFigKiwiData sync wrapper (zero callers) and the
silent try/catch fallback in parseFigKiwiContainer that masked
corrupt data as raw bytes.
- buildFigKiwi uses Bun.zstdCompressSync when available, matching the
zstd decompression path already used on import.
- Fixed setSavedVersion ordering in read.ts — must run after
requestRender to capture the post-bump version, preventing spurious
dirty-state immediately after file reload.
Tests: GUID collision (2 and 3 node), clone isolation, parse failure,
text export zstd compatibility, gold-preview round-trip.
* fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type
The internal representation uses EXCLUDE for exclude boolean operations,
but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so
round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to
VALID_NODE_TYPES and increase timeout for heavy material3 fixture test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style: format export-node.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: add type guard after null assertion in guid-collision test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): detect zstd-compressed data before zlib inflate
fflate's inflateSync silently accepts zstd-compressed data and returns
garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f
fd) before attempting zlib decompression. Also revert the EXCLUDE enum
addition to the kiwi schema since the export-node.ts mapping is
sufficient.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): add length guard before zstd magic byte check
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(scene-graph): group clone regression coverage
* ci: retrigger CI checks
* test: increase timeouts for heavy .fig fixture tests on slow CI runners
Gold-preview.fig and material3.fig parsing/export tests consistently
exceed the 5s bun:test default timeout on GitHub Actions runners.
Increase to 30s for: beforeAll codec init, clipboard roundtrip,
glyph blob roundtrip, group reclassification, and text measurement.
* test: add individual test timeouts for heavy .fig fixture tests
The beforeAll timeout helped but individual test() calls also need
explicit 30s timeouts since bun:test applies the 5s default per-test.
Fixes remaining CI flakes in glyph-blob roundtrip and clipboard
roundtrip tests.
* test: increase beforeAll timeout for render cache test
The canvas/render cache test loads gold-preview.fig AND initializes
CanvasKit (Skia WASM), which is much slower than the other fixture
tests. Use 60s timeout to account for slow CI runners.
* fix(export): reserve document GUID to prevent 0:0 namespace collision
- Add docGuid (0:0) to assignedGuidValues before processing imported
node source.ids, preventing an imported node with source.id "0:0"
from reusing the document's GUID slot
- Add regression test using session-0 source.ids to verify nodes
survive roundtrip without document GUID collision
- Remove unnecessary async keyword from synchronous component
metadata test
* fix(export): guard canvas GUID reuse with assignedGuidValues check
- Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries():
if an imported page's source.id maps to a GUID already in
assignedGuidValues, generate a fresh counter-based GUID instead
- Prevents canvas-level last-write-wins when multiple pages share
the same source.id or a page uses 0:0
* fix(test): use explicit little-endian writes and fix misleading test title
- Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly
- Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify
* test(io): use file-level timeout for heavy fixture
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-06-23 15:05:23 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 16:31:44 +00:00
|
|
|
const guid = importedGuid ?? { sessionID: 1, localID: localIdCounter.value++ }
|
2026-05-20 15:32:01 +00:00
|
|
|
context.nodeIdToGuid?.set(nodeId, guid)
|
fix(export): prevent GUID collisions and file corruption on .fig round-trip (#333)
* fix(export): prevent GUID collisions and file corruption on .fig round-trip
Nodes sharing the same imported source.id (component instance children,
cloned subtrees) silently overwrote each other on export because the
GUID assignment reused imported GUID values without checking for
duplicates. This caused data loss on reimport — only the last node
with a given GUID survived.
- Track all assigned GUID values in a Set for O(1) collision detection.
- Scan imported source.ids for both sessionID 0 and 1 before assigning
any new GUIDs, so the counter starts past every imported value.
- Fall back to counter-based GUIDs when source.id collides with an
already-assigned value.
Additional fixes in the same change set:
- cloneTree now deep-copies source.fig via structuredClone, preventing
mutations on a clone from corrupting the original node's kiwi payload.
- Removed decompressFigKiwiData sync wrapper (zero callers) and the
silent try/catch fallback in parseFigKiwiContainer that masked
corrupt data as raw bytes.
- buildFigKiwi uses Bun.zstdCompressSync when available, matching the
zstd decompression path already used on import.
- Fixed setSavedVersion ordering in read.ts — must run after
requestRender to capture the post-bump version, preventing spurious
dirty-state immediately after file reload.
Tests: GUID collision (2 and 3 node), clone isolation, parse failure,
text export zstd compatibility, gold-preview round-trip.
* fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type
The internal representation uses EXCLUDE for exclude boolean operations,
but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so
round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to
VALID_NODE_TYPES and increase timeout for heavy material3 fixture test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style: format export-node.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: add type guard after null assertion in guid-collision test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): detect zstd-compressed data before zlib inflate
fflate's inflateSync silently accepts zstd-compressed data and returns
garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f
fd) before attempting zlib decompression. Also revert the EXCLUDE enum
addition to the kiwi schema since the export-node.ts mapping is
sufficient.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): add length guard before zstd magic byte check
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(scene-graph): group clone regression coverage
* ci: retrigger CI checks
* test: increase timeouts for heavy .fig fixture tests on slow CI runners
Gold-preview.fig and material3.fig parsing/export tests consistently
exceed the 5s bun:test default timeout on GitHub Actions runners.
Increase to 30s for: beforeAll codec init, clipboard roundtrip,
glyph blob roundtrip, group reclassification, and text measurement.
* test: add individual test timeouts for heavy .fig fixture tests
The beforeAll timeout helped but individual test() calls also need
explicit 30s timeouts since bun:test applies the 5s default per-test.
Fixes remaining CI flakes in glyph-blob roundtrip and clipboard
roundtrip tests.
* test: increase beforeAll timeout for render cache test
The canvas/render cache test loads gold-preview.fig AND initializes
CanvasKit (Skia WASM), which is much slower than the other fixture
tests. Use 60s timeout to account for slow CI runners.
* fix(export): reserve document GUID to prevent 0:0 namespace collision
- Add docGuid (0:0) to assignedGuidValues before processing imported
node source.ids, preventing an imported node with source.id "0:0"
from reusing the document's GUID slot
- Add regression test using session-0 source.ids to verify nodes
survive roundtrip without document GUID collision
- Remove unnecessary async keyword from synchronous component
metadata test
* fix(export): guard canvas GUID reuse with assignedGuidValues check
- Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries():
if an imported page's source.id maps to a GUID already in
assignedGuidValues, generate a fresh counter-based GUID instead
- Prevents canvas-level last-write-wins when multiple pages share
the same source.id or a page uses 0:0
* fix(test): use explicit little-endian writes and fix misleading test title
- Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly
- Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify
* test(io): use file-level timeout for heavy fixture
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-06-23 15:05:23 +00:00
|
|
|
context.assignedGuidValues?.add(`${guid.sessionID}:${guid.localID}`)
|
2026-05-20 15:32:01 +00:00
|
|
|
return guid
|
|
|
|
|
}
|
|
|
|
|
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
// 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'
|
|
|
|
|
])
|
|
|
|
|
|
2026-05-20 18:24:58 +00:00
|
|
|
function applyRawFigmaNodeFields(
|
|
|
|
|
context: SceneNodeToKiwiContext,
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
nc: KiwiNodeChange
|
|
|
|
|
): void {
|
2026-07-17 23:22:01 +00:00
|
|
|
const materialized = materializeFigmaPayload(effectiveFigmaRawNodeFields(node), context.blobs, {
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
blobIndexByHex: context.blobIndexByHex,
|
|
|
|
|
includePaintVariables: true,
|
|
|
|
|
includeVariableMaps: true
|
2026-05-24 19:37:03 +00:00
|
|
|
}) as Partial<KiwiNodeChange>
|
|
|
|
|
for (const key of Object.keys(materialized) as (keyof KiwiNodeChange)[]) {
|
|
|
|
|
if (RAW_FIELDS_OVERRIDE_BLOCKLIST.has(String(key))) continue
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
// 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) {
|
2026-07-20 22:16:37 +00:00
|
|
|
nc[key] = materialized[key]
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-05-24 11:10:27 +00:00
|
|
|
if (
|
|
|
|
|
key === 'effects' &&
|
|
|
|
|
node.source.id &&
|
|
|
|
|
context.assetRefToVarGuid &&
|
|
|
|
|
context.assetRefToVarGuid.size > 0
|
|
|
|
|
) {
|
2026-07-20 22:16:37 +00:00
|
|
|
nc[key] = convertColorVarAssetRefs(materialized[key], context.assetRefToVarGuid)
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
continue
|
2026-05-24 19:27:31 +00:00
|
|
|
}
|
|
|
|
|
if (key === 'derivedTextData' && node.source.id) {
|
2026-05-28 06:57:46 +00:00
|
|
|
nc.derivedTextData = materialized.derivedTextData
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (key === 'textDecorationFillPaints' && node.source.id) {
|
|
|
|
|
nc.textDecorationFillPaints = materialized.textDecorationFillPaints
|
2026-05-24 19:27:31 +00:00
|
|
|
continue
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
}
|
|
|
|
|
// Skip any key already set on nc — explicit serialization takes priority
|
2026-05-24 19:37:03 +00:00
|
|
|
if (key in nc) continue
|
|
|
|
|
nc[key] = materialized[key]
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 22:16:37 +00:00
|
|
|
/** 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
|
2026-05-24 21:26:02 +00:00
|
|
|
const assetRef = alias.assetRef
|
2026-05-24 11:10:27 +00:00
|
|
|
const lookupKey = assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
const guid = assetRefToVarGuid.get(lookupKey) ?? assetRefToVarGuid.get(assetRef.key)
|
2026-07-20 22:16:37 +00:00
|
|
|
if (!guid) return value
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
return {
|
2026-07-20 22:16:37 +00:00
|
|
|
...value,
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
colorVar: {
|
|
|
|
|
...colorVar,
|
2026-07-20 22:16:37 +00:00
|
|
|
value: { ...colorVar.value, alias: { guid } }
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-07-20 22:16:37 +00:00
|
|
|
return converted.some((value, index) => value !== values[index]) ? (converted as T) : values
|
2026-05-20 18:24:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 17:56:13 +00:00
|
|
|
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 }
|
2026-05-20 23:07:02 +00:00
|
|
|
if (node.source.fig.symbolOverrides.length > 0) {
|
2026-05-22 21:18:33 +00:00
|
|
|
symbolData.symbolOverrides = materializeFigmaPayload(
|
|
|
|
|
node.source.fig.symbolOverrides,
|
|
|
|
|
context.blobs,
|
|
|
|
|
{
|
|
|
|
|
blobIndexByHex: context.blobIndexByHex,
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
includePaintVariables: true,
|
|
|
|
|
includeVariableMaps: true
|
2026-05-22 21:18:33 +00:00
|
|
|
}
|
|
|
|
|
)
|
2026-05-20 17:56:13 +00:00
|
|
|
}
|
2026-05-20 23:07:02 +00:00
|
|
|
if (node.source.fig.uniformScaleFactor != null) {
|
|
|
|
|
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
|
2026-05-20 17:56:13 +00:00
|
|
|
}
|
|
|
|
|
nc.symbolData = symbolData as KiwiNodeChange['symbolData']
|
|
|
|
|
}
|
2026-05-20 23:07:02 +00:00
|
|
|
if (node.source.fig.componentPropAssignments.length > 0) {
|
2026-05-20 18:07:29 +00:00
|
|
|
nc.componentPropAssignments = materializeFigmaPayload(
|
2026-05-20 23:07:02 +00:00
|
|
|
node.source.fig.componentPropAssignments,
|
2026-05-20 19:57:13 +00:00
|
|
|
context.blobs,
|
2026-05-20 22:49:04 +00:00
|
|
|
{
|
|
|
|
|
blobIndexByHex: context.blobIndexByHex,
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
includePaintVariables: true,
|
|
|
|
|
includeVariableMaps: true
|
2026-05-20 22:49:04 +00:00
|
|
|
}
|
2026-05-20 18:07:29 +00:00
|
|
|
)
|
|
|
|
|
}
|
2026-05-20 23:07:02 +00:00
|
|
|
if (node.source.fig.derivedSymbolData.length > 0) {
|
2026-05-22 21:18:33 +00:00
|
|
|
nc.derivedSymbolData = materializeFigmaPayload(
|
|
|
|
|
node.source.fig.derivedSymbolData,
|
|
|
|
|
context.blobs,
|
|
|
|
|
{
|
|
|
|
|
blobIndexByHex: context.blobIndexByHex,
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
includePaintVariables: true,
|
|
|
|
|
includeVariableMaps: true
|
2026-05-22 21:18:33 +00:00
|
|
|
}
|
|
|
|
|
)
|
2026-05-20 17:56:13 +00:00
|
|
|
}
|
2026-05-20 23:07:02 +00:00
|
|
|
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
|
|
|
|
|
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
|
2026-05-20 17:56:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:59:49 +00:00
|
|
|
function componentPropertyPreferredValues(definition: ComponentPropertyDefinition) {
|
|
|
|
|
if (definition.type === 'INSTANCE_SWAP' && definition.preferredValues?.length) {
|
|
|
|
|
return {
|
|
|
|
|
instanceSwapValues: definition.preferredValues.map((key) => ({ 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'
|
|
|
|
|
return 'VISIBLE'
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 00:37:58 +00:00
|
|
|
export function buildComponentPropIndex(
|
|
|
|
|
graph: SceneGraph
|
|
|
|
|
): ReadonlyMap<string, ComponentPropertyDefinition> {
|
|
|
|
|
const definitions = new Map<string, ComponentPropertyDefinition>()
|
2026-07-17 18:59:49 +00:00
|
|
|
for (const candidate of graph.getAllNodes()) {
|
2026-07-18 00:37:58 +00:00
|
|
|
for (const definition of candidate.componentPropertyDefinitions) {
|
|
|
|
|
if (!definitions.has(definition.id)) definitions.set(definition.id, definition)
|
|
|
|
|
}
|
2026-07-17 18:59:49 +00:00
|
|
|
}
|
2026-07-18 00:37:58 +00:00
|
|
|
return definitions
|
2026-07-17 18:59:49 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 21:44:49 +00:00
|
|
|
function shouldSerializeRawBackedField(
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
rawField: string,
|
|
|
|
|
hasValue: boolean,
|
|
|
|
|
alreadySerialized = false
|
|
|
|
|
): boolean {
|
2026-07-17 23:22:01 +00:00
|
|
|
return hasValue && !(rawField in effectiveFigmaRawNodeFields(node)) && !alreadySerialized
|
2026-07-17 21:44:49 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:59:49 +00:00
|
|
|
function applyComponentMetadata(
|
|
|
|
|
context: SceneNodeToKiwiContext,
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
nc: KiwiNodeChange
|
|
|
|
|
): void {
|
2026-05-12 18:48:42 +00:00
|
|
|
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
|
2026-05-20 21:09:12 +00:00
|
|
|
if (node.type === 'COMPONENT_SET' || node.isPublishable) nc.isPublishable = node.isPublishable
|
|
|
|
|
if (node.type === 'COMPONENT' || node.isSymbolPublishable) {
|
2026-05-20 20:11:09 +00:00
|
|
|
nc.isSymbolPublishable = node.isSymbolPublishable
|
|
|
|
|
}
|
2026-05-12 18:48:42 +00:00
|
|
|
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
|
|
|
|
|
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
|
|
|
|
|
const componentPropDefs = node.componentPropertyDefinitions
|
|
|
|
|
.map((def) => {
|
|
|
|
|
const id = parseGuidOrNull(def.id)
|
|
|
|
|
return id
|
|
|
|
|
? {
|
|
|
|
|
id,
|
|
|
|
|
name: def.name,
|
2026-05-16 10:09:48 +00:00
|
|
|
type: componentPropertyTypeForKiwi(def.type),
|
2026-07-17 18:59:49 +00:00
|
|
|
initialValue: componentPropertyValue(def.type, def.defaultValue, context.graph),
|
|
|
|
|
preferredValues: componentPropertyPreferredValues(def)
|
2026-05-12 18:48:42 +00:00
|
|
|
}
|
|
|
|
|
: null
|
|
|
|
|
})
|
|
|
|
|
.filter((def): def is NonNullable<typeof def> => def !== null)
|
2026-07-17 21:44:49 +00:00
|
|
|
if (shouldSerializeRawBackedField(node, 'componentPropDefs', componentPropDefs.length > 0)) {
|
|
|
|
|
nc.componentPropDefs = componentPropDefs
|
|
|
|
|
}
|
2026-05-12 18:48:42 +00:00
|
|
|
|
2026-07-17 18:59:49 +00:00
|
|
|
const componentPropRefs = node.componentPropertyReferences
|
|
|
|
|
.map((ref) => {
|
|
|
|
|
const defID = parseGuidOrNull(ref.propertyId)
|
|
|
|
|
if (!defID) return null
|
|
|
|
|
return { defID, componentPropNodeField: componentPropertyNodeField(ref.field) }
|
|
|
|
|
})
|
|
|
|
|
.filter((ref): ref is NonNullable<typeof ref> => ref !== null)
|
2026-07-17 21:44:49 +00:00
|
|
|
if (shouldSerializeRawBackedField(node, 'componentPropRefs', componentPropRefs.length > 0)) {
|
|
|
|
|
nc.componentPropRefs = componentPropRefs
|
|
|
|
|
}
|
2026-07-17 18:59:49 +00:00
|
|
|
|
|
|
|
|
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
|
|
|
|
|
.map(([propertyId, value]) => {
|
|
|
|
|
const defID = parseGuidOrNull(propertyId)
|
2026-07-18 00:37:58 +00:00
|
|
|
const definition = context.componentPropertyDefinitionsById.get(propertyId)
|
2026-07-17 18:59:49 +00:00
|
|
|
return defID && definition
|
|
|
|
|
? {
|
|
|
|
|
defID,
|
|
|
|
|
value: componentPropertyValue(definition.type, value, context.graph)
|
|
|
|
|
}
|
|
|
|
|
: null
|
|
|
|
|
})
|
|
|
|
|
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null)
|
2026-07-17 21:44:49 +00:00
|
|
|
if (
|
|
|
|
|
shouldSerializeRawBackedField(
|
|
|
|
|
node,
|
|
|
|
|
'componentPropAssignments',
|
|
|
|
|
componentPropAssignments.length > 0,
|
|
|
|
|
Boolean(nc.componentPropAssignments)
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-07-17 18:59:49 +00:00
|
|
|
nc.componentPropAssignments = componentPropAssignments
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 18:48:42 +00:00
|
|
|
const variantPropSpecs = node.variantPropSpecs
|
|
|
|
|
.map((spec) => {
|
|
|
|
|
const propDefId = parseGuidOrNull(spec.propDefId)
|
|
|
|
|
return propDefId ? { propDefId, value: spec.value } : null
|
|
|
|
|
})
|
|
|
|
|
.filter((spec): spec is NonNullable<typeof spec> => spec !== null)
|
2026-07-17 21:44:49 +00:00
|
|
|
if (shouldSerializeRawBackedField(node, 'variantPropSpecs', variantPropSpecs.length > 0)) {
|
|
|
|
|
nc.variantPropSpecs = variantPropSpecs
|
|
|
|
|
}
|
2026-05-12 18:48:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 17:56:13 +00:00
|
|
|
function exportNodeSize(node: SceneNode): Vector {
|
2026-07-17 23:22:01 +00:00
|
|
|
const rawSize = effectiveFigmaSourcePayload(node).rawSize
|
|
|
|
|
return rawSize ? { ...rawSize } : { x: node.width, y: node.height }
|
2026-05-20 17:56:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
|
2026-07-17 23:22:01 +00:00
|
|
|
const rawTransform = effectiveFigmaSourcePayload(node).rawTransform
|
|
|
|
|
return rawTransform ? { ...rawTransform } : context.computeExportTransform(node)
|
2026-05-20 17:56:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 22:49:04 +00:00
|
|
|
function hasRawGeometryPayload(node: SceneNode): boolean {
|
2026-07-17 23:22:01 +00:00
|
|
|
const rawNodeFields = effectiveFigmaRawNodeFields(node)
|
|
|
|
|
return 'fillGeometry' in rawNodeFields || 'strokeGeometry' in rawNodeFields
|
2026-05-20 22:49:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasRawVectorPayload(node: SceneNode): boolean {
|
2026-07-17 23:22:01 +00:00
|
|
|
return 'vectorData' in effectiveFigmaRawNodeFields(node)
|
2026-05-20 22:49:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 07:27:02 +00:00
|
|
|
const SUPPORTED_NORMALIZED_EFFECT_TYPES = new Set([
|
|
|
|
|
'DROP_SHADOW',
|
|
|
|
|
'INNER_SHADOW',
|
|
|
|
|
'LAYER_BLUR',
|
|
|
|
|
'BACKGROUND_BLUR',
|
|
|
|
|
'FOREGROUND_BLUR'
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
function hasRawUnsupportedEffects(node: SceneNode): boolean {
|
2026-07-17 23:22:01 +00:00
|
|
|
const effects = effectiveFigmaRawNodeFields(node).effects
|
2026-05-27 07:27:02 +00:00
|
|
|
return (
|
|
|
|
|
Array.isArray(effects) &&
|
|
|
|
|
effects.some(
|
|
|
|
|
(effect) =>
|
|
|
|
|
effect &&
|
|
|
|
|
typeof effect === 'object' &&
|
|
|
|
|
'type' in effect &&
|
|
|
|
|
!SUPPORTED_NORMALIZED_EFFECT_TYPES.has(String(effect.type))
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 22:49:04 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 17:28:22 +00:00
|
|
|
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) }
|
2026-07-17 21:44:49 +00:00
|
|
|
if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids)
|
2026-07-17 17:28:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 11:40:19 +00:00
|
|
|
function applyNodeVisualProps(
|
|
|
|
|
context: SceneNodeToKiwiContext,
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
nc: KiwiNodeChange
|
|
|
|
|
): void {
|
2026-03-28 14:07:21 +00:00
|
|
|
if (node.independentStrokeWeights) {
|
|
|
|
|
nc.borderStrokeWeightsIndependent = true
|
|
|
|
|
nc.borderTopWeight = node.borderTopWeight
|
|
|
|
|
nc.borderRightWeight = node.borderRightWeight
|
|
|
|
|
nc.borderBottomWeight = node.borderBottomWeight
|
|
|
|
|
nc.borderLeftWeight = node.borderLeftWeight
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 21:23:23 +00:00
|
|
|
if (node.fills.length > 0) {
|
|
|
|
|
nc.fillPaints = node.fills.map((fill, index) =>
|
|
|
|
|
applyColorVariableBinding(
|
|
|
|
|
context,
|
|
|
|
|
node,
|
|
|
|
|
context.fillToKiwiPaint(fill),
|
|
|
|
|
`fills/${index}/color`
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-28 14:07:21 +00:00
|
|
|
|
|
|
|
|
context.serializeCornerRadii(node, nc)
|
|
|
|
|
|
2026-05-27 07:27:02 +00:00
|
|
|
if (node.effects.length > 0 && !hasRawUnsupportedEffects(node)) {
|
2026-03-28 14:07:21 +00:00
|
|
|
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,
|
2026-05-20 10:12:16 +00:00
|
|
|
visible: effect.visible,
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
blendMode: effect.blendMode ?? 'NORMAL',
|
2026-05-20 10:12:16 +00:00
|
|
|
showShadowBehindNode: effect.showShadowBehindNode
|
2026-03-28 14:07:21 +00:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (node.type === 'TEXT') {
|
2026-05-20 13:59:31 +00:00
|
|
|
context.serializeTextProps(
|
|
|
|
|
node,
|
|
|
|
|
nc,
|
|
|
|
|
context.graph,
|
|
|
|
|
context.fontDigestMap,
|
|
|
|
|
context.blobs,
|
|
|
|
|
context.glyphBlobMap
|
|
|
|
|
)
|
2026-03-28 14:07:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 22:49:04 +00:00
|
|
|
if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent
|
2026-07-17 17:28:22 +00:00
|
|
|
applySharedStyleProps(node, nc)
|
2026-03-28 14:07:21 +00:00
|
|
|
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
|
|
|
|
|
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
|
|
|
|
|
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
|
2026-07-17 23:22:01 +00:00
|
|
|
const rawNodeFields = effectiveFigmaRawNodeFields(node)
|
|
|
|
|
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in rawNodeFields) {
|
2026-07-17 16:01:42 +00:00
|
|
|
nc.strokeJoin = node.strokeJoin
|
|
|
|
|
}
|
2026-07-17 23:22:01 +00:00
|
|
|
if (node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT || 'miterLimit' in rawNodeFields) {
|
2026-07-17 16:01:42 +00:00
|
|
|
nc.miterLimit = node.strokeMiterLimit
|
|
|
|
|
}
|
2026-03-28 14:07:21 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sceneNodeToKiwiWithContext(
|
|
|
|
|
node: SceneNode,
|
|
|
|
|
parentGuid: GUID,
|
|
|
|
|
childIndex: number,
|
|
|
|
|
localIdCounter: { value: number },
|
|
|
|
|
context: SceneNodeToKiwiContext
|
|
|
|
|
): KiwiNodeChange[] {
|
2026-05-20 15:32:01 +00:00
|
|
|
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
|
|
|
|
|
sessionID: 1,
|
|
|
|
|
localID: localIdCounter.value++
|
|
|
|
|
}
|
2026-03-28 14:07:21 +00:00
|
|
|
|
|
|
|
|
const strokePaints = createStrokePaints(context, node)
|
|
|
|
|
|
|
|
|
|
const nc: KiwiNodeChange = {
|
|
|
|
|
guid,
|
2026-05-20 22:49:04 +00:00
|
|
|
parentIndex: {
|
|
|
|
|
guid: parentGuid,
|
2026-05-20 23:07:02 +00:00
|
|
|
position: node.source.orderKey ?? context.fractionalPosition(childIndex)
|
2026-05-20 22:49:04 +00:00
|
|
|
},
|
2026-03-28 14:07:21 +00:00
|
|
|
type: context.mapToFigmaType(node.type),
|
|
|
|
|
name: node.name,
|
|
|
|
|
visible: node.visible,
|
|
|
|
|
opacity: node.opacity,
|
|
|
|
|
phase: 'CREATED',
|
2026-05-20 17:56:13 +00:00
|
|
|
size: exportNodeSize(node),
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
transform: exportNodeTransform(context, node)
|
|
|
|
|
}
|
2026-07-17 17:28:22 +00:00
|
|
|
if (node.sharedStyleType) nc.styleType = node.sharedStyleType
|
fix(kiwi): import Figma groups as groups
* fix(kiwi): import Figma groups as GROUP nodes instead of FRAME
- Map FRAME nodes with `resizeToFit === true` to `GROUP` type on import
- Write `resizeToFit = true` when exporting `GROUP` nodes back to Kiwi format
- Document findings and diagnosis in packages/docs/development/group-to-frame-import-issue.md
- Extract resolveNodeType helper to satisfy cyclomatic complexity limits
* test(kiwi): harden Figma group import + add coverage
Follow-up hardening on the group import/export fix:
- convert.ts: reorder so the COMPONENT_SET check runs before the FRAME→GROUP
reclassification, and guard the group check on the absence of auto-layout
(`stackMode` unset/NONE). Figma auto-layout "hug" frames use
stackPrimarySizing/stackCounterSizing, not `resizeToFit`, so this prevents a
component-set or auto-layout frame that happens to carry `resizeToFit` from being
misclassified as a group.
- Add tests/engine/io/fig/import/group-reclassify.test.ts: unit coverage for the
reclassification (FRAME+resizeToFit → GROUP, plain frame stays FRAME, auto-layout
hug frame stays FRAME), plus a real-file assertion that gold-preview.fig imports
its groups as GROUP.
- Add tests/engine/io/fig/roundtrip/group.test.ts: a created GROUP survives
export → re-import as a GROUP (validates the export-side resizeToFit write).
- Update exhaustive.test.ts gold-preview golden: 519 nodes that were misimported as
FRAME are now correctly GROUP (FRAME 4525→4006, GROUP 519), and export size shifts
594758→594770 because exported groups now carry resizeToFit.
Note: the pre-existing material3 variables roundtrip failure
("Invalid value EXCLUDE for enum BooleanOperation") is unrelated to groups and
also fails on master — out of scope here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(kiwi): remove temporary group-to-frame analysis doc
It was a scratch diagnostic artifact (and contained a hardcoded local path);
the rationale now lives in the fix's commit messages, code comments, and tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-06-06 10:49:19 +00:00
|
|
|
if (node.type === 'GROUP') {
|
|
|
|
|
nc.resizeToFit = true
|
|
|
|
|
}
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
// 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
|
2026-03-28 14:07:21 +00:00
|
|
|
}
|
fix(kiwi): repair .fig roundtrip - missing nodes, schema mismatch, GUID collisions
Exporting a .fig from OpenPencil and reimporting lost most nodes
(38323 to 57 on gold-preview.fig) and the same files wouldn't open
in Figma. gold-preview.fig now roundtrips into Figma. I haven't
traced each symptom to a specific fix with confidence, but the
changes address the underlying causes:
- fractionalPosition overflow past 94 siblings produced characters
outside Figma's printable ASCII range (33-126); gold-preview.fig
has nodes with 492 children so this hit immediately. New telescoping
~-prefix scheme matches Figma's own encoding.
- re-export used OpenPencil's subset kiwi schema, likely producing
field IDs misaligned with the embedded schema. Preserve and use
the original schema (graph.figSchemaDeflated) instead.
- variable GUIDs assigned before canvas entries could collide with
source.id-derived canvas GUIDs. Reversed the order, scan all
imported GUIDs to advance the counter.
- auto-layout child sort ignored parentIndex.position, using only
transform position which may not reflect original tree order for
imported nodes. Sort by position first, transform as tiebreaker.
- Object.assign of rawNodeFields overwrote explicit serialization.
Materialize with paint variables, apply blocklist, skip keys
already set on nc.
Also fixes: strokeWeight defaulting on strokeless nodes, colorVar
assetRef to guid conversion, per-corner radii emission, textAlignVertical
hardcoding, textAutoResize override for imported nodes, stackReverseZIndex
roundtrip, variable key/version preservation, blendMode on effects,
textCase emission.
Adds exhaustive roundtrip test comparing G0 to G1 to G2 across schema
bytes, node count, tree paths, scene props, and raw node fields.
2026-05-24 08:29:14 +00:00
|
|
|
if (node.locked) nc.locked = true
|
2026-03-28 14:07:21 +00:00
|
|
|
|
|
|
|
|
applyNodeVisualProps(context, node, nc)
|
2026-07-17 18:59:49 +00:00
|
|
|
applyComponentMetadata(context, node, nc)
|
2026-05-20 17:56:13 +00:00
|
|
|
applyInstancePayload(context, node, nc, localIdCounter)
|
2026-05-18 21:23:23 +00:00
|
|
|
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
2026-05-20 19:22:43 +00:00
|
|
|
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
fix(export): prevent GUID collisions and file corruption on .fig round-trip (#333)
* fix(export): prevent GUID collisions and file corruption on .fig round-trip
Nodes sharing the same imported source.id (component instance children,
cloned subtrees) silently overwrote each other on export because the
GUID assignment reused imported GUID values without checking for
duplicates. This caused data loss on reimport — only the last node
with a given GUID survived.
- Track all assigned GUID values in a Set for O(1) collision detection.
- Scan imported source.ids for both sessionID 0 and 1 before assigning
any new GUIDs, so the counter starts past every imported value.
- Fall back to counter-based GUIDs when source.id collides with an
already-assigned value.
Additional fixes in the same change set:
- cloneTree now deep-copies source.fig via structuredClone, preventing
mutations on a clone from corrupting the original node's kiwi payload.
- Removed decompressFigKiwiData sync wrapper (zero callers) and the
silent try/catch fallback in parseFigKiwiContainer that masked
corrupt data as raw bytes.
- buildFigKiwi uses Bun.zstdCompressSync when available, matching the
zstd decompression path already used on import.
- Fixed setSavedVersion ordering in read.ts — must run after
requestRender to capture the post-bump version, preventing spurious
dirty-state immediately after file reload.
Tests: GUID collision (2 and 3 node), clone isolation, parse failure,
text export zstd compatibility, gold-preview round-trip.
* fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type
The internal representation uses EXCLUDE for exclude boolean operations,
but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so
round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to
VALID_NODE_TYPES and increase timeout for heavy material3 fixture test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style: format export-node.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: add type guard after null assertion in guid-collision test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): detect zstd-compressed data before zlib inflate
fflate's inflateSync silently accepts zstd-compressed data and returns
garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f
fd) before attempting zlib decompression. Also revert the EXCLUDE enum
addition to the kiwi schema since the export-node.ts mapping is
sufficient.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): add length guard before zstd magic byte check
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(scene-graph): group clone regression coverage
* ci: retrigger CI checks
* test: increase timeouts for heavy .fig fixture tests on slow CI runners
Gold-preview.fig and material3.fig parsing/export tests consistently
exceed the 5s bun:test default timeout on GitHub Actions runners.
Increase to 30s for: beforeAll codec init, clipboard roundtrip,
glyph blob roundtrip, group reclassification, and text measurement.
* test: add individual test timeouts for heavy .fig fixture tests
The beforeAll timeout helped but individual test() calls also need
explicit 30s timeouts since bun:test applies the 5s default per-test.
Fixes remaining CI flakes in glyph-blob roundtrip and clipboard
roundtrip tests.
* test: increase beforeAll timeout for render cache test
The canvas/render cache test loads gold-preview.fig AND initializes
CanvasKit (Skia WASM), which is much slower than the other fixture
tests. Use 60s timeout to account for slow CI runners.
* fix(export): reserve document GUID to prevent 0:0 namespace collision
- Add docGuid (0:0) to assignedGuidValues before processing imported
node source.ids, preventing an imported node with source.id "0:0"
from reusing the document's GUID slot
- Add regression test using session-0 source.ids to verify nodes
survive roundtrip without document GUID collision
- Remove unnecessary async keyword from synchronous component
metadata test
* fix(export): guard canvas GUID reuse with assignedGuidValues check
- Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries():
if an imported page's source.id maps to a GUID already in
assignedGuidValues, generate a fresh counter-based GUID instead
- Prevents canvas-level last-write-wins when multiple pages share
the same source.id or a page uses 0:0
* fix(test): use explicit little-endian writes and fix misleading test title
- Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly
- Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify
* test(io): use file-level timeout for heavy fixture
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-06-23 15:05:23 +00:00
|
|
|
if (node.type === 'BOOLEAN_OPERATION')
|
2026-06-06 13:37:04 +00:00
|
|
|
nc.booleanOperation = toKiwiBooleanOperation(node.booleanOperation)
|
2026-03-28 14:07:21 +00:00
|
|
|
if (strokePaints.length > 0) nc.strokePaints = strokePaints
|
|
|
|
|
|
|
|
|
|
context.serializeLayoutProps(node, nc)
|
2026-05-20 22:49:04 +00:00
|
|
|
context.serializeGeometry(nodeForGeometryExport(node), nc, context.blobs)
|
2026-03-28 14:07:21 +00:00
|
|
|
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
|
2026-05-20 18:24:58 +00:00
|
|
|
applyRawFigmaNodeFields(context, node, nc)
|
2026-07-20 21:23:00 +00:00
|
|
|
const variableModeBySetMap = serializeVariableModes(
|
|
|
|
|
node,
|
|
|
|
|
context.varIdToGuid,
|
|
|
|
|
context.modeIdToGuid
|
|
|
|
|
)
|
|
|
|
|
if (variableModeBySetMap) nc.variableModeBySetMap = variableModeBySetMap
|
2026-03-28 14:07:21 +00:00
|
|
|
|
feat(export): persist per-node export settings
* fix(ui): anchor AppSelect dropdown to its trigger
AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger
as-child). reka-ui's Select popper captures its trigger element via
useForwardExpose on mount, but that capture resolves to null when the
trigger sits inside another primitive's as-child slot. With no anchor,
floating-ui positioned the menu at the viewport origin and flipped it
off-screen (~y:-412), so clicking the dropdown appeared to do nothing.
Wrap the trigger in a layout-neutral span, keep the styled <Tip>
tooltip, and pass that span to SelectContent's `reference` prop so the
popper anchors explicitly on every open (PopperContent prefers
props.reference over the broken auto-capture, and re-reads it on each
open/reopen).
Fixes every dropdown built on AppSelect across the inspector (export
scale/format, typography, effects, stroke, flex/grid layout, variants,
fills, gradients, color format).
Verified live: menu opens directly below the trigger, fully on-screen,
across repeated open/select/reopen cycles; tooltip still shows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(export): editable custom scale with 1024x cap
The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like:
an editable field where you can type any multiplier (e.g. 9x, 1.5x) in
addition to picking a preset from the chevron menu. Custom values are used
as-is and are never added to the preset list.
Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would
allocate an enormous canvas and crash the renderer. The clamp lives in the
export data model (clampExportScale, applied in updateScale) so it defends
every caller, and is reused by the input for immediate display feedback
(9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts.
New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for
presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions
correctly). The active preset shows a checkmark.
Verified live across custom entry, clamping (both bounds), decimals,
invalid input, and preset selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(export): store export settings per node
Export settings were global and transient: every selection showed a
default 1x PNG row, and nothing was remembered per layer or persisted
with the document.
Store export settings on each SceneNode (exportSettings: ExportSetting[]),
defaulting to empty so the panel shows only its header and add button
until the user adds a row. Settings apply across multi-select and target
the current page when nothing is selected; add/edit/remove are undoable.
Persist settings with the document via open-pencil pluginData in .fig
(lossless, including webp). On import, prefer app pluginData; otherwise
map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without
overwriting raw native fields on re-export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(export): working format dropdown, JPG/WEBP export, and zip bundling
- Anchor AppSelect dropdown via native title so the format selector is
clickable in multi-row export panels (was rendering off-screen)
- Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which
CanvasKit's encodeToBytes returns null for in this build (fixes the
"Nothing to export" error)
- Bundle multi-format exports into a single zip; a single export still
downloads the file directly
- Default each added export row to 2x the previous scale (1x -> 2x -> 4x)
- Add e2e coverage for the multi-row dropdown, zip bundling, and direct
single-file download
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* revert(export): drop AppSelect native-title workaround, superseded by #325
The shared <Tip> path is now handled generally by #325; #321 should not carry
the AppSelect change. Reverts src/components/ui/AppSelect.vue to master.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(export): address review feedback (#321)
- Clear raw native exportSettings when the user edits/clears export rows so they
don't resurrect from the import fallback on reopen (scene-graph/source-metadata).
- Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE),
not just in the UI; centralize the bounds in core/scene-graph/export-scale and
reuse them from the vue helpers (single source of truth).
- Export the rows the panel shows (activeSettings) for every target so a
multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows
whenever targets diverge.
- Sanitize zip entry names (strip separators, parent refs, control chars) so layer
names can't escape or corrupt the archive.
- Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a
silent PNG so we never write PNG bytes under a .jpg/.webp extension.
Add regression tests for the native-settings clear and import-scale clamp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
|
|
|
applyExportSettingsPluginData(node)
|
2026-05-05 11:58:02 +00:00
|
|
|
const pluginData = mergePluginData(node.pluginData)
|
2026-03-28 14:07:21 +00:00
|
|
|
if (pluginData.length > 0) nc.pluginData = pluginData
|
|
|
|
|
if (node.pluginRelaunchData.length > 0) {
|
|
|
|
|
nc.pluginRelaunchData = serializePluginRelaunchData(node.pluginRelaunchData)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result: KiwiNodeChange[] = [nc]
|
2026-05-20 16:10:23 +00:00
|
|
|
const children =
|
|
|
|
|
node.type === 'INSTANCE'
|
|
|
|
|
? []
|
|
|
|
|
: context.graph.getChildren(node.id).filter((child) => !child.internalOnly)
|
2026-03-28 14:07:21 +00:00
|
|
|
for (let i = 0; i < children.length; i++) {
|
|
|
|
|
result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context))
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|