トリミングしたのに加工前の画像が復元できてしまうPNGの調査
社内でも話題に上がってた「トリミングしたはずのスクリーンショットから全体画像を復元できてしまうことがある」という問題について気になったので調べました。
概要
もともとはGoogle Pixelでのスクリーンショットに関する不具合だったようですが、WindowsのSnipping Toolでも再現するとのことなので確認しました。
再現手順
まずSnipping Toolでスクリーンショットを撮り、保存したファイルを別のフォルダーにコピーしておきます。その後、Snipping Toolを開いてトリミングをして、同じ名前でファイルを保存します。
1つめの全体のスクリーンショットを 1_whole.png 、2つ目のトリミング済み画像を 2_trimed.png という名前にしておきます。
ファイルの状態
画像を普通に見るだけだと問題は見当たりませんが、よくファイルサイズを確認すると全く同一サイズでした。これは確かに問題がありそうです
PNGファイルの構造
結果的には遠回りだったのですが、問題のある画像ファイルの中身についてバイナリレベルで見ていきます。
PNGの仕様
PNGファイルの構造は次のようになっています。『【画像処理】ヘッダ情報の確認とバイナリエディタからの画像編集(PNG編) – Qiita』などを参考にしました
最初の8バイトはPNGファイルであることを示す特定の値が入っていますが、それ以降は全て”チャンク”と呼ばれる構造になっています。このチャンクは、
- Length: 最初4バイトにそのチャンク本体の長さ
- Chunk Type: 次の4バイト(ASCIIで4文字)にチャンクの種類名
- Chunk Data: チャンク本体(0バイト以上の可変長)
- CRC: 最後4バイトは誤り検出符号
で構成されています。ファイルの終端符号のIENDもチャンクで表現されているのが面白い点です。
PNGのチャンクを調べるスクリプト
PNGファイルが分かりやすい構成をしているので、TypeScriptで「PNGファイルがどんなチャンクを持っているのか」調べるスクリプトを書きました。実行環境はNode.jsでなくDenoを使っています。一部抜粋のため、全文はGistを参照してください。
const fileName = "1_whole.png"; const rawData = await Deno.readFile(fileName); const seekView = new SeekDataView(rawData); console.log(`File: ${fileName} (${rawData.byteLength} bytes)`); const pngSignature = seekView.readArray(8); // 8 bytes of PNG signature if (String.fromCharCode(...pngSignature.slice(0, 4)) !== "\x89PNG") { throw new Error("Invalid PNG signature"); } const chunks: PNGChunk[] = []; while (seekView.offset < seekView.data.byteLength) { const length = seekView.readUint32(); const type = String.fromCharCode(...seekView.readArray(4)); const data = seekView.readArray(length); const crc = seekView.readUint32(); chunks.push({ length, type, data, crc }); console.log(`Chunk: ${type} (length: ${length} bytes) [offset: 0x${toHex(seekView.offset - length - 12)}]`); }
https://gist.github.com/kishimotonico/98fda283ee9ace36cc7f80ce9b70fa38
雑に作ったため、ファイル名を変数で指定する仕様になっている他、破損チャンクなど想定外のデータの読み込み時には実行時例外が発生します。
このスクリプトで、上で作った 1_whole.png と 2_trimed.png の2つのファイルを調べてみるとこんな実行結果でした。
$ deno run --allow-read=. main.ts File: 1_whole.png (154144 bytes) Chunk: IHDR (length: 13 bytes) [offset: 0x08] Chunk: sRGB (length: 1 bytes) [offset: 0x21] Chunk: gAMA (length: 4 bytes) [offset: 0x2e] Chunk: pHYs (length: 9 bytes) [offset: 0x3e] Chunk: IDAT (length: 65445 bytes) [offset: 0x53] Chunk: IDAT (length: 65524 bytes) [offset: 0x10004] Chunk: IDAT (length: 23044 bytes) [offset: 0x20004] Chunk: IEND (length: 0 bytes) [offset: 0x25a14]
$ deno run --allow-read=. main.ts File: 2_trimed.png (154144 bytes) Chunk: IHDR (length: 13 bytes) [offset: 0x08] Chunk: sRGB (length: 1 bytes) [offset: 0x21] Chunk: gAMA (length: 4 bytes) [offset: 0x2e] Chunk: pHYs (length: 9 bytes) [offset: 0x3e] Chunk: IDAT (length: 21980 bytes) [offset: 0x53] Chunk: IEND (length: 0 bytes) [offset: 0x563b] error: Uncaught (in promise) RangeError: Offset is outside the bounds of the DataView
1_whole.pngについては正常に全てのチャンクが取得できています。その一方、2_trimed.pngはIENDチャンクまで読み込めたようですが、その次のチャンクを読み込もうとしてエラーが発生しています。
ここまでで2つの画像データのチャンク構成が分かったので、WinMergeを使いバイナリで比較してみます。
2_trimed.pngでIENDチャンク以降のデータに問題があるようなので、offset: 0x563bの付近を見てみるとこんな感じです。
画像だと分かりにくいですが、2_trimed.pngのIENDチャンク以降のデータは1_whole.pngと同じデータになっています。これをまとめると、このような状況です。
ついでにファイル先頭の差分を確認してみるとこのような差分でした。違っているのはPNGのIHDRチャンクで指定されている画像サイズ(width, height)と、それに依存するCRC、IDATのチャンク長のみです。
ちょっと中途半端ですが、ひとまずPNGについていろいろ調べることができたので以上にします。
余談・あとがき
本当は加工前の画像データを復元する、というところまでできれば嬉しかったのですが難しそうです。
「1_whole.pngのメタデータ(画像サイズ)を2_trimed.pngに上書きして調整すれば、2_trimed.pngからトリミング前の画像を一部復元できるかな」と試してみたのですが、これはダメでした。それもそのはずで、PNGのIDATはdeflate圧縮された状態で格納されるため、単に一部データをそのまま復元して埋め込んだだけでは正常にデータを展開できないと考えられます。そのため、破損データ(今回の場合、2_trimed.pngに残っている1_whole.pngの一部データ)を強引に復元するためには、deflate圧縮についても考慮する必要がありそうです。
という経緯があり、この記事での一番の成果は、PNGのチャンク構造を調べるTypeScriptのコードを書いたことでした。
先月から社内で導入されているGitHub Copilotを使って上のコードを書いたのですが、Tabキーを押すだけでコードが出来上がっていくのが便利です。