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:
@@ -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" });
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user