diff --git a/packages/core/src/kiwi/fig/container/kiwi.ts b/packages/core/src/kiwi/fig/container/kiwi.ts index 5ae556861..f28783201 100644 --- a/packages/core/src/kiwi/fig/container/kiwi.ts +++ b/packages/core/src/kiwi/fig/container/kiwi.ts @@ -1,75 +1,7 @@ -import { deflateSync, inflateSync } from 'fflate' - -export const FIG_KIWI_DEFAULT_VERSION = 101 - -export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null { - const header = new TextDecoder().decode(binary.slice(0, 8)) - if (header !== 'fig-kiwi') return null - - const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength) - let offset = 12 - - const chunks: Uint8Array[] = [] - while (offset < binary.length) { - const chunkLen = view.getUint32(offset, true) - offset += 4 - chunks.push(binary.slice(offset, offset + chunkLen)) - offset += chunkLen - } - return chunks.length >= 2 ? chunks : null -} - -export async function decompressFigKiwiDataAsync(compressed: Uint8Array): Promise { - if ( - compressed.length >= 4 && - compressed[0] === 0x28 && - compressed[1] === 0xb5 && - compressed[2] === 0x2f && - compressed[3] === 0xfd - ) { - const { decompress } = await import('fzstd') - return decompress(compressed) - } - try { - return inflateSync(compressed) - } catch { - const { decompress } = await import('fzstd') - return decompress(compressed) - } -} - -export function buildFigKiwi( - schemaDeflated: Uint8Array, - dataRaw: Uint8Array, - version = FIG_KIWI_DEFAULT_VERSION -): Uint8Array { - let dataCompressed: Uint8Array - const zstdCompress: ((data: Uint8Array) => Uint8Array) | undefined = (() => { - const g = globalThis as { Bun?: { zstdCompressSync?: (data: Uint8Array) => Uint8Array } } - return g.Bun?.zstdCompressSync - })() - if (zstdCompress) { - dataCompressed = zstdCompress(dataRaw) - } else { - dataCompressed = deflateSync(dataRaw) - } - - const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataCompressed.length - const out = new Uint8Array(total) - const view = new DataView(out.buffer) - - out.set(new TextEncoder().encode('fig-kiwi'), 0) - view.setUint32(8, version, true) - - let offset = 12 - view.setUint32(offset, schemaDeflated.length, true) - offset += 4 - out.set(schemaDeflated, offset) - offset += schemaDeflated.length - - view.setUint32(offset, dataCompressed.length, true) - offset += 4 - out.set(dataCompressed, offset) - - return out -} +export { + buildFigKiwi, + decompressFigKiwiData, + decompressFigKiwiDataAsync, + FIG_KIWI_DEFAULT_VERSION, + parseFigKiwiChunks +} from '@open-pencil/kiwi/fig/container' diff --git a/packages/kiwi/package.json b/packages/kiwi/package.json index 25ab7e16c..b0bb2bc6c 100644 --- a/packages/kiwi/package.json +++ b/packages/kiwi/package.json @@ -28,6 +28,12 @@ "import": "./dist/fig/codec.js", "default": "./dist/fig/codec.js" }, + "./fig/container": { + "types": "./dist/src/fig/container.d.ts", + "bun": "./src/fig/container.ts", + "import": "./dist/fig/container.js", + "default": "./dist/fig/container.js" + }, "./fig/parse": { "types": "./dist/src/fig/parse.d.ts", "bun": "./src/fig/parse.ts", diff --git a/packages/kiwi/scripts/smoke-dist.ts b/packages/kiwi/scripts/smoke-dist.ts index 05647c85d..bdd88b584 100644 --- a/packages/kiwi/scripts/smoke-dist.ts +++ b/packages/kiwi/scripts/smoke-dist.ts @@ -1,4 +1,5 @@ import { getSchemaBytes, initCodec, isCodecReady } from '../dist/fig/codec.js' +import { buildFigKiwi, parseFigKiwiChunks } from '../dist/fig/container.js' import { parseFigKiwiContainer } from '../dist/fig/parse.js' import { figmaSchema, getKiwiMessageType, parseSchema, validateSchema } from '../dist/index.js' @@ -27,3 +28,8 @@ if (!isCodecReady() || getSchemaBytes().length === 0) { if (parseFigKiwiContainer(new TextEncoder().encode('not-fig-kiwi')) !== null) { throw new Error('Failed to reject invalid FIG Kiwi container') } + +const figKiwi = buildFigKiwi(new Uint8Array([1]), new Uint8Array([2])) +if (parseFigKiwiChunks(figKiwi)?.length !== 2) { + throw new Error('Failed to read built FIG Kiwi container chunks') +} diff --git a/packages/kiwi/src/fig/container.ts b/packages/kiwi/src/fig/container.ts new file mode 100644 index 000000000..5ac08bb10 --- /dev/null +++ b/packages/kiwi/src/fig/container.ts @@ -0,0 +1,64 @@ +import { deflateSync, inflateSync } from 'fflate' + +export const FIG_KIWI_DEFAULT_VERSION = 101 + +export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null { + const header = new TextDecoder().decode(binary.slice(0, 8)) + if (header !== 'fig-kiwi') return null + + const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength) + let offset = 12 + + const chunks: Uint8Array[] = [] + while (offset < binary.length) { + const chunkLen = view.getUint32(offset, true) + offset += 4 + chunks.push(binary.slice(offset, offset + chunkLen)) + offset += chunkLen + } + return chunks.length >= 2 ? chunks : null +} + +export function decompressFigKiwiData(compressed: Uint8Array): Uint8Array { + try { + return inflateSync(compressed) + } catch { + throw new Error('Failed to decompress fig-kiwi data') + } +} + +export async function decompressFigKiwiDataAsync(compressed: Uint8Array): Promise { + try { + return inflateSync(compressed) + } catch { + const fzstd = await import('fzstd') + return fzstd.decompress(compressed) + } +} + +export function buildFigKiwi( + schemaDeflated: Uint8Array, + dataRaw: Uint8Array, + version = FIG_KIWI_DEFAULT_VERSION +): Uint8Array { + const dataDeflated = deflateSync(dataRaw) + + const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataDeflated.length + const out = new Uint8Array(total) + const view = new DataView(out.buffer) + + out.set(new TextEncoder().encode('fig-kiwi'), 0) + view.setUint32(8, version, true) + + let offset = 12 + view.setUint32(offset, schemaDeflated.length, true) + offset += 4 + out.set(schemaDeflated, offset) + offset += schemaDeflated.length + + view.setUint32(offset, dataDeflated.length, true) + offset += 4 + out.set(dataDeflated, offset) + + return out +} diff --git a/packages/kiwi/tests/container.test.ts b/packages/kiwi/tests/container.test.ts new file mode 100644 index 000000000..2147a750a --- /dev/null +++ b/packages/kiwi/tests/container.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test' + +import { deflateSync } from 'fflate' + +import { + buildFigKiwi, + decompressFigKiwiData, + decompressFigKiwiDataAsync, + FIG_KIWI_DEFAULT_VERSION, + parseFigKiwiChunks +} from '../src/fig/container' + +describe('Figma FIG Kiwi container helpers', () => { + test('builds and parses fig-kiwi chunks', () => { + const schemaDeflated = new Uint8Array([1, 2, 3]) + const dataRaw = new TextEncoder().encode('payload') + const binary = buildFigKiwi(schemaDeflated, dataRaw) + + const chunks = parseFigKiwiChunks(binary) + + expect(chunks).not.toBeNull() + expect(chunks?.[0]).toEqual(schemaDeflated) + expect(chunks?.[1]).toEqual(deflateSync(dataRaw)) + }) + + test('uses the default container version', () => { + const binary = buildFigKiwi(new Uint8Array([1]), new Uint8Array([2])) + const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength) + + expect(view.getUint32(8, true)).toBe(FIG_KIWI_DEFAULT_VERSION) + }) + + test('decompresses deflated data synchronously and asynchronously', async () => { + const dataRaw = new TextEncoder().encode('payload') + const deflated = deflateSync(dataRaw) + + expect(decompressFigKiwiData(deflated)).toEqual(dataRaw) + await expect(decompressFigKiwiDataAsync(deflated)).resolves.toEqual(dataRaw) + }) + + test('rejects non fig-kiwi data', () => { + expect(parseFigKiwiChunks(new TextEncoder().encode('not-fig-kiwi'))).toBeNull() + }) +}) diff --git a/packages/kiwi/tsdown.config.ts b/packages/kiwi/tsdown.config.ts index eb538a96d..7f32c29d5 100644 --- a/packages/kiwi/tsdown.config.ts +++ b/packages/kiwi/tsdown.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ 'schema-runtime': './src/schema-runtime/index.ts', fig: './src/fig/index.ts', 'fig/codec': './src/fig/codec.ts', + 'fig/container': './src/fig/container.ts', 'fig/parse': './src/fig/parse.ts' }, plugins: [rawText()],