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
+102
View File
@@ -0,0 +1,102 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { parsePngTextChunks, PNG_SIGNATURE } = require("../../web/js/png-metadata.js");
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
function u32(n) {
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
}
/** A chunk with a dummy CRC (the parser does not verify it). */
function chunk(type, dataBytes) {
return [...u32(dataBytes.length), ...Buffer.from(type, "latin1"), ...dataBytes, 0, 0, 0, 0];
}
const IHDR = chunk("IHDR", [...u32(1), ...u32(1), 8, 6, 0, 0, 0]);
const IEND = chunk("IEND", []);
function tEXt(keyword, text) {
return chunk("tEXt", [...Buffer.from(keyword, "latin1"), 0, ...Buffer.from(text, "latin1")]);
}
function iTXt(keyword, text, { compressed = false } = {}) {
return chunk("iTXt", [
...Buffer.from(keyword, "latin1"), 0,
compressed ? 1 : 0, 0, // compression flag, compression method
0, // empty language tag
0, // empty translated keyword
...Buffer.from(text, "utf8"),
]);
}
function png(...chunks) {
return Uint8Array.from([...SIGNATURE, ...IHDR, ...chunks.flat(), ...IEND]);
}
test("exports the PNG signature", () => {
assert.deepEqual([...PNG_SIGNATURE], SIGNATURE);
});
test("reads a tEXt chunk written by ComfyUI", () => {
const bytes = png(tEXt("prompt", '{"a":1}'));
assert.deepEqual(parsePngTextChunks(bytes), { prompt: '{"a":1}' });
});
test("reads several text chunks and an uncompressed iTXt chunk with UTF-8 text", () => {
const bytes = png(tEXt("prompt", '{"a":1}'), iTXt("workflow", '{"b":"日本語 🙂"}'), tEXt("Software", "ComfyUI"));
assert.deepEqual(parsePngTextChunks(bytes), {
prompt: '{"a":1}',
workflow: '{"b":"日本語 🙂"}',
Software: "ComfyUI",
});
});
test("tEXt is Latin-1, so byte 0xE9 is an e-acute", () => {
const bytes = png(chunk("tEXt", [...Buffer.from("Comment", "latin1"), 0, 0xe9]));
assert.deepEqual(parsePngTextChunks(bytes), { Comment: "é" });
});
test("skips compressed iTXt and zTXt payloads instead of returning garbage", () => {
const zTXt = chunk("zTXt", [...Buffer.from("Comment", "latin1"), 0, 0, 0x78, 0x9c, 1, 2, 3]);
const bytes = png(iTXt("workflow", "zzz", { compressed: true }), zTXt, tEXt("prompt", "ok"));
assert.deepEqual(parsePngTextChunks(bytes), { prompt: "ok" });
});
test("ignores non-text chunks and a text chunk without a NUL separator", () => {
const bytes = png(chunk("IDAT", [1, 2, 3]), chunk("tEXt", [...Buffer.from("nonul", "latin1")]), tEXt("k", "v"));
assert.deepEqual(parsePngTextChunks(bytes), { k: "v" });
});
test("accepts an ArrayBuffer as well as a Uint8Array", () => {
const bytes = png(tEXt("prompt", "x"));
assert.deepEqual(parsePngTextChunks(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)), { prompt: "x" });
});
test("returns {} for garbage, empty and non-PNG input", () => {
assert.deepEqual(parsePngTextChunks(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])), {});
assert.deepEqual(parsePngTextChunks(new Uint8Array(0)), {});
assert.deepEqual(parsePngTextChunks(Uint8Array.from(Buffer.from("GIF89a....................", "latin1"))), {});
assert.deepEqual(parsePngTextChunks(null), {});
assert.deepEqual(parsePngTextChunks("not bytes"), {});
});
test("a file truncated inside a text chunk yields only the chunks that were complete", () => {
const full = png(tEXt("prompt", "first"), tEXt("workflow", "second-second-second"));
const cutInsideSecond = full.subarray(0, full.length - IEND.length - 6);
assert.deepEqual(parsePngTextChunks(cutInsideSecond), { prompt: "first" });
const cutInsideFirst = full.subarray(0, SIGNATURE.length + IHDR.length + 10);
assert.deepEqual(parsePngTextChunks(cutInsideFirst), {});
});
test("a chunk whose declared length overruns the file is ignored", () => {
const bogus = [...u32(0x7fffffff), ...Buffer.from("tEXt", "latin1"), ...Buffer.from("k\0v", "latin1")];
const bytes = Uint8Array.from([...SIGNATURE, ...IHDR, ...tEXt("ok", "1"), ...bogus]);
assert.deepEqual(parsePngTextChunks(bytes), { ok: "1" });
});
test("stops at IEND even if bytes follow it", () => {
const bytes = Uint8Array.from([...png(tEXt("a", "1")), ...tEXt("b", "2")]);
assert.deepEqual(parsePngTextChunks(bytes), { a: "1" });
});
+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) {