將 mkbitmap 編譯為 WebAssembly

在「什麼是 WebAssembly 以及其來源?」一節中,我解釋我們今天的 WebAssembly 如何辦到。本文將說明我將現有 C 程式 mkbitmap 編譯至 WebAssembly 的方法。這比 hello World 範例更加複雜,因為其中包含處理檔案、在 WebAssembly 與 JavaScript 區域之間通訊,以及繪圖到畫布上,但可讓您管理得夠大,不會讓您感到疲乏。

本文旨在協助想要瞭解 WebAssembly 的網頁開發人員,並逐步說明如何將 mkbitmap 這類程式碼編譯到 WebAssembly。提醒您,如果應用程式或程式庫在首次執行時未進行編譯,是完全正常的行為,因此以下所述的某些步驟無法順利運作,因此我必須反向追蹤,然後以不同的方式再試一次。這篇文章並未呈現出功能從空中掉落的神奇最終編譯指令,而是描述我的實際進度,其中包含一些令人困擾。

關於「mkbitmap

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

您可以傳遞多個選項和一或多個檔案名稱,藉此使用 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 擁有的前置字串時,建議套件將套件設為一般使用者並建構,且只以 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 ./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 將 JavaScript 檔案重新命名為 mkbitmap.js (如有需要,請分別呼叫 mv potrace potrace.js)。 現在,我們要在指令列中使用 Node.js 執行檔案,然後執行 node mkbitmap.js --version,確認它是否有效:

$ 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 會決定是否自動納入檔案系統支援。許多程式都不需要檔案,而且檔案系統支援規模也過小,因此在無法理解原因時,Escripten 會避免加入這類檔案。這表示如果您的 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。函式會傳回您轉換為 File 物件並儲存至磁碟的 Uint8Array,因為瀏覽器通常不支援透過瀏覽器直接檢視 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 審查過。