概要
PNG画像自体に情報を埋め込めたら便利かなーと思ってPNGのテキスト領域について調べたのでそのメモです。
テクスチャを EncodeToPNG()
でPNGデータ化したあとに、テキスト領域を追加してファイルに書き出し、それを読み込んでパースして表示、というところまでをやります。
今回実装したものはGitHubにあげてあるので、実際の挙動を見たい方はそちらをご覧ください。
PNGデータの構造
まずはPNGデータの構造について知らないと始まらないのでそのあたりについてまとめます。
PNGのデータ構造はシグネチャとチャンクのふたつに分けることができます。そしてチャンクは複数種類あり、必須となる IHDR
チャンク(ヘッダ)、IDAT
チャンク(データ)、IEND
チャンク(フッタ)は必ず含まれます。今回はテキストエリアを示す tEXt
チャンクを新たに作り、それを埋め込んでみます。
チャンクの種類などは以下のサイトが参考になります。
大まかにデータ構造を図にすると以下のようになります。
シグネチャ
シグネチャは、そのファイルがPNGファイルであることを示すものです。ファイルの先頭の8バイトにそれを示す情報が書き込まれているので、それをチェックすることでPNG画像かどうかを判別することができます。
具体的にはファイルの先頭8バイトのデータが 89 50 4E 47 0D 0A 1A 0A
という並びになっています。
こちらの記事によると以下のような意味のようです。
PNGであること
その画像ファイルがPNGであることは、ファイル先頭の8バイトを読めばわかります。 JPEGではFF D8の2バイトから始まりますが、PNGファイルではファイルの先頭に8バイトの89 50 4E 47 0D 0A 1A 0A
が存在するようです。文字列にすると\x89PNG\r\n\x1a\n
こうなります。 先頭の\x89
は非ASCII文字で、この非ASCII文字からファイルが始まることでテキストファイルとの区別を付けられるようにしているそうです。また、7bit目をクリアする不正なファイル転送を検知できたり、\r\n
などが含まれているのも改行コードを勝手に変換されてしまうのを検知するためだそうです。
こういう、バイナリの構造を知ると発見があって面白いですね。
チャンクの構造
チャンクは複数種類ありますが基本的な構造はすべて同じになっています。
オフセット(サイズ) | 名称 | 内容 |
---|---|---|
0x0000 (4) | Length | Chunk Dataのサイズ |
0x0004 (4) | Chunk Type | Chunk Typeを示す4文字 |
0x0008 (n) | Chunk Data | チャンクのデータ |
0x---- (4) | CRC (Cyclic Redundancy Check) | 整合性チェックのためのCRC値 |
最初の4バイトにデータのサイズ、続く4バイトにチャンクの種類を示す4文字の文字列が入ります。そして Length
によって定義された分だけデータが続き、最後にCRCの値(詳細は後述)で終わります。
どこに埋め込むか
さて、PNGデータの構造が分かったところで、実際に tEXt
チャンクをどこに入れるかを考えます。PNGデータの構造としてシグネチャの次に必ず IHDR
チャンクが来ます。その後は任意のチャンクが続きます。なのでシグネチャ + IHDR
チャンクの次に決め打ちで tEXt
チャンクを埋め込みます。
IHDRチャンクの構造
IHDR
チャンクの構造も確認しておきましょう。
オフセット(サイズ) | 名称 | 内容 |
---|---|---|
0x0000 (4) | Length | Chunk Dataのサイズで、IHDRは常に 13 |
0x0004 (4) | Chunk Type | 常に 0x49 0x48 0x44 0x52 。ASCIIコードで "IHDR" となる |
0x0008 (4) | Chunk Data | 画像の幅 |
0x000C (4) | 〃 | 画像の高さ |
0x0010 (1) | 〃 | ビット深度 有効な値は1, 2, 4, 8, 16 |
0x0011 (1) | 〃 | カラータイプ 1 - パレット使用 2 - カラー 4 - αチャンネル |
0x0012 (1) | 〃 | 圧縮手法 |
0x0013 (1) | 〃 | フィルター手法 |
0x0014 (1) | 〃 | インターレース手法 |
0x0015 (4) | CRC (Cyclic Redundancy Check) | 整合性チェックのためのCRC値 |
挿入位置はIndex 33から
ヘッダのデータサイズは常に 13
なので、Length
+ Chunk Type
+ Chunk Data
+ CRC
の合計は 4 + 4 + 13 + 4 = 25
となります。そしてシグネチャの 8
を足して 33
バイトがシグネチャとヘッダを合わせたバイトサイズとなります。なので、インデックス 33
から tEXt
チャンクを挿入してやればいいことになりますね。
tEXtチャンク構造
次に、tEXt
チャンクの構造を確認しましょう。
オフセット(サイズ) | 名称 | 内容 |
---|---|---|
0x0000 (4) | Length | Chunk Dataのサイズ |
0x0004 (4) | Chunk Type | 常に 0x74 0x45 0x58 0x74 。ASCIIコードで "tEXt" となる |
0x0008 (1~79) | Chunk Data | キーワード |
0x---- (1) | 〃 | 常に 0 |
0x---- (n) | 〃 | テキスト文字列 指定しない( 0 )ことも可。文字コードは**Latin-1 [IOS/IEC-8859-1]を使用 |
0x0015 (4) | CRC (Cyclic Redundancy Check) | 整合性チェックのためのCRC値 |
キーワードは79バイト以内なら任意で指定できます。一般的なキーワードは以下。
キーワード | 解釈 |
---|---|
Title | 画像のタイトル Short (one line) title or caption for image |
Author | 作者の名前 |
Description | 画像の説明 |
Copyright | 著作権の通知 |
Creation Time | 画像の作成日時 |
Software | 作成に使用したソフト |
Disclaimer | 公的な使用の拒否について |
Warning | 注意事項 |
Source | 画像の作成に用いたもの |
Comment | 雑多なコメント、例)GIFコメントからの転換 |
C#で埋め込みの実装
情報がそろったので実際に実装するコードを見ていきましょう。まず最初は tEXt
チャンクデータを生成する部分です。
private Encoding _latin1 = Encoding.GetEncoding(28591); private byte[] CreateTextChunkData() { // `tEXt` はASCIIエンコーディング byte[] chunkTypeData = Encoding.ASCII.GetBytes("tEXt"); // keywordはLatin1エンコーディング byte[] keywordData = _latin1.GetBytes("Comment"); // 区切り用の `0` を配列で確保 byte[] separatorData = new byte[] { 0 }; // data部分はLatin1エンコーディング byte[] textData = _latin1.GetBytes(_embedText); int headerSize = sizeof(byte) * (chunkTypeData.Length + sizeof(int)); int footerSize = sizeof(byte) * 4; // CRC int chunkDataSize = keywordData.Length + separatorData.Length + textData.Length; // チャンクデータ部分を生成 byte[] chunkData = new byte[chunkDataSize]; Array.Copy(keywordData, 0, chunkData, 0, keywordData.Length); Array.Copy(separatorData, 0, chunkData, keywordData.Length, separatorData.Length); Array.Copy(textData, 0, chunkData, keywordData.Length + separatorData.Length, textData.Length); // Length用データ byte[] lengthData = BitConverter.GetBytes(chunkDataSize); // CRCを計算(※) uint crc = Crc32.Hash(0, chunkTypeData); crc = Crc32.Hash(crc, chunkData); byte[] crcData = BitConverter.GetBytes(crc); // 全体のデータを確保 byte[] data = new byte[headerSize + chunkDataSize + footerSize]; // LengthとCRCはビッグエンディアンにする必要があるのかReverseする必要がある(※) Array.Reverse(lengthData); Array.Reverse(crcData); Array.Copy(lengthData, 0, data, 0, lengthData.Length); Array.Copy(chunkTypeData, 0, data, lengthData.Length, chunkTypeData.Length); Array.Copy(chunkData, 0, data, lengthData.Length + chunkTypeData.Length, chunkData.Length); Array.Copy(crcData, 0, data, lengthData.Length + chunkTypeData.Length + chunkData.Length, crcData.Length); return data; }
細かい点についてはコード内のコメントをご覧ください。以下、※印の部分について説明します。
LengthとCRCはビッグエンディアンにする
これが仕様なのか分かりませんが、Length
と CRC
はビッグエンディアンにする必要があるようです。ここは利用している圧縮に関係がありそう。以下の記事にそのあたりについて言及があるので詳細はそちらをご覧ください。
CRCの計算
CRCは巡回冗長検査と呼ばれ、Cyclic Redundancy Checkの頭文字を取ったものです。Wikipediaによると、
誤り検出符号の一種で、主にデータ転送などに伴う偶発的な誤りの検出によく使われている。送信側は定められた生成多項式で除算した余りを検査データとして付加して送信し、受信側で同じ生成多項式を使用してデータを除算し、その余りを比較照合することによって受信データの誤り・破損を検出する。
と書かれており、要はデータの破損チェックです。
CRCの計算はチャンクタイプとチャンクデータを利用する
PNGデータのCRCはチャンクタイプとチャンクデータを用いて計算を行います。コードでは以下の部分です。
uint crc = Crc32.Hash(0, chunkTypeData); crc = Crc32.Hash(crc, chunkData); byte[] crcData = BitConverter.GetBytes(crc);
まず、ChunkData
のCRCを求め、さらにそれと ChunkData
のCRCを求めます。そしてチャンクの最後にこのデータを付け足します。
Crc32
の実装は以下のようになっています。
public static class Crc32 { private static uint[] _crcTable = MakeCrcTable(); private static uint[] MakeCrcTable() { uint[] a = new uint[256]; for (uint i = 0; i < a.Length; ++i) { uint c = i; for (int j = 0; j < 8; ++j) { c = ((c & 1) != 0) ? (0xedb88320 ^ (c >> 1)) : (c >> 1); } a[i] = c; } return a; } private static uint Calculate(uint crc, byte[] buffer) { uint c = crc; for (int i = 0; i < buffer.Length; ++i) { c = _crcTable[(c ^ buffer[i]) & 0xff] ^ (c >> 8); } return c; } public static uint Hash(uint crc, byte[] buffer) { crc ^= 0xffffffff; return Calculate(crc, buffer) ^ 0xffffffff; } }
PNGデータにテキストを埋め込む
前段までで tEXt
チャンクのデータの準備が出来ました。あとはこれをデータに埋め込んでやれば完成です。実際に埋め込んでいる部分のコードを抜粋します。
byte[] data = tex.EncodeToPNG(); byte[] chunkData = CreateTextChunkData(); int embeddedDataSize = data.Length + chunkData.Length; byte[] embeddedData = new byte[embeddedDataSize]; // Copy the PNG header to the result. Array.Copy(data, 0, embeddedData, 0, PngParser.PngHeaderSize); // Add a tEXT chunk. Array.Copy(chunkData, 0, embeddedData, PngParser.PngHeaderSize, chunkData.Length); // Join the data chunks to the result. Array.Copy(data, PngParser.PngHeaderSize, embeddedData, PngParser.PngHeaderSize + chunkData.Length, data.Length - PngParser.PngHeaderSize); File.WriteAllBytes(FilePath, embeddedData);
まず最初に、tex.EncodeToPNG()
によってテクスチャをPNGデータ化します。そして前述したように、シグネチャとヘッダの位置の次の位置からデータを埋め込みます。PngParser.PngHeaderSize
は定数の 33
です。
新しいデータ用に作成した embeddedData
にシグネチャとヘッダ部分をコピーし、その後にテクストチャンクデータを挿入します。そして最後に、元のデータの後半部分を連結して完成です。
まとめ
気軽な気持ちで始めたテキスト埋め込みの実装ですが、PNGデータの仕様にとても詳しくなりました。ちなみに仕様に沿って埋め込んでいるので当然、UnityのAPIである Texture2D.LoadImage(byte[] data)
で読み込んでも正常にテクスチャをロードすることができます。
ただこの LoadImage
、処理が重いので Texture2D.LoadRawTextureData
で読み込ませたくなったので、PNGデータの展開も自作してみました。次回はPNGデータの展開についてもまとめようと思います。