🧑‍💻

#152 PNG画像に埋め込まれたメタデータをブラウザ上で可視化するChrome拡張を作ってみた

に公開

はじめに

PNG画像には、見た目には現れないテキスト情報が埋め込まれていることがあります。

今回は、PNG画像に含まれるメタデータをブラウザ上で簡単に確認できるChrome拡張機能を作成しました。

PNGのメタデータとは

PNGファイルは、「チャンク(chunk)」と呼ばれるブロック単位で構成されています。画像を描画するために必要なチャンク(IHDRやIDATなど)だけでなく、テキストベースのメタ情報を格納できるチャンクも存在します。

主なメタデータ用チャンクは以下のとおりです

  • tEXt:プレーンテキスト(非圧縮)
  • zTXt:圧縮されたテキスト
  • iTXt:国際化対応テキスト(UTF-8、圧縮対応)
  • カスタムチャンクや画像末尾への追加データ(APPEND領域)

たとえば、Stable Diffusionで生成された画像には、プロンプトやモデル名などがこれらのチャンクに埋め込まれていることがあります。

拡張機能の構成

このChrome拡張機能は、画像にマウスをホバーするだけで、メタデータをオーバーレイ表示します。表示内容はそのままコピーも可能です。

ディレクトリ構成は以下のとおりです

MetadataExtractor/
├── manifest.json
├── content.js
├── pako.min.js
└── overlay.css

manifest.json

{
  "manifest_version": 3,
  "name": "Metadata Extractor",
  "version": "1.0",
  "description": "Hover images to see embedded metadata",
  "permissions": ["scripting"],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["pako.min.js", "content.js"],
      "css": ["overlay.css"]
    }
  ],
  "host_permissions": ["<all_urls>"]
}

content.js

function extractMetadataFromPNG(bytes) {
    const decoder = new TextDecoder("utf-8");
    let i = 8; // PNG signature
    const results = [];

    // 描画系の情報はスキップ対象にする
    const skipChunks = new Set([
        "IHDR", "PLTE", "IDAT", "IEND",
        "bKGD", "pHYs", "gAMA", "cHRM", "sRGB",
        "hIST", "tIME", "tRNS", "sBIT", "sPLT"
    ]);

    while (i < bytes.length) {
        const length = (bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3];
        const type = decoder.decode(bytes.slice(i + 4, i + 8));
        const data = bytes.slice(i + 8, i + 8 + length);

        if (skipChunks.has(type)) {
            i += 8 + length + 4;
            continue;
        }

        try {
            switch (type) {
                case "tEXt": {
                    const content = decoder.decode(data);
                    const [key, value] = content.split("\0");
                    results.push(`[tEXt] ${key} = ${value}`);
                    break;
                }
                case "zTXt": {
                    const nullPos = data.indexOf(0);
                    const key = decoder.decode(data.slice(0, nullPos));
                    const compressed = data.slice(nullPos + 2);
                    const text = pako.inflate(compressed, { to: "string" });
                    results.push(`[zTXt] ${key} = ${text}`);
                    break;
                }
                case "iTXt": {
                    const parsed = parseITXtChunk(data);
                    results.push(`[iTXt] ${parsed.keyword}` +
                        (parsed.languageTag ? ` [lang: ${parsed.languageTag}]` : '') +
                        `\n${parsed.text}`);
                    break;
                }
                default: {
                    if (/^[a-zA-Z]{4}$/.test(type)) {
                        const content = decoder.decode(data).trim();
                        const printable = /^[\x20-\x7E\r\n\t]+$/.test(content);
                        if (printable) {
                            results.push(`[${type}] ${content}`);
                        } else {
                            results.push(`[${type}] (binary data, ${data.length} bytes)\nExample: ${toHexPreview(data)}`);
                        }
                    }
                }
            }
        } catch (err) {
            console.warn(`Failed to parse chunk ${type}:`, err);
        }

        i += 8 + length + 4;
    }

    const appended = extractAppendedData(bytes);
    if (appended) results.push(appended);

    return results.length > 0 ? results.join("\n\n") : null;
}

function parseITXtChunk(data) {
    const decoder = new TextDecoder("utf-8");
    let offset = 0;

    const keywordEnd = data.indexOf(0, offset);
    const keyword = decoder.decode(data.slice(offset, keywordEnd));
    offset = keywordEnd + 1;

    const compressionFlag = data[offset++];
    const compressionMethod = data[offset++];

    const languageTagEnd = data.indexOf(0, offset);
    const languageTag = decoder.decode(data.slice(offset, languageTagEnd));
    offset = languageTagEnd + 1;

    const translatedKeywordEnd = data.indexOf(0, offset);
    const translatedKeyword = decoder.decode(data.slice(offset, translatedKeywordEnd));
    offset = translatedKeywordEnd + 1;

    const textData = data.slice(offset);
    const text = (compressionFlag === 1)
        ? pako.inflate(textData, { to: "string" })
        : decoder.decode(textData);

    return { keyword, languageTag, translatedKeyword, text };
}

