JSでバイナリを扱う 第3回 ファイルからバイト列を取り出す
前回作ったPNG画像の幅と高さを読み取るコードは、バイト列をコードに直接書き写したものでした。今回はBlob・Fileを使って実際のファイルからバイト列を取り出せるようにします。ファイルの中身を覗ける16進ダンプのツールを作り、それを応用して、任意のPNG画像から幅と高さを読み取れるようにします。
- カテゴリー
- JavaScript/TypeScript >
- ECMAScript
発行
はじめに
前回は、バイト列そのものを保持する「箱」(ArrayBuffer)と、それを解釈する「窓」(TypedArray・DataView)という分離を軸に、バイト列を読み書きする方法を見てきました。締めくくりには、DataViewを使ってPNG画像のヘッダから幅と高さを読み取りましたね。
ただ、前回のコードには、実務の視点で見ると足りないところが1つありました。実際のファイルを読み込んでいなかったことです。読み取ったバイト列は、次のようにコードへ直接書き写したものでした。
前回のPNGヘッダの例(冒頭のみ抜粋)
// あるPNG画像の先頭26バイト(署名 + IHDRの一部)
const bytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG署名
/* ...以下略... */
]);
実際のファイルの中身は、読み込むまでわかりません。ユーザーが選ぶファイルは毎回違うからです。前回の例を実務につなげるには、対象のファイルを指定して、そこからバイト列を取り出して解析する手段が必要です。今回のテーマは、これです。
進め方は2段階です。
- まず、ファイルの中身をバイト列のまま表示する16進ダンプのツールを作る。どんなファイルでも中身を覗ける、バイナリ解析の第一歩です
- 次にそれを応用して、ユーザーが選んだ任意のPNG画像から幅と高さを読み取る。前回はハードコードで代用していた読み取りが、実際のファイルに対して動くようになります
1つ目の16進ダンプは、次のような表示をするツールです。たとえばHello, CodeGrid!と書いたテキストファイルを読み込むと、こう表示されます。
今回作るもの:テキストファイルの16進ダンプ表示
00000000 48 65 6c 6c 6f 2c 20 43 6f 64 65 47 72 69 64 21 Hello, CodeGrid!
テキストに限らず、どんなファイルでも覗けます。PNG画像を読み込むと、こうなります。
今回作るもの:PNGファイルの16進ダンプ表示(抜粋)
00000000 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 .PNG........IHDR
00000010 00 00 04 00 00 00 02 00 08 02 00 00 00 93 af 89 ................
00000020 ee 00 00 73 5b 49 44 41 54 78 01 ec dd 21 0c a9 ...s[IDATx...!..
(以下略)
表示は3列の構成です。第1回でバイナリエディタの画面として紹介した、あの表示ですね。
- 1列目:オフセット。その行の先頭バイトが、ファイルの先頭から数えて何バイト目にあるか
- 2列目:バイトの値を16進数2桁で並べたもの。1行に
16バイトずつ表示する - 3列目:同じ
16バイトを、文字として読んでみた結果
1つ注意したいのは、オフセットも16進数で表記していることです。1行に16バイトずつ表示しているので、2行目の先頭はファイルの16バイト目。16は16進数で10なので、オフセットは00000010となります(「10バイト目」ではありません)。バイトの値を16進数で書くのに合わせて位置も16進数で書くのが、バイナリエディタやhexdumpなどのツールに共通する慣習です。
コラム:hexdumpを試してみる
hexdumpは、macOSやLinuxに標準で備わっている、ファイルの中身を16進数で表示するコマンドラインツールです。ターミナルで次のように実行できます。
hexdumpでファイルの中身を見る
hexdump -C hello.txt
hexdumpの出力
00000000 48 65 6c 6c 6f 2c 20 43 6f 64 65 47 72 69 64 21 |Hello, CodeGrid!|
00000010
-Cは、オフセット・16進数・文字の3列で表示するオプションです。8バイトごとに空白の区切りが入る、文字の列が|で囲まれる、最後の行にファイル全体のバイト数がオフセットとして表示される、といった細部の違いはありますが、構成は今回作るツールとまったく同じです。興味があれば、手元のファイルで試してみてください。
ちなみに、最初の例のHello, CodeGrid!は、半角スペースと!を含めてちょうど16文字です。第1回で見たとおりASCIIの範囲は1文字が1バイトなので合計16バイトとなり、1行にぴったり収まっているわけです。
なお、この記事で作るデモのソースコードは、サンプルリポジトリ(codegrid/2026-js-binary)にまとまっています。手元で動かしながら読み進めたい場合は参照してください。
まずは、ファイルを受け取ってその中身を取り出すためのオブジェクト、FileとBlobから見ていきましょう。
Blob:バイト列を持ち運ぶ「塊」
<input type="file">で選択されたファイルは、JavaScriptではFileというオブジェクトとして受け取ります。ただし、その中身を取り出すためのメソッド(このあと使うarrayBuffer()やslice())は、実はFile自身ではなく、FileのもとになっているBlobが持っています。そこで先に、Blobとは何かを押さえておきましょう。
Blobは、ファイルのように扱えるバイナリデータの塊を表すオブジェクトです。実務では、自分で作るよりもAPIから受け取るものとして出会うことが多いでしょう。たとえばfetch()でサーバーから画像を取得すると、レスポンスのblob()メソッドでBlobが手に入ります。
fetchで画像をBlobとして受け取る
const res = await fetch('photo.png');
const blob = await res.blob();
console.log(blob.size); // 画像のバイト数
console.log(blob.type); // "image/png"
sizeはバイト数、typeは中身の種類を表すMIMEタイプです。このtypeは、バイト列を解析して得たものではなく、HTTPレスポンスのContent-Typeヘッダーの値を引き継いだものです。バイト列そのものはタイプ情報を持っていないので、MIMEタイプという「ラベル」を外側から添えている――これがBlobの役割です。
「バイト列の入れ物なら、前回のArrayBufferと同じでは?」と思うかもしれません。しかし、この2つは性格がはっきり違います。
ArrayBufferは、窓を被せて中身を読み書きするための作業場。中身のバイト列が何のデータなのかという情報は持たないBlobは、中身のバイト列に「これはtext/plainだ」「これはimage/pngだ」というラベル(MIMEタイプ)を添えて持ち運ぶための塊。一度作ると中身を変更できない(イミュータブル)
詳細は後述しますが、中身を読むには、いったん箱であるArrayBufferへ変換し、Uint8Arrayなどの窓を被せることになります。保管と受け渡しはBlob、読み書きはArrayBufferという役割分担だと捉えてください。
補足:Blobは自分で作ることもできる
new Blob()コンストラクタを使えば、Blobを自分で作ることもできます。JavaScriptで組み立てたCSVやJSONなどのデータを、ファイルとしてユーザーにダウンロードさせる、といった場面で使われます。その際のMIMEタイプは、new Blob(データ, { type: 'text/csv' })のように、第2引数で自分で指定します。ここでも中身から自動で判定されるわけではありません。
File:選択されたファイルはBlobとして届く
Blobがわかると、Fileはすぐに理解できます。FileはBlobを継承したオブジェクトで、Blobの性質に加えてファイル名(name)や更新日時(lastModified)といった情報を持っています。
Fileは、<input type="file">でユーザーがファイルを選択すると、そのfilesプロパティから取り出せます。
ファイル選択のinput
<input type="file" id="picker">
選択されたFileからArrayBufferを取り出す
const picker = document.getElementById('picker');
picker.addEventListener('change', async () => {
const file = picker.files[0]; // 選択されたファイル(Fileオブジェクト)
console.log(file.name); // ファイル名
console.log(file.size); // バイト数
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
console.log(bytes); // ファイルの中身がバイト列として手に入る
});
ポイントはfile.arrayBuffer()です。これはFile(やBlob)の中身を、前回扱ったArrayBufferとして取り出すメソッドです。ここで前回までの知識と実際のファイルがつながります。ArrayBufferさえ手に入れば、あとは前回やったことがそのまま通用します。Uint8ArrayやDataViewの窓を被せて、中身を自由に読めばよいのです。
補足:ドラッグ&ドロップでファイルを受け取るには
dragoverイベントとdropイベントを扱えば、ドラッグ&ドロップでも同じようにFileを受け取れます(dropイベントのdataTransfer.filesにFileが入っています)。ただし本記事の本筋から外れるので、解説は割愛します。このあと登場するデモは、ドラッグ&ドロップにも対応しているので、興味があればソースコードを参照してください。
ファイルの中身をバイト列のまま覗く
道具が揃ったので、冒頭に掲げた16進ダンプのツールを作りましょう。あらためて完成形です。たとえばHello, CodeGrid!と書いたテキストファイルを読み込むと、次のように表示します。
16進ダンプの出力例
00000000 48 65 6c 6c 6f 2c 20 43 6f 64 65 47 72 69 64 21 Hello, CodeGrid!
1列目がオフセット、2列目がバイトの値の16進数、3列目がそれを文字として読んだ結果、という3列の構成でしたね。
仕組みはシンプルです。選択されたFileをarrayBuffer()でArrayBufferにし、Uint8Arrayの窓を被せてバイト列にし、16バイトずつこの3列に整形していきます。
バイト列を16進ダンプ文字列にする
function toHexDump(bytes) {
let result = '';
for (let offset = 0; offset < bytes.length; offset += 16) {
const slice = bytes.subarray(offset, offset + 16);
// 各バイトを2桁の16進数に
const hex = Array.from(slice, b => b.toString(16).padStart(2, '0')).join(' ');
// 表示可能な文字はそのまま、それ以外は "." に
const ascii = Array.from(slice, b =>
b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : '.'
).join('');
result += offset.toString(16).padStart(8, '0') + ' ' + hex.padEnd(47) + ' ' + ascii + '\n';
}
return result;
}
2列目を作っているのがhexの行です。中心になっているのはb.toString(16).padStart(2, '0')の部分です。toString(16)は数値を16進数表記の文字列に変換するメソッドで、引数の16は「16進数で」という指定です。255なら"ff"になります。ただし15以下の値では"a"のように1桁になってしまうため、padStart(2, '0')で2桁に満たないぶんを先頭の0で埋めて、"0a"に揃えています。第1回で触れた「バイトの値は16進数2桁で書く」という習慣どおり、1バイトはちょうど16進数2桁に収まるので、すべてのバイトを2桁で統一して表示するわけです。
3列目を作っているのがasciiの行です。同じバイト列を、今度は文字として読んでみる処理です。String.fromCharCode(b)は、文字コードの番号bに対応する文字を返すメソッドです。ただし、すべてのバイトに表示できる文字が割り当てられているわけではありません。そのまま表示できるのは0x20(空白)から0x7e(~)までの範囲なので、その範囲のバイトだけを文字に変換し、それ以外(改行などの制御文字や、ASCIIの範囲外の値)は.で表示しています。第1回でやった「バイト列を文字コードの規則で読む」を、そのままコードにした部分です。
最後のresult +=の行で、1列目のオフセット、2列目のhex、3列目のasciiをつないで1行を組み立てています。オフセットはoffset.toString(16).padStart(8, '0')で8桁の16進数に揃えます。hex.padEnd(47)は、2列目の幅を47文字(2桁×16個+間の空白15個)に固定する処理です。最終行のようにバイトが16個に満たない行でも、これで3列目の開始位置が揃います。
選択されたファイルを読んでダンプする
picker.addEventListener('change', async () => {
const file = picker.files[0];
const buffer = await file.arrayBuffer(); // File -> ArrayBuffer
const bytes = new Uint8Array(buffer); // 窓を被せてバイト列に
output.textContent = toHexDump(bytes.subarray(0, 256)); // 先頭256バイトを表示
});
これで完成です。冒頭で示した2段階の前半、「選択されたファイルの中身をバイト列のまま表示する」ツールができました。
試しに.png画像を選んでみてください。右側の文字列はほとんど.になるはずです。これが第1回で話した「文字コードで読んでも意味をなさないバイト列」、すなわちバイナリの正体です。
そして先頭行に注目してください。89 50 4e 47 0d 0a 1a 0a――前回コードに書き写したPNG署名と、まったく同じバイト列が並んでいるはずです。あのハードコードされた数値の並びは、実際のPNGファイルの先頭をそのまま写し取ったものだったのです。
実際のPNGファイルから幅と高さを読む
いよいよ、冒頭で予告した2段階の後半です。前回の締めくくりで、コードに書き写したバイト列にDataViewの窓を被せて、PNGの幅と高さを読み取りましたね。あれと同じことを、今度は選択されたPNGファイルでやってみましょう。ここがつながれば、前回ハードコードで代用していた読み取りを、実際のファイルに対して行えるようになります。
幅と高さが入っているのは、ヘッダの先頭26バイトです。16進ダンプのようにファイル全体を読み込んでもよいのですが、必要なのが先頭だけなら、全体をメモリに載せるのは無駄です。こういうときのために、BlobとFileにはslice()というメソッドが用意されています。指定した範囲だけを切り出した新しいBlobを返すもので、配列のslice()と感覚は近いものです。
先頭26バイトだけを切り出す
const head = file.slice(0, 26);
console.log(head.size); // 26
注目すべきは、slice()がこの時点では実際にデータをコピーしないことです。元のバイナリへの「ここからここまで」という参照をもつだけなので、巨大なファイルでも軽量に扱えます。「数GBの動画ファイルの先頭だけを読んで形式を判定する」といった処理も、ファイル全体をメモリに載せずに行えるわけです。
これで材料が揃いました。slice()で切り出したBlobをarrayBuffer()で箱にし、DataViewの窓で読みます。次のコードのfileは、16進ダンプのときと同じく、<input type="file">で選択されたFileオブジェクトです。
選択されたPNGの幅と高さを読む
const head = file.slice(0, 26); // 先頭26バイトだけのBlob
const buffer = await head.arrayBuffer(); // Blob -> ArrayBuffer
const view = new DataView(buffer); // DataViewの窓を被せる
console.log(view.getUint32(16, false)); // 幅(ビッグエンディアンで読む)
console.log(view.getUint32(20, false)); // 高さ
getUint32(16, false)の意味――16バイト目から4バイトをビッグエンディアンの符号なし整数として読む――は、前回説明したとおりです。変わったのは、バイト列の出どころがコード中の配列から実際のファイルになったことだけです。ファイルがどれだけ大きくても、slice()のおかげで実際に読み込むのは先頭の26バイトだけで済みます。
動くものを下のデモに用意しました。手元のPNG画像を選ぶと、その幅と高さが表示されます。画像ビューアーで見られる「画像のサイズ」を、自分のコードで直接読み取れたことになります。
まとめ
今回は、バイト列の「入手経路」を扱いました。
Blobは、MIMEタイプのラベルを添えてバイト列を持ち運ぶ、イミュータブルな塊FileはBlobを継承していて、選択されたファイルはFileとして届くarrayBuffer()で中身をArrayBufferとして取り出せば、前回までの「箱と窓」の知識がそのまま使えるslice()はコピーせずに範囲を切り出せるので、大きなファイルの一部だけを読むのに向く
16進ダンプのデモが完成し、第1回の「すべてはバイト列」、第2回の「箱と窓」、そして今回の「実際のファイル」が一本につながりました。
次回はいよいよ最終回です。第1回から予告してきた「選んだ画像をグレースケールにするデモ」を、ついに完成させます。ユーザーが選んだ画像をURL.createObjectURL()で画面に表示し、Canvasを使ってピクセルのバイト列を直接書き換える――「すべてはバイト列」という出発点が、画像という一見特別なデータでも成り立つことを確かめて、シリーズを締めくくります。