將 mkbitmap 編譯為 WebAssembly

在「什麼是 WebAssembly?它的起源為何?」中,剛剛談到我們今天遇到的 WebAssembly 是怎麼辦到的,在這篇文章中,我們會示範將現有 C 程式 mkbitmap 編譯為 WebAssembly 的方法。這個例子比「hello world」範例還要複雜,因為其中包括處理檔案、在 WebAssembly 和 JavaScript 之間進行通訊,以及繪圖到畫布上。不過,雖然程式碼還是能管理,不會讓您不知所措。

本文是為想要學習 WebAssembly 的網頁程式開發人員所撰寫,並逐步說明如何將類似 mkbitmap 編譯到 WebAssembly 以繼續。不過,雖然在首次執行時,無法取得某個應用程式或程式庫進行編譯,這是很正常的現象,導致以下某些步驟最終無法運作,所以我必須反向操作,再嘗試其他方法。這篇文章並未以魔法最終編譯指令的方式,呈現從天空中掉落的樣子,而是描述我實際進展的情況,其中有一些困擾。

關於「mkbitmap

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

您可以使用 mkbitmap 來傳遞多個選項和一或多個檔案名稱。詳情請參閱這項工具的手冊

$ mkbitmap [options] [filename...]
彩色卡通圖片。
原始圖片 (「來源」)。
經過預先處理後,卡通圖片轉換成灰階。
先完成資源調度,然後為以下門檻:mkbitmap -f 2 -s 2 -t 0.48 (來源)。

取得程式碼

第一步是取得 mkbitmap 的原始碼。您可以在專案的網站中找到這組 ID。在撰寫本文時,potrace-1.16.tar.gz 是最新版本。

在本機編譯及安裝

下一步是在本機編譯並安裝這項工具,以熟悉運作方式。INSTALL 檔案包含下列操作說明:

  1. cd 設為包含套件原始碼的目錄,並輸入 ./configure 以設定系統的套件。

    執行 configure 可能需要一些時間。執行期間會輸出一些訊息,告知正在檢查哪些功能。

  2. 輸入 make 編譯套件。

  3. 視需要輸入 make check 來執行套件隨附的任何自我測試 (通常是使用剛建構的已解除安裝二進位檔)。

  4. 輸入 make install 來安裝程式、任何資料檔案與說明文件。安裝至根層級擁有的前置字串時,建議將套件設為一般使用者並進行建構,且只有以 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 提供兩個簡單的指令碼,可讓你將 makefile 設為使用 emcc 取代 gcc,在多數情況下,專案目前的建構系統保持不變。

說明文件包括 (為求簡潔,經過略做修改):

試想您通常使用下列指令建構的情況:

./configure
make

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

emconfigure ./configure
emmake make

因此,./configure 會變為 emconfigure ./configure,而 make 會變為 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 的新目錄,然後建立可載入 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]沒有指定檔案名稱引數,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,告知 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。

首次實際執行

完成所有設定後,請執行 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();

可預覽輸入 .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 ClegRachel Andrew