將 mkbitmap 編譯為 WebAssembly

在「什麼是 WebAssembly 以及其來源?」一文中,我已說明我們如何開發出今天的 WebAssembly。本文將說明如何將現有的 C 程式 mkbitmap 編譯為 WebAssembly。這個範例比「Hello, World」範例複雜,因為它包含處理檔案、在 WebAssembly 和 JavaScript 之間進行通訊,以及在畫布上繪圖,但仍可控管,不會讓您感到不知所措。

本文適用對象為想瞭解 WebAssembly 的網頁程式開發人員,並逐步說明如何將 mkbitmap 之類的內容編譯為 WebAssembly。在此先提醒您,第一次執行時無法編譯應用程式或程式庫是正常現象,這也是為何下方某些步驟無法運作的原因,因此我必須回溯並以不同方式嘗試。這篇文章並未提供神奇的最終編譯指令,而是描述我實際的進度,包括一些挫折。

關於「mkbitmap

mkbitmap C 程式會讀取圖片,並依序套用下列一或多個作業:反轉、高通濾鏡、縮放和閾值。每個作業都可以個別控制,並開啟或關閉。mkbitmap 的主要用途是將彩色或灰階圖片轉換為適合其他程式輸入的格式,特別是追蹤程式 potrace,這是 SVGcode 的基礎。mkbitmap 是一種預處理工具,特別適合將掃描的線條圖 (例如卡通或手寫文字) 轉換為高解析度的雙層圖像。

您可以透過傳遞多個選項和一或多個檔案名稱來使用 mkbitmap。如需所有詳細資料,請參閱工具的說明文件頁面

$ mkbitmap [options] [filename...]
彩色卡通圖片。
原始圖片 (來源)。
經過預處理後,卡通圖片已轉換為灰階。
先縮放,再設定閾值: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,後者是本文的重點。您可以執行 mkbitmap --version 來驗證是否正常運作。以下是我的機器執行所有四個步驟的輸出內容,為了簡潔起見,我們已大幅精簡內容:

步驟 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 提供兩個簡單的 Shell 指令碼,可設定您的 Makefile 使用 emcc 做為 gcc 的即插即用替代項目。在大多數情況下,專案的其他目前建構系統都不會變更。

說明文件接著會繼續說明 (為了簡潔起見,我們稍微編輯了內容):

假設您通常使用下列指令建構:

./configure
make

如要使用 Emscripten 建構,請改用下列指令:

emconfigure ./configure
emmake make

因此,./configure 會變成 emconfigure ./configuremake 會變成 emmake 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

最後兩個看起來很有希望,因此將 cd 放入 src/ 目錄。另外還有兩個新的對應檔案: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.jsmkbitmap.wasm 檔案複製到名為 mkbitmap 的新目錄,然後建立 index.html HTML 範本檔案,以便載入 mkbitmap.js JavaScript 檔案。

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

啟動提供 mkbitmap 目錄的本機伺服器,並在瀏覽器中開啟該目錄。畫面上會顯示提示,要求您輸入內容。這符合預期,因為根據工具的 man 頁面「[i]如果未提供檔案名稱引數,mkbitmap 會充當篩選器,從標準輸入內容讀取」,對 Emscripten 來說,預設為 prompt()

mkbitmap 應用程式顯示提示,要求使用者輸入內容。

防止自動執行

如要停止 mkbitmap 立即執行,並讓它等待使用者輸入內容,您需要瞭解 Emscripten 的 Module 物件。Module 是一個全域 JavaScript 物件,其中的屬性會在 Emscripten 產生的程式碼執行期間的不同點上呼叫。您可以提供 Module 的實作項目,以控制程式碼的執行情形。Emscripten 應用程式啟動時,會查看 Module 物件上的值並套用。

mkbitmap 的情況下,請將 Module.noInitialRun 設為 true,以免在初次執行時顯示提示。建立名為 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 不會自動加入檔案系統支援的情況之一,因此您必須明確告知系統這麼做。也就是說,您需要按照先前所述的 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 資料夾。

修改 index.html,讓它只載入 ES 模組 script.js,然後從中匯入 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 物件記錄到開發人員工具控制台,且提示已消失,因為 mkbitmapmain() 函式不再在啟動時呼叫。

顯示白色畫面的 mkbitmap 應用程式,顯示記錄至開發人員工具控制台的模組物件。

手動執行主函式

接下來,請執行 Module.callMain() 手動呼叫 mkbitmapmain() 函式。callMain() 函式會使用引數陣列,這些引數會逐一比對您在指令列上傳遞的內容。如果您在指令列上執行 mkbitmap -v,則會在瀏覽器中呼叫 Module.callMain(['-v'])。這會將 mkbitmap 版本號碼記錄到開發人員工具控制台。

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

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

run();

顯示白色畫面的 mkbitmap 應用程式,其中 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 支援的輸入格式包括 PNM (PBMPGMPPM) 和 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() 通常只會執行一次。

// 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();

mkbitmap 應用程式顯示記憶體檔案系統中的陣列檔案,包括 example.bmp。

第一次實際執行

一切就緒後,請執行 Module.callMain(['example.bmp']) 來執行 mkbitmap。記錄 MEMFS '/' 資料夾的內容,您應該會在 example.bmp 輸入檔案旁看到新建立的 example.pbm 輸出檔案。

// 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();

mkbitmap 應用程式顯示記憶體檔案系統中的檔案陣列,包括 example.bmp 和 example.pbm。

從記憶體檔案系統取得輸出檔案

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();

macOS Finder 顯示輸入 .bmp 檔案和輸出 .pbm 檔案的預覽畫面。

新增互動式 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 共同審查。