mkbitmap を WebAssembly にコンパイルする

WebAssembly の概要と入手元では、今日の WebAssembly に至った経緯を説明しました。この記事では、既存の C プログラム mkbitmap を WebAssembly にコンパイルする方法について説明します。ファイルの操作、WebAssembly と JavaScript のランド間の通信、キャンバスへの描画などが含まれるため、Hello World の例よりも複雑ですが、負担に感じないほど管理可能です。

この記事は、WebAssembly について学びたいと考えているウェブ デベロッパー向けに書かれており、mkbitmap のようなものを WebAssembly にコンパイルする場合の進め方を順を追って説明しています。公正な警告として、初回実行時にアプリやライブラリがコンパイルされないのはまったく正常なことです。そのため、以下で説明する手順の一部は機能しなかったので、バックトラックして別の方法で再試行する必要がありました。この記事では、魔法の最終的なコンパイル コマンドが空から落ちてくるかのように示されているのではなく、実際の進捗状況や不満も述べています。

mkbitmap の概要

mkbitmap C プログラムは画像を読み取り、その画像にオペレーション(反転、ハイパス フィルタリング、スケーリング、しきい値処理の順番)を適用します。各オペレーションは個別に制御したり、オンまたはオフにしたりできます。mkbitmap の主な用途は、カラー画像やグレースケール画像を他のプログラム、特に SVGcode の基礎をなすトレース プログラム potrace の入力に適した形式に変換することです。mkbitmap は前処理ツールとして特に、スキャンした線画(漫画や手書きのテキストなど)を高解像度の 2 層画像に変換する場合に特に便利です。

mkbitmap を使用するには、複数のオプションと 1 つまたは複数のファイル名を渡します。詳細については、ツールの man ページをご覧ください。

$ mkbitmap [options] [filename...]
カラーの漫画の画像。
元の画像(Source)。
漫画の画像は前処理後にグレースケールに変換されました。
最初にスケーリングされ、次にしきい値が設定される: mkbitmap -f 2 -s 2 -t 0.48出典)。

コードを取得する

まず、mkbitmap のソースコードを取得します。プロジェクトのウェブサイトから入手できます。このドキュメントの作成時点では、potrace-1.16.tar.gz が最新バージョンです。

ローカルでコンパイルしてインストールする

次のステップでは、ツールをローカルでコンパイルしてインストールし、動作を確認します。INSTALL ファイルには、次の手順が含まれています。

  1. cd でパッケージのソースコードを含むディレクトリに移動し、「./configure」と入力してシステムのパッケージを構成します。

    configure の実行には時間がかかることがあります。実行中、チェックしている機能を示すメッセージを出力します。

  2. make」と入力してパッケージをコンパイルします。

  3. 必要に応じて、「make check」と入力して、パッケージに付属のセルフテストを実行します。通常は、ビルドされたばかりのアンインストール済みバイナリを使用します。

  4. make install と入力して、プログラム、データファイル、ドキュメントをインストールします。root が所有するプレフィックスにインストールする場合は、パッケージを通常のユーザーとして構成してビルドし、root 権限で make install フェーズのみを実行することをおすすめします。

この手順を行うと、potracemkbitmap という 2 つの実行可能ファイルが作成されます。この記事では後者について取り上げます。正しく動作することを確認するには、mkbitmap --version を実行します。これは、私のマシンからの 4 つのステップすべての出力であり、簡潔にするために大幅にトリミングされています。

ステップ 1、./configure:

 $ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... ./install-sh -c -d
checking for gawk... no
checking for mawk... no
checking for nawk... no
checking for awk... awk
checking whether make sets $(MAKE)... yes
[…]
config.status: executing libtool commands

ステップ 2、make:

$ make
/Applications/Xcode.app/Contents/Developer/usr/bin/make  all-recursive
Making all in src
clang -DHAVE_CONFIG_H -I. -I..     -g -O2 -MT main.o -MD -MP -MF .deps/main.Tpo -c -o main.o main.c
mv -f .deps/main.Tpo .deps/main.Po
[…]
make[2]: Nothing to be done for `all-am'.

ステップ 3、make check:

$ make check
Making check in src
make[1]: Nothing to be done for `check'.
Making check in doc
make[1]: Nothing to be done for `check'.
[…]
============================================================================
Testsuite summary for potrace 1.16
============================================================================
# TOTAL: 8
# PASS:  8
# SKIP:  0
# XFAIL: 0
# FAIL:  0
# XPASS: 0
# ERROR: 0
============================================================================
make[1]: Nothing to be done for `check-am'.

ステップ 4、sudo make install:

