将 mkbitmap 编译为 WebAssembly

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

本文面向希望学习 WebAssembly 的 Web 开发者,将逐步介绍如果您想将 mkbitmap 等内容编译为 WebAssembly,可以采取哪些措施。需要提醒的是,首次运行时无法编译应用或库是完全正常的,这也是下面所述的某些步骤最终无法正常运行的原因,因此我需要回过头来,换种方式再试一次。本文不会像天上掉馅饼一样展示神奇的最终编译命令,而是会描述我实际取得的进展,包括一些挫折。

关于mkbitmap

mkbitmap C 程序会读取图片,并按以下顺序对其应用一个或多个操作:反转、高通滤波、缩放和阈值处理。每个操作都可以单独控制和开启或关闭。mkbitmap 的主要用途是将彩色或灰度图片转换为适合作为其他程序(尤其是构成 SVGcode 基础的跟踪程序 potrace)输入的格式。作为预处理工具,mkbitmap 特别适用于将扫描的线条图形(例如漫画或手写文字)转换为高分辨率双级图像。

您可以通过向 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 的构建项目文档中指出:

使用 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

最后两个看起来很有希望,因此请 cdsrc/ 目录。现在,还有两个新的对应文件: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 目录的本地服务器,并在浏览器中打开该目录。您应该会看到一条提示您输入内容的提示。这是预期行为,因为根据该工具的 man 页面“[i]f no filename arguments are given, then mkbitmap acts as a filter, reading from standard input”,对于 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 应用,其中显示了记录到开发者工具控制台的模块对象。

手动执行主函数

下一步是通过运行 Module.callMain() 手动调用 mkbitmapmain() 函数。callMain() 函数接受一个实参数组,该数组与您在命令行上传递的实参一一对应。如果您要在命令行中运行 mkbitmap -v,则需要在浏览器中调用 Module.callMain(['-v'])。这会将 mkbitmap 版本号记录到 DevTools 控制台。

// 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 Finder,其中显示了输入 .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 审核。