From 54cffb301cacd4709cd148991329d324f7ef2b9b Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Sun, 10 May 2026 15:49:37 +0800 Subject: [PATCH] fix(ui): store favorite share PNG metadata as iTXt --- .../ui/src/utils/favorite-share-export.ts | 98 ++++++++++++++++++- .../unit/utils/favorite-share-export.spec.ts | 36 ++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/utils/favorite-share-export.ts b/packages/ui/src/utils/favorite-share-export.ts index 140e4f0c..436e1237 100644 --- a/packages/ui/src/utils/favorite-share-export.ts +++ b/packages/ui/src/utils/favorite-share-export.ts @@ -4,6 +4,7 @@ import type { PromptContent, PromptExample, } from '@prompt-optimizer/core' +import { strFromU8, strToU8, unzlibSync, zlibSync } from 'fflate' import { createFavoriteResourcePackageFromJson, @@ -1232,7 +1233,7 @@ export const createFavoriteSharePng = async ( const rendered = await canvasToPngBlob(canvas) const pngBytes = new Uint8Array(await rendered.arrayBuffer()) const payloadText = JSON.stringify(result.payload) - const withMetadata = insertPngTextChunk(pngBytes, FAVORITE_SHARE_PNG_TEXT_KEYWORD, payloadText) + const withMetadata = insertPngInternationalTextChunk(pngBytes, FAVORITE_SHARE_PNG_TEXT_KEYWORD, payloadText) return { blob: new Blob([uint8ArrayToArrayBuffer(withMetadata)], { type: 'image/png' }), result, @@ -1286,6 +1287,60 @@ export const insertPngTextChunk = ( const encoder = new TextEncoder() const type = encoder.encode('tEXt') const data = encoder.encode(`${keyword}\u0000${text}`) + const chunk = createPngChunk(type, data) + + return insertPngChunkAfterIhdr(pngBytes, chunk) +} + +export const insertPngInternationalTextChunk = ( + pngBytes: Uint8Array, + keyword: string, + text: string, + options: { + compressed?: boolean + languageTag?: string + translatedKeyword?: string + } = {}, +): Uint8Array => { + if (!isPng(pngBytes)) throw new Error('Not a PNG file') + const encoder = new TextEncoder() + const type = encoder.encode('iTXt') + const keywordBytes = encoder.encode(keyword) + const languageBytes = encoder.encode(options.languageTag || '') + const translatedKeywordBytes = encoder.encode(options.translatedKeyword || '') + const textBytes = strToU8(text) + const compressed = options.compressed !== false + const encodedTextBytes = compressed ? zlibSync(textBytes) : textBytes + const data = new Uint8Array( + keywordBytes.length + + 1 + + 1 + + 1 + + languageBytes.length + + 1 + + translatedKeywordBytes.length + + 1 + + encodedTextBytes.length, + ) + let offset = 0 + data.set(keywordBytes, offset) + offset += keywordBytes.length + data[offset++] = 0 + data[offset++] = compressed ? 1 : 0 + data[offset++] = 0 + data.set(languageBytes, offset) + offset += languageBytes.length + data[offset++] = 0 + data.set(translatedKeywordBytes, offset) + offset += translatedKeywordBytes.length + data[offset++] = 0 + data.set(encodedTextBytes, offset) + + const chunk = createPngChunk(type, data) + return insertPngChunkAfterIhdr(pngBytes, chunk) +} + +const createPngChunk = (type: Uint8Array, data: Uint8Array): Uint8Array => { const chunk = new Uint8Array(12 + data.length) writeUint32(chunk, 0, data.length) chunk.set(type, 4) @@ -1294,7 +1349,10 @@ export const insertPngTextChunk = ( crcInput.set(type, 0) crcInput.set(data, type.length) writeUint32(chunk, 8 + data.length, crc32(crcInput)) + return chunk +} +const insertPngChunkAfterIhdr = (pngBytes: Uint8Array, chunk: Uint8Array): Uint8Array => { const ihdrLength = readUint32(pngBytes, 8) const insertOffset = 8 + 12 + ihdrLength const output = new Uint8Array(pngBytes.length + chunk.length) @@ -1328,11 +1386,49 @@ export const readPngTextChunk = ( } } } + if (type === 'iTXt') { + const text = readPngInternationalTextData(pngBytes.slice(dataStart, dataEnd), keyword) + if (text !== null) return text + } offset = dataEnd + 4 } return null } +const readPngInternationalTextData = ( + data: Uint8Array, + expectedKeyword: string, +): string | null => { + const decoder = new TextDecoder() + const keywordEnd = data.indexOf(0) + if (keywordEnd <= 0 || keywordEnd + 5 > data.length) return null + + const keyword = decoder.decode(data.slice(0, keywordEnd)) + if (keyword !== expectedKeyword) return null + + const compressionFlag = data[keywordEnd + 1] + const compressionMethod = data[keywordEnd + 2] + if (compressionFlag !== 0 && compressionFlag !== 1) return null + if (compressionMethod !== 0) return null + + let offset = keywordEnd + 3 + const languageEnd = data.indexOf(0, offset) + if (languageEnd < 0) return null + offset = languageEnd + 1 + const translatedKeywordEnd = data.indexOf(0, offset) + if (translatedKeywordEnd < 0) return null + offset = translatedKeywordEnd + 1 + + const textBytes = data.slice(offset) + try { + return compressionFlag === 1 + ? strFromU8(unzlibSync(textBytes)) + : strFromU8(textBytes) + } catch { + return null + } +} + const parseSharePayload = (value: string): FavoriteSharePayload => { const parsed = JSON.parse(value) as unknown if ( diff --git a/packages/ui/tests/unit/utils/favorite-share-export.spec.ts b/packages/ui/tests/unit/utils/favorite-share-export.spec.ts index 38e84d41..a605e829 100644 --- a/packages/ui/tests/unit/utils/favorite-share-export.spec.ts +++ b/packages/ui/tests/unit/utils/favorite-share-export.spec.ts @@ -7,6 +7,7 @@ import { createFavoriteShareFavorite, createFavoriteShareHtml, createFavoriteSharePng, + insertPngInternationalTextChunk, insertPngTextChunk, readFavoriteSharePackage, readPngTextChunk, @@ -164,6 +165,18 @@ const createMinimalPng = (): Uint8Array => new Uint8Array([ 0xae, 0x42, 0x60, 0x82, ]) +const readPngChunkTypes = (bytes: Uint8Array): string[] => { + const decoder = new TextDecoder() + const types: string[] = [] + let offset = 8 + while (offset + 12 <= bytes.length) { + const length = new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0) + types.push(decoder.decode(bytes.slice(offset + 4, offset + 8))) + offset += 12 + length + } + return types +} + describe('favorite share export', () => { it('filters the importable favorite with WYSIWYG section semantics', () => { const favorite = createFavoriteShareFavorite(createFavorite(), { @@ -324,13 +337,31 @@ describe('favorite share export', () => { expect(example.inputImageAssetIds).toBeUndefined() }) - it('can write and read PNG text metadata chunks', () => { + it('can read legacy PNG tEXt metadata chunks', () => { const png = createMinimalPng() const withChunk = insertPngTextChunk(png, 'PromptOptimizerFavoriteShare', '{"ok":true}') expect(readPngTextChunk(withChunk, 'PromptOptimizerFavoriteShare')).toBe('{"ok":true}') }) + it('writes and reads PNG iTXt metadata chunks with UTF-8 JSON', () => { + const png = createMinimalPng() + const payload = JSON.stringify({ ok: true, favoriteTitle: '纸偶微距童话 🧚' }) + const withChunk = insertPngInternationalTextChunk(png, 'PromptOptimizerFavoriteShare', payload) + + expect(readPngTextChunk(withChunk, 'PromptOptimizerFavoriteShare')).toBe(payload) + }) + + it('reads uncompressed PNG iTXt metadata chunks', () => { + const png = createMinimalPng() + const payload = JSON.stringify({ ok: true, favoriteTitle: '收藏分享' }) + const withChunk = insertPngInternationalTextChunk(png, 'PromptOptimizerFavoriteShare', payload, { + compressed: false, + }) + + expect(readPngTextChunk(withChunk, 'PromptOptimizerFavoriteShare')).toBe(payload) + }) + it('embeds an importable package in the original PNG export', async () => { const fillText = vi.fn() const canvas = { @@ -360,7 +391,10 @@ describe('favorite share export', () => { }) const packageBytes = readFavoriteSharePackage(await blob.arrayBuffer()) + const chunkTypes = readPngChunkTypes(new Uint8Array(await blob.arrayBuffer())) const exported = readFavoritesFromPackageBytes(packageBytes) + expect(chunkTypes).toContain('iTXt') + expect(chunkTypes).not.toContain('tEXt') expect(exported.favorites[0].content).toBe('') expect(exported.favorites[0].metadata.reproducibility.examples).toEqual([]) expect(fillText).toHaveBeenCalledWith('IMPORT NOTE', expect.any(Number), expect.any(Number))