mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 06:47:33 +00:00
fix: properly parse PNG iTXt chunks per specification (#8530)
## Summary Fixes PNG iTXt chunk parsing to comply with the PNG specification. ## Problem The current iTXt parser incorrectly reads the text content immediately after the keyword null terminator, but iTXt chunks have additional fields: - Compression flag (1 byte) - Compression method (1 byte) - Language tag (null-terminated) - Translated keyword (null-terminated) - Text content This caused PNGs that correctly follow the spec to fail loading with JSON parse errors due to leading null bytes. ## Solution Skip the compression flag, method, language tag, and translated keyword fields before reading the text content. Fixes #8150 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8530-fix-properly-parse-PNG-iTXt-chunks-per-specification-2fa6d73d36508189bef4cc5fa3899096) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
BIN
browser_tests/assets/workflowInMedia/workflow_itxt.png
Normal file
BIN
browser_tests/assets/workflowInMedia/workflow_itxt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 517 B |
@@ -16,6 +16,7 @@ test.describe(
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow_prompt_parameters.png',
|
||||
'workflow_itxt.png',
|
||||
'workflow.webm',
|
||||
// Skipped due to 3d widget unstable visual result.
|
||||
// 3d widget shows grid after fully loaded.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
245
src/scripts/metadata/png.test.ts
Normal file
245
src/scripts/metadata/png.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getFromPngBuffer } from './png'
|
||||
|
||||
function createPngWithChunk(
|
||||
chunkType: string,
|
||||
keyword: string,
|
||||
content: string,
|
||||
options: {
|
||||
compressionFlag?: number
|
||||
compressionMethod?: number
|
||||
languageTag?: string
|
||||
translatedKeyword?: string
|
||||
} = {}
|
||||
): ArrayBuffer {
|
||||
const {
|
||||
compressionFlag = 0,
|
||||
compressionMethod = 0,
|
||||
languageTag = '',
|
||||
translatedKeyword = ''
|
||||
} = options
|
||||
|
||||
const signature = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
])
|
||||
const typeBytes = new TextEncoder().encode(chunkType)
|
||||
const keywordBytes = new TextEncoder().encode(keyword)
|
||||
const contentBytes = new TextEncoder().encode(content)
|
||||
|
||||
let chunkData: Uint8Array
|
||||
if (chunkType === 'iTXt') {
|
||||
const langBytes = new TextEncoder().encode(languageTag)
|
||||
const transBytes = new TextEncoder().encode(translatedKeyword)
|
||||
const totalLength =
|
||||
keywordBytes.length +
|
||||
1 +
|
||||
2 +
|
||||
langBytes.length +
|
||||
1 +
|
||||
transBytes.length +
|
||||
1 +
|
||||
contentBytes.length
|
||||
|
||||
chunkData = new Uint8Array(totalLength)
|
||||
let pos = 0
|
||||
chunkData.set(keywordBytes, pos)
|
||||
pos += keywordBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData[pos++] = compressionFlag
|
||||
chunkData[pos++] = compressionMethod
|
||||
chunkData.set(langBytes, pos)
|
||||
pos += langBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(transBytes, pos)
|
||||
pos += transBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(contentBytes, pos)
|
||||
} else {
|
||||
chunkData = new Uint8Array(keywordBytes.length + 1 + contentBytes.length)
|
||||
chunkData.set(keywordBytes, 0)
|
||||
chunkData[keywordBytes.length] = 0
|
||||
chunkData.set(contentBytes, keywordBytes.length + 1)
|
||||
}
|
||||
|
||||
const lengthBytes = new Uint8Array(4)
|
||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
||||
|
||||
const crc = new Uint8Array(4)
|
||||
|
||||
const iendType = new TextEncoder().encode('IEND')
|
||||
const iendLength = new Uint8Array(4)
|
||||
const iendCrc = new Uint8Array(4)
|
||||
|
||||
const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4
|
||||
const result = new Uint8Array(total)
|
||||
|
||||
let offset = 0
|
||||
result.set(signature, offset)
|
||||
offset += signature.length
|
||||
|
||||
result.set(lengthBytes, offset)
|
||||
offset += 4
|
||||
result.set(typeBytes, offset)
|
||||
offset += 4
|
||||
result.set(chunkData, offset)
|
||||
offset += chunkData.length
|
||||
result.set(crc, offset)
|
||||
offset += 4
|
||||
|
||||
result.set(iendLength, offset)
|
||||
offset += 4
|
||||
result.set(iendType, offset)
|
||||
offset += 4
|
||||
result.set(iendCrc, offset)
|
||||
|
||||
return result.buffer
|
||||
}
|
||||
|
||||
describe('getFromPngBuffer', () => {
|
||||
it('returns empty object for invalid PNG', async () => {
|
||||
const invalidData = new ArrayBuffer(8)
|
||||
const result = await getFromPngBuffer(invalidData)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('parses tEXt chunk', async () => {
|
||||
const workflow = '{"nodes":[]}'
|
||||
const buffer = createPngWithChunk('tEXt', 'workflow', workflow)
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
|
||||
it('parses comf chunk', async () => {
|
||||
const prompt = '{"1":{"class_type":"Test"}}'
|
||||
const buffer = createPngWithChunk('comf', 'prompt', prompt)
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['prompt']).toBe(prompt)
|
||||
})
|
||||
|
||||
it('parses uncompressed iTXt chunk', async () => {
|
||||
const workflow = '{"nodes":[{"id":1}]}'
|
||||
const buffer = createPngWithChunk('iTXt', 'workflow', workflow, {
|
||||
compressionFlag: 0,
|
||||
compressionMethod: 0
|
||||
})
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
|
||||
it('parses iTXt chunk with language tag and translated keyword', async () => {
|
||||
const workflow = '{"test":"value"}'
|
||||
const buffer = createPngWithChunk('iTXt', 'workflow', workflow, {
|
||||
compressionFlag: 0,
|
||||
languageTag: 'en',
|
||||
translatedKeyword: 'Workflow'
|
||||
})
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
|
||||
it('parses compressed iTXt chunk', async () => {
|
||||
const workflow = '{"nodes":[{"id":1,"type":"KSampler"}]}'
|
||||
const contentBytes = new TextEncoder().encode(workflow)
|
||||
|
||||
const stream = new CompressionStream('deflate')
|
||||
const writer = stream.writable.getWriter()
|
||||
await writer.write(contentBytes)
|
||||
await writer.close()
|
||||
|
||||
const reader = stream.readable.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
}
|
||||
const compressedBytes = new Uint8Array(
|
||||
chunks.reduce((acc, c) => acc + c.length, 0)
|
||||
)
|
||||
let pos = 0
|
||||
for (const chunk of chunks) {
|
||||
compressedBytes.set(chunk, pos)
|
||||
pos += chunk.length
|
||||
}
|
||||
|
||||
const buffer = createPngWithCompressedITXt(
|
||||
'workflow',
|
||||
compressedBytes,
|
||||
'',
|
||||
''
|
||||
)
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
})
|
||||
|
||||
function createPngWithCompressedITXt(
|
||||
keyword: string,
|
||||
compressedContent: Uint8Array,
|
||||
languageTag: string,
|
||||
translatedKeyword: string
|
||||
): ArrayBuffer {
|
||||
const signature = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
])
|
||||
const typeBytes = new TextEncoder().encode('iTXt')
|
||||
const keywordBytes = new TextEncoder().encode(keyword)
|
||||
const langBytes = new TextEncoder().encode(languageTag)
|
||||
const transBytes = new TextEncoder().encode(translatedKeyword)
|
||||
|
||||
const totalLength =
|
||||
keywordBytes.length +
|
||||
1 +
|
||||
2 +
|
||||
langBytes.length +
|
||||
1 +
|
||||
transBytes.length +
|
||||
1 +
|
||||
compressedContent.length
|
||||
|
||||
const chunkData = new Uint8Array(totalLength)
|
||||
let pos = 0
|
||||
chunkData.set(keywordBytes, pos)
|
||||
pos += keywordBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData[pos++] = 1
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(langBytes, pos)
|
||||
pos += langBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(transBytes, pos)
|
||||
pos += transBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(compressedContent, pos)
|
||||
|
||||
const lengthBytes = new Uint8Array(4)
|
||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
||||
|
||||
const crc = new Uint8Array(4)
|
||||
const iendType = new TextEncoder().encode('IEND')
|
||||
const iendLength = new Uint8Array(4)
|
||||
const iendCrc = new Uint8Array(4)
|
||||
|
||||
const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4
|
||||
const result = new Uint8Array(total)
|
||||
|
||||
let offset = 0
|
||||
result.set(signature, offset)
|
||||
offset += signature.length
|
||||
result.set(lengthBytes, offset)
|
||||
offset += 4
|
||||
result.set(typeBytes, offset)
|
||||
offset += 4
|
||||
result.set(chunkData, offset)
|
||||
offset += chunkData.length
|
||||
result.set(crc, offset)
|
||||
offset += 4
|
||||
result.set(iendLength, offset)
|
||||
offset += 4
|
||||
result.set(iendType, offset)
|
||||
offset += 4
|
||||
result.set(iendCrc, offset)
|
||||
|
||||
return result.buffer
|
||||
}
|
||||
@@ -1,26 +1,59 @@
|
||||
async function decompressZlib(
|
||||
data: Uint8Array<ArrayBuffer>
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const stream = new DecompressionStream('deflate')
|
||||
const writer = stream.writable.getWriter()
|
||||
try {
|
||||
await writer.write(data)
|
||||
await writer.close()
|
||||
} finally {
|
||||
writer.releaseLock()
|
||||
}
|
||||
|
||||
const reader = stream.readable.getReader()
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = []
|
||||
let totalLength = 0
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
totalLength += value.length
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
||||
export function getFromPngBuffer(buffer: ArrayBuffer): Record<string, string> {
|
||||
// Get the PNG data as a Uint8Array
|
||||
export async function getFromPngBuffer(
|
||||
buffer: ArrayBuffer
|
||||
): Promise<Record<string, string>> {
|
||||
const pngData = new Uint8Array(buffer)
|
||||
const dataView = new DataView(pngData.buffer)
|
||||
|
||||
// Check that the PNG signature is present
|
||||
if (dataView.getUint32(0) !== 0x89504e47) {
|
||||
console.error('Not a valid PNG file')
|
||||
return {}
|
||||
}
|
||||
|
||||
// Start searching for chunks after the PNG signature
|
||||
let offset = 8
|
||||
let txt_chunks: Record<string, string> = {}
|
||||
// Loop through the chunks in the PNG file
|
||||
const txt_chunks: Record<string, string> = {}
|
||||
|
||||
while (offset < pngData.length) {
|
||||
// Get the length of the chunk
|
||||
const length = dataView.getUint32(offset)
|
||||
// Get the chunk type
|
||||
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8))
|
||||
if (type === 'tEXt' || type == 'comf' || type === 'iTXt') {
|
||||
// Get the keyword
|
||||
|
||||
if (type === 'tEXt' || type === 'comf' || type === 'iTXt') {
|
||||
let keyword_end = offset + 8
|
||||
while (pngData[keyword_end] !== 0) {
|
||||
keyword_end++
|
||||
@@ -28,11 +61,48 @@ export function getFromPngBuffer(buffer: ArrayBuffer): Record<string, string> {
|
||||
const keyword = String.fromCharCode(
|
||||
...pngData.slice(offset + 8, keyword_end)
|
||||
)
|
||||
// Get the text
|
||||
const contentArraySegment = pngData.slice(
|
||||
keyword_end + 1,
|
||||
offset + 8 + length
|
||||
)
|
||||
|
||||
let textStart = keyword_end + 1
|
||||
let isCompressed = false
|
||||
let compressionMethod = 0
|
||||
|
||||
if (type === 'iTXt') {
|
||||
const chunkEnd = offset + 8 + length
|
||||
isCompressed = pngData[textStart] === 1
|
||||
compressionMethod = pngData[textStart + 1]
|
||||
textStart += 2
|
||||
|
||||
while (pngData[textStart] !== 0 && textStart < chunkEnd) {
|
||||
textStart++
|
||||
}
|
||||
if (textStart < chunkEnd) textStart++
|
||||
|
||||
while (pngData[textStart] !== 0 && textStart < chunkEnd) {
|
||||
textStart++
|
||||
}
|
||||
if (textStart < chunkEnd) textStart++
|
||||
}
|
||||
|
||||
let contentArraySegment = pngData.slice(textStart, offset + 8 + length)
|
||||
|
||||
if (isCompressed) {
|
||||
if (compressionMethod === 0) {
|
||||
try {
|
||||
contentArraySegment = await decompressZlib(contentArraySegment)
|
||||
} catch (e) {
|
||||
console.error(`Failed to decompress iTXt chunk "${keyword}":`, e)
|
||||
offset += 12 + length
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`Unsupported compression method ${compressionMethod} for iTXt chunk "${keyword}"`
|
||||
)
|
||||
offset += 12 + length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const contentJson = new TextDecoder('utf-8').decode(contentArraySegment)
|
||||
txt_chunks[keyword] = contentJson
|
||||
}
|
||||
@@ -42,14 +112,21 @@ export function getFromPngBuffer(buffer: ArrayBuffer): Record<string, string> {
|
||||
return txt_chunks
|
||||
}
|
||||
|
||||
export function getFromPngFile(file: File) {
|
||||
return new Promise<Record<string, string>>((r) => {
|
||||
export async function getFromPngFile(
|
||||
file: File
|
||||
): Promise<Record<string, string>> {
|
||||
return new Promise<Record<string, string>>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
r(getFromPngBuffer(event.target.result as ArrayBuffer))
|
||||
reader.onload = async (event) => {
|
||||
const buffer = event.target?.result
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
reject(new Error('Failed to read file as ArrayBuffer'))
|
||||
return
|
||||
}
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user