將 mkbitmap 編譯為 WebAssembly

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

本文適用於想瞭解 WebAssembly 的網頁程式開發人員,並逐步說明如何將 mkbitmap 等項目編譯為 WebAssembly。提醒您,第一次執行時無法編譯應用程式或程式庫是完全正常的,因此以下某些步驟可能無法順利完成,您可能需要回溯並以其他方式重試。這篇文章並未直接提供最終的編譯指令,而是描述我的實際進展,包括一些挫折。

關於「mkbitmap

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

如要使用 mkbitmap,請傳遞多個選項和一或多個檔案名稱。如需所有詳細資料,請參閱工具的 man 頁面

$ 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 的「建構專案」文件指出:

使用 Emscripten 建構大型專案非常簡單。Emscripten 提供兩個簡單的指令碼,可將 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

最後兩個看起來很有希望,因此請進入 src/ 目錄。cd現在還有兩個新的對應檔案,分別是 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.js 執行檔案 (執行 node mkbitmap.js --version),確認是否正常運作:

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

您已成功將 mkbitmap 編譯為 WebAssembly。現在,下一步是在瀏覽器中運作。

mkbitmap 透過瀏覽器中的 WebAssembly

mkbitmap.jsmkbitmap.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 目錄的本機伺服器,並在瀏覽器中開啟。畫面上會顯示要求輸入內容的提示。這是預期行為,因為根據工具的手冊頁面,「[i]f no filename arguments are given, then mkbitmap acts as a filter, reading from standard input」(如果未提供任何檔案名稱引數,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++ 程式碼確實會使用檔案,系統就會自動加入檔案系統支援。

很遺憾,Emscripten 不會自動為 mkbitmap 納入檔案系統支援,因此您必須明確告知。也就是說,您需要按照先前所述的 emconfigureemmake 步驟操作,並透過 CFLAGS 引數設定幾個額外的標記。下列旗標也可能對其他專案有所幫助。

此外,在這個特定案例中,您需要將 --host 標記設為 wasm32,向 configure 指令碼說明您要編譯 WebAssembly。

最終的 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 物件。

手動執行主要函式

下一步是執行 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。

首次實際執行

一切就緒後,請執行 mkbitmap,方法是執行 Module.callMain(['example.bmp'])。記錄 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();

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 審查。