refactor(kiwi): consume standalone Kiwi package

This commit is contained in:
Danila Poyarkov 2026-06-06 11:48:33 +03:00
parent f5bd2425b6
commit 6877516f06
24 changed files with 37 additions and 7001 deletions

View file

@ -17,6 +17,7 @@
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@open-pencil/dom-css": "workspace:*",
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/vue": "workspace:*",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@tailwindcss/vite": "^4.2.1",
@ -120,6 +121,7 @@
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@open-pencil/kiwi": "workspace:*",
"@tauri-apps/api": "^2",
"acorn": "^8.16.0",
"canvaskit-wasm": "^0.40.0",

View file

@ -52,6 +52,7 @@
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@open-pencil/dom-css": "workspace:*",
"@open-pencil/kiwi": "workspace:*",
"@open-pencil/vue": "workspace:*",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@tailwindcss/vite": "^4.2.1",

View file

@ -177,6 +177,7 @@
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@open-pencil/kiwi": "workspace:*",
"@tauri-apps/api": "^2",
"acorn": "^8.16.0",
"canvaskit-wasm": "^0.40.0",

View file

@ -1,5 +1,7 @@
import { inflateSync, deflateSync } from 'fflate'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
import { shapeTextForClipboard } from './canvas/text'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/fig/codec'
import type { NodeChange as KiwiNodeChange } from './kiwi/fig/codec'
@ -15,7 +17,6 @@ import {
makeCanvasNodeChange,
buildFontDigestMap
} from './kiwi/fig/node-change/serialize'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/schema-runtime'
import { randomInt } from './random'
import type { SceneGraph, SceneNode } from './scene-graph'
import { buildDerivedTextDataV4 } from './text/derived-text/clipboard'

View file

@ -1,6 +1,8 @@
import type { CanvasKit } from 'canvaskit-wasm'
import { deflateSync, inflateSync } from 'fflate'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
import type { SkiaRenderer } from '#core/canvas'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '#core/constants'
import { renderThumbnail } from '#core/io/formats/raster'
@ -16,7 +18,6 @@ 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'

View file

