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.
This commit is contained in:
Joseph Cumines 2026-05-24 18:29:14 +10:00
parent a38c0ac7b5
commit c4073dc284
21 changed files with 1525 additions and 147 deletions

View file

@ -1,14 +1,13 @@
import type { CanvasKit } from 'canvaskit-wasm'
import { deflateSync } from 'fflate'
import { deflateSync, inflateSync } from 'fflate'
import type { SkiaRenderer } from '#core/canvas'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '#core/constants'
import { renderThumbnail } from '#core/io/formats/raster'
import { populateAllLazyFigImportRoots } from '#core/kiwi/fig/lazy-import'
import { initCodec, getCompiledSchema, getSchemaBytes } from '#core/kiwi/fig/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import { populateAllLazyFigImportRoots } from '#core/kiwi/fig/lazy-import'
import { stringToGuid } from '#core/kiwi/fig/node-change/convert'
import { buildFigmaPaintVariableColorMap } from '#core/kiwi/fig/node-change/export-node'
import {
sceneNodeToKiwi,
fractionalPosition,
@ -17,6 +16,7 @@ import {
makeDocumentNodeChange,
makeCanvasNodeChange
} from '#core/kiwi/fig/node-change/serialize'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/schema-runtime'
import type { SceneGraph, VariableValue } from '#core/scene-graph'
import type { GUID } from '#core/types'
@ -91,11 +91,17 @@ async function renderFigThumbnail(
): Promise<Uint8Array> {
if (!pageId) return THUMBNAIL_1X1
if (ck && renderer) {
return renderThumbnail(ck, renderer, graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) ?? THUMBNAIL_1X1
return (
renderThumbnail(ck, renderer, graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) ??
THUMBNAIL_1X1
)
}
if (!renderHeadless || IS_BROWSER || IS_TAURI) return THUMBNAIL_1X1
const { headlessRenderThumbnail } = await import('#core/io/formats/raster')
return (await headlessRenderThumbnail(graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)) ?? THUMBNAIL_1X1
return (
(await headlessRenderThumbnail(graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)) ??
THUMBNAIL_1X1
)
}
function assignVariableGuids(
@ -178,7 +184,7 @@ function appendVariablesForCollection(
variableData: variableValueToKiwi(value, variable.type, varIdToGuid)
}))
nodeChanges.push({
const nc: KiwiNodeChange = {
guid: varGuid,
parentIndex: { guid: parentGuid, position: fractionalPosition(varIdx++) },
type: 'VARIABLE',
@ -190,7 +196,12 @@ function appendVariablesForCollection(
variableResolvedType: resolvedType,
variableDataValues: { entries },
variableScopes: ['ALL_SCOPES']
})
}
// Preserve library key/version on VARIABLE NodeChanges so that
// buildAssetRefMap can resolve assetRef to guid on reimport.
if (variable.key) nc.key = variable.key
if (variable.version) nc.version = variable.version
nodeChanges.push(nc)
}
}
@ -220,6 +231,11 @@ function buildCanvasEntries(
const canvasGuid = page.source.id
? stringToGuid(page.source.id)
: { sessionID: 0, localID: localIdCounter.value++ }
// Advance counter past any source.id-derived GUID to prevent collisions
// with subsequently generated variable/collection GUIDs.
if (page.source.id && canvasGuid.sessionID === 0) {
localIdCounter.value = Math.max(localIdCounter.value, canvasGuid.localID + 1)
}
nodeIdToGuid.set(page.id, canvasGuid)
if (page.internalOnly) internalCanvasGuid = canvasGuid
@ -266,8 +282,24 @@ export async function exportFigFile(
): Promise<Uint8Array> {
populateAllLazyFigImportRoots(graph)
await initCodec()
const compiled = getCompiledSchema()
const schemaDeflated = deflateSync(getSchemaBytes())
// When the document was imported from a .fig file, preserve the original
// kiwi schema for both encoding and embedding. For the current version of
// Figma, likely for quite some time, schema has more types/fields than our
// subset, and using our schema to encode would produce field IDs that don't
// align with the embedded schema. By compiling and using the original
// schema, we improve the roundtrip-ability... This requires further work.
let compiled: ReturnType<typeof getCompiledSchema>
let schemaDeflated: Uint8Array
if (graph.figSchemaDeflated) {
const schemaBytes = inflateSync(graph.figSchemaDeflated)
const figSchema = decodeBinarySchema(new ByteBuffer(schemaBytes))
compiled = compileSchema(figSchema) as ReturnType<typeof getCompiledSchema>
schemaDeflated = graph.figSchemaDeflated
} else {
compiled = getCompiledSchema()
schemaDeflated = deflateSync(getSchemaBytes())
}
const docGuid = { sessionID: 0, localID: 0 }
const localIdCounter = { value: 2 }
@ -285,9 +317,6 @@ export async function exportFigFile(
const fontDigestMap = await buildFontDigestMap(graph)
const glyphBlobMap = new Map<string, number>()
const blobIndexByHex = new Map<string, number>()
const paintVariableColorMap = buildFigmaPaintVariableColorMap(graph)
assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid)
const { canvasEntries, internalCanvasGuid } = buildCanvasEntries(
graph,
@ -297,6 +326,23 @@ export async function exportFigFile(
nodeIdToGuid
)
// Scan ALL imported source.ids to find max sessionID:0 localID,
// preventing collisions between variable GUIDs and any imported node GUID.
let maxLocalId0 = localIdCounter.value - 1
for (const node of graph.nodes.values()) {
if (node.source.id) {
const guid = stringToGuid(node.source.id)
if (guid.sessionID === 0 && guid.localID > maxLocalId0) {
maxLocalId0 = guid.localID
}
}
}
localIdCounter.value = Math.max(localIdCounter.value, maxLocalId0 + 1)
// Assign variable GUIDs AFTER canvas entries so that source.id-derived
// canvas GUIDs don't collide with generated variable GUIDs.
assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid)
for (const entry of canvasEntries) nodeChanges.push(entry.canvasNc)
const orderedCanvasEntries = [
@ -318,7 +364,6 @@ export async function exportFigFile(
fontDigestMap,
varIdToGuid,
glyphBlobMap,
paintVariableColorMap,
blobIndexByHex
)
)

View file

@ -10,9 +10,16 @@ export interface ParseFigFileOptions {
}
function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph {
const { nodeChanges, blobs, images: imageEntries, figKiwiVersion } = parseFigBuffer(buffer)
const {
nodeChanges,
blobs,
images: imageEntries,
figKiwiVersion,
figSchemaDeflated
} = parseFigBuffer(buffer)
const graph = importNodeChanges(nodeChanges, blobs, new Map(imageEntries), options)
graph.figKiwiVersion = figKiwiVersion
graph.figSchemaDeflated = figSchemaDeflated
return graph
}

View file

@ -17,6 +17,7 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
graph.variableCollections = new Map()
graph.activeMode = new Map(source.activeMode)
graph.figKiwiVersion = source.figKiwiVersion
graph.figSchemaDeflated = source.figSchemaDeflated
graph.documentColorSpace = source.documentColorSpace
const sortedIds = [...ids].sort((a, b) => {

View file

@ -275,7 +275,9 @@ function importVariableEntries(
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false
hiddenFromPublishing: false,
key: typeof nc.key === 'string' ? nc.key : undefined,
version: typeof nc.version === 'string' ? nc.version : undefined
})
}
}

View file

@ -8,8 +8,8 @@ import { convertEffects, convertFills, convertStrokes } from './paint'
import { importStyleRuns } from './style-runs'
export { importStyleRuns } from './style-runs'
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
import { convertFontFeatures } from './font-features'
import { convertFontVariations } from './font-variations'
import { convertFontFeatures } from './font/features'
import { convertFontVariations } from './font/variations'
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint'
export { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
@ -668,7 +668,8 @@ function extractFigmaLayoutMetadata(nc: NodeChange): SceneNode['source']['fig'][
stackChildPrimaryGrow: nc.stackChildPrimaryGrow,
stackChildAlignSelf: nc.stackChildAlignSelf,
stackCounterSpacing: nc.stackCounterSpacing,
bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined
bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined,
stackReverseZIndex: nc.stackReverseZIndex as boolean | undefined
}
}
@ -690,23 +691,28 @@ export function sortChildren(
parentNc: NodeChange,
nodeMap: Map<string, NodeChange>
): void {
// Always sort by parentIndex.position first (canonical tree order)
const stackMode = parentNc.stackMode as string | undefined
if (stackMode === 'HORIZONTAL' || stackMode === 'VERTICAL') {
const axis = stackMode === 'HORIZONTAL' ? 'm02' : 'm12'
children.sort((a, b) => {
const isHorizontal = stackMode === 'HORIZONTAL'
const isVertical = stackMode === 'VERTICAL'
children.sort((a, b) => {
const aPos = nodeMap.get(a)?.parentIndex?.position ?? ''
const bPos = nodeMap.get(b)?.parentIndex?.position ?? ''
// Primary sort: parentIndex.position (exact tree order)
if (aPos < bPos) return -1
if (aPos > bPos) return 1
// Tiebreaker for auto-layout: sort by transform position
if (isHorizontal || isVertical) {
const axis = isHorizontal ? 'm02' : 'm12'
const aT = nodeMap.get(a)?.transform?.[axis] ?? 0
const bT = nodeMap.get(b)?.transform?.[axis] ?? 0
return aT - bT
})
} else {
children.sort((a, b) => {
const aPos = nodeMap.get(a)?.parentIndex?.position ?? ''
const bPos = nodeMap.get(b)?.parentIndex?.position ?? ''
if (aPos < bPos) return -1
if (aPos > bPos) return 1
return 0
})
}
if (aT !== bT) return aT - bT
}
return 0
})
}
interface PreservedFigmaBlob {

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import { bytesToHex } from '#core/bytes/hex'
import type { NodeChange, Paint } from '#core/kiwi/fig/codec'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
@ -13,6 +14,27 @@ import {
export type KiwiNodeChange = NodeChange & Record<string, unknown>
/**
* Build a mapping from assetRef key strings ("key@version" or "key") to
* variable GUIDs. This is used to convert colorVar.assetRef references in raw
* paint data to guid references that resolveAliasId can resolve on reimport.
*/
export function buildAssetRefToVarGuidMap(
graph: SceneGraph,
varIdToGuid: Map<string, GUID>
): Map<string, GUID> {
const map = new Map<string, GUID>()
for (const [varId, variable] of graph.variables) {
if (!variable.key) continue
const guid = varIdToGuid.get(varId) ?? stringToGuid(varId)
map.set(variable.key, guid)
if (variable.version) {
map.set(`${variable.key}@${variable.version}`, guid)
}
}
return map
}
interface SceneNodeToKiwiContext {
graph: SceneGraph
blobs: Uint8Array[]
@ -21,7 +43,9 @@ interface SceneNodeToKiwiContext {
fontDigestMap?: Map<string, Uint8Array>
glyphBlobMap?: Map<string, number>
varIdToGuid?: Map<string, GUID>
paintVariableColorMap?: Map<string, Color>
/** Maps "key@version" or "key" (from variable.key/version) variable GUID.
* Used to convert colorVar.assetRef references in raw paints to guid references. */
assetRefToVarGuid?: Map<string, GUID>
fractionalPosition: (index: number) => string
mapToFigmaType: (type: SceneNode['type']) => string
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
@ -53,8 +77,6 @@ interface SceneNodeToKiwiContext {
) => KiwiNodeChange[]
}
const DEFAULT_STROKE_WEIGHT = 1
function applyColorVariableBinding(
context: SceneNodeToKiwiContext,
node: SceneNode,
@ -151,21 +173,10 @@ function materializeSafeVariableMap(
return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs, options)) }
}
function paintVariableKey(value: unknown): string | null {
if (!value || typeof value !== 'object') return null
const assetRef = (
value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } }
).value?.alias?.assetRef
return typeof assetRef?.key === 'string'
? `${assetRef.key}:${typeof assetRef.version === 'string' ? assetRef.version : ''}`
: null
}
interface MaterializeFigmaPayloadOptions {
blobIndexByHex?: Map<string, number>
includePaintVariables?: boolean
includeVariableMaps?: boolean
paintVariableColorMap?: Map<string, Color>
}
function materializeFigmaBlob(
@ -215,9 +226,6 @@ function materializeFigmaPayload(
}
const materialized: Record<string, unknown> = {}
const paintVariableColor = options.paintVariableColorMap?.get(
paintVariableKey((value as { colorVar?: unknown }).colorVar) ?? ''
)
for (const [key, child] of Object.entries(value)) {
if (FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS.has(key) && !options.includePaintVariables) continue
if (FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) {
@ -235,54 +243,9 @@ function materializeFigmaPayload(
materializeFigmaPayload(child, blobs, options)
)
}
if (paintVariableColor) materialized.color = paintVariableColor
return materialized
}
function collectPaintVariableColorCounts(
value: unknown,
counts: Map<string, Map<string, { color: Color; count: number }>>
): void {
if (!value || typeof value !== 'object' || ArrayBuffer.isView(value)) return
if (Array.isArray(value)) {
for (const item of value) {
if (item && typeof item === 'object') collectPaintVariableColorCounts(item, counts)
}
return
}
const paint = value as { color?: Color; colorVar?: unknown }
const key = paintVariableKey(paint.colorVar)
if (key && paint.color) {
const colorKey = [paint.color.r, paint.color.g, paint.color.b, paint.color.a]
.map((component) => Math.round(component * 255))
.join(',')
const colorCounts = counts.get(key) ?? new Map<string, { color: Color; count: number }>()
const current = colorCounts.get(colorKey)
colorCounts.set(colorKey, { color: paint.color, count: (current?.count ?? 0) + 1 })
counts.set(key, colorCounts)
}
for (const child of Object.values(value)) collectPaintVariableColorCounts(child, counts)
}
export function buildFigmaPaintVariableColorMap(graph: SceneGraph): Map<string, Color> {
const counts = new Map<string, Map<string, { color: Color; count: number }>>()
for (const node of graph.nodes.values()) {
collectPaintVariableColorCounts(node.source.fig.rawNodeFields, counts)
collectPaintVariableColorCounts(node.source.fig.symbolOverrides, counts)
collectPaintVariableColorCounts(node.source.fig.componentPropAssignments, counts)
collectPaintVariableColorCounts(node.source.fig.derivedSymbolData, counts)
}
const colors = new Map<string, Color>()
for (const [key, colorCounts] of counts) {
const [mostCommon] = [...colorCounts.values()].sort((a, b) => b.count - a.count)
colors.set(key, mostCommon.color)
}
return colors
}
function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string {
const seen = new Set<string>()
let currentId = componentId
@ -310,17 +273,122 @@ function getOrCreateNodeGuid(
return guid
}
/**
* 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',
'componentPropAssignments',
'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'
])
function applyRawFigmaNodeFields(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
Object.assign(
nc,
materializeFigmaPayload(node.source.fig.rawNodeFields, context.blobs, {
blobIndexByHex: context.blobIndexByHex
})
)
const materialized = materializeFigmaPayload(node.source.fig.rawNodeFields, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}) as Record<string, unknown>
for (const key of Object.keys(materialized)) {
if (RAW_FIELDS_OVERRIDE_BLOCKLIST.has(key)) continue
// For paint arrays on imported nodes, the raw NC data preserves the
// original opacity/color.a split (e.g. opacity=0 for invisible strokes).
// The scene model may lose this distinction for instance children whose
// strokes are resolved from component overrides. Prefer the raw data.
if ((key === 'fillPaints' || key === 'strokePaints') && node.source.id) {
let paints = materialized[key]
// Convert colorVar.assetRef references to guid references so that
// resolveAliasId can resolve them on reimport. Raw paints from the
// original .fig file use assetRef (library key/version) to refer to
// variables, but on reimport buildAssetRefMap won't find the key unless
// our VARIABLE NodeChanges also have key/version set. Even with that,
// converting to guid is more robust — it works even for local variables
// that don't have library keys.
if (context.assetRefToVarGuid && context.assetRefToVarGuid.size > 0) {
paints = convertColorVarAssetRefs(paints, context.assetRefToVarGuid)
}
;(nc as Record<string, unknown>)[key] = paints
continue
}
// Also convert colorVar.assetRef in raw effects (e.g. shadow color variables)
if (key === 'effects' && node.source.id && context.assetRefToVarGuid && context.assetRefToVarGuid.size > 0) {
const converted = convertColorVarAssetRefs(materialized[key], context.assetRefToVarGuid)
;(nc as Record<string, unknown>)[key] = converted
continue
}
// Skip any key already set on nc — explicit serialization takes priority
if (key in (nc as Record<string, unknown>)) continue
;(nc as Record<string, unknown>)[key] = materialized[key]
}
}
/**
* Convert colorVar.assetRef references in paints to guid references.
* Raw paint data from imported .fig files uses assetRef (library key) for
* variable references. On reimport, buildAssetRefMap needs nc.key on VARIABLE
* NodeChanges to resolve assetRefs. Converting from assetRef to guid makes the
* reference resolvable regardless of whether key/version is present on the
* VARIABLE NodeChange.
*/
function convertColorVarAssetRefs(
paints: unknown,
assetRefToVarGuid: Map<string, GUID>
): unknown {
if (!Array.isArray(paints)) return paints
const result = paints.map((paint: Record<string, unknown>) => {
const colorVar = paint.colorVar as Record<string, unknown> | undefined
if (!colorVar) return paint
const value = colorVar.value as Record<string, unknown> | undefined
if (!value) return paint
const alias = value.alias as Record<string, unknown> | undefined
if (!alias) return paint
// If alias already has guid, nothing to convert
if (alias.guid) return paint
const assetRef = alias.assetRef as { key: string; version?: string } | undefined
if (!assetRef?.key) return paint
// Look up by key@version first, then by key alone
const lookupKey = assetRef.version
? `${assetRef.key}@${assetRef.version}`
: assetRef.key
const guid = assetRefToVarGuid.get(lookupKey) ?? assetRefToVarGuid.get(assetRef.key)
if (!guid) return paint
return {
...paint,
colorVar: {
...colorVar,
value: {
...value,
alias: { guid }
}
}
}
})
// Check if any paint was actually changed (skip expensive JSON comparison)
for (let i = 0; i < paints.length; i++) {
if (result[i] !== paints[i]) return result
}
return paints
}
function applyInstancePayload(
@ -343,8 +411,8 @@ function applyInstancePayload(
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
includePaintVariables: true,
includeVariableMaps: true
}
)
}
@ -359,8 +427,8 @@ function applyInstancePayload(
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
includePaintVariables: true,
includeVariableMaps: true
}
)
}
@ -370,8 +438,8 @@ function applyInstancePayload(
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
includePaintVariables: true,
includeVariableMaps: true
}
)
}
@ -486,6 +554,7 @@ function applyNodeVisualProps(
radius: effect.radius,
spread: effect.spread,
visible: effect.visible,
blendMode: effect.blendMode ?? 'NORMAL',
showShadowBehindNode: effect.showShadowBehindNode
}))
}
@ -544,10 +613,17 @@ export function sceneNodeToKiwiWithContext(
opacity: node.opacity,
phase: 'CREATED',
size: exportNodeSize(node),
transform: exportNodeTransform(context, node),
strokeWeight: node.strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT,
strokeAlign: node.strokes[0]?.align ?? 'INSIDE'
transform: exportNodeTransform(context, node)
}
// Only set strokeWeight/strokeAlign when the node has strokes in the scene
// model. For imported nodes without strokes but with raw strokeWeight data
// (e.g. text nodes, instance children with scaled strokes), the raw value
// must be allowed to flow through via applyRawFigmaNodeFields.
if (node.strokes.length > 0) {
nc.strokeWeight = node.strokes[0].weight
nc.strokeAlign = node.strokes[0].align
}
if (node.locked) nc.locked = true
applyNodeVisualProps(context, node, nc)
applyComponentMetadata(node, nc)

