🎨

役に立たない『JavaScript 30 行で BMP 画像を作る方法』

2021/02/28に公開

TL;DR

  • JavaScript 30 行で透過度つき BMP を作ります
  • ユースケースはありますが、ほとんどのひとには役に立たないと思います
30 行で BMP を作るコード
const BMP_HEADER_BASE64 =
  'Qk0AAAAAAAAAAHoAAABsAAAAAAAAAAAAAAABACAAAwAAAAAAAADDDgAAww4AAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/0JHUnM';
const BMP_HEADER = Uint8Array.from(atob(BMP_HEADER_BASE64), (c) => c.charCodeAt(0));
const BMP_FILESIZE_OFFSET = 2;
const BMP_WIDTH_OFFSET = 18;
const BMP_HEIGHT_OFFSET = 22;
const BMP_IMAGESIZE_OFFSET = 34;

/**
 * @param param {object}
 * @param param.width {number}
 * @param param.height {number}
 * @param param.data {Uint8Array | Uint8ClampedArray}
 * @returns {Uint8Array}
 */
const convertRGBAToBMP = ({ width, height, data }) => {
  const result = new Uint8Array(BMP_HEADER.byteLength + data.byteLength);
  result.set(BMP_HEADER);
  result.set(data, BMP_HEADER.byteLength);

  const dataView = new DataView(result.buffer);
  dataView.setUint32(BMP_FILESIZE_OFFSET, result.byteLength, true);
  dataView.setUint32(BMP_WIDTH_OFFSET, width, true);
  dataView.setInt32(BMP_HEIGHT_OFFSET, -height, true);
  dataView.setUint32(BMP_IMAGESIZE_OFFSET, data.byteLength, true);

  return result;
};

ユースケース

新しい画像フォーマットをブラウザで表示させる

近年、WebP[1] / AVIF[2] のような新しい画像フォーマットが注目されています。新しい画像フォーマットは、ブラウザが対応していないと表示できません。WebP は 2020 年に主要ブラウザで表示できるようになりましたが、AVIF は 2021 年 2 月現在、Chrome のみ対応している状況です。

WebP / AVIF の対応状況

ブラウザが対応していない場合でも、自前でデコーダーを用意すれば表示させる方法はあります。次の記事では、ServiceWorker でデコーダーを実装し、BMP に変換してレスポンスすることで、 <img> タグに非対応の画像フォーマットを読み込ませています。 このときに、JavaScript から BMP を作る必要があります

高速に HTML Canvas から画像データを作る

HTML Canvas から画像データを作るときは HTMLCanvasElement.toBlob()[3] を使います。このとき出力される画像フォーマットは、指定がない場合 PNG になります。

PNG ではデータを Deflate で圧縮するため、PNG を作るときには時間がかかります。仮に大量の画像を出力するとなると、圧縮にかかる時間的コストも大きくなります。

一方、BMP を作るときは RGBA データをそのまま使うため、出力する時間はとても短くなります。実際にベンチマークで測ると、HTMLCanvasElement.toBlob() に比べて、BMP を作るコードのほうが圧倒的に速いことがわかります。画像のデータサイズよりも出力時間を優先するケースでは、JavaScript から BMP を作ると効果的です

ベンチマーク

Run all tests をクリックするとベンチマークを実行できます。

BMP フォーマットについて

今回作る BMP は、大きく分けて 3 つの層に分かれています。BITMAPFILEHEADER / BITMAPV4HEADER / ビットマップデータの 3 層について、ここでは解説します。

BITMAPFILEHEADER

BMP の最初 14 bytes は BITMAPFILEHEADER が格納されます。マジックナンバーである BM から始まり、ファイルサイズ、ビットマップデータまでのオフセットを格納します。

ビットマップデータは、カラーパレットを使わない場合、後述の BITMAPV4HEADER のあとにあります。そのため、ビットマップデータまでのオフセットは、 BITMAPFILEHEADERBITMAPV4HEADER のサイズを足した 122 になります。

つまり、ファイルサイズ以外は、BITMAPFILEHEADER は同じ値を使えます。あらかじめテンプレートのようにヘッダーの値を用意しておくと、短いコードで BMP を作れます。

BITMAPV4HEADER

108 bytes からなる、画像についての情報が格納されるヘッダーです。詳細は Microsoft DocsWikipedia を参照してください。

今回は、ある程度決まりきった構造で BMP を作成するため、ほとんど同じ値を流用できます。ここでは、特に留意すべき項目について説明します。

ビットマップデータの格納形式と height の値

BMP フォーマットは、基本的にボトムアップ形式でビットマップデータを格納します。言い換えると、画像の左下から 1 行ずつデータを格納する必要があります。一般的な RGBA データ列は、画像の左上から 1 行ずつデータが書かれているので、このままだと「ピクセルを格納する順序を並び替える処理」を作らないといけません