@ -8,11 +8,11 @@
import { decompress as zstdDecompress } from 'fzstd'
import { parseColor } from '#core/color'
import { compileSchema, encodeBinarySchema } from '#core/kiwi/schema-runtime'
import { figmaSchema, isZstdCompressed, getKiwiMessageType } from '@open-pencil/kiwi/fig'
import { compileSchema, encodeBinarySchema } from '@open-pencil/kiwi/schema-runtime'
import { parseColor } from '#core/color'
import { isZstdCompressed, getKiwiMessageType } from './protocol'
import figmaSchema from './schema'
import * as VariableBindings from './variable-bindings'
interface CompiledSchema {

View file

@ -1,237 +0,0 @@
/**
* Figma Multiplayer Protocol
*
* This module handles the low-level WebSocket communication with Figma's
* multiplayer server. The protocol uses:
*
* - Kiwi binary serialization (schema-based, like Protocol Buffers)
* - Zstd compression for all messages
* - Session-based authentication via cookies
*
* Message types (from Figma's schema):
* 0 = JOIN_START - Server sends session info
* 1 = NODE_CHANGES - Create/update/delete nodes
* 2 = USER_CHANGES - User presence updates
* 3 = JOIN_END - Initial sync complete
* 4 = SIGNAL - Various metadata (reconnect info, etc.)
* 5 = STYLE - Style updates
* ...and more
*
* Wire format:
* All messages are Zstd-compressed Kiwi-encoded binary data.
* Zstd magic bytes: 0x28 0xB5 0x2F 0xFD
*/
export const MESSAGE_TYPES = {
JOIN_START: 0,
NODE_CHANGES: 1,
USER_CHANGES: 2,
JOIN_END: 3,
SIGNAL: 4,
STYLE: 5,
STYLE_SET: 6,
JOIN_START_SKIP_RELOAD: 7,
NOTIFY_SHOULD_UPGRADE: 8,
UPGRADE_DONE: 9,
UPGRADE_REFRESH: 10,
SCENE_GRAPH_QUERY: 11,
SCENE_GRAPH_REPLY: 12,
DIFF: 13,
CLIENT_BROADCAST: 14
} as const
export const NODE_TYPES = {
NONE: 0,
DOCUMENT: 1,
CANVAS: 2,
GROUP: 3,
FRAME: 4,
BOOLEAN_OPERATION: 5,
VECTOR: 6,
STAR: 7,
LINE: 8,
ELLIPSE: 9,
RECTANGLE: 10,
REGULAR_POLYGON: 11,
ROUNDED_RECTANGLE: 12,
TEXT: 13,
SLICE: 14,
SYMBOL: 15,
INSTANCE: 16,
STICKY: 17,
SHAPE_WITH_TEXT: 18,
CONNECTOR: 19,
CODE_BLOCK: 20,
WIDGET: 21,
STAMP: 22,
MEDIA: 23,
HIGHLIGHT: 24,
SECTION: 25,
SECTION_OVERLAY: 26,
WASHI_TAPE: 27,
VARIABLE: 28
} as const
export const NODE_PHASES = {
CREATED: 0,
REMOVED: 1
} as const
export const BLEND_MODES = {
PASS_THROUGH: 0,
NORMAL: 1,
DARKEN: 2,
MULTIPLY: 3,
LINEAR_BURN: 4,
COLOR_BURN: 5,
LIGHTEN: 6,
SCREEN: 7,
LINEAR_DODGE: 8,
COLOR_DODGE: 9,
OVERLAY: 10,
SOFT_LIGHT: 11,
HARD_LIGHT: 12,
DIFFERENCE: 13,
EXCLUSION: 14,
HUE: 15,
SATURATION: 16,
COLOR: 17,
LUMINOSITY: 18
} as const
export const PAINT_TYPES = {
SOLID: 0,
GRADIENT_LINEAR: 1,
GRADIENT_RADIAL: 2,
GRADIENT_ANGULAR: 3,
GRADIENT_DIAMOND: 4,
IMAGE: 5,
EMOJI: 6,
VIDEO: 7
} as const
/**
* Zstd magic bytes
*/
export const ZSTD_MAGIC = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd])
// ============================================================================
// Kiwi Binary Format Constants
// ============================================================================
/**
* Kiwi uses field numbers to identify message fields.
* Field 1 with value = message type indicates the message kind.
*/
export const KIWI = {
/** First byte of valid Kiwi messages (field number 1) */
MESSAGE_MARKER: 1,
/** Field number for sessionID in JOIN_START message */
SESSION_ID_FIELD: 2,
/** Varint continuation bit (MSB set = more bytes follow) */
VARINT_CONTINUE_BIT: 0x80,
/** Varint value mask (lower 7 bits contain data) */
VARINT_VALUE_MASK: 0x7f,
/** Bits per varint byte */
VARINT_BITS_PER_BYTE: 7
} as const
/**
* Valid session ID range (based on observed Figma behavior)
*/
export const SESSION_ID = {
MIN: 10000,
MAX: 1000000
} as const
/**
* Parse a varint from a Uint8Array at given position
* Returns [value, newPosition]
*/
export function parseVarint(data: Uint8Array, pos: number): [number, number] {
let value = 0
let shift = 0
while (pos < data.length) {
const byte = data[pos]
pos++
value |= (byte & KIWI.VARINT_VALUE_MASK) << shift
if (!(byte & KIWI.VARINT_CONTINUE_BIT)) {
break
}
shift += KIWI.VARINT_BITS_PER_BYTE
}
return [value, pos]
}
/**
* Check if data is a valid Kiwi message
*/
export function isKiwiMessage(data: Uint8Array): boolean {
return data.length >= 2 && data[0] === KIWI.MESSAGE_MARKER
}
/**
* Get message type from Kiwi message
*/
export function getKiwiMessageType(data: Uint8Array): number | null {
if (!isKiwiMessage(data)) return null
return data[1] ?? null
}
/**
* fig-wire header magic (first 8 bytes of some messages)
*/
export const FIG_WIRE_MAGIC = 'fig-wire'
/**
* Check if data is Zstd-compressed
*/
export function isZstdCompressed(data: Uint8Array): boolean {
return (
data.length >= 4 && data[0] === 0x28 && data[1] === 0xb5 && data[2] === 0x2f && data[3] === 0xfd
)
}
/**
* Check if data has fig-wire header
*/
export function hasFigWireHeader(data: Uint8Array): boolean {
if (data.length < 8) return false
const header = new TextDecoder().decode(data.slice(0, 8))
return header === FIG_WIRE_MAGIC
}
/**
* Skip fig-wire header and find zstd data
* Header format: "fig-wire" (8 bytes) + version (4 bytes LE) + zstd data
*/
export function skipFigWireHeader(data: Uint8Array): Uint8Array {
if (!hasFigWireHeader(data)) return data
// Skip 8 bytes header + 4 bytes version
return data.slice(12)
}
/**
* Current multiplayer protocol version
*/
export const PROTOCOL_VERSION = 151
/**
* Build WebSocket URL for Figma multiplayer
*/
export function buildMultiplayerUrl(fileKey: string, trackingId?: string): string {
const params = new URLSearchParams({
role: 'editor',
version: String(PROTOCOL_VERSION),
recentReload: '0',
tracking_session_id: trackingId || `ws-${Date.now()}`
})
return `wss://www.figma.com/api/multiplayer/${fileKey}?${params}`
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
import { parseSchema, validateSchema } from '#core/kiwi/schema-runtime'
import schemaText from './fig.kiwi?raw'
const schema = parseSchema(schemaText)
validateSchema(schema)
export default schema

View file

@ -1,9 +1,10 @@
import { unzipSync, inflateSync } from 'fflate'
import { decompress as zstdDecompress } from 'fzstd'
import { isZstdCompressed } from '@open-pencil/kiwi/fig'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
import type { FigmaMessage, NodeChange } from '#core/kiwi/fig/codec'
import { isZstdCompressed } from '#core/kiwi/fig/codec/protocol'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/schema-runtime'
/**
* Deduplicates pluginData/pluginRelaunchData entries on raw NodeChange objects.

View file

@ -48,4 +48,4 @@ export {
getKiwiMessageType,
parseVarint,
FIG_WIRE_MAGIC
} from './fig/codec/protocol'
} from '@open-pencil/kiwi/fig'

View file

@ -1,252 +0,0 @@
let int32 = new Int32Array(1)
let float32 = new Float32Array(int32.buffer)
const textDecoder = new TextDecoder()
export class ByteBuffer {
private _data: Uint8Array
private _index: number
length: number
constructor(data?: Uint8Array) {
if (data && !(data instanceof Uint8Array)) {
throw new Error('Must initialize a ByteBuffer with a Uint8Array')
}
this._data = data || new Uint8Array(256)
this._index = 0
this.length = data ? data.length : 0
}
/**
* Returns a view into the internal buffer, not a copy.
*
* Consumers transferring this Uint8Array across thread boundaries (e.g. via
* postMessage with transferables) MUST copy first: `new Uint8Array(buffer)`.
* Otherwise, if multiple Uint8Arrays share the same underlying ArrayBuffer and
* one is transferred, all views into that buffer become detached.
*/
toUint8Array(): Uint8Array {
return this._data.subarray(0, this.length)
}
readByte(): number {
return this._data[this._index++]
}
readByteArray(): Uint8Array {
const length = this.readVarUint()
const start = this._index
this._index = start + length
return this._data.slice(start, start + length)
}
readVarFloat(): number {
const index = this._index
const data = this._data
const first = data[index]
if (first === 0) {
this._index = index + 1
return 0
}
let bits = first | (data[index + 1] << 8) | (data[index + 2] << 16) | (data[index + 3] << 24)
this._index = index + 4
bits = (bits << 23) | (bits >>> 9)
int32[0] = bits
return float32[0]
}
readVarUint(): number {
const data = this._data
let i = this._index
let b = data[i++]
let value = b & 127
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 7
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 14
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 21
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 28
this._index = i
return value >>> 0
}
readVarInt(): number {
let value = this.readVarUint() | 0
return value & 1 ? ~(value >>> 1) : value >>> 1
}
readVarUint64(): bigint {
let value = BigInt(0)
let shift = BigInt(0)
let seven = BigInt(7)
let byte: number
while ((byte = this.readByte()) & 128 && shift < 56) {
value |= BigInt(byte & 127) << shift
shift += seven
}
value |= BigInt(byte) << shift
return value
}
readVarInt64(): bigint {
let value = this.readVarUint64()
let one = BigInt(1)
let sign = value & one
value >>= one
return sign ? ~value : value
}
readString(): string {
const data = this._data
const start = this._index
let i = start
while (data[i] !== 0) i++
this._index = i + 1
return textDecoder.decode(data.subarray(start, i))
}
private _growBy(amount: number): void {
if (this.length + amount > this._data.length) {
let data = new Uint8Array((this.length + amount) << 1)
data.set(this._data)
this._data = data
}
this.length += amount
}
writeByte(value: number): void {
let index = this.length
this._growBy(1)
this._data[index] = value
}
writeByteArray(value: Uint8Array): void {
this.writeVarUint(value.length)
let index = this.length
this._growBy(value.length)
this._data.set(value, index)
}
writeVarFloat(value: number): void {
let index = this.length
// Reinterpret as an integer
float32[0] = value
let bits = int32[0]
// Move the exponent to the first 8 bits
bits = (bits >>> 23) | (bits << 9)
// Optimization: use a single byte to store zero and denormals (check for an exponent of 0)
if ((bits & 255) === 0) {
this.writeByte(0)
return
}
// Endian-independent 32-bit write
this._growBy(4)
let data = this._data
data[index] = bits
data[index + 1] = bits >> 8
data[index + 2] = bits >> 16
data[index + 3] = bits >> 24
}
writeVarUint(value: number): void {
if (value < 0 || value > 0xffff_ffff) throw new Error('Outside uint range: ' + value)
do {
let byte = value & 127
value >>>= 7
this.writeByte(value ? byte | 128 : byte)
} while (value)
}
writeVarInt(value: number): void {
if (value < -0x8000_0000 || value > 0x7fff_ffff) throw new Error('Outside int range: ' + value)
this.writeVarUint(((value << 1) ^ (value >> 31)) >>> 0)
}
writeVarUint64(value: bigint | string): void {
if (typeof value === 'string') value = BigInt(value)
else if (typeof value !== 'bigint')
throw new Error(`Expected bigint but got ${typeof value}: ${String(value)}`)
if (value < 0 || value > BigInt('0xFFFFFFFFFFFFFFFF'))
throw new Error('Outside uint64 range: ' + value)
let mask = BigInt(127)
let seven = BigInt(7)
for (let i = 0; value > mask && i < 8; i++) {
this.writeByte(Number(value & mask) | 128)
value >>= seven
}
this.writeByte(Number(value))
}
writeVarInt64(value: bigint | string): void {
if (typeof value === 'string') value = BigInt(value)
else if (typeof value !== 'bigint')
throw new Error(`Expected bigint but got ${typeof value}: ${String(value)}`)
if (value < -BigInt('0x8000000000000000') || value > BigInt('0x7FFFFFFFFFFFFFFF'))
throw new Error('Outside int64 range: ' + value)
let one = BigInt(1)
this.writeVarUint64(value < 0 ? ~(value << one) : value << one)
}
writeString(value: string): void {
let codePoint
for (let i = 0; i < value.length; i++) {
// Decode UTF-16
let a = value.charCodeAt(i)
if (i + 1 === value.length || a < 0xd800 || a >= 0xdc00) {
codePoint = a
} else {
let b = value.charCodeAt(++i)
codePoint = (a << 10) + b + (0x10000 - (0xd800 << 10) - 0xdc00)
}
// Strings are null-terminated
if (codePoint === 0) {
throw new Error('Cannot encode a string containing the null character')
}
// Encode UTF-8
if (codePoint < 0x80) {
this.writeByte(codePoint)
} else {
if (codePoint < 0x800) {
this.writeByte(((codePoint >> 6) & 0x1f) | 0xc0)
} else {
if (codePoint < 0x10000) {
this.writeByte(((codePoint >> 12) & 0x0f) | 0xe0)
} else {
this.writeByte(((codePoint >> 18) & 0x07) | 0xf0)
this.writeByte(((codePoint >> 12) & 0x3f) | 0x80)
}
this.writeByte(((codePoint >> 6) & 0x3f) | 0x80)
}
this.writeByte((codePoint & 0x3f) | 0x80)
}
}
// Strings are null-terminated
this.writeByte(0)
}
}

