画像フォーマット「PNG」には、テキストや独自メタデータを埋め込める領域があるのをご存じでしょうか。それを利用すれば、画像にメモを添付したり、関連データを付加することができます。そこで、今回は、メタテキストを読み書きするCLIツールを作ってみましょう。

  • PNG画像にテキストを埋め込むCLIツールを作ろう

    PNG画像にテキストを埋め込むCLIツールを作ろう

PNG画像の構造について

そもそも、『PNG(Portable Network Graphics)』とは、画像を可逆圧縮で保存するためのファイル形式です。1996年に公開されましたが、現在ではWebやアプリで広く使われるようになりました。

PNGが広く普及した大きな理由の一つは、オープンで自由に使えることでした。誰でも無料で実装できて、ファイル構造もシンプルだったので、さまざまなツールがPNGをサポートしました。

それでは、実際にPNGのファイルフォーマットを確認してみましょう。基本的には、次の図のようなものとなっています。これを見ると、とてもシンプルな構造であることが分かります。

  • PNG画像の基本構造

    PNG画像の基本構造

チャンクタイプには、4文字の英数字を指定するのですが、各チャンクには、次のようなチャンクを指定できます。

- ヘッダ情報(IHDR) --- PNGのヘッダ情報を指定
- パレット情報(PLTE) --- 画像のパレットデータを指定
- 画像データ(IDAT) --- zlibで圧縮した実画像データを指定
- 終端を表す(IEND) --- PNGファイルの末尾を指定 
- テキストデータ(iTXt) --- テキストデータ
- キーワード付テキスト(tEXt) --- キーワード付きテキストデータを指定

今回は、簡単なテキストデータ「iTXt」を管理するCLIツールを作ってみましょう。

PNGファイルにメタテキストを読み書きするプログラム

それでは、段階的にプログラムを作っていきましょう。まずは、PNGファイルをパースして、チャンクタイプとチャンクサイズを表示するプログラムを作ってみましょう。

ターミナル(WindowsならPowerShell、macOSならターミナル)を起動してプロジェクトを作成しましょう。また、PNGファイルに新たなチャンクを追加するのに、CRC-32の計算が必要になります。そこでクレート「crc32_light」をプロジェクトに追加しましょう。

# プロジェクトフォルダを作成
mkdir png_meta_tool
cd png_meta_tool
# プロジェクトを初期化
cargo init
# 以前連載で作成したCRC計算クレートを追加
cargo add crc32_light

加えて、テスト用に適当なPNGファイルを用意して、「test.png」という名前で追加しておきましょう。下記のようなフォルダ構造のプロジェクトが生成されます。

<png_meta_tool>
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── test.png

