バイナリファイルからPNGを抜き出してみたくなった、ただそれだけ。Rubyでサクッとやる。
コードと実行例
filename = ARGV[0]
prefix = File.basename(filename, ".*")
PNG_REGEXP = /
\x89PNG\r\n\x1A\n
\x00\x00\x00\x0D IHDR .{13} .{4}
.*?
\x00\x00\x00\x00 IEND \xAE\x42\x60\x82
/mnx
File.binread(filename).enum_for(:scan, PNG_REGEXP).with_index(1) do |data, i|
File.binwrite("#{prefix}-%04d.png" % i, data)
end
$ ruby extract-png.rb imageres.dll
$ ls *.png
imageres-0001.png
imageres-0002.png
...
imageres-0337.png
imageres-0338.png
説明
PNGの仕様
PNGのマジックナンバー(ファイル種別を示す先頭の固定バイト列)は \x89PNG\r\n\x1A\n
の8バイト。
その後はチャンクと呼ばれる <データ長><チャンク名><データ本体><CRC-32>
の並びが何個も続く。
- 最初のチャンクは
IHDR
でデータは常に13バイトなので、チャンク名までの8バイトが固定値となる。マジックナンバーと合わせるとPNGの先頭16バイトが固定値。 - 最後のチャンクは
IEND
でデータは無いので、データ長とCRC(巡回冗長検査)も含む12バイトが固定値となる1。これがPNGの末尾。
PNGを厳密に探すには最低でも、マジックナンバーから始まってチャンクが連続することを確認しなければいけない。今回はそこまでするのが面倒なので、バイナリデータからPNGの先頭と末尾を探し出せばOKとした。
正規表現の構築
PNGが複数ある場合もあるので、PNGの先頭~末尾はなるべく短くなるよう抽出しなければならない。言い換えると、PNGの途中に先頭や末尾と同じ文字列が登場してはいけない。しっかり対策するには否定先読みや非包含オペレーターのような機能が必要だが、先頭や末尾の条件を長くしておけば誤検知はほぼ防げる2。そのため今回は最短マッチ .*?
だけで済ませた。
正規表現にはいくつかオプションを指定している。
- m:
.
を改行コードにもマッチさせる。今回はテキストでなくバイナリなので、改行コードを特別扱いさせてはいけない。 - n: 正規表現の文字コードをバイナリ(ASCII-8BIT)に指定する。
- x: 正規表現中の空白を無視する。単に可読性を高めるためで、利用は必須ではない。
ファイル入出力と文字列スキャン
本当はファイルが巨大な場合を考慮して少しずつ入力+スキャンできればよかった(StringScannerのIO版みたいな感じ?)が、方法がわからなかったので IO.binread
で丸ごと読み込むことにした。※IOクラスに慣れなくてFileクラスを使った。
正規表現による抽出は String#scan
でいいものの、何となく以下のことを考慮して Object#enum_for
を組み合わせた。
-
scan(PNG_REGEXP) { ... }
だと、連番を振る変数を別に用意しなければいけない。 -
scan(PNG_REGEXP).each.with_index(1) { ... }
だと、抽出した全PNGデータが一旦配列で保持されてしまいメモリを消費する。
参考
-
Portable Network Graphics (PNG) Specification (Second Edition)
- 5 Datastream structure
- 11 Chunk specifications
- Ruby リファレンスマニュアル > 正規表現
- ruby - StringScanner scanning IO instead of a string - Stack Overflow