View file

@ -1,102 +0,0 @@
import { ByteBuffer } from './bb'
import { Schema, Field, Definition, DefinitionKind } from './schema'
let types: (string | null)[] = ['bool', 'byte', 'int', 'uint', 'float', 'string', 'int64', 'uint64']
let kinds: DefinitionKind[] = ['ENUM', 'STRUCT', 'MESSAGE']
export function decodeBinarySchema(buffer: Uint8Array | ByteBuffer): Schema {
let bb = buffer instanceof ByteBuffer ? buffer : new ByteBuffer(buffer)
let definitionCount = bb.readVarUint()
let definitions: Definition[] = []
// Read in the schema
for (let i = 0; i < definitionCount; i++) {
let definitionName = bb.readString()
let kind = bb.readByte()
let fieldCount = bb.readVarUint()
let fields: Field[] = []
for (let j = 0; j < fieldCount; j++) {
let fieldName = bb.readString()
let type = bb.readVarInt()
let isArray = !!(bb.readByte() & 1)
let value = bb.readVarUint()
fields.push({
name: fieldName,
line: 0,
column: 0,
type: kinds[kind] === 'ENUM' ? null : (type as any),
isArray: isArray,
isDeprecated: false,
value: value
})
}
definitions.push({
name: definitionName,
line: 0,
column: 0,
kind: kinds[kind],
fields: fields
})
}
// Bind type names afterwards
for (let i = 0; i < definitionCount; i++) {
let fields = definitions[i].fields
for (let j = 0; j < fields.length; j++) {
let field = fields[j]
let type = field.type as any as number | null
if (type !== null && type < 0) {
if (~type >= types.length) {
throw new Error('Invalid type ' + type)
}
field.type = types[~type]
} else {
if (type !== null && type >= definitions.length) {
throw new Error('Invalid type ' + type)
}
field.type = type === null ? null : definitions[type].name
}
}
}
return {
package: null,
definitions: definitions
}
}
export function encodeBinarySchema(schema: Schema): Uint8Array {
let bb = new ByteBuffer()
let definitions = schema.definitions
let definitionIndex: { [name: string]: number } = {}
bb.writeVarUint(definitions.length)
for (let i = 0; i < definitions.length; i++) {
definitionIndex[definitions[i].name] = i
}
for (let i = 0; i < definitions.length; i++) {
let definition = definitions[i]
bb.writeString(definition.name)
bb.writeByte(kinds.indexOf(definition.kind))
bb.writeVarUint(definition.fields.length)
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j]
let type = types.indexOf(field.type)
bb.writeString(field.name)
bb.writeVarInt(type === -1 ? definitionIndex[field.type!] : ~type)
bb.writeByte(field.isArray ? 1 : 0)
bb.writeVarUint(field.value)
}
}
return bb.toUint8Array()
}

