将 mkbitmap 编译为 WebAssembly

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

本文是为想要学习 WebAssembly 的 Web 开发者编写的,并逐步展示了如果您想将像 mkbitmap 这样的程序编译为 WebAssembly,您可能会如何进行。作为一项公平的警告,在第一次运行时无法编译应用程序或库是完全正常的,这就是为什么下面描述的某些步骤最终没有奏效,所以我需要回溯并尝试其他方法。本文没有展示神奇的最终编译命令,仿佛它是从天而降,而是描述了我的实际进展,包括一些挫折。

关于 mkbitmap

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

您可以通过传递一些选项和一个或多个文件名来使用 mkbitmap。有关所有详细信息,请参阅该工具的 man 页面

$ mkbitmap [options] [filename...]
Cartoon image in color.
原始图像(来源)。
Cartoon image converted to grayscale after preprocessing.
先缩放,然后阈值化: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 拥有的前缀中时,建议将软件包配置并构建为常规用户,并且只有 make install 阶段以 root 权限执行。

按照这些步骤,您应该最终得到两个可执行文件,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 将 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 目录提供服务,并在浏览器中打开它。您应该看到一个提示,要求您输入。这是预期的,因为根据该工具的 man 页面“[i]如果没有给出文件名参数,则 mkbitmap 充当过滤器,从标准输入读取”,对于 Emscripten,默认情况下是 prompt()

The mkbitmap app showing a prompt that asks for input.

阻止自动执行

要阻止 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,
};

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

要为应用程序提供输入,您可以使用 Emscripten 的 文件系统 支持在 Module.FS 中。文档的包含文件系统支持部分指出

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();

现在在浏览器中打开应用程序时,您应该在 DevTools 控制台中看到记录的 Module 对象,并且提示已消失,因为 mkbitmapmain() 函数在启动时不再被调用。

The mkbitmap app with a white screen, showing the Module object logged to the DevTools console.

手动执行 main 函数

下一步是手动调用 mkbitmapmain() 函数,方法是运行 Module.callMain()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();

The mkbitmap app with a white screen, showing the mkbitmap version number logged to the DevTools console.

重定向标准输出

默认情况下,标准输出 (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();

The mkbitmap app showing the mkbitmap version number.

将输入文件放入内存文件系统

要将输入文件放入内存文件系统,您需要等效于命令行上的 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();

The mkbitmap app showing an array of files in the memory file system, including example.bmp.

首次实际执行

一切就绪后,通过运行 Module.callMain(['example.bmp']) 来执行 mkbitmap。记录 MEMFS 的 '/' 文件夹的内容,您应该看到新创建的 example.pbm 输出文件与 example.bmp 输入文件并排。

// 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();

The mkbitmap app showing an array of files in the memory file system, including example.bmp and 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 with a preview of the input .bmp file and the output .pbm file.

添加交互式 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 审阅。