\underset{\text{ピクセルの位置}}{ \begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \\ 7 & 8 & 9 \\ \end{bmatrix} } \Longrightarrow \underset{\text{ビットマップデータ(正の height の場合)}}{ \begin{bmatrix} 7 & 8 & 9 & 4 & 5 & 6 & 1 & 2 & 3 \end{bmatrix} }

height の符号を反転させてマイナスの値にすると、ビットマップデータをトップダウン形式で格納できるようになります。こうすることで、画像の左上からデータを格納できるため、RGBA データ列のピクセル順序に手を加えずに済みます

\underset{\text{ピクセルの位置}}{ \begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \\ 7 & 8 & 9 \\ \end{bmatrix} } \Longrightarrow \underset{\text{ビットマップデータ(負の height の場合)}}{ \begin{bmatrix} 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 & 9 \end{bmatrix} }

If bV4Height is positive, the bitmap is a bottom-up DIB and its origin is the lower-left corner.
If bV4Height is negative, the bitmap is a top-down DIB and its origin is the upper-left corner.

Source: https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv4header

カラーマスク

圧縮形式が BI_BITFIELDS の場合、0x00360x0045 の領域には、RGBA それぞれのカラーマスクを格納します。

32 bit BMP では一般的に Blue / Green / Red / Alpha の順でビットマップデータを書き込みますImageData などは Red / Green / Blue / Alpha の順でデータが組まれているため、このままだと並び替える処理を作らないといけません

\underset{\text{RGBA データ列}}{ \begin{bmatrix} Red & Green & Blue & Alpha \end{bmatrix} } \Longrightarrow \underset{\text{ビットマップデータ}}{ \begin{bmatrix} Blue & Green & Red & Alpha \end{bmatrix} }

カラーマスクを使うと、ビットマップデータの並び順を変えることができます。Red を先頭に持ってくるためには、赤のカラーマスクを 0xFF 0x00 0x00 0x00 と指定します。Green / Blue / Alpha についても同じ要領でカラーマスクを指定します。これによって、RGBA データ列をそのままの順序で、ビットマップデータに書き込むことができます

ビットマップデータ

ヘッダーのあとには、ビットマップデータを入れます。ビットマップデータは、水平方向 1 列を格納するときのバイト数が 4 の倍数である必要があります。4 の倍数でないときは 0x00 で足りないバイト数を埋めます。

今回作る 32 bit BMP の場合は、常に 1 pixel に 4 bytes 使うため、1 列で使うバイト数は必然的に 4 の倍数になります。よって、この対応は考慮しなくて問題ありません。RGBA データ列はそのままビットマップデータとして書き込むことができます

コードの解説

BMP の構造を抑えたところで、実際に 30 行で書いた BMP を作るコードを解説します。

30 行で BMP を作るコード
const BMP_HEADER_BASE64 =
  'Qk0AAAAAAAAAAHoAAABsAAAAAAAAAAAAAAABACAAAwAAAAAAAADDDgAAww4AAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/0JHUnM';
const BMP_HEADER = Uint8Array.from(atob(BMP_HEADER_BASE64), (c) => c.charCodeAt(0));
const BMP_FILESIZE_OFFSET = 2;
const BMP_WIDTH_OFFSET = 18;
const BMP_HEIGHT_OFFSET = 22;
const BMP_IMAGESIZE_OFFSET = 34;

/**
 * @param param {object}
 * @param param.width {number}
 * @param param.height {number}
 * @param param.data {Uint8Array | Uint8ClampedArray}
 * @returns {Uint8Array}
 */
const convertRGBAToBMP = ({ width, height, data }) => {
  const result = new Uint8Array(BMP_HEADER.byteLength + data.byteLength);
  result.set(BMP_HEADER);
  result.set(data, BMP_HEADER.byteLength);

  const dataView = new DataView(result.buffer);
  dataView.setUint32(BMP_FILESIZE_OFFSET, result.byteLength, true);
  dataView.setUint32(BMP_WIDTH_OFFSET, width, true);
  dataView.setInt32(BMP_HEIGHT_OFFSET, -height, true);
  dataView.setUint32(BMP_IMAGESIZE_OFFSET, data.byteLength, true);

  return result;
};

ヘッダーを作る

const BMP_HEADER_BASE64 =
  'Qk0AAAAAAAAAAHoAAABsAAAAAAAAAAAAAAABACAAAwAAAAAAAADDDgAAww4AAAAAAAAAAAAA/wAAAAD/AAAAAP8AAAAA/0JHUnM';
const BMP_HEADER = Uint8Array.from(atob(BMP_HEADER_BASE64), (c) => c.charCodeAt(0));

BMP_HEADER_BASE64 は、画像によって変わらず同じ値になるヘッダーを Base64 にしたものです。Uint8Array のほうが取り回しやすいため、2 行目で Base64 から Uint8Array に変換しています。

window.atob()[4] で Base64 をバイナリー文字列に変換します。しかし、このままでは文字列なので、Uint8Array に変換できません。そこで、Uint8Array.from()[5] を使います。