$ sudo make install
Password:
Making install in src
 .././install-sh -c -d '/usr/local/bin'
  /bin/sh ../libtool   --mode=install /usr/bin/install -c potrace mkbitmap '/usr/local/bin'
[…]
make[2]: Nothing to be done for `install-data-am'.

正常に機能したかどうかを確認するには、mkbitmap --version を実行します。

$ mkbitmap --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.

バージョンの詳細が表示されたら、mkbitmap が正常にコンパイルされ、インストールされています。次に、これらの手順を WebAssembly で機能するようにします。

mkbitmap を WebAssembly にコンパイルする

Emscripten は、C/C++ プログラムを WebAssembly にコンパイルするためのツールです。Emscripten の Building Projects ドキュメントには、次のように記載されています。

Emscripten を使用した大規模なプロジェクトの構築はとても簡単です。Emscripten には、gcc の代わりに emcc を使用するように makefile を構成する 2 つの簡単なスクリプトが用意されています。ほとんどの場合、プロジェクトの現在のビルドシステムの残りの部分は変更されません。

ドキュメントは続いています(簡潔にするために多少編集を加えています)。

通常のコマンドでビルドする場合を考えます。

./configure
make

Emscripten を使用してビルドする場合は、代わりに次のコマンドを使用します。

emconfigure ./configure
emmake make

したがって、基本的に ./configureemconfigure ./configure に、makeemmake make になります。以下では、mkbitmap を使用してこの処理を行う方法を示します。

ステップ 0、make clean:

$ make clean
Making clean in src
 rm -f potrace mkbitmap
test -z "" || rm -f
rm -rf .libs _libs
[…]
rm -f *.lo

ステップ 1、emconfigure ./configure:

$ emconfigure ./configure
configure: ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... ./install-sh -c -d
checking for gawk... no
checking for mawk... no
checking for nawk... no
checking for awk... awk
[…]
config.status: executing libtool commands

ステップ 2、emmake make:

$ emmake make
make: make
/Applications/Xcode.app/Contents/Developer/usr/bin/make  all-recursive
Making all in src
/opt/homebrew/Cellar/emscripten/3.1.36/libexec/emcc -DHAVE_CONFIG_H -I. -I..     -g -O2 -MT main.o -MD -MP -MF .deps/main.Tpo -c -o main.o main.c
mv -f .deps/main.Tpo .deps/main.Po
[…]
make[2]: Nothing to be done for `all'.

問題がなければ、ディレクトリのどこかに .wasm ファイルが作成されます。これを確認するには、find . -name "*.wasm" を実行します。

$ find . -name "*.wasm"
./a.wasm
./src/mkbitmap.wasm
./src/potrace.wasm

最後の 2 つは有望なので、cdsrc/ ディレクトリに移動します。対応する 2 つのファイル mkbitmappotrace も新たに追加されました。この記事では、mkbitmap のみが関係します。.js 拡張子がないというのはややわかりにくいですが、実際には JavaScript ファイルであり、簡単な head 呼び出しで検証できます。

$ cd src/
$ head -n 20 mkbitmap
// include: shell.js
// The Module object: Our interface to the outside world. We import
// and export values on it. There are various ways Module can be used:
// 1. Not defined. We create it here
// 2. A function parameter, function(Module) { ..generated code.. }
// 3. pre-run appended it, var Module = {}; ..generated code..
// 4. External script tag defines var Module.
// We need to check if Module already exists (e.g. case 3 above).
// Substitution will be replaced with actual code on later stage of the build,
// this way Closure Compiler will not mangle it (e.g. case 4. above).
// Note that if you want to run closure, and also to use Module
// after the generated code, you will need to define   var Module = {};
// before the code. Then that object will be used in the code, and you
// can continue to use Module afterwards as well.
var Module = typeof Module != 'undefined' ? Module : {};

// --pre-jses are emitted after the Module integration code, so that they can
// refer to Module (if they choose; they can also define Module)

mv mkbitmap mkbitmap.js を呼び出して(必要に応じてそれぞれ mv potrace potrace.js も)、JavaScript ファイルの名前を mkbitmap.js に変更します。次に、コマンドラインで node mkbitmap.js --version を実行し、Node.js でファイルを実行し、正常に機能するかどうかを確認します。

$ node mkbitmap.js --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.

mkbitmap を WebAssembly にコンパイルしました。次は、ブラウザで動作するようにします。

ブラウザで WebAssembly を使用する mkbitmap

mkbitmap.js ファイルと mkbitmap.wasm ファイルを mkbitmap という新しいディレクトリにコピーし、mkbitmap.js JavaScript ファイルを読み込む index.html HTML ボイラープレート ファイルを作成します。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>mkbitmap</title>
  </head>
  <body>
    <script src="mkbitmap.js"></script>
  </body>
</html>

