e.blog

主にUnity/UE周りのことについてまとめていきます

PNGのText ChunkにC#でデータを書き込む

概要

PNG画像自体に情報を埋め込めたら便利かなーと思ってPNGのテキスト領域について調べたのでそのメモです。

テクスチャを EncodeToPNG()PNGデータ化したあとに、テキスト領域を追加してファイルに書き出し、それを読み込んでパースして表示、というところまでをやります。

今回実装したものはGitHubにあげてあるので、実際の挙動を見たい方はそちらをご覧ください。

github.com



PNGデータの構造

まずはPNGデータの構造について知らないと始まらないのでそのあたりについてまとめます。

PNGのデータ構造はシグネチャチャンクのふたつに分けることができます。そしてチャンクは複数種類あり、必須となる IHDR チャンク(ヘッダ)、IDAT チャンク(データ)、IEND チャンク(フッタ)は必ず含まれます。今回はテキストエリアを示す tEXt チャンクを新たに作り、それを埋め込んでみます。

チャンクの種類などは以下のサイトが参考になります。

www.setsuki.com

大まかにデータ構造を図にすると以下のようになります。

f:id:edo_m18:20220213230255p:plain

シグネチャ

シグネチャは、そのファイルが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はビッグエンディアンにする

これが仕様なのか分かりませんが、LengthCRC はビッグエンディアンにする必要があるようです。ここは利用している圧縮に関係がありそう。以下の記事にそのあたりについて言及があるので詳細はそちらをご覧ください。

darkcrowcorvus.hatenablog.jp

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);

まず、ChunkDataCRCを求め、さらにそれと ChunkDataCRCを求めます。そしてチャンクの最後にこのデータを付け足します。

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データの展開についてもまとめようと思います。

参考にした記事

qiita.com www.engineer-log.com light11.hatenadiary.com