View file

@ -1,12 +0,0 @@
export type { Schema, Definition, Field } from './schema'
export { ByteBuffer } from './bb'
export { compileSchema } from './js'
export { decodeBinarySchema, encodeBinarySchema } from './binary'
export { parseSchema } from './parser'
export {
validateSchema,
expectFieldNumber,
expectEnumValue,
findDefinition,
findField
} from './validate'

View file

@ -1,339 +0,0 @@
import { ByteBuffer } from './bb'
import { Schema, Definition } from './schema'
import { error, quote } from './util'
function compileDecode(
definition: Definition,
definitions: { [name: string]: Definition }
): string {
let lines: string[] = []
let indent = ' '
lines.push('function (bb) {')
lines.push(' var result = {};')
lines.push(' if (!(bb instanceof this.ByteBuffer)) {')
lines.push(' bb = new this.ByteBuffer(bb);')
lines.push(' }')
lines.push('')
if (definition.kind === 'MESSAGE') {
lines.push(' while (true) {')
lines.push(' switch (bb.readVarUint()) {')
lines.push(' case 0:')
lines.push(' return result;')
lines.push('')
indent = ' '
}
for (let i = 0; i < definition.fields.length; i++) {
let field = definition.fields[i]
let code: string
switch (field.type) {
case 'bool': {
code = '!!bb.readByte()'
break
}
case 'byte': {
code = 'bb.readByte()' // only used if not array
break
}
case 'int': {
code = 'bb.readVarInt()'
break
}
case 'uint': {
code = 'bb.readVarUint()'
break
}
case 'float': {
code = 'bb.readVarFloat()'
break
}
case 'string': {
code = 'bb.readString()'
break
}
case 'int64': {
code = 'bb.readVarInt64()'
break
}
case 'uint64': {
code = 'bb.readVarUint64()'
break
}
default: {
let type = definitions[field.type!]
if (!type) {
error(
'Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name),
field.line,
field.column
)
} else if (type.kind === 'ENUM') {
code = 'this[' + quote(type.name) + '][bb.readVarUint()]'
} else {
code = 'this[' + quote('decode' + type.name) + '](bb)'
}
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' case ' + field.value + ':')
}
if (field.isArray) {
if (field.isDeprecated) {
if (field.type === 'byte') {
lines.push(indent + 'bb.readByteArray();')
} else {
lines.push(indent + 'var length = bb.readVarUint();')
lines.push(indent + 'while (length-- > 0) ' + code + ';')
}
} else {
if (field.type === 'byte') {
lines.push(indent + 'result[' + quote(field.name) + '] = bb.readByteArray();')
} else {
lines.push(indent + 'var length = bb.readVarUint();')
lines.push(indent + 'var values = result[' + quote(field.name) + '] = Array(length);')
lines.push(indent + 'for (var i = 0; i < length; i++) values[i] = ' + code + ';')
}
}
} else {
if (field.isDeprecated) {
lines.push(indent + code + ';')
} else {
lines.push(indent + 'result[' + quote(field.name) + '] = ' + code + ';')
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' break;')
lines.push('')
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' default:')
lines.push(' throw new Error("Attempted to parse invalid message");')
lines.push(' }')
lines.push(' }')
} else {
lines.push(' return result;')
}
lines.push('}')
return lines.join('\n')
}
function compileEncode(
definition: Definition,
definitions: { [name: string]: Definition }
): string {
let lines: string[] = []
lines.push('function (message, bb) {')
lines.push(' var isTopLevel = !bb;')
lines.push(' if (isTopLevel) bb = new this.ByteBuffer();')
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j]
let code: string
if (field.isDeprecated) {
continue
}
switch (field.type) {
case 'bool': {
code = 'bb.writeByte(value);'
break
}
case 'byte': {
code = 'bb.writeByte(value);' // only used if not array
break
}
case 'int': {
code = 'bb.writeVarInt(value);'
break
}
case 'uint': {
code = 'bb.writeVarUint(value);'
break
}
case 'float': {
code = 'bb.writeVarFloat(value);'
break
}
case 'string': {
code = 'bb.writeString(value);'
break
}
case 'int64': {
code = 'bb.writeVarInt64(value);'
break
}
case 'uint64': {
code = 'bb.writeVarUint64(value);'
break
}
default: {
let type = definitions[field.type!]
if (!type) {
throw new Error('Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name))
} else if (type.kind === 'ENUM') {
code =
'var encoded = this[' +
quote(type.name) +
'][value]; ' +
'if (encoded === void 0) throw new Error("Invalid value " + JSON.stringify(value) + ' +
quote(' for enum ' + quote(type.name)) +
'); ' +
'bb.writeVarUint(encoded);'
} else {
code = 'this[' + quote('encode' + type.name) + '](value, bb);'
}
}
}
lines.push('')
lines.push(' var value = message[' + quote(field.name) + '];')
lines.push(' if (value != null) {') // Comparing with null using "!=" also checks for undefined
if (definition.kind === 'MESSAGE') {
lines.push(' bb.writeVarUint(' + field.value + ');')
}
if (field.isArray) {
if (field.type === 'byte') {
lines.push(' bb.writeByteArray(value);')
} else {
lines.push(' var values = value, n = values.length;')
lines.push(' bb.writeVarUint(n);')
lines.push(' for (var i = 0; i < n; i++) {')
lines.push(' value = values[i];')
lines.push(' ' + code)
lines.push(' }')
}
} else {
lines.push(' ' + code)
}
if (definition.kind === 'STRUCT') {
lines.push(' } else {')
lines.push(
' throw new Error(' + quote('Missing required field ' + quote(field.name)) + ');'
)
}
lines.push(' }')
}
// A field id of zero is reserved to indicate the end of the message
if (definition.kind === 'MESSAGE') {
lines.push(' bb.writeVarUint(0);')
}
lines.push('')
lines.push(' if (isTopLevel) return bb.toUint8Array();')
lines.push('}')
return lines.join('\n')
}
export function compileSchemaJS(schema: Schema): string {
let definitions: { [name: string]: Definition } = {}
let name = schema.package
let js: string[] = []
if (name !== null) {
js.push('var ' + name + ' = exports || ' + name + ' || {}, exports;')
} else {
js.push('var exports = exports || {};')
name = 'exports'
}
js.push(name + '.ByteBuffer = ' + name + '.ByteBuffer || require("kiwi-schema").ByteBuffer;')
for (let i = 0; i < schema.definitions.length; i++) {
let definition = schema.definitions[i]
definitions[definition.name] = definition
}
for (let i = 0; i < schema.definitions.length; i++) {
let definition = schema.definitions[i]
switch (definition.kind) {
case 'ENUM': {
let value: any = {}
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j]
value[field.name] = field.value
value[field.value] = field.name
}
js.push(name + '[' + quote(definition.name) + '] = ' + JSON.stringify(value, null, 2) + ';')
break
}
case 'STRUCT':
case 'MESSAGE': {
js.push('')
js.push(
name +
'[' +
quote('decode' + definition.name) +
'] = ' +
compileDecode(definition, definitions) +
';'
)
js.push('')
js.push(
name +
'[' +
quote('encode' + definition.name) +
'] = ' +
compileEncode(definition, definitions) +
';'
)
break
}
default: {
error(
'Invalid definition kind ' + quote(definition.kind),
definition.line,
definition.column
)
break
}
}
}
js.push('')
return js.join('\n')
}
export function compileSchema(schema: Schema): any {
let result = {
ByteBuffer: ByteBuffer
}
new Function('exports', compileSchemaJS(schema))(result)
return result
}