mkbitmap ディレクトリを提供するローカル サーバーを起動し、ブラウザで開きます。入力を求めるプロンプトが表示されます。これは想定どおりです。ツールの man ページによると「ファイル名の引数が指定されていない場合は、mkbitmap がフィルタとして機能し、標準入力から読み取る」(Emscripten の場合はデフォルトで prompt() である)ため、これは想定どおりです。

入力を求めるプロンプトを表示している mkbitmap アプリ。

自動実行を禁止する

mkbitmap の実行を直ちに停止してユーザー入力を待機させるには、Emscripten の Module オブジェクトを理解する必要があります。Module は、Emscripten が生成したコードが実行時のさまざまな時点で呼び出す属性を持つグローバル JavaScript オブジェクトです。Module を実装して、コードの実行を制御できます。Emscripten アプリケーションが起動すると、Module オブジェクトの値が参照されて適用されます。

mkbitmap の場合は、Module.noInitialRuntrue に設定して、初回実行でプロンプトが表示されないようにします。script.js というスクリプトを作成し、index.html<script src="mkbitmap.js"></script> の前に追加して、script.js に次のコードを追加します。アプリを再読み込みすると、プロンプトは表示されなくなります。

var Module = {
  // Don't run main() at page load
  noInitialRun: true,
};

ビルドフラグを追加してモジュラー ビルドを作成する

アプリに入力するには、Module.FS で Emscripten のファイル システム サポートを使用します。ドキュメントのファイル システムのサポートを含めるセクションには、次のように記載されています。

Emscripten は、ファイル システムのサポートを自動的に含めるかどうかを決定します。多くのプログラムはファイルを必要とせず、ファイル システムのサポートは無視できないサイズです。そのため、Emscripten は、理由がないときはファイルを含めないようにします。つまり、C/C++ コードがファイルにアクセスしない場合、FS オブジェクトやその他のファイル システム API は出力に含まれません。一方、C/C++ コードでファイルを使用する場合は、ファイル システムのサポートが自動的に含まれます。

残念ながら mkbitmap は、Emscripten にファイル システムのサポートを自動的に組み込まないケースの 1 つであるため、明示的に指示する必要があります。つまり、前述の emconfigureemmake のステップに従い、CFLAGS 引数でフラグをさらにいくつか設定する必要があります。以下のフラグは、他のプロジェクトでも役立つ場合があります。

また、この特定のケースでは、--host フラグを wasm32 に設定して、WebAssembly 用にコンパイルすることを configure スクリプトに伝える必要があります。

最後の emconfigure コマンドは次のようになります。

$ emconfigure ./configure --host=wasm32 CFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0'

忘れずに emmake make を再度実行し、新しく作成したファイルを mkbitmap フォルダにコピーしてください。

ES モジュール script.js のみを読み込むように index.html を変更し、そこから mkbitmap.js モジュールをインポートします。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>mkbitmap</title>
  </head>
  <body>
    <!-- No longer load `mkbitmap.js` here -->
    <script src="script.js" type="module"></script>
  </body>
</html>
// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  console.log(Module);
};

run();

ブラウザでアプリを開くと、Module オブジェクトが DevTools コンソールに記録されます。プロンプトは表示されなくなります。これは、起動時に mkbitmapmain() 関数が呼び出されなくなったためです。

白い画面の mkbitmap アプリ。DevTools コンソールに記録された Module オブジェクトが表示されています。

main 関数を手動で実行する

次のステップでは、Module.callMain() を実行して、mkbitmapmain() 関数を手動で呼び出します。callMain() 関数は引数の配列を受け取ります。この配列は、コマンドラインで渡すものを 1 つずつ照合します。コマンドラインで mkbitmap -v を実行する場合は、ブラウザで Module.callMain(['-v']) を呼び出します。これにより、mkbitmap のバージョン番号が DevTools コンソールに記録されます。

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  Module.callMain(['-v']);
};

run();

白い画面の mkbitmap アプリ。DevTools コンソールに記録された mkbitmap バージョン番号が表示されています。

標準出力をリダイレクトする

デフォルトの標準出力(stdout)はコンソールです。ただし、出力を変数に格納する関数など、別のものにリダイレクトすることは可能です。つまり、Module.print プロパティを設定すると、出力を HTML に追加できます。

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  let consoleOutput = 'Powered by ';
  const Module = await loadWASM({
    print: (text) => (consoleOutput += text),
  });
  Module.callMain(['-v']);
  document.body.textContent = consoleOutput;
};

run();

mkbitmap バージョン番号を表示する mkbitmap アプリ

入力ファイルをメモリ ファイル システムに取り込む

