diff --git a/packages/core/src/bytes/hex.ts b/packages/core/src/bytes/hex.ts new file mode 100644 index 000000000..e594cc38b --- /dev/null +++ b/packages/core/src/bytes/hex.ts @@ -0,0 +1,21 @@ +export function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error('Hex string must contain an even number of characters') + } + + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + if (Number.isNaN(byte)) throw new Error('Hex string contains invalid characters') + bytes[i] = byte + } + return bytes +} + +export function bytesToHex(bytes: Uint8Array): string { + let hex = '' + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0') + } + return hex +} diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index a237eeea8..f3110ff95 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -318,22 +318,11 @@ function compressViaWorker( worker.terminate() } - const imgCopies = imageEntries.map((e) => ({ - name: e.name, - data: new Uint8Array(e.data) - })) - - const transferables = [ - schemaDeflated.buffer, - kiwiData.buffer, - thumbnailPng.buffer, - ...imgCopies.map((e) => e.data.buffer) - ] - - worker.postMessage( - { schemaDeflated, kiwiData, thumbnailPng, metaJson, images: imgCopies }, - transferables - ) + // Do NOT use transferables here. toUint8Array() in ByteBuffer returns a view of the + // internal buffer, so transferring kiwiData.buffer or schemaDeflated.buffer detaches + // buffers that may be shared with other views, causing "already detached" errors on + // subsequent saves. Structured clone (the default) copies the data safely. + worker.postMessage({ schemaDeflated, kiwiData, thumbnailPng, metaJson, images: imageEntries }) }) } diff --git a/packages/core/src/kiwi/binary/codec.ts b/packages/core/src/kiwi/binary/codec.ts index 7922ab37a..ac1b5d741 100644 --- a/packages/core/src/kiwi/binary/codec.ts +++ b/packages/core/src/kiwi/binary/codec.ts @@ -195,7 +195,7 @@ export interface Paint { blendMode?: string stops?: { color: Color; position: number }[] transform?: Matrix - image?: { hash: string } + image?: { hash: string | Uint8Array } imageScaleMode?: string colorVariableBinding?: VariableBinding colorVar?: { diff --git a/packages/core/src/kiwi/binary/variable-bindings.ts b/packages/core/src/kiwi/binary/variable-bindings.ts index cea3bc41f..b09d9331c 100644 --- a/packages/core/src/kiwi/binary/variable-bindings.ts +++ b/packages/core/src/kiwi/binary/variable-bindings.ts @@ -1,3 +1,4 @@ +import { hexToBytes } from '#core/bytes/hex' import type { GUID } from '#core/types' import type { NodeChange, Paint } from './codec' @@ -87,7 +88,7 @@ export function encodeNodeChangeWithVariables( hex = injectVariableBinding(hex, '2701', strokeBinding) } - return new Uint8Array(hex.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? []) + return hexToBytes(hex) } function injectVariableBinding(hex: string, marker: string, binding: { variableID: GUID }): string { diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index 4e64a52a9..de5cb73b4 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -1,3 +1,4 @@ +import { hexToBytes } from '#core/bytes/hex' import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text/data' import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands' @@ -205,7 +206,7 @@ function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint { paint.stops = f.gradientStops.map((s) => ({ color: safeColor(s.color), position: s.position })) } if (f.gradientTransform) paint.transform = f.gradientTransform - if (f.imageHash) paint.image = { hash: f.imageHash } + if (f.imageHash) paint.image = { hash: hexToBytes(f.imageHash) } if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode if (f.imageTransform) paint.transform = f.imageTransform return paint diff --git a/tests/engine/io/fig/export/images.test.ts b/tests/engine/io/fig/export/images.test.ts index 2c299d069..0c0643ebe 100644 --- a/tests/engine/io/fig/export/images.test.ts +++ b/tests/engine/io/fig/export/images.test.ts @@ -108,5 +108,16 @@ describe('fig export/import with images', () => { expect(new Uint8Array(expectDefined(restored.images.get(hash2), 'restored image 2'))).toEqual( bytes2 ) + + const restoredImg1 = expectDefined( + [...restored.getAllNodes()].find((node) => node.name === 'Img1'), + 'restored image node 1' + ) + const restoredImg2 = expectDefined( + [...restored.getAllNodes()].find((node) => node.name === 'Img2'), + 'restored image node 2' + ) + expect(restoredImg1.fills[0].imageHash).toBe(hash1) + expect(restoredImg2.fills[0].imageHash).toBe(hash2) }) })