From c4073dc2844a33aa85244cfab4d748e970d8d5de Mon Sep 17 00:00:00 2001 From: Joseph Cumines Date: Sun, 24 May 2026 18:29:14 +1000 Subject: [PATCH] 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. --- packages/core/src/io/formats/fig/export.ts | 71 ++- packages/core/src/io/formats/fig/read.ts | 9 +- packages/core/src/io/subgraph.ts | 1 + packages/core/src/kiwi/fig/import.ts | 4 +- .../core/src/kiwi/fig/node-change/convert.ts | 40 +- .../src/kiwi/fig/node-change/export-node.ts | 230 ++++--- .../{font-digests.ts => font/digests.ts} | 0 .../{font-features.ts => font/features.ts} | 0 .../src/kiwi/fig/node-change/font/index.ts | 3 + .../variations.ts} | 0 .../src/kiwi/fig/node-change/serialize.ts | 82 ++- .../src/kiwi/fig/node-change/style-runs.ts | 4 +- .../kiwi/fig/node-change/text-data-export.ts | 4 +- packages/core/src/kiwi/fig/parse/core.ts | 10 +- packages/core/src/kiwi/fig/parse/transfer.ts | 14 +- packages/core/src/kiwi/fig/parse/worker.ts | 10 +- packages/core/src/scene-graph/index.ts | 4 +- packages/core/src/scene-graph/types.ts | 6 +- .../io/fig/roundtrip/exhaustive.test.ts | 575 +++++++++++++++++ .../io/fig/roundtrip/glyph-blob.test.ts | 12 +- tests/engine/io/fig/roundtrip/helpers.ts | 593 ++++++++++++++++++ 21 files changed, 1525 insertions(+), 147 deletions(-) rename packages/core/src/kiwi/fig/node-change/{font-digests.ts => font/digests.ts} (100%) rename packages/core/src/kiwi/fig/node-change/{font-features.ts => font/features.ts} (100%) create mode 100644 packages/core/src/kiwi/fig/node-change/font/index.ts rename packages/core/src/kiwi/fig/node-change/{font-variations.ts => font/variations.ts} (100%) create mode 100644 tests/engine/io/fig/roundtrip/exhaustive.test.ts create mode 100644 tests/engine/io/fig/roundtrip/helpers.ts diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index e00a3a526..65fdb0a32 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -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 { 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 { 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 + let schemaDeflated: Uint8Array + if (graph.figSchemaDeflated) { + const schemaBytes = inflateSync(graph.figSchemaDeflated) + const figSchema = decodeBinarySchema(new ByteBuffer(schemaBytes)) + compiled = compileSchema(figSchema) as ReturnType + 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() const blobIndexByHex = new Map() - 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 ) ) diff --git a/packages/core/src/io/formats/fig/read.ts b/packages/core/src/io/formats/fig/read.ts index fe882f655..5a1b77629 100644 --- a/packages/core/src/io/formats/fig/read.ts +++ b/packages/core/src/io/formats/fig/read.ts @@ -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 } diff --git a/packages/core/src/io/subgraph.ts b/packages/core/src/io/subgraph.ts index 867695bf1..c9f59c44e 100644 --- a/packages/core/src/io/subgraph.ts +++ b/packages/core/src/io/subgraph.ts @@ -17,6 +17,7 @@ function cloneIntoGraph(source: SceneGraph, ids: Set): 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) => { diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index e4a470d8b..205210cb9 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -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 }) } } diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 112e0c6b3..c1cacb98e 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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 ): 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 { diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 6082adbe6..5ff60a794 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -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 +/** + * 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 +): Map { + const map = new Map() + for (const [varId, variable] of graph.variables) { + if (!variable.key) continue + const guid = varIdToGuid.get(varId) ?? stringToGuid(varId) + map.set(variable.key, guid) + if (variable.version) { + map.set(`${variable.key}@${variable.version}`, guid) + } + } + return map +} + interface SceneNodeToKiwiContext { graph: SceneGraph blobs: Uint8Array[] @@ -21,7 +43,9 @@ interface SceneNodeToKiwiContext { fontDigestMap?: Map glyphBlobMap?: Map varIdToGuid?: Map - paintVariableColorMap?: Map + /** 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 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 includePaintVariables?: boolean includeVariableMaps?: boolean - paintVariableColorMap?: Map } function materializeFigmaBlob( @@ -215,9 +226,6 @@ function materializeFigmaPayload( } const materialized: Record = {} - 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> -): 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() - 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 { - const counts = new Map>() - 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() - 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() 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 + 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)[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)[key] = converted + continue + } + // Skip any key already set on nc — explicit serialization takes priority + if (key in (nc as Record)) continue + ;(nc as Record)[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 +): unknown { + if (!Array.isArray(paints)) return paints + const result = paints.map((paint: Record) => { + const colorVar = paint.colorVar as Record | undefined + if (!colorVar) return paint + const value = colorVar.value as Record | undefined + if (!value) return paint + const alias = value.alias as Record | 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) diff --git a/packages/core/src/kiwi/fig/node-change/font-digests.ts b/packages/core/src/kiwi/fig/node-change/font/digests.ts similarity index 100% rename from packages/core/src/kiwi/fig/node-change/font-digests.ts rename to packages/core/src/kiwi/fig/node-change/font/digests.ts diff --git a/packages/core/src/kiwi/fig/node-change/font-features.ts b/packages/core/src/kiwi/fig/node-change/font/features.ts similarity index 100% rename from packages/core/src/kiwi/fig/node-change/font-features.ts rename to packages/core/src/kiwi/fig/node-change/font/features.ts diff --git a/packages/core/src/kiwi/fig/node-change/font/index.ts b/packages/core/src/kiwi/fig/node-change/font/index.ts new file mode 100644 index 000000000..d51fc7a70 --- /dev/null +++ b/packages/core/src/kiwi/fig/node-change/font/index.ts @@ -0,0 +1,3 @@ +export { convertFontFeatures, applyFontFeaturesToKiwi } from './features' +export { figmaAxisTagToString, stringToFigmaAxisTag, convertFontVariations } from './variations' +export { buildFontDigestMap } from './digests' diff --git a/packages/core/src/kiwi/fig/node-change/font-variations.ts b/packages/core/src/kiwi/fig/node-change/font/variations.ts similarity index 100% rename from packages/core/src/kiwi/fig/node-change/font-variations.ts rename to packages/core/src/kiwi/fig/node-change/font/variations.ts diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 82a20ccaa..47168a481 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -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['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 | 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, varIdToGuid?: Map, glyphBlobMap = new Map(), - paintVariableColorMap?: Map, blobIndexByHex?: Map ): 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, diff --git a/packages/core/src/kiwi/fig/node-change/style-runs.ts b/packages/core/src/kiwi/fig/node-change/style-runs.ts index 1ba31339b..ac5d86733 100644 --- a/packages/core/src/kiwi/fig/node-change/style-runs.ts +++ b/packages/core/src/kiwi/fig/node-change/style-runs.ts @@ -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' diff --git a/packages/core/src/kiwi/fig/node-change/text-data-export.ts b/packages/core/src/kiwi/fig/node-change/text-data-export.ts index 3e68ac2b2..b045cbfdc 100644 --- a/packages/core/src/kiwi/fig/node-change/text-data-export.ts +++ b/packages/core/src/kiwi/fig/node-change/text-data-export.ts @@ -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) diff --git a/packages/core/src/kiwi/fig/parse/core.ts b/packages/core/src/kiwi/fig/parse/core.ts index 708dd7d55..dbb9188da 100644 --- a/packages/core/src/kiwi/fig/parse/core.ts +++ b/packages/core/src/kiwi/fig/parse/core.ts @@ -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 + } } diff --git a/packages/core/src/kiwi/fig/parse/transfer.ts b/packages/core/src/kiwi/fig/parse/transfer.ts index 54c8b0119..e1c8c63eb 100644 --- a/packages/core/src/kiwi/fig/parse/transfer.ts +++ b/packages/core/src/kiwi/fig/parse/transfer.ts @@ -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, { diff --git a/packages/core/src/kiwi/fig/parse/worker.ts b/packages/core/src/kiwi/fig/parse/worker.ts index 3e17b78d7..42292df81 100644 --- a/packages/core/src/kiwi/fig/parse/worker.ts +++ b/packages/core/src/kiwi/fig/parse/worker.ts @@ -18,11 +18,17 @@ type WorkerScope = typeof self & { self.onmessage = (e: MessageEvent) => { 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) }) } diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index 7a2e3e3d0..44527c8ea 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -48,6 +48,8 @@ export class SceneGraph { activeMode = new Map() 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 = createNanoEvents() private absPosCache = new Map() @@ -325,8 +327,6 @@ export class SceneGraph { 'maxHeight' ]) - - runPreviewUpdates(fn: () => void): void { this.previewMutationDepth++ try { diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 15c12ea8e..3666a148d 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -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 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 { diff --git a/tests/engine/io/fig/roundtrip/exhaustive.test.ts b/tests/engine/io/fig/roundtrip/exhaustive.test.ts new file mode 100644 index 000000000..05fa36812 --- /dev/null +++ b/tests/engine/io/fig/roundtrip/exhaustive.test.ts @@ -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 { + const map = new Map() + 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)[k], + (b as Record)[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() + 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, + bNodes: Map, + label: string, + verifiers: Map = 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)[k], + (bNode as Record)[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, + bNodes: Map, + label: string, + verifiers: Map = 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).source as Record | undefined + const bRaw = (bNode as Record).source as Record | undefined + const aFig = aRaw?.fig as Record | undefined + const bFig = bRaw?.fig as Record | undefined + const aFields = aFig?.rawNodeFields as Record | undefined + const bFields = bFig?.rawNodeFields as Record | 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 + const bObj = b as Record + 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 + const bObj = b as Record + 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 + let g0Graph!: SceneGraph + let g0Bytes!: ArrayBuffer + let g0Zip!: Record + let g0Chunks!: Uint8Array[] + let g1!: Map + let g1Graph!: SceneGraph + let g1Export!: Uint8Array + let g2!: Map + 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 | null = null + function ensureG1(): Promise { + 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 | null = null + function ensureG2(): Promise { + 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() + 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) +} diff --git a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts index 6900976d5..a26cffac1 100644 --- a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts +++ b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts @@ -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() @@ -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) diff --git a/tests/engine/io/fig/roundtrip/helpers.ts b/tests/engine/io/fig/roundtrip/helpers.ts new file mode 100644 index 000000000..e90140694 --- /dev/null +++ b/tests/engine/io/fig/roundtrip/helpers.ts @@ -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 + 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 + bNodes: Map + 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 { + verifiers: Map +} + +/** 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 { + if (!v || typeof v !== 'object') return false + const c = v as Record + 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, + bm: Record, + i: number, + ctx: VerifierContext +): void { + const amKey = am.key as Record | undefined + const bmKey = bm.key as Record | 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[], + bMeta: Record[], + ctx: VerifierContext +): void { + for (let i = 0; i < aMeta.length; i++) { + verifySingleFontMetadata(aMeta[i], bMeta[i], i, ctx) + } +} + +function verifyBaselines( + bBaselines: Record[], + 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([ + [ + '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, + bd: Record +): 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 | undefined + const bi = bd.initialValue as Record | undefined + if (ai || bi) { + if (!ai || !bi) return false + const aiText = ai.textValue as Record | undefined + const biText = bi.textValue as Record | undefined + const aiSwap = ai.instanceSwapValue as Record | 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 | undefined + const bVal = b as Record | undefined + if (!aVal && !bVal) return true + if (!aVal || !bVal) return false + const aAlias = (aVal.value as Record)?.alias as Record | undefined + const bAlias = (bVal.value as Record)?.alias as Record | 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([ + [ + 'letterSpacing', + (ctx) => { + if (isIdempotent(ctx)) return JSON.stringify(ctx.a) === JSON.stringify(ctx.b) + const g1raw = ctx.b as Record | 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 | 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[] | undefined + const bVal = ctx.b as Record[] | 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 | undefined + const bVal = ctx.b as Record | undefined + if (!aVal && !bVal) return true + if (!aVal || !bVal) return true + + const aMeta = (aVal.fontMetaData as Record[]) ?? [] + const bMeta = (bVal.fontMetaData as Record[]) ?? [] + 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[]) ?? [] + 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) + }] +])