This article was originally published on the Notepad Neo engineering blog. Notepad Neo is a free online notepad that runs entirely in the browser, with no account and no server, so every export has to be generated client-side.
The requirement was ordinary: an Export to Word button that produces a file Word actually opens, with the formatting intact, from an app with no server. The obvious answer is a library. The obvious answer costs somewhere between 200 KB and 500 KB minified, on a site whose entire JavaScript payload is smaller than that, for a feature most visitors never click.
So the question became how much of the format actually has to be implemented. The answer turned out to be less than expected in one direction and considerably more in another.
A .docx is a ZIP archive with a particular set of files in it. ZIP supports several compression methods and every real archiver uses deflate — but method 0, store, is also in the specification, means "the bytes are here verbatim", and is supported by every implementation that reads ZIP at all.
That removes the only genuinely hard dependency. The parts of a small document are a few kilobytes of XML; skipping deflate costs a little file size and avoids either shipping a compressor or depending on CompressionStream, which would have made the feature unavailable in older Safari.
What remains is byte layout. A ZIP file is three kinds of record written in a fixed order, each introduced by a four-byte magic number:
Diagram: The byte layout of a stored ZIP entry Every multi-byte field is little-endian. That is not a detail you can defer — a big-endian signature is simply not a ZIP file. (The interactive diagram is in the original article.)
The buffer sizing falls straight out of the record sizes, so the whole archive is one allocation and one DataView — no array concatenation, no growing:
Every write is view.setUint32(offset, value, true) — that third argument is little-endian, and it is on every single call.
The one algorithm that has to be implemented is the checksum. It is the standard table-driven CRC-32 with polynomial 0xEDB88320, built once at module load:
The >>> 0 at the end is not decoration. JavaScript's bitwise operators produce signed 32-bit integers, so without the unsigned shift the result can be negative and setUint32 writes the wrong bytes. It is the kind of thing that produces a file which is correct for most inputs and corrupt for some.
ZIP stores modification times in the MS-DOS packed format from 1980, and it has two consequences worth knowing before you spend an afternoon on an off-by-one: the year is stored as an offset from 1980, and the seconds field holds two-second units, so odd seconds do not survive the round trip.
Nothing in Word reads this field in a way a user notices, but writing something structurally invalid there is a free way to make a file look suspicious to a strict extractor.
Inside the archive, a .docx follows the Open Packaging Conventions. The rule that catches people is that nothing is discovered by convention — every part has to be declared by content type, and every reference between parts has to go through an explicit relationship file. Word does not guess.
Diagram: The seven parts of a minimal docx package Hyperlinks are the only part of this that is dynamic. Each link encountered during the DOM walk gets an rId, and the relationships file is generated afterwards from the accumulated list. (The interactive diagram is in the original article.)
Every part is a template string. There is no XML DOM serialiser involved anywhere in this pipeline — the parts are small, fully known, and easier to read as literal XML than as builder calls.
The exporter walks the live editor DOM read-only and asks getComputedStyle for every property, rather than parsing style attributes. That decision pays for itself three times over.
It resolves stylesheet defaults — an h1 styled font-size: 2em in the editor's CSS reports as 28px, and the exporter never needs to know the rule exists. It collapses the two ways a size can arrive (a stylesheet rule, or an inline span written by the font-size tool) into one code path. And it makes zoom free: zoom is implemented as transform: scale(), and a transform does not affect computed font metrics, so a document exported at 150% zoom is byte-identical to one exported at 100%.
