Long after Word moved to the XML-based .docx, organizations still sit on
archives of the old binary .doc format, and sooner or later something
modern needs to read them. Most current parsing libraries understandably
focus on .docx; the old format, a container of OLE compound-document
streams, is the part nobody wants to touch.
I needed exactly that in a Go service: extract text and metadata from legacy
.doc files. The result is
go-catdoc, which runs a 1990s C
program inside the Go process, through WebAssembly.
The usual options for using C from Go
There’s a battle-tested tool for this already:
catdoc, a small C program
that has been reading .doc files since the nineties. The question is how to
use it from Go.
Shelling out to the catdoc binary means your deployment needs catdoc installed, in the right version, on every target machine and in every container image. The library stops being self-contained. Binding it with cgo works, but costs you painless cross-compilation and adds a C toolchain to every build environment, which is a lot of infrastructure for a small parsing dependency. And reimplementing the format in pure Go is off the table for me: the OLE compound document format plus the Word binary format is an enormous, quirk-ridden spec, and a rewrite would trail catdoc’s decades of accumulated edge cases for years.
Instead, I compiled catdoc itself to WebAssembly with Emscripten, embedded
the resulting binary in the Go package with go:embed, and executed it with
wazero, a WebAssembly runtime written in pure Go.
The whole compiled module is about 90 KB. The Go side embeds it, compiles it
once, and keeps the compiled module in a cache so repeated calls don’t pay
the startup cost again. Because wazero itself has no cgo anywhere, the
package remains a normal Go dependency: go get, cross-compile to any
platform Go supports, done. The C code runs sandboxed inside the process,
with no access to anything you don’t explicitly hand it.
Using it looks like any other Go library:
f, _ := os.Open("report.doc")
text, err := gocatdoc.GetTextFromFile(f)
Faking a filesystem
catdoc is a command-line program. It expects to open a file by path, and it
loads its character-set tables from files too. WebAssembly modules under WASI
see the filesystem you mount for them, so go-catdoc hands the module a fake
one: a minimal fs.FS implementation that wraps the caller’s io.ReadSeeker
and serves it as a file mounted at a fixed path, with the charset tables
embedded in the package and mounted alongside. catdoc opens what it thinks
are files on disk and actually reads from whatever reader you passed in,
which never has to touch disk at all.
Extracting the document metadata
Text extraction worked with stock catdoc, but I also needed document metadata: title, author, last author, subject, keywords, comments. That lives in a separate OLE stream called SummaryInformation, which catdoc didn’t expose. So the vendored catdoc source is lightly modified to read it, and to pull annotation authors out as well.
To make sense of the binary layout while doing that, I wrote an
ImHex pattern file that parses the
SummaryInformation object visually. It ships in the repository, so if you
ever need to poke at .doc internals yourself, that’s a decent starting
point.
When this pattern is the right call
Wrapping proven C in WebAssembly instead of rewriting it is something I’d reach for again, and not just for document formats. It fits when the existing C code is genuinely trusted, the format or protocol is too gnarly to reimplement economically, and you want a self-contained library rather than a deployment dependency. You get memory isolation as a bonus: a parser bug in the C corrupts its own sandbox and your process survives.
It’s the same approach I take to legacy modernization work in general: keep the code that has proven itself, and put a boundary you can test around it.