将 mkbitmap 编译为 WebAssembly

什么是 WebAssembly?它来自哪里?部分,我解释了我们今天是如何完成 WebAssembly 的。在本文中,我将向您展示如何将现有的 C 程序 mkbitmap 编译为 WebAssembly。它比 hello world 示例更复杂,因为它涉及到处理文件、在 WebAssembly 和 JavaScript 平台之间进行通信,以及在画布上绘制,但它仍然易于管理,不会让您感到无所适从。

本文面向想要学习 WebAssembly 的 Web 开发者,并逐步介绍了如果您想将 mkbitmap 等代码编译为 WebAssembly,应如何继续操作。诚挚的警告是,在首次运行时无法让应用或库进行编译是完全正常的,这就是下述某些步骤最终不起作用的原因,因此我需要回溯,然后以不同的方式重试。本文并没有展示神奇的最终编译命令,就好像它是从空中消失一样,而是描述了我的实际进度(包括一些令人沮丧的内容)。

关于mkbitmap

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

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

最后两个命令看起来不错,因此请使用 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 没有文件名参数,那么 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 应用,其中显示了记录到开发者工具控制台的 Module 对象。

手动执行 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 审核。