入力ファイルをメモリ ファイル システムに取り込むには、コマンドラインに mkbitmap filename と同等のものが必要です。このアプローチを理解するため、まず、mkbitmap がどのように入力を想定し、出力を生成するかの背景をいくつか説明します。

mkbitmap でサポートされている入力形式は、PNMPBMPGMPPM)と BMP です。出力形式は、ビットマップの場合は PBM、グレーマップの場合は PGM です。filename 引数が指定されている場合、mkbitmap はデフォルトで出力ファイルを作成します。その名前は、入力ファイル名の接尾辞を .pbm に変更することによって取得されます。たとえば、入力ファイル名が example.bmp の場合、出力ファイル名は example.pbm になります。

Emscripten は、ローカル ファイル システムをシミュレートする仮想ファイル システムを提供します。これにより、同期ファイル API を使用するネイティブ コードをほとんど、またはまったく変更せずにコンパイルして実行できます。mkbitmap が入力ファイルを filename コマンドライン引数として渡されたかのように読み取るには、Emscripten が提供する FS オブジェクトを使用する必要があります。

FS オブジェクトは、メモリ内ファイル システム(一般的に MEMFS と呼ばれます)を基盤としており、仮想ファイル システムにファイルを書き込むために使用する writeFile() 関数を備えています。次のコードサンプルに示すように、writeFile() を使用します。

ファイル書き込みオペレーションが機能したことを確認するには、パラメータ '/' を指定して FS オブジェクトの readdir() 関数を実行します。example.bmp と、常に自動的に作成されるデフォルト ファイルがいくつか表示されます。

バージョン番号を出力する以前の Module.callMain(['-v']) の呼び出しが削除されています。これは、Module.callMain() が一般的に 1 回だけの実行が想定される関数であるためです。

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
  Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
  console.log(Module.FS.readdir('/'));
};

run();

example.bmp を含むメモリ ファイル システム内のファイルの配列を表示する mkbitmap アプリ

最初の実際の実行

すべての準備が整ったら、Module.callMain(['example.bmp']) を実行して mkbitmap を実行します。MEMFS の '/' フォルダの内容をログに記録すると、新しく作成された example.pbm 出力ファイルが example.bmp 入力ファイルの横に表示されます。

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
  Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
  Module.callMain(['example.bmp']);
  console.log(Module.FS.readdir('/'));
};

run();

example.bmp と example.pbm を含むメモリ ファイル システム内のファイルの配列を示す mkbitmap アプリ

メモリ ファイル システムから出力ファイルを取得する

FS オブジェクトの readFile() 関数を使用すると、最後のステップで作成した example.pbm をメモリ ファイル システムから取得できます。この関数は Uint8Array を返します。これを File オブジェクトに変換し、ディスクに保存します。これは通常、ブラウザは直接ブラウザで表示する PBM ファイルをサポートしていないためです。(ファイルを保存するより洗練された方法もありますが、最も広くサポートされているのは動的に作成された <a download> を使用する方法です)。ファイルを保存したら、任意の画像ビューアで開くことができます。

// This is `script.js`.
import loadWASM from './mkbitmap.js';

const run = async () => {
  const Module = await loadWASM();
  const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
  Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
  Module.callMain(['example.bmp']);
  const output = Module.FS.readFile('example.pbm', { encoding: 'binary' });
  const file = new File([output], 'example.pbm', {
    type: 'image/x-portable-bitmap',
  });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(file);
  a.download = file.name;
  a.click();
};

run();

入力 .bmp ファイルと出力 .pbm ファイルのプレビューが表示された macOS Finder

インタラクティブな UI を追加する

この時点で、入力ファイルはハードコードされており、mkbitmapデフォルトのパラメータで実行されます。最後のステップでは、ユーザーが入力ファイルを動的に選択し、mkbitmap パラメータを微調整してから、選択したオプションでツールを実行できるようにします。

// Corresponds to `mkbitmap -o output.pbm input.bmp -s 8 -3 -f 4 -t 0.45`.
Module.callMain(['-o', 'output.pbm', 'input.bmp', '-s', '8', '-3', '-f', '4', '-t', '0.45']);

PBM 画像形式は特に解析が難しいわけではないため、JavaScript コードを用意すれば、出力画像のプレビューを表示することもできます。この方法については、以下の埋め込みのデモソースコードをご覧ください。

まとめ

これで、mkbitmap を WebAssembly にコンパイルし、ブラウザで動作させることができました。デッドエンドもあり、うまく機能するまで何度もツールをコンパイルしなければなりませんでしたが、上で書いたように、それは経験の一部です。行き詰まった場合は、StackOverflow の webassembly タグにも注意してください。それでは、コンパイルをお楽しみください。

謝辞

この記事は、Sam CleggRachel Andrew によってレビューされました。