diff --git a/tests/js/png-metadata.test.js b/tests/js/png-metadata.test.js
new file mode 100644
index 0000000..2b45799
--- /dev/null
+++ b/tests/js/png-metadata.test.js
@@ -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" });
+});
diff --git a/web/admin.html b/web/admin.html
index 3edd812..c2c6e6e 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -7,6 +7,7 @@
+
diff --git a/web/gallery.html b/web/gallery.html
index 062744a..ea388e6 100644
--- a/web/gallery.html
+++ b/web/gallery.html
@@ -7,6 +7,7 @@
+
diff --git a/web/js/admin.js b/web/js/admin.js
index 255a681..877b0be 100644
--- a/web/js/admin.js
+++ b/web/js/admin.js
@@ -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) {
diff --git a/web/js/gallery.js b/web/js/gallery.js
index ebcdb28..2a00939 100644
--- a/web/js/gallery.js
+++ b/web/js/gallery.js
@@ -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) {
diff --git a/web/js/png-metadata.js b/web/js/png-metadata.js
new file mode 100644
index 0000000..c813676
--- /dev/null
+++ b/web/js/png-metadata.js
@@ -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} 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);
diff --git a/web/metadata.html b/web/metadata.html
index 6a80fca..b283ae5 100644
--- a/web/metadata.html
+++ b/web/metadata.html
@@ -6,6 +6,7 @@
Image Gallery - Generation Data
+
@@ -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) {