Merge pull request #256 from guimaurer/fix/image-fill-hash-encoding

fix: image fills lost after save . hex hash corrupted in Kiwi byte[] encoding
This commit is contained in:
Danila Poyarkov 2026-05-17 13:03:22 +03:00 committed by GitHub
commit 123a5ceddb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 42 additions and 19 deletions

View file

@ -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
}

View file

@ -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 })
})
}

View file

@ -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?: {

View file

@ -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 {

View file

@ -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

View file

@ -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)
})
})