refactor(kiwi): move variable binding codec

This commit is contained in:
Danila Poyarkov 2026-06-06 11:57:26 +03:00
parent 6877516f06
commit 086ab2d2ae
4 changed files with 108 additions and 23 deletions

View file

@ -8,13 +8,19 @@
import { decompress as zstdDecompress } from 'fzstd'
import { figmaSchema, isZstdCompressed, getKiwiMessageType } from '@open-pencil/kiwi/fig'
import {
figmaSchema,
isZstdCompressed,
getKiwiMessageType,
encodeVarint,
encodePaintWithVariableBinding as encodePaintVariableBinding,
encodeNodeChangeWithVariables as encodeNodeChangeVariableBindings,
parseVariableId
} from '@open-pencil/kiwi/fig'
import { compileSchema, encodeBinarySchema } from '@open-pencil/kiwi/schema-runtime'
import { parseColor } from '#core/color'
import * as VariableBindings from './variable-bindings'
interface CompiledSchema {
encodeMessage(message: unknown): Uint8Array
decodeMessage(data: Uint8Array): unknown
@ -123,7 +129,7 @@ export function encodeMessage(message: FigmaMessage): Uint8Array {
// Build nodeChanges array with our encoded nodes
const ncBytes: number[] = [0x04] // field 4
ncBytes.push(...VariableBindings.encodeVarint(nodeChangeBytes.length)) // array length
ncBytes.push(...encodeVarint(nodeChangeBytes.length)) // array length
for (const ncArr of nodeChangeBytes) {
ncBytes.push(...Array.from(ncArr))
}
@ -557,19 +563,14 @@ export function encodePaintWithVariableBinding(
if (!compiledSchema) {
throw new Error('Codec not initialized. Call initCodec() first.')
}
return VariableBindings.encodePaintWithVariableBinding(
compiledSchema,
paint,
variableSessionID,
variableLocalID
)
return encodePaintVariableBinding(compiledSchema, paint, variableSessionID, variableLocalID)
}
export { parseVariableId } from './variable-bindings'
export { parseVariableId }
export function encodeNodeChangeWithVariables(nodeChange: NodeChange): Uint8Array {
if (!compiledSchema) {
throw new Error('Codec not initialized. Call initCodec() first.')
}
return VariableBindings.encodeNodeChangeWithVariables(compiledSchema, nodeChange)
return encodeNodeChangeVariableBindings(compiledSchema, nodeChange)
}

View file

@ -1,2 +1,3 @@
export { default as figmaSchema } from './schema'
export * from './protocol'
export * from './variable-bindings'

View file

@ -1,9 +1,22 @@
import { hexToBytes } from '#core/bytes/hex'
import type { GUID } from '#core/types'
export interface GUID {
sessionID: number
localID: number
}
import type { NodeChange, Paint } from './index'
export interface VariableBinding {
variableID: GUID
}
export interface VariableBindingCodec {
export interface PaintWithVariableBinding {
colorVariableBinding?: VariableBinding
}
export interface NodeChangeWithVariableBindings {
fillPaints?: PaintWithVariableBinding[]
strokePaints?: PaintWithVariableBinding[]
}
export interface VariableBindingCodec<Paint, NodeChange> {
encodePaint(paint: Paint): Uint8Array
encodeNodeChange(nodeChange: NodeChange): Uint8Array
}
@ -18,8 +31,8 @@ export function encodeVarint(value: number): number[] {
return bytes
}
export function encodePaintWithVariableBinding(
codec: VariableBindingCodec,
export function encodePaintWithVariableBinding<Paint extends PaintWithVariableBinding>(
codec: VariableBindingCodec<Omit<Paint, 'colorVariableBinding'>, unknown>,
paint: Paint,
variableSessionID: number,
variableLocalID: number
@ -52,12 +65,18 @@ export function parseVariableId(variableId: string): GUID | null {
}
}
export function encodeNodeChangeWithVariables(
codec: VariableBindingCodec,
export function encodeNodeChangeWithVariables<NodeChange extends NodeChangeWithVariableBindings>(
codec: VariableBindingCodec<
unknown,
Omit<NodeChange, 'fillPaints' | 'strokePaints'> & {
fillPaints?: Omit<PaintWithVariableBinding, 'colorVariableBinding'>[]
strokePaints?: Omit<PaintWithVariableBinding, 'colorVariableBinding'>[]
}
>,
nodeChange: NodeChange
): Uint8Array {
const hasFillBinding = nodeChange.fillPaints?.some((p) => p.colorVariableBinding)
const hasStrokeBinding = nodeChange.strokePaints?.some((p) => p.colorVariableBinding)
const hasFillBinding = nodeChange.fillPaints?.some((paint) => paint.colorVariableBinding)
const hasStrokeBinding = nodeChange.strokePaints?.some((paint) => paint.colorVariableBinding)
if (!hasFillBinding && !hasStrokeBinding) {
return codec.encodeNodeChange(nodeChange)
@ -91,7 +110,7 @@ export function encodeNodeChangeWithVariables(
return hexToBytes(hex)
}
function injectVariableBinding(hex: string, marker: string, binding: { variableID: GUID }): string {
function injectVariableBinding(hex: string, marker: string, binding: VariableBinding): string {
const markerIdx = hex.indexOf(marker)
if (markerIdx === -1) return hex
@ -132,3 +151,13 @@ function injectVariableBinding(hex: string, marker: string, binding: { variableI
return beforeVar + varHex + afterVar
}
function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error('Hex string must have an even length')
const bytes = new Uint8Array(hex.length / 2)
for (let index = 0; index < bytes.length; index++) {
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16)
}
return bytes
}

View file

@ -0,0 +1,54 @@
import { describe, expect, test } from 'bun:test'
import {
encodeNodeChangeWithVariables,
encodePaintWithVariableBinding,
encodeVarint,
parseVariableId
} from '../src/fig/variable-bindings'
describe('variable binding codec helpers', () => {
test('encodes varints', () => {
expect(encodeVarint(0)).toEqual([0])
expect(encodeVarint(127)).toEqual([127])
expect(encodeVarint(128)).toEqual([128, 1])
expect(encodeVarint(16_384)).toEqual([128, 128, 1])
})
test('parses Figma variable IDs', () => {
expect(parseVariableId('VariableID:123:456')).toEqual({ sessionID: 123, localID: 456 })
expect(parseVariableId('not-a-variable')).toBeNull()
})
test('appends paint variable binding bytes', () => {
const codec = {
encodePaint: () => new Uint8Array([0x01, 0x02, 0x00]),
encodeNodeChange: () => new Uint8Array()
}
const encoded = encodePaintWithVariableBinding(
codec,
{ colorVariableBinding: { variableID: { sessionID: 5, localID: 130 } } },
5,
130
)
expect([...encoded]).toEqual([
0x01, 0x02, 0x15, 0x01, 0x04, 0x01, 0x05, 0x82, 0x01, 0x00, 0x00, 0x02, 0x03, 0x03, 0x04,
0x00, 0x00
])
})
test('injects node change variable bindings after paint markers', () => {
const codec = {
encodePaint: () => new Uint8Array(),
encodeNodeChange: () => new Uint8Array([0x26, 0x01, 0x04, 0x01, 0x00, 0xaa])
}
const encoded = encodeNodeChangeWithVariables(codec, {
fillPaints: [{ colorVariableBinding: { variableID: { sessionID: 2, localID: 3 } } }]
})
expect(Buffer.from(encoded).toString('hex')).toBe('260104011501040102030000020303040000aa')
})
})