Running catdoc in the browser with WebAssembly

A while back I wrote about go-catdoc, which reads legacy .doc files from Go by running the C catdoc tool as WebAssembly through wazero. That library also powers a free in-browser .doc extractor on this site: drop an old Word file, read its text and hidden metadata, with nothing uploaded.

The first version of that browser tool worked, but it was embarrassingly heavy: 7.2 MB, and almost none of it was the parser. Cutting it down came from one realization: the browser was already doing the job I was shipping a whole runtime to do.

A WebAssembly runtime inside a WebAssembly runtime

go-catdoc is a Go library, so the obvious way into a browser is Go’s own GOOS=js GOARCH=wasm target: compile the package, load it with Go’s wasm_exec.js glue, call it from JavaScript. That is what the first version did, and it worked on the first try.

It also made no sense once I looked at what was actually running. catdoc is compiled to a roughly 90 KB WebAssembly module. go-catdoc runs that module with wazero, a WebAssembly runtime written in Go. Compile all of that to wasm for the browser and you get the browser’s WebAssembly engine, running the Go runtime as wasm, running wazero (now also wasm), interpreting the 90 KB catdoc module. A WebAssembly runtime, inside a WebAssembly runtime. The download came to 7.2 MB, most of it the Go runtime and wazero, there to host a module the browser could run on its own.

The browser is a WebAssembly runtime. Hand it the catdoc module directly and both wazero and the Go runtime disappear.

What “directly” costs you

catdoc’s module is a standalone WASI reactor. Unlike a command, which runs main once and exits, a reactor is initialized once and then kept around to have its exported functions called, which is exactly what a page wants. Running it without wazero means supplying what wazero used to: the WASI system calls the module imports, and a filesystem for it to read.

The module imports sixteen WASI preview1 functions (path_open, fd_read and fd_seek, fd_write for stdout, clock_time_get, plus the usual directory and environment stubs) and one Emscripten memory-growth hook. wazero implements all of WASI; I only needed a read-only slice of it, which is a couple hundred lines of JavaScript. It goes in as a plain import object:

const wasi = {
  fd_read(fd, iovs, n, nreadPtr) { /* ... */ },
  fd_seek(fd, offset, whence, newPtr) { /* ... */ },
  path_open(dirfd, /* ... */ fdPtr) { /* ... */ },
  fd_write(fd, iovs, n, nwrittenPtr) { /* capture stdout */ },
  // twelve more
};
// module is already compiled, so instantiate resolves to the instance itself
const instance = await WebAssembly.instantiate(module, {
  wasi_snapshot_preview1: wasi,
  env: { emscripten_notify_memory_growth() {} },
});
instance.exports._initialize();
instance.exports.get_text();      // output arrives on stdout

Faking a filesystem, again

The Go library solved file access with a fake fs.FS. In the browser I do the same thing with a Map. catdoc opens its input by path and loads character-set tables from files, so the shim mounts the picked file at a fixed path and the charset tables under /charsets/, all in memory. path_open looks the path up in the map, fd_read and fd_seek walk an offset through the bytes, and fd_write to file descriptor 1 collects stdout. catdoc opens what it believes are files on disk and reads from JavaScript objects that never touch one.

The charset tables are the one bulky part. catdoc ships a directory of them for converting between code pages, and text extraction needs them (the metadata functions convert nothing, so they work without a single table). Rather than fetch dozens of small files, I pack them into one bundle at build time and unpack it into the map once, on first use.

The bug that only broke the text

The first end-to-end run instantiated cleanly and returned every metadata field correctly: title, author, subject, keywords. The extracted text came back empty.

Metadata working while text failed was the clue. Text extraction is the part that reads the charset tables, so the tables were the suspect, and they were: every read of a charset file returned zero bytes. The cause is a detail of how wasi-libc does buffered input. When catdoc’s fscanf reads a table, wasi-libc hands fd_read two buffers in one call: a leading zero-length one, then the real one. My first fd_read saw the zero-length buffer, read zero bytes into it, and treated that as end-of-file:

// wrong: a zero-length buffer is not EOF
if (bytesRead === 0) break;

So it stopped before the real buffer ever got filled. Every table came back empty, text conversion had nothing to work with, and the metadata path, which never reads a table, was unaffected. The fix is to skip empty buffers instead of ending on them, and stop only at a genuine end of file:

for (let i = 0; i < n; i++) {
  const len = view.getUint32(iovs + i * 8 + 4, true);
  if (len === 0) continue;               // skip, don't treat as EOF
  const remaining = file.bytes.length - file.off;
  if (remaining <= 0) break;             // real EOF
  // copy min(len, remaining) bytes into the buffer
}

After that, all nine exported functions matched the go-catdoc reference output exactly.

The result

The tool now fetches a 90 KB catdoc module and a 480 KB charset bundle, about 570 KB against the old 7.2 MB. More than ten times smaller, and the Go runtime, wazero, and wasm_exec.js are gone. It runs under the site’s strict Content Security Policy with only wasm-unsafe-eval added on the tool pages, which permits compiling and instantiating WebAssembly but not eval or new Function. The parsing still happens entirely on the visitor’s machine.

When to skip the runtime

Shipping a language’s wasm runtime to the browser is the right call when you are moving real application code there, Go or otherwise, and want its standard library and ecosystem to come along. It is the wrong call when the thing you actually need is one standalone WASI module, and the language runtime is only there to host it. The browser already hosts WebAssembly. A reactor module with a handful of WASI imports can run on a page directly, and you write the imports it needs rather than shipping a runtime that implements all of them.

It is the same instinct as the Go version: keep the proven C, put a boundary you can test around it, and carry nothing more than that boundary needs.

Related service: Performance-critical engineering , Web, API and backend development

Working on something similar?

Tell me what you're trying to build. I'll let you know honestly whether I'm a good fit.

Get in touch
Start a conversation