function extractAppendedData(bytes) {
    const IEND_MAGIC = [0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82];
    const iendIndex = findSequence(bytes, IEND_MAGIC);
    if (iendIndex === -1) return null;

    const extra = bytes.slice(iendIndex + 12);
    if (extra.length === 0) return null;

    const result = [`[APPEND] ${extra.length} bytes found after IEND.`];
    result.push(`Example: ${toHexPreview(extra)}`);
    return result.join("\n");
}

function findSequence(bytes, pattern) {
    for (let i = 0; i <= bytes.length - pattern.length; i++) {
        let match = true;
        for (let j = 0; j < pattern.length; j++) {
            if (bytes[i + j] !== pattern[j]) {
                match = false;
                break;
            }
        }
        if (match) return i;
    }
    return -1;
}

function toHexPreview(data) {
    return [...data.slice(0, 16)]
        .map(b => b.toString(16).padStart(2, '0'))
        .join(' ').toUpperCase();
}

let currentOverlay = null;

function showMetadataOverlay(img, text) {
    if (currentOverlay) return currentOverlay;

    const div = document.createElement("div");
    div.className = "metadata-overlay";

    const pre = document.createElement("pre");
    pre.textContent = text;
    div.appendChild(pre);

    const button = document.createElement("button");
    button.textContent = "Copy";
    button.onclick = (e) => {
        e.stopPropagation();
        e.preventDefault();
        navigator.clipboard.writeText(text);
        button.textContent = "Copied!";
        setTimeout(() => button.textContent = "Copy", 1000);
    };
    div.appendChild(button);

    const rect = img.getBoundingClientRect();
    const wrapper = document.createElement("div");
    wrapper.style.position = "absolute";
    wrapper.style.top = `${window.scrollY + rect.top}px`;
    wrapper.style.left = `${window.scrollX + rect.left}px`;
    wrapper.style.width = `${rect.width}px`;
    wrapper.style.height = `${rect.height}px`;
    wrapper.style.zIndex = "9998";
    wrapper.appendChild(div);
    document.body.appendChild(wrapper);

    wrapper.addEventListener("mouseleave", (event) => {
        if (!wrapper.contains(event.relatedTarget)) {
            wrapper.remove();
            currentOverlay = null;
            img.dataset.metadataOverlayActive = "false";
        }
    });

    currentOverlay = wrapper;
    return wrapper;
}

document.addEventListener("mouseover", async (e) => {
    const img = e.target;
    if (!(img instanceof HTMLImageElement)) return;
    if (!img.src.toLowerCase().endsWith(".png")) return;
    if (img.dataset.metadataOverlayActive === "true") return;

    img.dataset.metadataOverlayActive = "true";

    let metadata = img.dataset.metadataText;
    if (!metadata) {
        try {
            const res = await fetch(img.src);
            const buffer = await res.arrayBuffer();
            metadata = extractMetadataFromPNG(new Uint8Array(buffer));
            if (!metadata) return;
            img.dataset.metadataText = metadata;
        } catch (err) {
            console.error("Failed to fetch image metadata", err);
            return;
        }
    }

    showMetadataOverlay(img, metadata);
});

overlay.css

.metadata-overlay {
  position: absolute;
  background: rgba(0, 0, 0, 0.7);
  color: #fff;
  padding: 8px 12px 24px;
  border-radius: 6px;
  z-index: 9999;
  font-size: 12px;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  max-width: 100%;
  max-height: 100%;
  box-sizing: border-box;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  overflow: hidden;
}

.metadata-overlay pre {
  margin: 0;
  flex-grow: 1;
  overflow: hidden;
  text-overflow: ellipsis;
}

.metadata-overlay button {
  pointer-events: auto;
  align-self: flex-end;
  margin-top: 8px;
  padding: 4px 8px;
  font-size: 12px;
  background: rgba(255, 255, 255, 0.9);
  color: #000;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.metadata-overlay button:hover {
  background: #333;
  color: #fff;
  box-shadow: 0 0 5px rgba(255, 255, 255, 0.6);
}

ライブラリ:pako

この拡張機能では、zTXt や iTXt チャンク内の圧縮されたテキストを解凍するために pako ライブラリを使用しています。

拡張機能のディレクトリ内に pako.min.js を直接配置して利用します。

pako.min.jsの取得方法

以下のURLから最新版のpako.min.jsをダウンロードして、拡張機能フォルダに配置してください。

https://cdnjs.com/libraries/pako

実際に使ってみる

以下のページで配布されているPNG画像には、tEXt, zTXt, iTXtチャンクが含まれています。

https://pmt.sourceforge.io/itxt/

画像にホバーするだけで、拡張機能がメタデータをオーバーレイ表示してくれます。もちろん、ボタンで内容をコピーすることも可能です。

なお、アプリ固有の独自形式でエンコードされている情報が埋め込まれている場合、埋め込まれていること自体は表示されますが、内容の解釈は行えません。

おわりに

今回は、画像にホバーするだけでメタ情報を視覚的に確認・コピーできる拡張機能を実現しました。

ブラウザに入れておくだけで、思わぬ情報が見えてくるかもしれません。ぜひ試してみてください!

最後まで読んでいただきありがとうございます。

参考リンク・出典

Discussion