View file

@ -1,287 +0,0 @@
import { Schema, Definition, Field, DefinitionKind } from './schema'
import { error, quote } from './util'
export let nativeTypes = ['bool', 'byte', 'float', 'int', 'int64', 'string', 'uint', 'uint64']
// These are special names on the object returned by compileSchema()
export let reservedNames = ['ByteBuffer', 'package']
let regex = /((?:-|\b)\d+\b|[=;{}]|\[\]|\[deprecated\]|\b[A-Za-z_][A-Za-z0-9_]*\b|\/\/.*|\s+)/g
let identifier = /^[A-Za-z_][A-Za-z0-9_]*$/
let whitespace = /^\/\/.*|\s+$/
let equals = /^=$/
let endOfFile = /^$/
let semicolon = /^;$/
let integer = /^-?\d+$/
let leftBrace = /^\{$/
let rightBrace = /^\}$/
let arrayToken = /^\[\]$/
let enumKeyword = /^enum$/
let structKeyword = /^struct$/
let messageKeyword = /^message$/
let packageKeyword = /^package$/
let deprecatedToken = /^\[deprecated\]$/
interface Token {
text: string
line: number
column: number
}
function tokenize(text: string): Token[] {
let parts = text.split(regex)
let tokens = []
let column = 0
let line = 0
for (let i = 0; i < parts.length; i++) {
let part = parts[i]
// Keep non-whitespace tokens
if (i & 1) {
if (!whitespace.test(part)) {
tokens.push({
text: part,
line: line + 1,
column: column + 1
})
}
}
// Detect syntax errors
else if (part !== '') {
error('Syntax error ' + quote(part), line + 1, column + 1)
}
// Keep track of the line and column counts
let lines = part.split('\n')
if (lines.length > 1) column = 0
line += lines.length - 1
column += lines[lines.length - 1].length
}
// End-of-file token
tokens.push({
text: '',
line: line,
column: column
})
return tokens
}
function parse(tokens: Token[]): Schema {
function current(): Token {
return tokens[index]
}
function eat(test: RegExp): boolean {
if (test.test(current().text)) {
index++
return true
}
return false
}
function expect(test: RegExp, expected: string): void {
if (!eat(test)) {
let token = current()
error('Expected ' + expected + ' but found ' + quote(token.text), token.line, token.column)
}
}
function unexpectedToken(): never {
let token = current()
error('Unexpected token ' + quote(token.text), token.line, token.column)
}
let definitions: Definition[] = []
let packageText = null
let index = 0
if (eat(packageKeyword)) {
packageText = current().text
expect(identifier, 'identifier')
expect(semicolon, '";"')
}
while (index < tokens.length && !eat(endOfFile)) {
let fields: Field[] = []
let kind: DefinitionKind
if (eat(enumKeyword)) kind = 'ENUM'
else if (eat(structKeyword)) kind = 'STRUCT'
else if (eat(messageKeyword)) kind = 'MESSAGE'
else unexpectedToken()
// All definitions start off the same
let name = current()
expect(identifier, 'identifier')
expect(leftBrace, '"{"')
// Parse fields
while (!eat(rightBrace)) {
let type: string | null = null
let isArray = false
let isDeprecated = false
// Enums don't have types
if (kind !== 'ENUM') {
type = current().text
expect(identifier, 'identifier')
isArray = eat(arrayToken)
}
let field = current()
expect(identifier, 'identifier')
// Structs don't have explicit values
let value: Token | null = null
if (kind !== 'STRUCT') {
expect(equals, '"="')
value = current()
expect(integer, 'integer')
if ((+value.text | 0) + '' !== value.text) {
error('Invalid integer ' + quote(value.text), value.line, value.column)
}
}
let deprecated = current()
if (eat(deprecatedToken)) {
if (kind !== 'MESSAGE') {
error('Cannot deprecate this field', deprecated.line, deprecated.column)
}
isDeprecated = true
}
expect(semicolon, '";"')
fields.push({
name: field.text,
line: field.line,
column: field.column,
type: type,
isArray: isArray,
isDeprecated: isDeprecated,
value: value !== null ? +value.text | 0 : fields.length + 1
})
}
definitions.push({
name: name.text,
line: name.line,
column: name.column,
kind: kind,
fields: fields
})
}
return {
package: packageText,
definitions: definitions
}
}
function verify(root: Schema): void {
let definedTypes = nativeTypes.slice()
let definitions: { [name: string]: Definition } = {}
// Define definitions
for (let i = 0; i < root.definitions.length; i++) {
let definition = root.definitions[i]
if (definedTypes.includes(definition.name)) {
error(
'The type ' + quote(definition.name) + ' is defined twice',
definition.line,
definition.column
)
}
if (reservedNames.includes(definition.name)) {
error(
'The type name ' + quote(definition.name) + ' is reserved',
definition.line,
definition.column
)
}
definedTypes.push(definition.name)
definitions[definition.name] = definition
}
// Check fields
for (let i = 0; i < root.definitions.length; i++) {
let definition = root.definitions[i]
let fields = definition.fields
if (definition.kind === 'ENUM' || fields.length === 0) {
continue
}
// Check types
for (let j = 0; j < fields.length; j++) {
let field = fields[j]
if (!definedTypes.includes(field.type!)) {
error(
'The type ' + quote(field.type!) + ' is not defined for field ' + quote(field.name),
field.line,
field.column
)
}
}
// Check values
let values: number[] = []
for (let j = 0; j < fields.length; j++) {
let field = fields[j]
if (values.includes(field.value)) {
error('The id for field ' + quote(field.name) + ' is used twice', field.line, field.column)
}
if (field.value <= 0) {
error(
'The id for field ' + quote(field.name) + ' must be positive',
field.line,
field.column
)
}
// Figma schema uses sparse field IDs (up to 435), so skip sequential check
values.push(field.value)
}
}
// Check that structs don't contain themselves
let state: { [name: string]: number } = {}
let check = (name: string): boolean => {
let definition = definitions[name]
if (definition && definition.kind === 'STRUCT') {
if (state[name] === 1) {
error(
'Recursive nesting of ' + quote(name) + ' is not allowed',
definition.line,
definition.column
)
}
if (state[name] !== 2 && definition) {
state[name] = 1
let fields = definition.fields
for (let i = 0; i < fields.length; i++) {
let field = fields[i]
if (!field.isArray) {
check(field.type!)
}
}
state[name] = 2
}
}
return true
}
for (let i = 0; i < root.definitions.length; i++) {
check(root.definitions[i].name)
}
}
export function parseSchema(text: string): Schema {
let schema = parse(tokenize(text))
verify(schema)
return schema
}

