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:
parent
a38c0ac7b5
commit
c4073dc284
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
3
packages/core/src/kiwi/fig/node-change/font/index.ts
Normal file
3
packages/core/src/kiwi/fig/node-change/font/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { convertFontFeatures, applyFontFeaturesToKiwi } from './features'
|
||||
export { figmaAxisTagToString, stringToFigmaAxisTag, convertFontVariations } from './variations'
|
||||
export { buildFontDigestMap } from './digests'
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
575
tests/engine/io/fig/roundtrip/exhaustive.test.ts
Normal file
575
tests/engine/io/fig/roundtrip/exhaustive.test.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
593
tests/engine/io/fig/roundtrip/helpers.ts
Normal file
593
tests/engine/io/fig/roundtrip/helpers.ts
Normal 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)
|
||||
}]
|
||||
])
|
||||
Loading…
Reference in a new issue