From C source to browser: Porting mkbitmap with Emscripten

mkbitmap is a C program that preprocesses images for tracing tools like potrace. It applies a configurable sequence of operations—inversion, highpass filtering, scaling, and thresholding—to convert color or grayscale images into bilevel format, which is especially useful for scanned line art and handwritten text. Getting this program to run in the browser requires working with files, bridging JavaScript and WebAssembly, and rendering to a canvas—more involved than a hello-world build, but still an approachable project for developers new to WebAssembly.

This walkthrough reflects a realistic compilation effort. Getting an existing program to build for WebAssembly often requires retries and adjustments, and the steps below include some detours taken along the way.

How mkbitmap works

mkbitmap processes input images through several optional stages controlled by command-line flags. Each operation—inversion, highpass filtering, scaling, and thresholding—can be enabled or disabled independently. The tool accepts one or more file names plus a set of options; the full behavior is documented in its man page:

$ mkbitmap [options] [filename...]

The program's primary role is to prepare images as input for potrace, which itself underlies projects like SVGcode.

Building locally first

Before attempting a WebAssembly port, it helps to build and run the tool natively to understand its behavior. The source is available from the project's website; the current release at the time of writing is potrace-1.16.tar.gz.

The standard build process follows the instructions in the package's INSTALL file:

  1. Run ./configure to prepare the build for your system. This step checks for available features and may take a while.
  2. Run make to compile the package.
  3. Optionally, run make check to execute any bundled self-tests.
  4. Run make install (typically with sudo) to install binaries and documentation when installing to system locations.

Following these steps produces two executables: potrace and mkbitmap. A successful build can be confirmed with mkbitmap --version. The output from these steps, trimmed for brevity, shows the expected flow from configuration through installation:

 $ ./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
$ 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'.
$ 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'.
$ 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'.

Finally, verifying the installed binary:

$ mkbitmap --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.

With a working native build, the next task is to reproduce the equivalent build pipeline for WebAssembly.

Porting the build to Emscripten

Emscripten compiles C and C++ to WebAssembly. Its documentation explains that existing project build systems can usually be reused with minimal changes, because Emscripten provides drop-in replacements for gcc. For projects using the standard configure and make flow, the commands become:

emconfigure ./configure
emmake make

The same procedure applies to mkbitmap's build. After cleaning any previous build artifacts, the wizard-driven steps translate directly:

$ make clean
Making clean in src
 rm -f potrace mkbitmap
test -z "" || rm -f
rm -rf .libs _libs
[…]
rm -f *.lo
$ 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
$ 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'.

If the build completes successfully, WebAssembly files appear in the project. Searching the tree locates them:

$ find . -name "*.wasm"
./a.wasm
./src/mkbitmap.wasm
./src/potrace.wasm

The output in src/ is most promising. Alongside compiled .wasm files, the directory now contains files named mkbitmap and potrace that have no file extension. Despite their names, these are actually JavaScript files generated by Emscripten—a fact made evident by inspecting them with 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)

Renaming the file to clarify its type—mv mkbitmap mkbitmap.js—makes the next step more intuitive. Testing the compiled output in Node.js confirms that the WebAssembly build works:

$ node mkbitmap.js --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.

At this point, the C program has successfully been ported to WebAssembly. The remaining step is to adapt it for use in a browser environment.

Running mkbitmap in the browser

Copy the generated mkbitmap.js and mkbitmap.wasm into a new mkbitmap directory, along with an index.html that loads the JavaScript file.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>mkbitmap</title>
  </head>
  <body>
    <script src="mkbitmap.js"></script>
  </body>
</html>

Serving this directory and opening it in the browser will show a prompt. This is expected: as the tool's man page explains, when no filename arguments are given, mkbitmap acts as a filter reading from standard input, which Emscripten maps to prompt() by default.

The mkbitmap app showing a prompt that asks for input.

Controlling startup

Emscripten's Module object controls how generated code runs. Setting Module.noInitialRun to true prevents the automatic execution that triggers the prompt. Place a script.js before the <script src="mkbitmap.js"></script> tag in index.html, with the following content:

var Module = {
  // Don't run main() at page load
  noInitialRun: true,
};

Reloading the app should no longer show the prompt.

A modular build with explicit file system support

File system APIs are not included in the output unless Emscripten detects that the C/C++ code needs them. mkbitmap is a case where the tool's file access is not recognized automatically, so file system support must be requested explicitly. Re-run the emconfigure and emmake steps with additional flags in CFLAGS:

  • -sFILESYSTEM=1 to include file system support.
  • -sEXPORTED_RUNTIME_METHODS=FS,callMain to expose Module.FS and Module.callMain.
  • -sMODULARIZE=1 and -sEXPORT_ES6 to emit a modern ES6 module.
  • -sINVOKE_RUN=0 to suppress the automatic initial run.

The --host flag must also be set to wasm32 so the configure script targets WebAssembly.

$ emconfigure ./configure --host=wasm32 CFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0'

Run emmake make again and copy the new output into the mkbitmap folder. Update index.html to load only the ES module script.js, which then imports 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();

In the browser, the Module object should be logged to the DevTools console, and the prompt no longer appears because main() is not called at startup.

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

Invoking main() manually

With Module.callMain(), you can run mkbitmap's main function with an array of command-line arguments. For example, Module.callMain(['-v']) prints the version number to the console, mirroring the mkbitmap -v command.

// 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.

Capturing standard output

Standard output defaults to the console. Redirect it by assigning the Module.print property to a function that stores the output, which you can then insert into the 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.

Feeding an input file

mkbitmap accepts PNM (PBM, PGM, PPM) and BMP input files. With a filename argument, it writes an output file named by replacing the input's extension with .pbm (or .pgm for graymaps).

Emscripten provides a virtual file system so native code that uses synchronous file APIs works unchanged. To supply an input file as a command-line argument, write it to MEMFS with FS.writeFile():

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

Verify the write by calling FS.readdir('/'); you'll see the input file among the default files Emscripten always creates.

Remove the earlier Module.callMain(['-v']) call, since callMain() is designed to run only once.

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

Running the tool

Now execute mkbitmap with Module.callMain(['example.bmp']). A listing of MEMFS's '/' directory should show the newly generated example.pbm alongside the input file.

// 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.

Retrieving the output

Use FS.readFile() to get the example.pbm out of MEMFS as a Uint8Array. Browsers generally don't display PBM files directly, so convert it to a File and trigger a download via a dynamically created <a download> element — the most widely supported approach, if not the most elegant. The saved file can be opened in any image viewer.

// 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.

Adding a user interface

The final step is to let the user choose an input file and adjust the tool's parameters before running mkbitmap with those settings.

// 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']);

Since PBM is a simple format, a little JavaScript can also produce an in-page preview of the output. The source code for the embedded demo demonstrates one such approach.