Canvas から生成した PNG 画像に独自の情報を埋め込む
こんにちは、Multimedia Engineering Team のいまやです。
Advent Calendar 8日目の記事ですが、特にそういうのとは関係なく好きなことを書きます。
はじめに
Canvas で色々やっていると、 Canvas#toDataURL()
を使って生成した PNG 画像に独自の情報を埋め込みたくなることがよくあると思います。
今回は、この生成した画像に独自の情報を埋め込む方法を説明したいと思います。
PNG フォーマットおさらい
PNG フォーマットについては(以前個人ブログですが)紹介したので、詳しく知りたい方はそちらをご覧ください。
- PNG 画像の解析と最適化ツール: http://imaya.blog.jp/archives/6136997.html
ここでは簡単なおさらいのみにします。
PNG は以下のような構造になっています。
- PNG シグネチャ(8バイト)
- チャンク群(以下のデータが連続して配置)
- データの長さ(4バイト)
- チャンクタイプ(4バイト)
- データ(可変)
- CRC32(4バイト)
簡単ですね。
あとで出てくるので少し記憶にとどめておいて欲しいのですが、チャンクのサイズはデータ+12バイトとなります。
今回は、IDATチャンクの前にプライベートチャンク(仕様に存在しないけど勝手につくっていいチャンク)をつくって挿入したいと思います。
準備
PNG のチャンクをつくるには、いくつかの決まり事を知る必要があったり、必要な計算などがあります。
具体的には以下のものです。
- チャンク名の命名規則
- プライベートチャンクの挿入位置
- CRC32 の計算
これらについて順に説明していきます。
チャンク名の命名規則
プライベートチャンクは勝手に作っていいチャンクですが、チャンク名にはルールがあります。
まず、前述のおさらいでも書いてありますが、4バイトなので(基本的には人間の読める)4文字となります。
そして、それぞれの文字の先頭のbitが立っているかどうかに意味があるようになっています。
(人間が簡単に判別するには、それぞれの文字が大文字(0)か小文字(1)かで判断することができます。)
それぞれの文字の先頭ビットがどういう意味を持つのかは以下の通りです。
- 1文字目: 必須チャンクかどうか。今回は必須チャンクではないので小文字(1)
- 2文字目: パブリックなチャンクかどうか。今回は勝手につくるプライベートなチャンクなので小文字(1)
- 3文字目: 予約。現在の仕様ではかならず大文字(0)
- 4文字目: 複写可能かどうか。画像の内容が変化してもコピーしてよいならば小文字(1)、コピーしてはいけないなら大文字(0)
上記の条件を踏まえて、今回は "hoGe" チャンクを作ることにしましょう。
「必須ではなく」「プライベートな」「画像の内容が変わってもコピー可能な」チャンクです。
プライベートチャンクの挿入位置
hoGe チャンクをつくるのを決めたのは良いですが、どこに挿入するかも決める必要があります。
基本的には必須チャンクである IHDR, PLTE, IDAT, IEND のどこにいれるかという話しになります。(このうち、IHDR, IEND は先頭と最後になくてはいけないという決まりがあります。)
仕様に記述されているのはどのチャンクの前後になくてはいけないなど決められているチャンクもありますが、今回はプライベートチャンクですので適当に最初の IDAT の前にいれることにします。
CRC32 の計算
CRC32 というのはチェックサムアルゴリズムの一種です。簡単なアルゴリズムなので調べればすぐに実装できるとおもいますが、今回は拙作の zlib.js で使用しているものを利用します。
CRC32 の対象となるのはチャンクタイプとデータ部分です。
実装
では、さっそく実装に入りたいと思います。
ここからはコードが中心になりますが、さほど難しくないと思います。
CRC32 を計算するライブラリのロード
事前に以下のような形で読み込んでおきます。
1 |
<script src="https://rawgithub.com/imaya/zlib.js/master/bin/crc32.min.js"></script> |
DataURL の作成
まずは埋め込む対象となる PNG 画像を Canvas#toDataURL()
で作成しましょう。
1 2 |
var canvas = document.createElement('canvas'); var dataurl = canvas.toDataURL(); |
ここでは適当に Canvas を作っていますが、もちろんアプリケーションなどで描画した Canvas でもかまいません。
Base64 デコード
base64 文字列のデコードは、window.atob を用います。使えない環境の場合は拙作の base64.js などのライブラリでやります。
また、この段階からついでに Uint8Array に変更しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 |
// Data URL からデータ部分を抜き出し var b64data = dataurl.split(',', 2); // Base64 デコード var decoded = window.atob(b64data.pop()); // Uint8Array に変換 var png = new Uint8Array( decoded.split('').map(function(char) { return char.charCodeAt(0); }) ); |
PNG への埋め込みの準備
ここから PNG バイナリを探索してチャンクの挿入を行う訳ですが、PNG バイナリのどこを読んでいるか覚える変数が必要となります。
また、埋め込んだ後のデータを保存するバッファと、どこまで書き込んだかも覚えておきましょう。
埋め込んだ後の全体のサイズは
- 現在のPNGバイナリのサイズ + 埋め込むデータのサイズ + 12
となります。なぜそうなるか分からない場合は、最初におさらいした PNG のチャンクの説明をみてください。
1 2 3 4 5 |
// 埋め込むデータ var data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]; // 書き込み用バッファ var implanted = new Uint8Array(png.length + data.length + 12); |
シグネチャのチェック
まず、ファイル先頭の PNG シグネチャが一致しているか確認しましょう。
これによって PNG ファイルかどうかを簡単に判別することができます。
1 2 3 4 5 |
var Signature = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10); if (String.fromCharCode.apply(null, png.subarray(rpos, rpos += 8)) !== Signature) { throw new Error('invalid signature'); } |
PNG のチャンクを探索する
シグネチャの確認が済んだら、ここからはチャンクが連続して配置されています。
必要のないチャンクは読み飛ばす事で高速に IDAT チャンクを探す事が出来ます。
前述のシグネチャのチェックとまとめて function にしておくと便利です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
function process(png, type, handler) { var dataLength; var chunkType; var nextChunkPos; var Signature = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10); var rpos = 0; // シグネチャの確認 if (String.fromCharCode.apply(null, png.subarray(rpos, rpos += 8)) !== Signature) { throw new Error('invalid signature'); } // チャンクの探索 while (rpos < png.length) { dataLength = ( (png[rpos++] << 24) | (png[rpos++] << 16) | (png[rpos++] << 8) | (png[rpos++] ) ) >>> 0; nextChunkPos = rpos + dataLength + 8; chunkType = String.fromCharCode.apply(null, png.subarray(rpos, rpos += 4)); if (chunkType === type) { return handler(png, rpos, dataLength); } rpos = nextChunkPos; } } process(png, 'IDAT', function(png, rpos, length) { // rpos - 8 = チャンクの開始位置 insertHogeChunk(implanted, data, png, rpos - 8); }); |
insertHogeChunk
メソッドの中身は後で作ります。
チャンクの作成
IDAT チャンクを見つけたら、その直前に hoGe チャンクを埋め込むので、埋め込むチャンクを作成する function を作っておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
function createHogeChunk(data) { var dataLength = data.length; var chunk = new Uint8Array(4 + 4 + dataLength + 4); var type = [104, 111, 71, 101]; var crc; var pos = 0; var i; // length chunk[pos++] = (dataLength >> 24) & 0xff; chunk[pos++] = (dataLength >> 16) & 0xff; chunk[pos++] = (dataLength >> 8) & 0xff; chunk[pos++] = (dataLength ) & 0xff; // type chunk[pos++] = type[0]; chunk[pos++] = type[1]; chunk[pos++] = type[2]; chunk[pos++] = type[3]; // data for (i = 0; i < dataLength; ++i) { chunk[pos++] = data[i]; } //crc crc = Zlib.CRC32.calc(type); crc = Zlib.CRC32.update(data, crc); chunk[pos++] = (crc >> 24) & 0xff; chunk[pos++] = (crc >> 16) & 0xff; chunk[pos++] = (crc >> 8) & 0xff; chunk[pos++] = (crc ) & 0xff; return chunk; } |
作成したチャンクの挿入
IDAT チャンクの前に先ほどの function をつかって hoGe チャンクを挿入します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function insertHogeChunk(implanted, data, png, rpos) { var chunk = createHogeChunk(data); var pos = 0; // IDAT チャンクの前までコピー implanted.set(png.subarray(0, rpos), pos); pos += rpos; // hoGe チャンクをコピー implanted.set(chunk, pos); pos += chunk.length; // IDAT チャンク以降をコピー implanted.set(png.subarray(rpos), pos); return implanted; } |
Base64 エンコード
ここまででプライベートチャンクを埋め込んだ PNG の作成はできているのですが、
Canvas#toDataURL が DataURL を返しているので、形式をあわせます。
Base64 デコード とおなじように window.btoa が使える場合はそちらを、使えない場合は base64.js などを使います。
1 2 3 4 5 6 7 8 |
// Uint8Array から bytestring に変換 var implantedString = ""; for (i = 0, il = implanted.length; i < il; ++i) { implantedString += String.fromCharCode(implanted[i]); } // Base64 に変換 var implantedBase64 = window.btoa(implantedString); |
DataURL の作成
Base64 に変換したのであとは DataURL 形式にするだけです。
1 |
var implantedDataURL = 'data:image/png;base64,' + implantedBase64; |
データの取り出し
基本的には挿入時と同じような処理で hoGe チャンクを探してデータ部分を抜き出すだけです。
埋め込み時に使った process()
を使います。
1 2 3 |
var extractedData = process(implanted, "hoGe", function(png, rpos, length) { return png.subarray(rpos, rpos += length); }); |
終わりに
みなさんどんどん画像に余計な情報を埋め込みましょう。
明日は石川さんです。