なお、CRC-32を計算するクレートは、本連載の15回目( https://news.mynavi.jp/techplus/article/rustalgorithm-15/ )で作成した関数を改良してRustのライブラリを集約する「crate.io」に登録したものです。CRC-32の計算は難しくないので確認してみると良いでしょう。

プログラムを動かしてみよう

今回はプログラムが少し長くなったので、プログラムの全体をこちらのGist( https://gist.github.com/kujirahand/002320e5255d3179491dfdf652d4ad72 )にアップロードしました。

まずは、プログラムを動かしてみましょう。上記のGistを開いてプログラムをコピーして、src/main.rsを書き換えてください。

そして、下記のコマンドを実行しましょう。以下のコマンドを実行すると、PNGファイル「test.png」の構造を表示します。

# test.pngを確認する
cargo run test.png

そして、PNGファイルにテキストを埋め込むには、次のようなコマンドを実行しましょう。

# test.pngにテキストを埋め込む
cargo run test.png "相談によって計画は成功する"

テキストを埋め込んだ後で、改めて「test.png」を確認してみましょう。

# test.pngを確認する
cargo run test.png

すると、テキストが埋め込まれているのを確認できます。次の画像は、実際に上記のコマンドを実行してみたものです。「cargo run test.png」を実行すると、「iTXt」チャンクが追加されて、テキストが埋め込まれているのを確認できます。

  • 「test.png」にテキストを埋め込んだところ

    「test.png」にテキストを埋め込んだところ

プログラムを読み解こう

次に、少しずつプログラムを抜粋して、内容を確認してみましょう。

以下は、PNGファイルを識別するための定数PNG_SIGを定義するプログラムです。というのも、PNGファイルの冒頭には、8バイトのPNGシグネチャがあります。それは、次のような固定の値です。ファイルを読み込んで、このシグネチャがなければPNGファイルではないためエラーにします。

// PNGシグネチャ --- (*1)
static PNG_SIG: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];

以下の部分は、PNGファイルの中にある各チャンクを表現するための構造体を定義したものです。なお、PngChunkを生成するとき、crcが0の時は、CRC-32を計算して自動で設定するようにしています。

// PNGチャンクを表す構造体を定義 --- (*2)
#[derive(Debug, Clone)]
struct PngChunk {
    length: u32,
    chunk_type: String,
    data: Vec<u8>,
    crc: u32, // CRC-32の値
}
impl PngChunk {
    fn new(length: u32, chunk_type: String, data: Vec<u8>, crc: u32) -> Self {
        let crc = if crc == 0 { calc_crc(&chunk_type, &data) } else { crc };
        Self { …省略… }
    }
}

そして、以下で、CRC-32を計算する関数も定義しました。crc32_light::crc32関数を利用しています。CRC-32の計算対象となるデータは、チャンクタイプとデータの部分です。

// CRCを計算する関数 --- (*3)
fn calc_crc(chunk_type: &str, data: &[u8]) -> u32 {
    let mut crc_data = Vec::new();
    crc_data.extend_from_slice(chunk_type.as_bytes());
    crc_data.extend_from_slice(&data);
    crc32(&crc_data)
}

そして、PNGファイルでは、基本的に数値データをビッグエンディアンとして扱います。そのため、32ビット整数を読むのに便利なread_u32_be関数を定義します。

// 32ビットのビッグエンディアン整数を読み取る関数 --- (*4)
fn read_u32_be(reader: &mut BufReader<File>) -> Result<u32, Box<dyn std::error::Error>> {
    let mut buf = [0u8; 4];
    reader.read_exact(&mut buf)?;
    Ok(u32::from_be_bytes(buf))
}

ちなみに、上記の補足ですが、16ビット以上の整数をファイルなどに保存する場合、数値をどのように保存するのかが問題になります。これを「バイトオーダー」と呼びます。

Windowsなど多くのPCは内部で数値を「リトルエンディアン」で扱いますが、ネットワークに関係する規約や初期のMac、IBMの大型汎用機などは、数値をビッグエンディアンとして扱います。そのため、手動で数値を読み書きする時は注意が必要です。

以下の部分は、PNGファイルを読み取る部分です。(*5)では、ファイルを少しずつ読み取っていきます。最初にPNGシグネチャを確認します。次に(*6)で、各チャンクを読み取ります。

// PNGファイルを読み取る関数 --- (*5)
fn read_png_file(file_path: &PathBuf) -> Result<Vec<PngChunk>, Box<dyn std::error::Error>> {
    let file = File::open(file_path)?;
    let mut reader = BufReader::new(file);
    // PNGシグネチャを読み取り
    let mut signature = [0u8; 8];
    reader.read_exact(&mut signature)?;
    if signature != PNG_SIG {
        return Err("有効なPNGファイルではありません".into());
    }  
    // チャンクを順次読み取り --- (*6)
    let mut chunks = Vec::new();
    while let Some(chunk) = read_chunk(&mut reader)? {
        chunks.push(chunk);
    }    
    Ok(chunks)
}

そして、以下の部分でチャンクを1つ読み取ります。(*8)でチャンクサイズ(チャンクのバイト数)を読み取り、(*9)でチャンクタイプを読み取り、(*10)でデータを読み取り、(*11)でCRC-32の値を読み取ります。正しく読み取ることができたら、(*12)で、PngChunk構造体を生成して関数の戻り値とします。

// PNGのチャンクを読み取る関数 --- (*7)
fn read_chunk(reader: &mut BufReader<File>) -> Result<Option<PngChunk>, Box<dyn std::error::Error>> {
    // チャンクの長さを読み取り --- (*8)
    let length = match read_u32_be(reader) {
        Ok(len) => len,
        Err(_) => return Ok(None), // ファイルの終端
    };
    // チャンクタイプを読み取り --- (*9)
    let mut chunk_type_bytes = [0u8; 4];
    reader.read_exact(&mut chunk_type_bytes)?;
    let chunk_type = String::from_utf8_lossy(&chunk_type_bytes).to_string();    
    // データを読み取り --- (*10)
    let mut data = vec![0u8; length as usize];
    reader.read_exact(&mut data)?;
    // CRCを読み取り --- (*11)
    let crc = read_u32_be(reader)?;
    // チャンクを構築して返す --- (*12)
    Ok(Some(PngChunk::new(length, chunk_type, data, crc)))
}

以下の部分では、PNGの各チャンクを表示します。特に、テキストデータがあったときに、テキストデータとしてデータを表示します。

// PNGチャンク情報を表示する関数 --- (*13)
fn show_info(chunks: &[PngChunk]) {
    for chunk in chunks {
        if chunk.chunk_type == "iTXt" { // テキストデータ
            let s = String::from_utf8_lossy(&chunk.data);
            println!("[iTXt] {}", s);
            continue;
        }
        …省略…
    }
}

そして、テキストチャンク(iTXt)を追加しているのが以下のプログラムです。単にチャンクの末尾に追加するのではなく、PNGファイルの末尾を表す「IEND」チャンクの直前に、テキストチャンクを挿入するように配慮しています。

// チャンクにテキストを追加する関数 --- (*14)
fn add_text(chunks: &mut Vec<PngChunk>, text: &str) {
    let new_chunk = PngChunk::new(text.len() as u32, "iTXt".to_string(), text.as_bytes().to_vec(), 0);
    // IENDチャンクを検索
    let i = chunks.iter().position(|c| c.chunk_type == "IEND");
    match i {
        Some(index) => {
            // IENDの前に追加
            chunks.insert(index, new_chunk);
        }
        None => {
            // IENDが見つからない場合は末尾に追加
            chunks.push(new_chunk);
        }
    }
}

まとめ

今回のプログラムは、主にPNGファイルにテキストチャンク(iTXt)を追加するものでした。実は、ほとんど同じプログラムを、以前Go言語で作成して姉妹コラム( https://news.mynavi.jp/techplus/article/gogogo-13/ )で紹介しています。RustとGoでプログラムを見比べてみると楽しいでしょう。

また、今回作ったCLIツールでは、PNGの情報を表示するか、テキストを追加するかの二つしか機能がないので、既存チャンクを削除したり、テキストを暗号化する機能などがあると便利でしょう。ぜひ、本稿を参考にして実装してみてください。

自由型プログラマー。くじらはんどにて、プログラミングの楽しさを伝える活動をしている。代表作に、日本語プログラミング言語「なでしこ」 、テキスト音楽「サクラ」など。2001年オンラインソフト大賞入賞、2004年度未踏ユース スーパークリエータ認定、2010年 OSS貢献者章受賞。これまで50冊以上の技術書を執筆した。直近では、「大規模言語モデルを使いこなすためのプロンプトエンジニアリングの教科書(マイナビ出版)」「Pythonでつくるデスクトップアプリ(ソシム)」「実践力を身につける Pythonの教科書 第2版」「シゴトがはかどる Python自動処理の教科書(マイナビ出版)」など。