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) {