Uint8Array.from() の第 2 引数は、第 1 引数で与えた iteratable なオブジェクトに対し、map 処理する関数を受け取ります。文字列は 1 文字ずつイテレートされるため、1 文字ずつ String.charCodeAt(0) を呼び出して、バイナリー文字列を数値列に変換します。

ヘッダーとビットマップデータを結合する

const result = new Uint8Array(BMP_HEADER.byteLength + data.byteLength);
result.set(BMP_HEADER);
result.set(data, BMP_HEADER.byteLength);

BMP のデータサイズは BMP_HEADER とビットマップデータのサイズを合わせたものになります。Uint8Array.set()[6] は、第 2 引数にオフセットを指定できるため、ヘッダーの直後にビットマップデータが入るようにオフセットを設定します。

ヘッダー内の画像ごとに異なる値を書き換える

const dataView = new DataView(result.buffer);
dataView.setUint32(BMP_FILESIZE_OFFSET, result.byteLength, true);
dataView.setUint32(BMP_WIDTH_OFFSET, width, true);
dataView.setInt32(BMP_HEIGHT_OFFSET, -height, true);
dataView.setUint32(BMP_IMAGESIZE_OFFSET, data.byteLength, true);

DataView[7] を使うと、エンディアンを考慮して ArrayBuffer にデータを書き込むことができます。BMP はリトルエンディアンですから、DataView.setInt32()[8] / DataView.setUint32()[9] の第 3 引数は true にします。height の値は、ビットマップデータをトップダウン形式で格納するために、符号をマイナスにして設定します。

たったこれだけで BMP を作ることができました

おわりに

JavaScript 30 行で透過度つき BMP 画像を作る方法を解説しました。BMP のデータ構造を紐解くと、ほぼ RGBA データ列そのままで BMP を構成できます。実際のコードも単純で、データを結合して少しいじるだけで BMP が作れました。

ここまでのコードをまとめたライブラリを @3846masa/bmp として npm に公開しています。JavaScript で BMP を作る必要があるケースは稀だと思いますが、よろしければご活用ください。

付録

処理中にブラウザが固まる場合は、WebWorker で処理する

今回の実装は同期処理で書かれているため、大きい画像を BMP にするときなどにブラウザが固まってしまう可能性があります。そのようなときは、WebWorker に移して別スレッドで処理すると良いでしょう。

次のように WebWorker に移したい処理を Function で囲い、Function.toString() で文字列にします。そして、文字列を足して Function が実行されるようなコードにします。これを WebWorker として読み込むことで、WebWorker 用のファイルを作らずとも別スレッドで動かすことができます

WebWorker で BMP を作るコード
function workerScript() {
  /* ここに 30 行のコードを書く */
  self.onmessage = ({ data: { width, height, data } }) => {
    try {
      const result = convertRGBAToBMP({ width, height, data });
      self.postMessage({ result }, [result.buffer]);
    } catch (error) {
      self.postMessage({ error });
    }
  };
}

const convertRGBAToBMP = ({ width, height, data }) => {
  const workerScriptBlob = new Blob([`(${workerScript.toString()})()`]);
  const workerScriptUrl = URL.createObjectURL(workerScriptBlob);
  const worker = new Worker(workerScriptUrl);

  const result = new Promise((resolve, reject) => {
    worker.onmessage = ({ data: { result, error } }) => {
      if (result) {
        resolve(result);
      } else {
        reject(Object.assign(new Error(error.message), error));
      }
    };
    worker.postMessage({ width, height, data }, [data.buffer]);
  });

  return result.finally(() => {
    worker.terminate();
    URL.revokeObjectURL(workerScriptUrl);
  });
};

カラーマスクがうまく適用されない場合は、BGRA の順でビットマップデータを格納する

Windows に搭載されている BMP デコーダーでは、上記の方法で指定したカラーマスクがうまく適用されません。具体的には Alpha が認識されずに黒くなってしまいます。ブラウザでは Internet Explorer と Legacy Edge (EdgeHTML) で表示がおかしくなります

対処法としては、Green / Blue / Red / Alpha の順でビットマップデータを格納する方法があります。処理が増えてしまいますが、万全を期すならば対処しておくとよいでしょう。

const result = new Uint8Array(BMP_HEADER.byteLength + data.byteLength);
result.set(BMP_HEADER);
result.set(data, BMP_HEADER.byteLength);

for (let offset = 0; offset < dataLength; offset += 4) {
  result[BMP_HEADER.byteLength + offset] = data[offset + 2];
  result[BMP_HEADER.byteLength + 2 + offset] = data[offset];
}
脚注
  1. https://developers.google.com/speed/webp/ ↩︎

  2. https://aomediacodec.github.io/av1-avif/ ↩︎

  3. https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/toBlob ↩︎

  4. https://developer.mozilla.org/ja/docs/Web/API/WindowOrWorkerGlobalScope/atob ↩︎

  5. https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/from ↩︎

  6. https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/set ↩︎

  7. https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView ↩︎

  8. https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView/setInt32 ↩︎

  9. https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView/setUint32 ↩︎

Discussion