View file

@ -1,24 +0,0 @@
export interface Schema {
package: string | null
definitions: Definition[]
}
export type DefinitionKind = 'ENUM' | 'STRUCT' | 'MESSAGE'
export interface Definition {
name: string
line: number
column: number
kind: DefinitionKind
fields: Field[]
}
export interface Field {
name: string
line: number
column: number
type: string | null
isArray: boolean
isDeprecated: boolean
value: number
}

View file

@ -1,10 +0,0 @@
export function quote(text: string): string {
return JSON.stringify(text)
}
export function error(text: string, line: number, column: number): never {
var error = new Error(text)
;(error as any).line = line
;(error as any).column = column
throw error
}

View file

@ -1,88 +0,0 @@
import type { Definition, Field, Schema } from './schema'
import { error, quote } from './util'
export function findDefinition(schema: Schema, name: string): Definition | null {
return schema.definitions.find((definition) => definition.name === name) ?? null
}
export function findField(schema: Schema, definitionName: string, fieldName: string): Field | null {
return (
findDefinition(schema, definitionName)?.fields.find((field) => field.name === fieldName) ?? null
)
}
export function expectFieldNumber(
schema: Schema,
definitionName: string,
fieldName: string,
expectedValue: number
): void {
const field = findField(schema, definitionName, fieldName)
if (!field) {
throw new Error(`Missing field ${definitionName}.${fieldName}`)
}
if (field.value !== expectedValue) {
throw new Error(
`Expected ${definitionName}.${fieldName} to use field ${expectedValue}, got ${field.value}`
)
}
}
export function expectEnumValue(
schema: Schema,
enumName: string,
memberName: string,
expectedValue: number
): void {
const definition = findDefinition(schema, enumName)
if (!definition) {
throw new Error(`Missing enum ${enumName}`)
}
if (definition.kind !== 'ENUM') {
throw new Error(`${enumName} is a ${definition.kind}, not an enum`)
}
const field = definition.fields.find((candidate) => candidate.name === memberName)
if (!field) {
throw new Error(`Missing enum member ${enumName}.${memberName}`)
}
if (field.value !== expectedValue) {
throw new Error(
`Expected ${enumName}.${memberName} to use value ${expectedValue}, got ${field.value}`
)
}
}
export function validateSchema(schema: Schema): void {
for (const definition of schema.definitions) {
validateUniqueFieldNames(definition)
if (definition.kind === 'ENUM') validateUniqueEnumValues(definition)
}
}
function validateUniqueFieldNames(definition: Definition): void {
const fieldsByName = new Set<string>()
for (const field of definition.fields) {
if (fieldsByName.has(field.name)) {
error(
`The field ${quote(field.name)} is defined twice in ${quote(definition.name)}`,
field.line,
field.column
)
}
fieldsByName.add(field.name)
}
}
function validateUniqueEnumValues(definition: Definition): void {
const fieldsByValue = new Set<number>()
for (const field of definition.fields) {
if (fieldsByValue.has(field.value)) {
error(
`The enum value ${field.value} is used twice in ${quote(definition.name)}`,
field.line,
field.column
)
}
fieldsByValue.add(field.value)
}
}

