Help us understand the problem. What is going on with this article?

Rust+nomでバイナリ(bmpファイル)を読もう

Rustでバイナリをパースする方法はいくつかありますが、nomでやってみたら以外と簡単だったので記事にします。

nomとは

GitHub: https://github.com/Geal/nom

Rustのパーサコンビネータライブラリのデファクトスタンダード的な存在です。昔はマクロ中心の記述が中心で、マクロに慣れないと辛いところもありましたが、バージョン5からimpl Traitを活用した関数中心の記述に変更されました。マクロを使わないと書けないところも残っていたりしますが、かなり簡単にパーサコンビネータを使うことができます。文字列だけでなくバイナリのパーサを書くのにも使えます。

目標

バイナリを読む練習として、今回は仕様がかなり簡単な部類のバイナリファイルであるbmp(Windows Bitmap)ファイルを読むことを目標とします。フォーマットはWikipediaを読めばわかります。bmpファイルの先頭に BITMAPFILEHEADER というファイルヘッダがあり、その後に情報ヘッダが続きます。情報ヘッダにはいくつかバージョンの違いがあるようですが、最初の40バイトは共通のようですので、そこまで読めたらあとの情報は切り捨てます。結論としては、ファイルを読み込んで次のBitMap構造体を作成することを目標とします。今回は簡単のため、インデックスカラーは扱わず、24ビットフルカラーか8ビットグレースケールのみ扱うこととします。

#[derive(Debug)]
struct BitMapFileHeader {
    /// ファイルサイズ
    file_size: u32,
    /// ファイル先頭からビットマップデータまでのオフセット
    offset: u32,
}

#[derive(Debug)]
struct BitMapInfoHeader {
    /// 横幅
    width: i32,
    /// 縦幅
    height: i32,
    /// 1ピクセルあたりのビット数
    bits_per_pixel: u16,
    /// 圧縮形式
    compression_method: u32,
    /// 画像データサイズ
    image_size: u32,
    /// 水平方向の解像度
    horizontal_resolution: i32,
    /// 垂直方向の解像度
    vertical_resolution: i32,
    /// カラーインデックスの数
    n_color_palette: u32,
    /// 使用されているカラーインデックスの数
    n_colors_used: u32,
}

struct BitMap {
    file_header: BitMapFileHeader,
    info_header: BitMapInfoHeader,
    /// ピクセルデータ
    pixels: Vec<u8>,
}

下準備

nomを使うために、次の依存関係をCargo.tomlに追記します。

[dependencies]
nom = "5"

use文を追加します。

use nom::bytes::complete::{tag, take};
use nom::number::complete::*;
use nom::IResult;
use nom::{error::ErrorKind, Err};
use std::io::Read;
use std::path::Path;

nomがパースの対象として扱える型は、今の所&[u8]&str、つまりメモリ上にロードされたバイナリ列か文字列です。今回はbmpファイルを読み込むので、ファイルの中身をメモリ上にロードします。

fn read_file<P: AsRef<Path>>(file_path: P) -> Vec<u8> {
    let mut file = std::fs::File::open(file_path).expect("file open failed");
    let mut buf = Vec::new();
    file.read_to_end(&mut buf).expect("file read failed");
    buf
}

BITMAPFILEHEADERの読み込み

まずはファイルの先頭にあるBITMAPFILEHEADERを読み出すことを考えます。このヘッダの定義を抜き出してみると、次のようになります。

先頭からのオフセット サイズ 格納する情報
0バイト 2バイト マジックナンバー 0x42, 0x4d
2バイト 4バイト ファイルサイズ
6バイト 2バイト 常に0
8バイト 2バイト 常に0
10バイト 4バイト ファイルヘッダの先頭アドレスからビットマップデータの先頭アドレスまでのオフセット(単位はバイト)

まず先頭は2バイトのマジックナンバー0x42, 0x4dなので、これに合致するかどうか判定します。そのためにnom::bytes::complete::tagを使います。これの定義は次のようになります。

pub fn tag<'a, T: 'a, Input: 'a, Error: ParseError<Input>>(
    tag: T
) -> impl Fn(Input) -> IResult<Input, Input, Error> where
    Input: InputTake + Compare<T>,
    T: InputLength + Clone, 

長ったらしくてぱっと見ではよくわからなくなりますが、戻り値がimpl Fnなので、関数として呼び出せることがわかります。そしてこの関数は、入力を受け取ってそれがtagに渡したバイナリ列と合致するか判定します。要するに次のように使います。ただし右辺に出てくるinputは読み出したファイルの内容で、&[u8]型とします。

let (input, _) = tag(b"\x42\x4d")(input)?;

