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:
- Run
./configureto prepare the build for your system. This step checks for available features and may take a while. - Run
maketo compile the package. - Optionally, run
make checkto execute any bundled self-tests. - Run
make install(typically withsudo) 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.

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=1to include file system support.-sEXPORTED_RUNTIME_METHODS=FS,callMainto exposeModule.FSandModule.callMain.-sMODULARIZE=1and-sEXPORT_ES6to emit a modern ES6 module.-sINVOKE_RUN=0to 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.

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

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

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.

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

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

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.



