将 mkbitmap 编译为 WebAssembly

什么是 WebAssembly?它从何而来?中,我解释了我们今天是如何完成 WebAssembly 的在本文中,我将向您展示将现有 C 程序 mkbitmap 编译到 WebAssembly 的方法。该示例比 hello world 示例更复杂,因为它涉及处理文件、在 WebAssembly 和 JavaScript 土地之间进行通信以及绘制到画布,但它仍然易于管理,不会让您应接不暇。

本文面向想要学习 WebAssembly 的 Web 开发者,并逐步介绍了如何将 mkbitmap 等代码编译为 WebAssembly。友情提醒,首次运行时未获得应用或库进行编译是完全正常的,这就是下文介绍的一些步骤最终不起作用的原因,因此我需要回溯,以不同的方式重试。这篇文章并没有像从天而降一样神奇的最终编译命令,而是描述了我的实际进度,包括一些令人沮丧的事。

关于mkbitmap

mkbitmap C 程序会读取图像,并按以下顺序对图像应用以下一项或多项操作:反转、高通滤波、缩放和阈值。每项操作都可以单独控制和开启或关闭。mkbitmap 的主要用途是将彩色或灰度图片转换为适合其他程序输入的格式,尤其是构成 SVGcode 基础的跟踪程序 potrace。作为一个预处理工具,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 ./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]f no filename arguments are provided, then mkbitmap is a filter, please read 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 应用,显示了已记录到开发者工具控制台的模块对象。

手动执行 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();

显示内存文件系统(包括 example.bmp)中的文件数组的 mkbitmap 应用。

首次实际执行

一切就绪后,通过运行 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();

显示内存文件系统(包括 example.bmp 和 example.pbm)中的文件数组的 mkbitmap 应用。

从内存文件系统中获取输出文件

借助 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。

添加交互式界面

此时,输入文件已经过硬编码,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 撰写。