tag(b"\x42\x4d")(input)は、inputの先頭2バイトがb"\x42\x4d"と一致するか判定します。この結果はResult型であり、一致しない場合はErrとなります。成功時の結果を得るためには?で取り出します。取り出されるのはタプルです。タプルの1番目は、入力を読み進めてて残った部分、今回はファイル先頭から2バイト進んだところを指す&[u8]となります。これを新しいinputとして定義します。タプルの2番目には、入力のうち`b"\x42\x4d"と一致した部分のスライスです。これはもう必要ないので_に代入します。

ここで、再定義したinputはファイルデータの3バイト目(オフセット2バイト)を指しています。ここからは4バイト整数のファイルサイズが格納されています。これを読み出すには、nom::number::complete::le_u32を用います。この関数は名前を見てわかる通り、リトルエンディアンの32bit符号なし整数を読み出します。これを使うと、ファイルサイズは次のように読み出せます。

let (input, file_size) = le_u32(input)?; // ファイルサイズ

戻り値のタプルのうち、1番目は先程と同様に、右辺で渡しているinputからさらに4バイト読み進めたのこりのスライスです。2番目は読み込んだ4バイトに入っていたデータをリトルエンディアンとして解釈して出力したu32の値です。これは必要な情報なのでfile_sizeと名前をつけておきます。

さて、ここまででtagle_u32というnomの関数を使ってきましたが、どちらもタプルを返しています 1 。このタプルの1番目は、入力を読み込んで使わなかった部分のスライスで、2番目は関数が読み込んだデータを解釈して生成した値であり、場合によって型はまちまちです。つまり、nomでパーサを書く場合、次のような関数を定義して使います。

fn my_parser(input: &[u8]) -> IResult<&[u8], MyData>;

このmy_parser関数は、inputの先頭部分を解釈し、IResult型を返します。IResultは次のように定義されており、中身はResultです。

type IResult<I, O, E = (I, ErrorKind)> = Result<(I, O), Err<E>>;

ResultのうちOkに入るのはタプルであり、タプルの中身は先程解説したのと同様、残りのインプットと、パーサが解釈した結果(MyData型)を返します。このような1部分を解釈するパーサ関数を組み合わせて全体のパーサを作成することがnomの基本方針です。

それではこの方針に基づき、bmpファイルの先頭を読み込み、BitMapFileHeader構造体を返す関数read_bitmap_file_header()を書いてみます。

fn read_bitmap_file_header(input: &[u8]) -> IResult<&[u8], BitMapFileHeader> {
    let (input, _) = tag(b"\x42\x4d")(input)?; // 先頭のマジックナンバー
    let (input, file_size) = le_u32(input)?; // ファイルサイズ
    let (input, _) = tag([0u8, 0])(input)?; // 0が入っている予約領域
    let (input, _) = tag([0u8, 0])(input)?; // 0が入っている予約領域
    let (input, offset) = le_u32(input)?; // 先頭から画像データdまでのオフセット
    Ok((input, BitMapFileHeader { file_size, offset }))
}

tagle_u32を用いて、順番に定義通り読み出していくだけです。最後に集まった情報(file_sizeoffset)からBitMapFileHeaderを生成し、読み残しのinputと一緒にタプルにして返します。

この関数内では、inputを読み進めるたびに再定義しています。別の名前にすることも可能です。ただしややこしくなるので同じ名前にするのがいいでしょう。

ところで、?を使っていることからわかるように、読み込みに失敗した関数はErrを返します。

情報ヘッダの読み込み

先述の通り、情報ヘッダには複数バージョンが存在しますが、40バイト読んであとは無視します。この場合、BITMAPINFOHEADERの定義通りに読んでいきます。

fn read_bitmap_info_header(input: &[u8]) -> IResult<&[u8], BitMapInfoHeader> {
    let (input, _) = le_u32(input)?; // ヘッダのサイズ
    let (input, width) = le_i32(input)?; // 横幅
    let (input, height) = le_i32(input)?; // 縦幅
    let (input, _) = tag(&[1u8, 0])(input)?; // プレーン数 (常に1)
    let (input, bits_per_pixel) = le_u16(input)?; // 1ピクセルあたりのビット数
    let (input, compression_method) = le_u32(input)?; // 圧縮形式
    let (input, image_size) = le_u32(input)?; // 画像データサイズ
    let (input, horizontal_resolution) = le_i32(input)?; // 水平方向の解像度
    let (input, vertical_resolution) = le_i32(input)?; // 垂直方向の解像度
    let (input, n_color_palette) = le_u32(input)?; // カラーインデックスの数
    let (input, n_colors_used) = le_u32(input)?; // 使用するカラーインデックスの数
    Ok((
        input,
        BitMapInfoHeader {
            width,
            height,
            bits_per_pixel,
            compression_method,
            image_size,
            horizontal_resolution,
            vertical_resolution,
            n_color_palette,
            n_colors_used,
        },
    ))
}

ちょっと長くなりましたが、やってることはread_bitmap_file_header()と同じく、順番に読んでいき、結果と残りの入力を返しているだけです。

ビットマップ全体の読み込み

ヘッダ部分を読むパーサができたので、ビットマップ全体を読む関数を作ります。

fn read_bmp(input: &[u8]) -> Result<BitMap, Err<(&[u8], ErrorKind)>> {
    let (input, file_header) = read_bitmap_file_header(input)?;
    let (input_pixels, input_info_header) = take(file_header.offset - 14)(input)?;
    let (_, info_header) = read_bitmap_info_header(input_info_header)?;
    let pixels = input_pixels.to_vec(); // ピクセルデータをそのままVec<u8>に格納

    Ok(BitMap {
        file_header,
        info_header,
        pixels,
    })
}

file_headerを読んだ後、ピクセルデータが格納される領域までのオフセットfile_header.offsetに基づき、inputread_bitmap_info_header()に渡すinput_info_headerと、ピクセルデータを指すinput_pixelsに分けます。これにはnom::bytes::complete::takeを用います。file_header.offsetはピクセルデータのファイル先頭からのオフセットですが、inputはすでにファイルヘッダの分だけ読み進めているので、takeに渡す値からその分を引いておきます。

今回はピクセル情報を生データそのままVecにしていますが、ピクセル情報を他の型へパースすることにもnomが使えるはずです。

ビットマップを表示する

ビットマップが読み込めたか調べるため、ヘッダ情報とピクセルを表示するBitMap::printを実装します。各ピクセルの明度に基づいてターミナル上にAscii Art風の表示をさせます。

impl BitMap {
    fn print(&self) {
        println!("{:?}", self.file_header);
        println!("{:?}", self.info_header);

        let bytes_per_pixel = i32::from(self.info_header.bits_per_pixel) / 8;
        let w = self.info_header.width * bytes_per_pixel;
        let w = if w % 4 == 0 { w } else { w - w % 4 + 4 };
        for y in (0..self.info_header.height).rev() {
            for x in 0..self.info_header.width {
                let i = (y * w + x * bytes_per_pixel) as usize;
                let value = if bytes_per_pixel == 3 {
                    (self.pixels[i] as u32 + self.pixels[i + 1] as u32 + self.pixels[i + 2] as u32) / 3
                } else {
                    self.pixels[i] as u32
                };
                let c = if value < 64 {
                    '*'
                } else if value < 128 {
                    '+'
                } else if value < 192 {
                    '-'
                } else {
                    ' '
                };
                print!("{}", c);
            }
            println!();
        }
    }
}

実行する

main関数は次のように実装します。エラーが起きた場合は表示だけはするようにします。

fn main() {
    let data = read_file("sample.bmp"); // ファイルを読み込んでVec<u8>にする
    match read_bmp(&data) { // パースする
        Ok(bmp) => {
            bmp.print();
        }
        Err(err) => {
            println!("{:?}", err);
        }
    }
}

読み込む画像として、次のようなRustのロゴを小さくしたものを用意します。

sample.png

これをWindows BitMapとしてsample.bmpに保存し、実行したところ以下のような出力となりました。

BitMapFileHeader { file_size: 1722, offset: 1146 }
BitMapInfoHeader { width: 24, height: 24, bits_per_pixel: 8, compression_method: 0, image_size: 576, horizontal_resolution: 11811, vertical_resolution: 11811, n_color_palette: 256, n_colors_used: 256 }
           -            
       - *+*++---       
     --**** +***+--     
     ***+-- ---***-     
   -**+    -    -***    
  -+*+            **--  
  +**+----------  -**-  
 -**************+  -*+- 
 +*+*+***********- ++*- 
-+- + ***-    ***+ - *- 
-**+- ***+   -***  -+*+ 
-**   **********-   -*+ 
+*+   **********+   -**-
 **   ***-   +***  -**+ 
+**---***+   -***+ +*** 
 +**********  ********  
 ***********  +*******- 
  +*+             -**-  
  ***++-        +-+**-  
   -*+ +          **    
   --*-*-     -++++-    
     *+**********+-     
       *+*****++-       
         + +---         

ヘッダ情報、ピクセルともに正しく読み込めていることが確認できました。

おわりに

今回題材に取り上げたビットマップファイルでは、ヘッダを順番に読んでいくだけで、nomの各種コンビネータの力はあまり発揮されているように感じませんが、それでも自力でパースするよりはずっと楽なはずです。古い記事ではnomはマクロ中心と紹介されていたりしますが、バージョン5以降は関数中心になり、敷居も下がっているので試してみるのはいかがでしょうか。


  1. 正確には、tagはそういった機能をもつimpl Fnを返します。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away