View file

@ -5,6 +5,7 @@
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "dist",
"rootDir": ".",
"types": ["bun"]

View file

@ -70,7 +70,7 @@ describe('text node export', () => {
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
await import('@open-pencil/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
@ -132,7 +132,7 @@ describe('text node export', () => {
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
await import('@open-pencil/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
@ -178,7 +178,7 @@ describe('text node export', () => {
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
await import('@open-pencil/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
@ -227,7 +227,7 @@ describe('text node export', () => {
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
await import('@open-pencil/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()

View file

@ -3,8 +3,9 @@ import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { parseSchema } from '@open-pencil/kiwi/schema-runtime'
import { FIGMA_RAW_NODE_FIELD_KEYS } from '#core/kiwi/fig/node-change/convert'
import { parseSchema } from '#core/kiwi/schema-runtime'
interface SchemaField {
name: string
@ -28,7 +29,7 @@ type SchemaCoverageBucket =
| 'mediaMotionMetadata'
| 'internalBookkeeping'
const SCHEMA_PATH = 'packages/core/src/kiwi/fig/codec/schema/fig.kiwi'
const SCHEMA_PATH = 'packages/kiwi/src/fig/schema/fig.kiwi'
const CODEC_PATH = 'packages/core/src/kiwi/fig/codec/index.ts'
function nodeChangeSchemaFields(): SchemaField[] {

View file

@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test'
import figSchema from '#core/kiwi/fig/codec/schema'
import { expectEnumValue, expectFieldNumber, validateSchema } from '#core/kiwi/schema-runtime'
import { figmaSchema as figSchema } from '@open-pencil/kiwi/fig'
import {
expectEnumValue,
expectFieldNumber,
validateSchema
} from '@open-pencil/kiwi/schema-runtime'
describe('Kiwi schema runtime', () => {
test('validates the static Figma schema', () => {

View file

@ -20,7 +20,12 @@ import { SceneGraph } from '#core/scene-graph'
import { fontManager } from '#core/text'
import { expectDefined } from '#tests/helpers/assert'
import { coreSourcePath, publicPath, testPath as repoTestPath } from '#tests/helpers/paths'
import {
coreSourcePath,
publicPath,
repoPath,
testPath as repoTestPath
} from '#tests/helpers/paths'
// === CLAIM EXTRACTION ===
// Each claim is: [doc_section, claim_text, verification_strategy]
@ -34,7 +39,7 @@ const rendererPath = coreSourcePath('canvas/renderer.ts')
const sgTypesPath = coreSourcePath('scene-graph/types.ts')
const nodeExportPath = coreSourcePath('kiwi/fig/node-change/export-node.ts')
const convertPath = coreSourcePath('kiwi/fig/node-change/paint.ts')
const schemaPath = coreSourcePath('kiwi/fig/codec/schema/fig.kiwi')
const schemaPath = repoPath('packages/kiwi/src/fig/schema/fig.kiwi')
const codecPath = coreSourcePath('kiwi/fig/codec/index.ts')
const lifecyclePath = coreSourcePath('canvas/renderer/lifecycle.ts')