fix(ui): store favorite share PNG metadata as iTXt

This commit is contained in:
linshen committed 2026-05-10 15:49:37 +08:00
1 parent b73454a613
commit 54cffb301c
2 files changed
+132 -2

No files matched your search

+97 -1
View File
@@ -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 (
@@ -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))