将 mkbitmap 编译为 WebAssembly

什么是 WebAssembly,它从何而来?一文中,我介绍了我们是如何得到今天的 WebAssembly 的。在本文中,我将展示如何将现有 C 程序 mkbitmap 编译为 WebAssembly。它比 hello world 示例更复杂,因为它涉及处理文件、在 WebAssembly 和 JavaScript 之间进行通信,以及绘制到画布,但仍然足够简单,不会让您感到不知所措。

本文面向想要学习 WebAssembly 的 Web 开发者,并逐步展示了如何将 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

最后两个看起来很有希望,因此 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.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 目录的本地服务器,并在浏览器中打开该目录。您应该会看到一个提示,要求您输入内容。这是预期行为,因为根据该工具的 man 页面“如果未提供任何文件名实参,则 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,
};

创建具有更多 build 标志的模块化 build

如需为应用提供输入,您可以在 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 应用,屏幕为白色,显示记录到开发者工具控制台的模块对象。

手动执行 main 函数

下一步是通过运行 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 支持的输入格式为 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() 是一个通常只运行一次的函数。

// 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 访达,其中显示了输入 .bmp 文件和输出 .pbm 文件的预览。

添加互动式界面

到目前为止,输入文件是硬编码的,并且 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 审核。