役に立たない『JavaScript 30 行で BMP 画像を作る方法』
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 の対応状況
Source: https://caniuse.com/webp
Source: https://caniuse.com/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
のあとにあります。そのため、ビットマップデータまでのオフセットは、 BITMAPFILEHEADER
と BITMAPV4HEADER
のサイズを足した 122
になります。
つまり、ファイルサイズ以外は、BITMAPFILEHEADER
は同じ値を使えます。あらかじめテンプレートのようにヘッダーの値を用意しておくと、短いコードで BMP を作れます。
BITMAPV4HEADER
108 bytes からなる、画像についての情報が格納されるヘッダーです。詳細は Microsoft Docs や Wikipedia を参照してください。
今回は、ある程度決まりきった構造で BMP を作成するため、ほとんど同じ値を流用できます。ここでは、特に留意すべき項目について説明します。
ビットマップデータの格納形式と height の値
BMP フォーマットは、基本的にボトムアップ形式でビットマップデータを格納します。言い換えると、画像の左下から 1 行ずつデータを格納する必要があります。一般的な RGBA データ列は、画像の左上から 1 行ずつデータが書かれているので、このままだと「ピクセルを格納する順序を並び替える処理」を作らないといけません。
height の符号を反転させてマイナスの値にすると、ビットマップデータをトップダウン形式で格納できるようになります。こうすることで、画像の左上からデータを格納できるため、RGBA データ列のピクセル順序に手を加えずに済みます。
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
の場合、0x0036
~ 0x0045
の領域には、RGBA それぞれのカラーマスクを格納します。
32 bit BMP では一般的に Blue / Green / Red / Alpha の順でビットマップデータを書き込みます。ImageData
などは Red / Green / Blue / Alpha の順でデータが組まれているため、このままだと並び替える処理を作らないといけません。
カラーマスクを使うと、ビットマップデータの並び順を変えることができます。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];
}
-
https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/toBlob ↩︎
-
https://developer.mozilla.org/ja/docs/Web/API/WindowOrWorkerGlobalScope/atob ↩︎
-
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/from ↩︎
-
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/set ↩︎
-
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView ↩︎
-
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView/setInt32 ↩︎
-
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView/setUint32 ↩︎
Discussion