refactor: one PNG metadata parser

admin.js, gallery.js and metadata.html each carried a copy of the PNG chunk
walker that reads ComfyUI's tEXt/iTXt entries (admin.js had two until the
shadowed one was removed). web/js/png-metadata.js is now the only parser:
it checks the signature, bounds-checks every chunk so a truncated file or a
bogus length cannot throw, decodes tEXt as Latin-1 and iTXt as UTF-8, skips
compressed zTXt/iTXt payloads instead of decoding them as garbage, and stops
at IEND. The three page-level parsePNGMetadata functions delegate to it.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent b2fb630f61
commit de194af8bc
7 changed files with 211 additions and 123 deletions
+1
View File
@@ -7,6 +7,7 @@
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
<script src="/prompt_manager/js/sse-stream.js"></script>
<script src="/prompt_manager/js/data-actions.js"></script>
+1
View File
@@ -7,6 +7,7 @@
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
<script src="/prompt_manager/js/sse-stream.js"></script>
<script src="/prompt_manager/js/data-actions.js"></script>
+2 -41
View File
@@ -2517,47 +2517,8 @@
}
}
async parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
if (type === 'tEXt' || type === 'iTXt' || type === 'zTXt') {
const chunkData = arrayBuffer.slice(offset + 8, offset + 8 + length);
let text;
if (type === 'tEXt') {
text = new TextDecoder().decode(chunkData);
} else if (type === 'iTXt') {
// iTXt format: keyword\0compression\0language\0translated_keyword\0text
const textData = new TextDecoder().decode(chunkData);
const parts = textData.split('\0');
if (parts.length >= 5) {
metadata[parts[0]] = parts[4];
}
text = textData;
} else if (type === 'zTXt') {
// zTXt is compressed - basic parsing (might need proper decompression)
text = new TextDecoder().decode(chunkData);
}
// Parse the text chunk for key-value pairs
const nullIndex = text.indexOf('\0');
if (nullIndex !== -1) {
const key = text.substring(0, nullIndex);
const value = text.substring(nullIndex + 1);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
return metadata;
parsePNGMetadata(arrayBuffer) {
return PngMetadata.parsePngTextChunks(arrayBuffer);
}
extractComfyUIData(metadata) {
+2 -41
View File
@@ -1548,47 +1548,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
}
async parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
if (type === 'tEXt' || type === 'iTXt' || type === 'zTXt') {
const chunkData = arrayBuffer.slice(offset + 8, offset + 8 + length);
let text;
if (type === 'tEXt') {
text = new TextDecoder().decode(chunkData);
} else if (type === 'iTXt') {
// iTXt format: keyword\0compression\0language\0translated_keyword\0text
const textData = new TextDecoder().decode(chunkData);
const parts = textData.split('\0');
if (parts.length >= 5) {
metadata[parts[0]] = parts[4];
}
text = textData;
} else if (type === 'zTXt') {
// zTXt is compressed - basic parsing (might need proper decompression)
text = new TextDecoder().decode(chunkData);
}
// Parse the text chunk for key-value pairs
const nullIndex = text.indexOf('\0');
if (nullIndex !== -1) {
const key = text.substring(0, nullIndex);
const value = text.substring(nullIndex + 1);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
return metadata;
parsePNGMetadata(arrayBuffer) {
return PngMetadata.parsePngTextChunks(arrayBuffer);
}
extractComfyUIData(metadata) {
+100
View File
@@ -0,0 +1,100 @@
/**
* PNG text-chunk reader: the one place that walks a PNG's chunks to pull out
* the tEXt/iTXt entries ComfyUI writes (`prompt`, `workflow`, ...).
*
* Only uncompressed text is returned. zTXt chunks and iTXt chunks with the
* compression flag set are skipped rather than decoded as garbage; ComfyUI
* never writes those. Nothing throws: unreadable input yields {}.
*
* Loadable in the browser (window.PngMetadata) and in Node for tests.
*/
(function (root) {
"use strict";
// Typed arrays cannot be frozen; the module keeps this private copy and only reads it
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CHUNK_HEADER = 8; // 4-byte length + 4-byte type
const CHUNK_CRC = 4;
function toBytes(input) {
if (input instanceof Uint8Array) return input;
if (typeof ArrayBuffer !== "undefined" && input instanceof ArrayBuffer) return new Uint8Array(input);
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
return null;
}
function hasSignature(bytes) {
if (bytes.length < PNG_SIGNATURE.length) return false;
for (let i = 0; i < PNG_SIGNATURE.length; i++) if (bytes[i] !== PNG_SIGNATURE[i]) return false;
return true;
}
function latin1(bytes, start, end) {
let out = "";
for (let i = start; i < end; i++) out += String.fromCharCode(bytes[i]);
return out;
}
function utf8(bytes, start, end) {
return new TextDecoder("utf-8").decode(bytes.subarray(start, end));
}
function indexOfNul(bytes, start, end) {
for (let i = start; i < end; i++) if (bytes[i] === 0) return i;
return -1;
}
/** tEXt: keyword NUL text, both Latin-1. */
function readTEXt(bytes, start, end) {
const nul = indexOfNul(bytes, start, end);
if (nul === -1) return null;
return [latin1(bytes, start, nul), latin1(bytes, nul + 1, end)];
}
/** iTXt: keyword NUL flag method language NUL translated NUL text(UTF-8). */
function readITXt(bytes, start, end) {
const keywordEnd = indexOfNul(bytes, start, end);
if (keywordEnd === -1 || keywordEnd + 2 >= end) return null;
const compressed = bytes[keywordEnd + 1] !== 0;
const langEnd = indexOfNul(bytes, keywordEnd + 3, end);
if (langEnd === -1) return null;
const translatedEnd = indexOfNul(bytes, langEnd + 1, end);
if (translatedEnd === -1) return null;
if (compressed) return null;
return [latin1(bytes, start, keywordEnd), utf8(bytes, translatedEnd + 1, end)];
}
/**
* @param {Uint8Array|ArrayBuffer|ArrayBufferView} input - PNG file bytes
* @returns {Object<string, string>} keyword -> text for every readable text chunk
*/
function parsePngTextChunks(input) {
const bytes = toBytes(input);
if (!bytes || !hasSignature(bytes)) return {};
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const result = {};
let offset = PNG_SIGNATURE.length;
while (offset + CHUNK_HEADER <= bytes.length) {
const length = view.getUint32(offset);
const type = latin1(bytes, offset + 4, offset + 8);
const dataStart = offset + CHUNK_HEADER;
const dataEnd = dataStart + length;
if (dataEnd + CHUNK_CRC > bytes.length) break; // truncated or bogus length
if (type === "IEND") break;
let entry = null;
if (type === "tEXt") entry = readTEXt(bytes, dataStart, dataEnd);
else if (type === "iTXt") entry = readITXt(bytes, dataStart, dataEnd);
if (entry) result[entry[0]] = entry[1];
offset = dataEnd + CHUNK_CRC;
}
return result;
}
const api = { parsePngTextChunks, PNG_SIGNATURE };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.PngMetadata = api;
})(typeof window !== "undefined" ? window : globalThis);
+3 -41
View File
@@ -6,6 +6,7 @@
<title>Image Gallery - Generation Data</title>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
@@ -99,47 +100,8 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
await copyToClipboard(allData);
}
async function parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
if (type === 'tEXt' || type === 'iTXt' || type === 'zTXt') {
const chunkData = arrayBuffer.slice(offset + 8, offset + 8 + length);
let text;
if (type === 'tEXt') {
text = new TextDecoder().decode(chunkData);
} else if (type === 'iTXt') {
// iTXt format: keyword\0compression\0language\0translated_keyword\0text
const textData = new TextDecoder().decode(chunkData);
const parts = textData.split('\0');
if (parts.length >= 5) {
metadata[parts[0]] = parts[4];
}
text = textData;
} else if (type === 'zTXt') {
// zTXt is compressed - basic parsing (might need proper decompression)
text = new TextDecoder().decode(chunkData);
}
// Parse the text chunk for key-value pairs
const nullIndex = text.indexOf('\0');
if (nullIndex !== -1) {
const key = text.substring(0, nullIndex);
const value = text.substring(nullIndex + 1);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
return metadata;
function parsePNGMetadata(arrayBuffer) {
return window.PngMetadata.parsePngTextChunks(arrayBuffer);
}
function extractComfyUIData(metadata) {