From cefa4331e317115e48f19cd6db6d631f9d39932c Mon Sep 17 00:00:00 2001 From: Guilherme Maurer Date: Wed, 13 May 2026 09:06:51 -0300 Subject: [PATCH 1/2] fix: image fills lost after save . hex hash corrupted in Kiwi byte[] encoding --- packages/core/src/io/formats/fig/export.ts | 21 +++++-------------- packages/core/src/kiwi/binary/codec.ts | 2 +- .../core/src/kiwi/node-change/serialize.ts | 10 ++++++++- 3 files changed, 15 insertions(+), 18 deletions(-) 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 e7ddb0010..f8278cf94 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/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index cd0d940d4..a31d0886d 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -192,6 +192,14 @@ export function safeColor(c: { r: number; g: number; b: number; a?: number }): C return { r: c.r, g: c.g, b: c.b, a: c.a ?? 1 } } +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint { const paint: Paint = { type: f.type, @@ -204,7 +212,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 From 3b323a87401a4108c4c589afbd59277093755d5d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 17 May 2026 12:58:43 +0300 Subject: [PATCH 2/2] test: cover fig image fill hash roundtrip --- tests/engine/image.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/engine/image.test.ts b/tests/engine/image.test.ts index 23ba17dfb..a5de5e513 100644 --- a/tests/engine/image.test.ts +++ b/tests/engine/image.test.ts @@ -429,5 +429,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) }) })