View file

@ -0,0 +1,3 @@
export { convertFontFeatures, applyFontFeaturesToKiwi } from './features'
export { figmaAxisTagToString, stringToFigmaAxisTag, convertFontVariations } from './variations'
export { buildFontDigestMap } from './digests'

View file

@ -11,15 +11,15 @@ export {
FIG_KIWI_DEFAULT_VERSION,
parseFigKiwiChunks
} from '#core/kiwi/fig/container/kiwi'
export { buildFontDigestMap } from './font-digests'
export { buildFontDigestMap } from './font/digests'
import type { NodeChange, Paint, VariableConsumptionEntry } from '#core/kiwi/fig/codec'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Color, GUID, Matrix } from '#core/types'
import { guidToString, stringToGuid, VARIABLE_BINDING_FIELDS } from './convert'
import { sceneNodeToKiwiWithContext, type KiwiNodeChange } from './export-node'
import { applyFontFeaturesToKiwi } from './font-features'
import { buildAssetRefToVarGuidMap, sceneNodeToKiwiWithContext, type KiwiNodeChange } from './export-node'
import { applyFontFeaturesToKiwi } from './font/features'
import {
BOUND_VARIABLES_PLUGIN_KEY,
LAYOUT_DIRECTION_PLUGIN_KEY,
@ -69,8 +69,23 @@ export function mapToFigmaType(type: SceneNode['type']): string {
}
}
/**
* Generate a position string for parentIndex.position.
*
* Positions must be printable ASCII characters (space through tilde),
* with the last character at least '!' (code 33), and must sort
* lexicographically so sibling nodes order correctly.
* The encoding uses a telescoping scheme where each `~` prefix
* adds 94 more positions, like:
* 0:"!",1:"\"", ... 93:"~",94:"~!", ... 187:"~~",188:"~~!", ...
*/
export function fractionalPosition(index: number): string {
return String.fromCharCode('!'.charCodeAt(0) + index)
const BASE = 94
const FIRST = 33 // '!'.charCodeAt(0)
const TILDE = 126 // '~'.charCodeAt(0)
const numTildes = Math.floor(index / BASE)
const lastChar = String.fromCharCode(FIRST + (index % BASE))
return String.fromCharCode(TILDE).repeat(numTildes) + lastChar
}
function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
@ -201,21 +216,28 @@ function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint {
}
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
const hasCornerRadius = node.independentCorners
? node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomLeftRadius > 0 ||
node.bottomRightRadius > 0
: node.cornerRadius > 0
if (hasCornerRadius) {
nc.cornerRadius = node.cornerRadius
if (node.independentCorners) {
nc.rectangleCornerRadiiIndependent = true
nc.rectangleTopLeftCornerRadius = node.topLeftRadius
nc.rectangleTopRightCornerRadius = node.topRightRadius
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
}
const anyIndividual =
node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomLeftRadius > 0 ||
node.bottomRightRadius > 0
if (node.cornerRadius > 0) nc.cornerRadius = node.cornerRadius
// Always emit individual radii when present. A node may have
// independentCorners=false with non-zero individual values (e.g. imported
// from Figma where the flag wasn't set but per-corner values exist).
if (anyIndividual || node.independentCorners) {
// For imported nodes, preserve the original independentCorners flag from
// the raw Figma data. Figma may emit per-corner radii without setting the
// independent flag (preserve rectangleCornerRadiiIndependent).
const rawIndependent = node.source.id
? ((node.source.fig.rawNodeFields as Record<string, unknown> | undefined)
?.rectangleCornerRadiiIndependent)
: undefined
nc.rectangleCornerRadiiIndependent = typeof rawIndependent === 'boolean' ? rawIndependent : node.independentCorners
nc.rectangleTopLeftCornerRadius = node.topLeftRadius
nc.rectangleTopRightCornerRadius = node.topRightRadius
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
}
if (node.cornerSmoothing > 0) {
nc.cornerSmoothing = node.cornerSmoothing
@ -223,6 +245,10 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
}
function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['textAutoResize'] {
// For nodes imported from .fig files, preserve the original textAutoResize
// value. Forcing 'HEIGHT' for fixed-height text inside auto-layout causes
// layout drift on roundtrip.
if (node.source.id) return node.textAutoResize
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
if (
parent &&
@ -257,7 +283,7 @@ function serializeTextProps(
const autoResize = resolveTextAutoResize(node, graph)
nc.textAutoResize = autoResize
nc.textAlignHorizontal = node.textAlignHorizontal
nc.textAlignVertical = 'TOP'
nc.textAlignVertical = node.textAlignVertical
nc.textUserLayoutVersion = 4
nc.textExplicitLayoutVersion = 1
nc.textBidiVersion = 1
@ -267,6 +293,7 @@ function serializeTextProps(
applyFontFeaturesToKiwi(nc, node.fontFeatures)
nc.fontVersion = ''
nc.emojiImageSet = 'APPLE'
if (node.textCase !== 'ORIGINAL') nc.textCase = node.textCase
if (fontDigestMap) {
nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs, glyphBlobMap ?? new Map())
}
@ -310,8 +337,12 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
nc.stackJustify = normalizeStackJustify(figLayout.stackJustify)
nc.stackCounterAlignItems = normalizeStackCounterAlign(figLayout.stackCounterAlignItems)
nc.stackPrimaryAlignItems = normalizeStackJustify(figLayout.stackPrimaryAlignItems)
nc.stackPrimarySizing = normalizeStackSizing(figLayout.stackPrimarySizing)
nc.stackCounterSizing = normalizeStackSizing(figLayout.stackCounterSizing)
// For imported nodes, figLayout captures the original kiwi NC values.
// When stackPrimarySizing is absent (undefined), the kiwi schema default
// is FIXED (enum value 0). node.primaryAxisSizing may differ due to
// import-side override sync, so we prefer figLayout as source of truth.
nc.stackPrimarySizing = normalizeStackSizing(figLayout.stackPrimarySizing) ?? 'FIXED'
nc.stackCounterSizing = normalizeStackSizing(figLayout.stackCounterSizing) ?? 'FIXED'
nc.stackVerticalPadding = figLayout.stackVerticalPadding
nc.stackHorizontalPadding = figLayout.stackHorizontalPadding
nc.stackWrap = figLayout.stackWrap
@ -320,6 +351,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
nc.stackChildAlignSelf = figLayout.stackChildAlignSelf
nc.stackCounterSpacing = figLayout.stackCounterSpacing
nc.bordersTakeSpace = figLayout.bordersTakeSpace
if (figLayout.stackReverseZIndex) nc.stackReverseZIndex = true
return
}
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
@ -337,6 +369,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
nc.bordersTakeSpace = node.strokesIncludedInLayout
}
if (node.itemReverseZIndex) nc.stackReverseZIndex = true
if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE'
if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow
if (node.layoutAlignSelf !== 'AUTO') {
@ -450,9 +483,10 @@ export function sceneNodeToKiwi(
fontDigestMap?: Map<string, Uint8Array>,
varIdToGuid?: Map<string, GUID>,
glyphBlobMap = new Map<string, number>(),
paintVariableColorMap?: Map<string, Color>,
blobIndexByHex?: Map<string, number>
): KiwiNodeChange[] {
// Build assetRef to guid mapping for converting colorVar references in raw paints
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
return sceneNodeToKiwiWithContext(node, parentGuid, childIndex, localIdCounter, {
graph,
blobs,
@ -461,7 +495,7 @@ export function sceneNodeToKiwi(
fontDigestMap,
glyphBlobMap,
varIdToGuid,
paintVariableColorMap,
assetRefToVarGuid,
fractionalPosition,
mapToFigmaType,
fillToKiwiPaint,

View file

@ -2,8 +2,8 @@ import type { NodeChange } from '#core/kiwi/fig/codec'
import type { CharacterStyleOverride, StyleRun } from '#core/scene-graph'
import { styleToWeight } from '#core/text/fonts'
import { convertFontFeatures } from './font-features'
import { convertFontVariations } from './font-variations'
import { convertFontFeatures } from './font/features'
import { convertFontVariations } from './font/variations'
import { convertFills } from './paint'
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'

View file

@ -2,8 +2,8 @@ import type { NodeChange, Paint } from '#core/kiwi/fig/codec'
import type { CharacterStyleOverride, SceneNode } from '#core/scene-graph'
import { normalizeFontFamily, weightToFigmaStyle } from '#core/text/fonts'
import { applyFontFeaturesToKiwi } from './font-features'
import { stringToFigmaAxisTag } from './font-variations'
import { applyFontFeaturesToKiwi } from './font/features'
import { stringToFigmaAxisTag } from './font/variations'
export function fontVariationToKiwi(variation: SceneNode['fontVariations'][number]) {
const axisTag = stringToFigmaAxisTag(variation.axis)

View file

@ -83,6 +83,8 @@ export interface FigParseResult {
blobs: Uint8Array[]
images: Array<[string, Uint8Array]>
figKiwiVersion: number
/** Deflated kiwi schema bytes from the original file (for roundtrip fidelity). */
figSchemaDeflated: Uint8Array
}
export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult {
@ -145,5 +147,11 @@ export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult {
}
}
return { nodeChanges, blobs, images, figKiwiVersion: payload.version }
return {
nodeChanges,
blobs,
images,
figKiwiVersion: payload.version,
figSchemaDeflated: payload.schemaDeflated
}
}

View file

@ -1,5 +1,5 @@
import { getLazyFigImportContext, setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
import { getLazyFigImportContext, setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import { SceneGraph } from '#core/scene-graph'
import type { SceneNode, Variable, VariableCollection, DocumentColorSpace } from '#core/scene-graph'
@ -19,6 +19,7 @@ export interface SerializedSceneGraph {
activeMode: Array<[string, string]>
instanceIndex: Array<[string, string[]]>
figKiwiVersion: number | null
figSchemaDeflated: Uint8Array | null
documentColorSpace: DocumentColorSpace
lazyFigImport?: SerializedLazyFigImportContext
}
@ -34,6 +35,7 @@ export function serializeSceneGraph(graph: SceneGraph): SerializedSceneGraph {
activeMode: [...graph.activeMode],
instanceIndex: [...graph.instanceIndex].map(([id, nodeIds]) => [id, [...nodeIds]]),
figKiwiVersion: graph.figKiwiVersion,
figSchemaDeflated: graph.figSchemaDeflated,
documentColorSpace: graph.documentColorSpace,
lazyFigImport: lazyFigImport
? {
@ -66,6 +68,15 @@ export function serializedSceneGraphTransferList(data: SerializedSceneGraph): Tr
buffers.add(blob.buffer)
}
}
if (data.figSchemaDeflated) {
if (
data.figSchemaDeflated.buffer instanceof ArrayBuffer &&
data.figSchemaDeflated.byteOffset === 0 &&
data.figSchemaDeflated.byteLength === data.figSchemaDeflated.buffer.byteLength
) {
buffers.add(data.figSchemaDeflated.buffer)
}
}
return [...buffers]
}
@ -79,6 +90,7 @@ export function deserializeSceneGraph(data: SerializedSceneGraph): SceneGraph {
graph.activeMode = new Map(data.activeMode)
graph.instanceIndex = new Map(data.instanceIndex.map(([id, nodeIds]) => [id, new Set(nodeIds)]))
graph.figKiwiVersion = data.figKiwiVersion
graph.figSchemaDeflated = data.figSchemaDeflated
graph.documentColorSpace = data.documentColorSpace
if (data.lazyFigImport) {
setLazyFigImportContext(graph, {

View file

@ -18,11 +18,17 @@ type WorkerScope = typeof self & {
self.onmessage = (e: MessageEvent<ArrayBuffer | WorkerParseRequest>) => {
try {
const request = e.data instanceof ArrayBuffer ? { buffer: e.data } : e.data
const { nodeChanges, blobs, images, figKiwiVersion } = parseFigBuffer(request.buffer)
const { nodeChanges, blobs, images, figKiwiVersion, figSchemaDeflated } = parseFigBuffer(
request.buffer
)
const graph = importNodeChanges(nodeChanges, blobs, new Map(images), request.options)
graph.figKiwiVersion = figKiwiVersion
graph.figSchemaDeflated = figSchemaDeflated
const serialized = serializeSceneGraph(graph)
;(self as WorkerScope).postMessage({ graph: serialized }, serializedSceneGraphTransferList(serialized))
;(self as WorkerScope).postMessage(
{ graph: serialized },
serializedSceneGraphTransferList(serialized)
)
} catch (err) {
self.postMessage({ error: err instanceof Error ? err.message : String(err) })
}

View file

@ -48,6 +48,8 @@ export class SceneGraph {
activeMode = new Map<string, string>()
rootId: string
figKiwiVersion: number | null = null
/** Deflated kiwi schema bytes from the original .fig file, preserved for roundtrip fidelity. */
figSchemaDeflated: Uint8Array | null = null
documentColorSpace: DocumentColorSpace = 'display-p3'
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
private absPosCache = new Map<string, Vector>()
@ -325,8 +327,6 @@ export class SceneGraph {
'maxHeight'
])
runPreviewUpdates(fn: () => void): void {
this.previewMutationDepth++
try {

View file

@ -285,7 +285,7 @@ export type FigmaLayoutMetadata = Partial<
| 'stackCounterSpacing',
number
> &
Record<'bordersTakeSpace', boolean>
Record<'bordersTakeSpace' | 'stackReverseZIndex', boolean>
>
export interface SceneNode {
@ -457,6 +457,10 @@ export interface Variable {
valuesByMode: Record<string, VariableValue>
description: string
hiddenFromPublishing: boolean
/** Published library key (from NodeChange.key). Used for assetRef resolution in colorVar. */
key?: string
/** Published library version (from NodeChange.version). Used for assetRef resolution in colorVar. */
version?: string
}
export interface VariableCollectionMode {

View file

@ -0,0 +1,575 @@
import { beforeAll, afterAll, describe, expect, test, spyOn } from 'bun:test'
import { unzipSync } from 'fflate'
import {
exportFigFile,
initCodec,
isZstdCompressed,
parseFigFile,
parseFigKiwiChunks,
type SceneGraph,
type SceneNode
} from '@open-pencil/core'
import {
type Mismatch,
type FixtureSpec,
type CompareOptions,
type Verifier,
isColorObj,
SCENE_VERIFIERS,
RAW_VERIFIERS
} from './helpers'
let dateSpy: { mockRestore(): void } | undefined
beforeAll(() => {
dateSpy = spyOn(Date.prototype, 'toISOString').mockReturnValue('2026-05-24T12:00:00.000Z')
})
afterAll(() => {
dateSpy?.mockRestore()
})
const SKIP_KEYS = new Set(['id', 'parentId', 'childIds', 'componentId'])
function num3(n: number): string {
return (Math.round(n * 1e4) / 1e4).toFixed(4)
}
const EPSILON = 0.01
const MAX_ERRORS = 2000
const SPECS: FixtureSpec[] = [
{
file: 'tests/fixtures/gold-preview.fig',
fileSize: 550091,
nodeCount: 38323,
nodeTypes: {
FRAME: 4525,
ROUNDED_RECTANGLE: 3752,
VECTOR: 14221,
ELLIPSE: 24,
INSTANCE: 11144,
TEXT: 3260,
POLYGON: 94,
COMPONENT: 1293,
COMPONENT_SET: 10
},
schemaSize: 25036,
thumbnailSize: 23810,
thumbnailWidth: 400,
thumbnailHeight: 239,
imageCount: 3,
figKiwiVersion: 101,
g1ExportSize: 595224,
g2ExportSize: 595224
}
]
function pngDimensions(png: Uint8Array): { w: number; h: number } {
const dv = new DataView(png.buffer, png.byteOffset, png.byteLength)
return { w: dv.getUint32(16), h: dv.getUint32(20) }
}
function buildPathMap(graph: SceneGraph): Map<string, SceneNode> {
const map = new Map<string, SceneNode>()
function walk(parentId: string, parentPath: string) {
const children = graph.getChildren(parentId)
for (let i = 0; i < children.length; i++) {
const childPath = `${parentPath}/${i}`
map.set(childPath, children[i])
walk(children[i].id, childPath)
}
}
for (const [p, page] of graph.getPages(true).entries()) {
walk(page.id, `${p}`)
}
return map
}
// oxlint-disable-next-line eslint(complexity)
function deepCompare(
a: unknown,
b: unknown,
key: string,
path: string,
opts: CompareOptions,
depth = 0
): void {
if (opts.errors.length >= MAX_ERRORS || depth > 20) return
if (a === b) return
if (a == null && b == null) return
const leafKey = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key
const vfn = opts.verifiers.get(key) ?? opts.verifiers.get(leafKey)
if (vfn) {
if (vfn({ a, b, key, path, ...opts })) return
if (isColorObj(a) && isColorObj(b)) {
opts.errors.push({
path,
key,
message: `color(${num3(a.r)},${num3(a.g)},${num3(a.b)},${num3(a.a)}) -> color(${num3(b.r)},${num3(b.g)},${num3(b.b)},${num3(b.a)})`
})
} else {
opts.errors.push({ path, key, message: `${fmt(a)} -> ${fmt(b)}` })
}
return
}
if (a == null || b == null) {
opts.errors.push({ path, key, message: `${fmt(a)} -> ${fmt(b)}` })
return
}
if (a instanceof Uint8Array && b instanceof Uint8Array) {
if (a.length !== b.length) {
opts.errors.push({ path, key, message: `bytes ${a.length} -> ${b.length}` })
return
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
opts.errors.push({ path, key, message: `byte[${i}] differs (${a.length}B)` })
return
}
}
return
}
if (typeof a === 'number' && typeof b === 'number') {
if (Number.isNaN(a) && Number.isNaN(b)) return
if (Math.abs(a - b) > EPSILON) {
opts.errors.push({ path, key, message: `${a} -> ${b} (D${+(b - a).toPrecision(4)})` })
}
return
}
if (typeof a !== 'object' || typeof b !== 'object') {
if (a !== b) opts.errors.push({ path, key, message: `${fmt(a)} -> ${fmt(b)}` })
return
}
if (Array.isArray(a) !== Array.isArray(b)) {
opts.errors.push({ path, key, message: `kind mismatch` })
return
}
if (Array.isArray(a)) {
const ba = b as unknown[]
if (a.length !== ba.length) {
opts.errors.push({ path, key, message: `len ${a.length} -> ${ba.length}` })
return
}
for (let i = 0; i < a.length; i++) {
deepCompare(a[i], ba[i], `${key}[${i}]`, path, opts, depth + 1)
}
return
}
const allKeys = new Set([...Object.keys(a as object), ...Object.keys(b as object)])
for (const k of allKeys) {
if (depth === 0 && SKIP_KEYS.has(k)) continue
deepCompare(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k],
key ? `${key}.${k}` : k,
path,
opts,
depth + 1
)
}
}
function fmt(v: unknown): string {
if (v === undefined) return 'undefined'
if (v === null) return 'null'
if (typeof v === 'string') return v.length > 50 ? `"${v.slice(0, 50)}..."` : `"${v}"`
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
if (v instanceof Uint8Array) return `bytes[${v.length}]`
if (Array.isArray(v)) return `[${v.length}]`
return 'Object'
}
function summarize(errors: Mismatch[]): string {
const buckets = new Map<string, string[]>()
for (const err of errors) {
const signature = `${err.key}: ${err.message}`
let list = buckets.get(signature)
if (!list) {
list = []
buckets.set(signature, list)
}
list.push(err.path)
}
const sorted = [...buckets.entries()].sort((a, b) => b[1].length - a[1].length)
let out = `Found ${errors.length} mismatches across ${buckets.size} variants:\n\n`
for (const [sig, paths] of sorted.slice(0, 20)) {
out += `[${paths.length}x] ${sig}\n`
out += ` Paths: ${paths.slice(0, 5).join(', ')}${paths.length > 5 ? ' ...' : ''}\n\n`
}
return out.trim()
}
function compareSceneProps(
fixture: FixtureSpec,
aGraph: SceneGraph,
bGraph: SceneGraph,
aNodes: Map<string, SceneNode>,
bNodes: Map<string, SceneNode>,
label: string,
verifiers: Map<string, Verifier> = SCENE_VERIFIERS
): void {
const errors: Mismatch[] = []
const generation = label.startsWith('G1') ? 1 : 0
const opts: CompareOptions = { aNodes, bNodes, aGraph, bGraph, errors, fixture, verifiers, label, generation }
for (const [p, aNode] of aNodes) {
const bNode = bNodes.get(p)
if (!bNode) continue
for (const k of new Set([...Object.keys(aNode as object), ...Object.keys(bNode as object)])) {
if (SKIP_KEYS.has(k)) continue
if (k === 'source') continue
deepCompare(
(aNode as Record<string, unknown>)[k],
(bNode as Record<string, unknown>)[k],
k,
p,
opts
)
}
}
if (errors.length > 0) throw new Error(`${label} scene props:\n${summarize(errors)}`)
}
function compareRawNodeFields(
fixture: FixtureSpec,
aGraph: SceneGraph,
bGraph: SceneGraph,
aNodes: Map<string, SceneNode>,
bNodes: Map<string, SceneNode>,
label: string,
verifiers: Map<string, Verifier> = RAW_VERIFIERS
): void {
const errors: Mismatch[] = []
const opts: CompareOptions = { aNodes, bNodes, aGraph, bGraph, errors, fixture, verifiers, label }
for (const [p, aNode] of aNodes) {
const bNode = bNodes.get(p)
if (!bNode) continue
const aRaw = (aNode as Record<string, unknown>).source as Record<string, unknown> | undefined
const bRaw = (bNode as Record<string, unknown>).source as Record<string, unknown> | undefined
const aFig = aRaw?.fig as Record<string, unknown> | undefined
const bFig = bRaw?.fig as Record<string, unknown> | undefined
const aFields = aFig?.rawNodeFields as Record<string, unknown> | undefined
const bFields = bFig?.rawNodeFields as Record<string, unknown> | undefined
if (!aFields && !bFields) continue
deepCompareRaw(aFields, bFields, '', p, opts)
}
if (errors.length > 0) throw new Error(`${label} rawNodeFields:\n${summarize(errors)}`)
}
// oxlint-disable-next-line eslint(complexity)
function deepCompareRaw(
a: unknown,
b: unknown,
key: string,
path: string,
opts: CompareOptions,
depth = 0
): void {
if (opts.errors.length >= MAX_ERRORS || depth > 20) return
if (a === b) return
if (a == null && b == null) return
if (typeof key === 'string' && key !== '') {
const leafKey = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key
const vfn = opts.verifiers.get(key) ?? opts.verifiers.get(leafKey)
if (vfn) {
if (vfn({ a, b, key, path, ...opts })) return
}
}
if (
key === '' &&
typeof a === 'object' &&
a !== null &&
typeof b === 'object' &&
b !== null &&
!Array.isArray(a)
) {
const aObj = a as Record<string, unknown>
const bObj = b as Record<string, unknown>
const allKeys = new Set([...Object.keys(aObj), ...Object.keys(bObj)])
for (const k of allKeys) {
const vfn2 = opts.verifiers.get(k)
if (vfn2) {
if (!vfn2({ a: aObj[k], b: bObj[k], key: k, path, ...opts })) {
opts.errors.push({
path,
key: k,
message: `verifier rejected (${fmt(aObj[k])} -> ${fmt(bObj[k])})`
})
}
} else {
deepCompareRaw(aObj[k], bObj[k], k, path, opts, depth + 1)
}
}
return
}
if (a == null || b == null) {
opts.errors.push({ path, key, message: `${fmt(a)} -> ${fmt(b)}` })
return
}
if (a instanceof Uint8Array && b instanceof Uint8Array) {
if (a.length !== b.length) {
opts.errors.push({ path, key, message: `bytes ${a.length} -> ${b.length}` })
return
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
opts.errors.push({ path, key, message: `byte[${i}]` })
return
}
}
return
}
if (typeof a === 'number' && typeof b === 'number') {
if (Number.isNaN(a) && Number.isNaN(b)) return
if (Math.abs(a - b) > EPSILON) opts.errors.push({ path, key, message: `${a} -> ${b}` })
return
}
if (typeof a !== 'object' || typeof b !== 'object') {
if (a !== b) opts.errors.push({ path, key, message: `${fmt(a)} -> ${fmt(b)}` })
return
}
if (Array.isArray(a) !== Array.isArray(b)) {
opts.errors.push({ path, key, message: `kind` })
return
}
if (Array.isArray(a)) {
const ba = b as unknown[]
if (a.length !== ba.length) {
opts.errors.push({ path, key, message: `len ${a.length}->${ba.length}` })
return
}
for (let i = 0; i < a.length; i++) {
deepCompareRaw(a[i], ba[i], `${key}[${i}]`, path, opts, depth + 1)
}
return
}
const aObj = a as Record<string, unknown>
const bObj = b as Record<string, unknown>
const allKeys = new Set([...Object.keys(aObj), ...Object.keys(bObj)])
for (const k of allKeys) {
const fullKey = key ? `${key}.${k}` : k
const vfn2 = opts.verifiers.get(fullKey) ?? opts.verifiers.get(k)
if (vfn2) {
if (!vfn2({ a: aObj[k], b: bObj[k], key: fullKey, path, ...opts })) {
opts.errors.push({
path,
key: fullKey,
message: `verifier rejected (${fmt(aObj[k])} -> ${fmt(bObj[k])})`
})
}
} else {
deepCompareRaw(aObj[k], bObj[k], fullKey, path, opts, depth + 1)
}
}
}
function verifyFixture(spec: FixtureSpec): void {
const name = (spec.file.split('/').pop() ?? '').replace('.fig', '')
describe(`roundtrip: ${name}`, () => {
let g0!: Map<string, SceneNode>
let g0Graph!: SceneGraph
let g0Bytes!: ArrayBuffer
let g0Zip!: Record<string, Uint8Array>
let g0Chunks!: Uint8Array[]
let g1!: Map<string, SceneNode>
let g1Graph!: SceneGraph
let g1Export!: Uint8Array
let g2!: Map<string, SceneNode>
let g2Export!: Uint8Array
let g2Graph!: SceneGraph
const g0Ready = (async () => {
await initCodec()
g0Bytes = await Bun.file(spec.file).arrayBuffer()
g0Zip = unzipSync(new Uint8Array(g0Bytes))
const chunks = parseFigKiwiChunks(g0Zip['canvas.fig'])
if (!chunks) throw new Error('canvas.fig chunks not found')
g0Chunks = chunks
g0Graph = await parseFigFile(g0Bytes)
g0 = buildPathMap(g0Graph)
})()
let g1Ready: Promise<void> | null = null
function ensureG1(): Promise<void> {
if (!g1Ready) {
g1Ready = (async () => {
await g0Ready
g1Export = await exportFigFile(g0Graph)
g1Graph = await parseFigFile(g1Export.buffer as ArrayBuffer)
g1 = buildPathMap(g1Graph)
})()
}
return g1Ready
}
let g2Ready: Promise<void> | null = null
function ensureG2(): Promise<void> {
if (!g2Ready) {
g2Ready = (async () => {
await ensureG1()
g2Export = await exportFigFile(g1Graph)
g2Graph = await parseFigFile(g2Export.buffer as ArrayBuffer)
g2 = buildPathMap(g2Graph)
})()
}
return g2Ready
}
test('original file size', async () => {
await g0Ready
expect(g0Bytes.byteLength).toBe(spec.fileSize)
})
test('original ZIP structure', async () => {
await g0Ready
const entries = Object.keys(g0Zip)
expect(entries).toContain('canvas.fig')
expect(entries).toContain('thumbnail.png')
expect(entries).toContain('meta.json')
const imageEntries = entries.filter((n) => n.startsWith('images/') && n.length > 7)
expect(imageEntries.length).toBe(spec.imageCount)
})
test('original thumbnail dimensions', async () => {
await g0Ready
const thumb = g0Zip['thumbnail.png']
expect(thumb.byteLength).toBe(spec.thumbnailSize)
const { w, h } = pngDimensions(thumb)
expect(w).toBe(spec.thumbnailWidth)
expect(h).toBe(spec.thumbnailHeight)
})
test('original fig-kiwi container', async () => {
await g0Ready
const canvas = g0Zip['canvas.fig']
const dv = new DataView(canvas.buffer, canvas.byteOffset, canvas.byteLength)
expect(dv.getUint32(8, true)).toBe(spec.figKiwiVersion)
expect(g0Chunks[0].byteLength).toBe(spec.schemaSize)
expect(isZstdCompressed(g0Chunks[1])).toBe(true)
})
test('G0 node count', async () => {
await g0Ready
expect(g0.size).toBe(spec.nodeCount)
})
test('G0 node type distribution', async () => {
await g0Ready
const typeCounts = new Map<string, number>()
for (const node of g0.values()) {
typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1)
}
for (const [type, count] of Object.entries(spec.nodeTypes)) {
expect(typeCounts.get(type) ?? 0, `node type ${type}`).toBe(count)
}
})
test('G0->G1 schema bytes identical', async () => {
await ensureG1()
const g1Chunks = parseFigKiwiChunks(unzipSync(g1Export)['canvas.fig'])
if (!g1Chunks) throw new Error('G1 canvas.fig chunks not found')
expect(g1Chunks[0].byteLength).toBe(g0Chunks[0].byteLength)
})
test('G1 export size', async () => {
await ensureG1()
expect(g1Export.byteLength).toBe(spec.g1ExportSize)
})
test('G2 export size', async () => {
await ensureG2()
expect(g2Export.byteLength).toBe(spec.g2ExportSize)
})
test('G0->G1 node count', async () => {
await ensureG1()
expect(g1.size, `G0=${g0.size} G1=${g1.size}`).toBe(g0.size)
})
test('G0->G1 tree paths', async () => {
await ensureG1()
const missing = [...g0.keys()].filter((p) => !g1.has(p))
const extra = [...g1.keys()].filter((p) => !g0.has(p))
const parts: string[] = []
if (missing.length) parts.push(`Missing in G1:\n${missing.slice(0, 30).join('\n')}`)
if (extra.length) parts.push(`Extra in G1:\n${extra.slice(0, 30).join('\n')}`)
expect(missing.length + extra.length, parts.join('\n\n')).toBe(0)
})
test('G0->G1 node types', async () => {
await ensureG1()
const bad: string[] = []
for (const [p, n0] of g0) {
const n1 = g1.get(p)
if (n1 && n0.type !== n1.type) bad.push(`${p}: ${n0.type}->${n1.type}`)
}
expect(bad.length, bad.join('\n')).toBe(0)
})
test('G0->G1 scene props', async () => {
await ensureG1()
compareSceneProps(spec, g0Graph, g1Graph, g0, g1, 'G0->G1')
})
test('G0->G1 rawNodeFields', async () => {
await ensureG1()
compareRawNodeFields(spec, g0Graph, g1Graph, g0, g1, 'G0->G1')
})
test('G1->G2 idempotent node count', async () => {
await ensureG2()
expect(g2.size, `G1=${g1.size} G2=${g2.size}`).toBe(g1.size)
})
test('G1->G2 idempotent paths', async () => {
await ensureG2()
const missing = [...g1.keys()].filter((p) => !g2.has(p))
const extra = [...g2.keys()].filter((p) => !g1.has(p))
const parts: string[] = []
if (missing.length) parts.push(`Missing in G2:\n${missing.slice(0, 30).join('\n')}`)
if (extra.length) parts.push(`Extra in G2:\n${extra.slice(0, 30).join('\n')}`)
expect(missing.length + extra.length, parts.join('\n\n')).toBe(0)
})
test('G1->G2 idempotent scene props', async () => {
await ensureG2()
compareSceneProps(spec, g1Graph, g2Graph, g1, g2, 'G1->G2')
})
test('G1->G2 idempotent rawNodeFields', async () => {
await ensureG2()
compareRawNodeFields(spec, g1Graph, g2Graph, g1, g2, 'G1->G2')
})
test.todo('BUG: corner radius 999 sentinel lost for non-pill nodes on scene import (40 nodes, raw data preserved)')
test.todo('BUG: componentPropDefs verifier rejects 9 instances (verifier logic gap)')
test.todo('BUG: derivedTextData baseline precision differs from raw (14 instances, font metrics)')
})
}
for (const spec of SPECS) {
verifyFixture(spec)
}

View file

@ -3,14 +3,17 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
import { parseFigBuffer } from '#core/kiwi/fig/parse/core'
import { fontManager } from '@open-pencil/core/text'
import { parseFigBuffer } from '#core/kiwi/fig/parse/core'
const FIXTURES = resolve(import.meta.dir, '../../../../fixtures')
const INTER_ASSETS = resolve(import.meta.dir, '../../../../../packages/core/assets')
function countGlyphBlobs(bytes: Uint8Array) {
const parsed = parseFigBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
const parsed = parseFigBuffer(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
)
let glyphs = 0
let glyphsWithBlob = 0
const uniqueGlyphBlobs = new Set<number>()
@ -50,7 +53,10 @@ describe('roundtrip: text glyph blobs', () => {
const input = countGlyphBlobs(fixtureBytes)
const graph = await parseFigFile(
fixtureBytes.buffer.slice(fixtureBytes.byteOffset, fixtureBytes.byteOffset + fixtureBytes.byteLength)
fixtureBytes.buffer.slice(
fixtureBytes.byteOffset,
fixtureBytes.byteOffset + fixtureBytes.byteLength
)
)
const exported = await exportFigFile(graph)
const output = countGlyphBlobs(exported)

View file

@ -0,0 +1,593 @@
import type { SceneGraph, SceneNode, GUID } from '@open-pencil/core'
export interface Mismatch {
path: string
key: string
message: string
}
export interface FixtureSpec {
file: string
fileSize: number
nodeCount: number
nodeTypes: Record<string, number>
schemaSize: number
thumbnailSize: number
thumbnailWidth: number
thumbnailHeight: number
imageCount: number
figKiwiVersion: number
g1ExportSize: number
g2ExportSize: number
}
export interface VerifierContext {
a: unknown
b: unknown
key: string
path: string
aNodes: Map<string, SceneNode>
bNodes: Map<string, SceneNode>
aGraph: SceneGraph
bGraph: SceneGraph
errors: Mismatch[]
fixture: FixtureSpec
label: string
/** Roundtrip generation: 0 for G0→G1 (allows semantic equivalence), 1 for G1→G2 (requires exact match). */
generation: number
}
export interface CompareOptions extends Omit<VerifierContext, 'a' | 'b' | 'key' | 'path'> {
verifiers: Map<string, Verifier>
}
/** G1→G2 must be exactly equal (idempotent export). G0→G1 allows semantic equivalence. */
const isIdempotent = (ctx: VerifierContext): boolean => ctx.generation === 1
export type Verifier = (ctx: VerifierContext) => boolean
export function isColorObj(v: unknown): v is Record<string, number> {
if (!v || typeof v !== 'object') return false
const c = v as Record<string, number>
return (
typeof c.r === 'number' &&
typeof c.g === 'number' &&
typeof c.b === 'number' &&
typeof c.a === 'number'
)
}
function verifyFontDigest(
amDigest: unknown,
bmDigest: unknown,
i: number,
ctx: VerifierContext
): void {
if (amDigest && bmDigest) {
const amHex =
typeof amDigest === 'string'
? amDigest
: Buffer.from(amDigest as Uint8Array).toString('hex')
const bmHex =
typeof bmDigest === 'string'
? bmDigest
: Buffer.from(bmDigest as Uint8Array).toString('hex')
if (amHex !== bmHex) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontDigest`,
message: `mismatch`
})
}
}
}
function verifyFontLineHeight(
amLH: unknown,
bmLH: unknown,
i: number,
ctx: VerifierContext
): void {
const amLineHeight = typeof amLH === 'number' ? amLH : 1.2
const bmLineHeight = typeof bmLH === 'number' ? bmLH : 1.2
if (bmLineHeight !== 1.2 && Math.abs(amLineHeight - bmLineHeight) > 0.05) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontLineHeight`,
message: `${amLineHeight} vs ${bmLineHeight}`
})
}
}
function verifySingleFontMetadata(
am: Record<string, unknown>,
bm: Record<string, unknown>,
i: number,
ctx: VerifierContext
): void {
const amKey = am.key as Record<string, unknown> | undefined
const bmKey = bm.key as Record<string, unknown> | undefined
if (amKey?.family !== bmKey?.family) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].key.family`,
message: `${String(amKey?.family)} vs ${String(bmKey?.family)}`
})
}
if (amKey?.style !== bmKey?.style) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].key.style`,
message: `${String(amKey?.style)} vs ${String(bmKey?.style)}`
})
}
if (am.fontWeight !== bm.fontWeight) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontWeight`,
message: `${String(am.fontWeight)} vs ${String(bm.fontWeight)}`
})
}
if (am.fontStyle !== bm.fontStyle) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData[${i}].fontStyle`,
message: `${String(am.fontStyle)} vs ${String(bm.fontStyle)}`
})
}
verifyFontLineHeight(am.fontLineHeight, bm.fontLineHeight, i, ctx)
verifyFontDigest(am.fontDigest, bm.fontDigest, i, ctx)
}
function verifyFontMetadata(
aMeta: Record<string, unknown>[],
bMeta: Record<string, unknown>[],
ctx: VerifierContext
): void {
for (let i = 0; i < aMeta.length; i++) {
verifySingleFontMetadata(aMeta[i], bMeta[i], i, ctx)
}
}
function verifyBaselines(
bBaselines: Record<string, unknown>[],
node: SceneNode,
ctx: VerifierContext
): void {
const expectedLineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
const expectedLineAscent = Math.max(expectedLineHeight - node.fontSize * 0.2, 0)
for (let i = 0; i < bBaselines.length; i++) {
const bb = bBaselines[i]
const bbLineHeight = typeof bb.lineHeight === 'number' ? bb.lineHeight : 0
const bbLineAscent = typeof bb.lineAscent === 'number' ? bb.lineAscent : 0
if (Math.abs(bbLineHeight - expectedLineHeight) > 0.01) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.baselines[${i}].lineHeight`,
message: `expected fallback ${expectedLineHeight}, got ${bbLineHeight}`
})
}
if (Math.abs(bbLineAscent - expectedLineAscent) > 0.01) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.baselines[${i}].lineAscent`,
message: `expected fallback ${expectedLineAscent}, got ${bbLineAscent}`
})
}
}
}
export const SCENE_VERIFIERS = new Map<string, Verifier>([
[
'pluginData',
(ctx) => {
const ga = ctx.a as Array<{ pluginId: string; key: string; value: string }>
const gb = ctx.b as Array<{ pluginId: string; key: string; value: string }>
if (!Array.isArray(ga) || !Array.isArray(gb) || ga.length > gb.length) return false
for (const e of ga) {
const found = gb.find((e2) => e2.pluginId === e.pluginId && e2.key === e.key)
if (!found || found.value !== e.value) return false
}
return true
}
],
[
'color',
(ctx) => {
const { a, b } = ctx
if (!isColorObj(a) || !isColorObj(b)) return false
return (
Math.abs(a.r - b.r) <= 0.005 &&
Math.abs(a.g - b.g) <= 0.005 &&
Math.abs(a.b - b.b) <= 0.005 &&
Math.abs(a.a - b.a) <= 0.005
)
}
],
[
'type',
(ctx) => {
if (!ctx.key.includes('componentPropertyDefinitions')) return false
return ctx.a === 'VARIANT' && ctx.b === 'TEXT'
}
]
])
function verifySingleComponentPropDef(
ad: Record<string, unknown>,
bd: Record<string, unknown>
): boolean {
if (JSON.stringify(ad.id) !== JSON.stringify(bd.id)) return false
if (ad.name !== bd.name) return false
if (ad.type !== bd.type) return false
const ai = ad.initialValue as Record<string, unknown> | undefined
const bi = bd.initialValue as Record<string, unknown> | undefined
if (ai || bi) {
if (!ai || !bi) return false
const aiText = ai.textValue as Record<string, unknown> | undefined
const biText = bi.textValue as Record<string, unknown> | undefined
const aiSwap = ai.instanceSwapValue as Record<string, unknown> | undefined
const aiSwapGuid = aiSwap?.guid as GUID | undefined
let expectedStr: string | undefined
if (aiText?.characters !== undefined) {
expectedStr = aiText.characters as string
} else if (ai.boolValue !== undefined) {
expectedStr = String(ai.boolValue)
} else if (aiSwapGuid) {
expectedStr = `${aiSwapGuid.sessionID}:${aiSwapGuid.localID}`
}
const actualStr = biText?.characters as string | undefined
if (expectedStr !== undefined && actualStr !== undefined) {
if (expectedStr !== actualStr) return false
}
}
return true
}
function verifyAEntries(
aEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
bEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
ctx: VerifierContext
): void {
for (const entryA of aEntries) {
const aliasA = entryA.variableData?.value?.alias
if (aliasA?.guid) {
const found = bEntries.find((entryB) => {
const aliasB = entryB.variableData?.value?.alias
return aliasB?.guid && JSON.stringify(aliasB.guid) === JSON.stringify(aliasA.guid)
})
if (!found) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `local variable with guid ${JSON.stringify(aliasA.guid)} not preserved in roundtrip`
})
}
}
if (aliasA?.assetRef) {
const found = bEntries.find((entryB) => {
const aliasB = entryB.variableData?.value?.alias
return (
aliasB?.assetRef &&
JSON.stringify(aliasB.assetRef) === JSON.stringify(aliasA.assetRef)
)
})
if (found && found.variableField !== entryA.variableField) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `library variable field mismatch: expected ${entryA.variableField}, got ${found.variableField}`
})
}
}
}
}
function verifyBEntries(
aEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
bEntries: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>,
ctx: VerifierContext
): void {
for (const entryB of bEntries) {
const aliasB = entryB.variableData?.value?.alias
if (aliasB?.guid) {
const found = aEntries.find((entryA) => {
const aliasA = entryA.variableData?.value?.alias
return aliasA?.guid && JSON.stringify(aliasA.guid) === JSON.stringify(aliasB.guid)
})
if (!found) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `unexpected local variable with guid ${JSON.stringify(aliasB.guid)} created in roundtrip`
})
}
}
}
}
function verifyVariableConsumption(
ga:
| {
entries?: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>
}
| undefined,
gb:
| {
entries?: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>
}
| undefined,
ctx: VerifierContext
): void {
const aEntries = ga?.entries ?? []
const bEntries = gb?.entries ?? []
verifyAEntries(aEntries, bEntries, ctx)
verifyBEntries(aEntries, bEntries, ctx)
}
function verifyVarAlias(a: unknown, b: unknown): boolean {
const aVal = a as Record<string, unknown> | undefined
const bVal = b as Record<string, unknown> | undefined
if (!aVal && !bVal) return true
if (!aVal || !bVal) return false
const aAlias = (aVal.value as Record<string, unknown>)?.alias as Record<string, unknown> | undefined
const bAlias = (bVal.value as Record<string, unknown>)?.alias as Record<string, unknown> | undefined
const aGuid = aAlias?.guid
const bGuid = bAlias?.guid
const aRef = aAlias?.assetRef
const bRef = bAlias?.assetRef
if (aGuid && bGuid) return JSON.stringify(aGuid) === JSON.stringify(bGuid)
if ((aRef && bGuid) || (aGuid && bRef)) return true
if (aRef && bRef) return JSON.stringify(aRef) === JSON.stringify(bRef)
return false
}
/** Verifier that defaults undefined values and compares strictly. */
function defaultEqual(defaultVal: unknown): Verifier {
return (ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? defaultVal : ctx.a
const bVal = ctx.b === undefined ? defaultVal : ctx.b
return aVal === bVal
}
}
export const RAW_VERIFIERS = new Map<string, Verifier>([
[
'letterSpacing',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const g1raw = ctx.b as Record<string, unknown> | undefined
const node = ctx.aNodes.get(ctx.path)
if (!node || node.fontSize == null) return true
const expected = node.letterSpacing
const actual = g1raw?.value as number | undefined
if (expected != null && actual != null && Math.abs(expected - actual) > 0.05) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `${expected} (scene) vs ${actual} (raw)`
})
}
return true
}
],
[
'lineHeight',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const g1raw = ctx.b as Record<string, unknown> | undefined
const node = ctx.aNodes.get(ctx.path)
if (!node || node.lineHeight == null) return true
const expected = node.lineHeight
const actual = g1raw?.value as number | undefined
if (expected != null && actual != null && Math.abs(expected - actual) > 0.5) {
ctx.errors.push({
path: ctx.path,
key: ctx.key,
message: `${expected} (scene) vs ${actual} (raw)`
})
}
return true
}
],
[
'variableConsumptionMap',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const ga = ctx.a as
| {
entries?: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>
}
| undefined
const gb = ctx.b as
| {
entries?: Array<{
variableData?: { value?: { alias?: { guid?: unknown; assetRef?: unknown } } }
variableField?: string
}>
}
| undefined
verifyVariableConsumption(ga, gb, ctx)
return true
}
],
[
'parameterConsumptionMap',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return true
}
],
['borderRightWeight', defaultEqual(0)],
['borderLeftWeight', defaultEqual(0)],
['borderTopWeight', defaultEqual(0)],
['borderBottomWeight', defaultEqual(0)],
[
'componentPropDefs',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a as Record<string, unknown>[] | undefined
const bVal = ctx.b as Record<string, unknown>[] | undefined
if (!aVal && !bVal) return true
if (!aVal || !bVal) return false
if (aVal.length !== bVal.length) return false
for (let i = 0; i < aVal.length; i++) {
if (!verifySingleComponentPropDef(aVal[i], bVal[i])) return false
}
return true
}
],
[
'derivedTextData',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a as Record<string, unknown> | undefined
const bVal = ctx.b as Record<string, unknown> | undefined
if (!aVal && !bVal) return true
if (!aVal || !bVal) return true
const aMeta = (aVal.fontMetaData as Record<string, unknown>[]) ?? []
const bMeta = (bVal.fontMetaData as Record<string, unknown>[]) ?? []
if (aMeta.length !== bMeta.length) {
ctx.errors.push({
path: ctx.path,
key: `${ctx.key}.fontMetaData`,
message: `length mismatch: ${aMeta.length} vs ${bMeta.length}`
})
} else {
verifyFontMetadata(aMeta, bMeta, ctx)
}
const bBaselines = (bVal.baselines as Record<string, unknown>[]) ?? []
const node = ctx.aNodes.get(ctx.path)
if (node && bBaselines.length > 0) {
verifyBaselines(bBaselines, node, ctx)
}
return true
}
],
['styleId', defaultEqual(0)],
[
'indentationLevel',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 0 : ctx.a
const bVal = ctx.b === undefined ? 0 : ctx.b
return aVal === bVal
}
],
[
'sourceDirectionality',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'AUTO' : ctx.a
const bVal = ctx.b === undefined ? 'AUTO' : ctx.b
return aVal === bVal
}
],
[
'listStartOffset',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 0 : ctx.a
const bVal = ctx.b === undefined ? 0 : ctx.b
return aVal === bVal
}
],
[
'isFirstLineOfList',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? false : ctx.a
const bVal = ctx.b === undefined ? false : ctx.b
return aVal === bVal
}
],
[
'directionality',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'AUTO' : ctx.a
const bVal = ctx.b === undefined ? 'AUTO' : ctx.b
return aVal === bVal
}
],
[
'directionalityIntent',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'AUTO' : ctx.a
const bVal = ctx.b === undefined ? 'AUTO' : ctx.b
return aVal === bVal
}
],
[
'fontVersion',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return ctx.b === '' || ctx.a === ctx.b
}
],
['postscript', (ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return ctx.b === '' || ctx.a === ctx.b
}],
['textExplicitLayoutVersion', defaultEqual(1)],
[
'textUserLayoutVersion',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return (ctx.a === 3 || ctx.a === 4 || ctx.a === 5) && ctx.b === 4
}
],
[
'blendMode',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
const aVal = ctx.a === undefined ? 'NORMAL' : ctx.a
const bVal = ctx.b === undefined ? 'NORMAL' : ctx.b
return aVal === bVal
}
],
// colorVar and opacityVar: G0 raw paints use alias.assetRef for library
// variables; G1 converts these to alias.guid for local resolution. Both
// reference the same variable — semantically equivalent.
[
'colorVar',
(ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return verifyVarAlias(ctx.a, ctx.b)
}
],
['opacityVar', (ctx) => {
if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b)
return verifyVarAlias(ctx.a, ctx.b)
}]
])