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.
The ZIP does not need to be compressed
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:
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:
// 30 bytes local header + name + data per entry; 46 + name per central entry; 22 EOCD.
const localSize = records.reduce((n, r) => n + 30 + r.name.length + r.data.length, 0);
const centralSize = records.reduce((n, r) => n + 46 + r.name.length, 0);
const out = new Uint8Array(localSize + centralSize + 22);
const view = new DataView(out.buffer);
Every write is view.setUint32(offset, value, true) — that third argument is
little-endian, and it is on every single call.
CRC-32, in twelve lines
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:
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(data: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
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.
DOS timestamps, which are stranger than they need to be
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.
function dosDateTime(d: Date): { date: number; time: number } {
return {
date: (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
};
}
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.
Seven parts, and every relationship spelled out
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.
rId, and the relationships file is generated afterwards from the accumulated list.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.
Read formatting from computed style, not from the markup
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%.
There is one place where computed style is actively misleading, and it is worth knowing about
because the bug it produces is subtle. Text decorations do not inherit the way font weight does.
A <b> nested inside a <u> reports
text-decoration: none for itself, even though it is visibly underlined on screen,
because the underline is painted by the ancestor. So decorations have to be OR-ed down the walk
rather than read fresh at each node:
p.underline = inherited.underline || tag === 'U' || deco.includes('underline');
p.strike = inherited.strike || tag === 'S' || tag === 'STRIKE' || deco.includes('line-through');
Units, and where the magic numbers come from
OOXML measures in several units at once, none of them pixels. Each conversion is a single constant, and each constant has a derivation worth writing down once:
OOXML child order is not advisory
This is the part that costs the most time if you have not met it before. The schema for
w:pPr and w:rPr is a sequence, not a set. The children have a
defined order, and Word validates it. Put w:bidi after w:ind instead of
before it and the file does not render slightly wrong — it fails to open, with a dialog that says
the content is unreadable and offers to recover it.
Every character property has a complex-script twin
Word keeps two parallel sets of character properties: one for Latin text and one for complex
scripts — Arabic, Hebrew, Thaana, Syriac and the Indic scripts. A run marked
<w:rtl/> is read as complex-script text, and Word then looks up its font, size
and weight in the complex-script set. If you only wrote the Latin set, that run renders in
Word's default complex-script font at its default size, ignoring everything you specified.
So bold is <w:b/><w:bCs/>. Italic is
<w:i/><w:iCs/>. Size is
<w:sz/><w:szCs/>. Font is one element with three attributes.
function rPrXml(p: RunProps, hyperlink = false, rtl = false): string {
const parts: string[] = [];
if (hyperlink) parts.push('<w:rStyle w:val="Hyperlink"/>');
if (p.font) {
const f = escapeXml(p.font);
parts.push(`<w:rFonts w:ascii="${f}" w:hAnsi="${f}" w:cs="${f}"/>`);
}
if (p.bold) parts.push('<w:b/><w:bCs/>');
if (p.italic) parts.push('<w:i/><w:iCs/>');
if (p.strike) parts.push('<w:strike/>');
if (p.color) parts.push(`<w:color w:val="${p.color}"/>`);
if (p.sizeHalfPts) parts.push(`<w:sz w:val="${p.sizeHalfPts}"/><w:szCs w:val="${p.sizeHalfPts}"/>`);
if (p.underline) parts.push('<w:u w:val="single"/>');
if (p.shading) parts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${p.shading}"/>`);
if (rtl) parts.push('<w:rtl/>');
return parts.length ? `<w:rPr>${parts.join('')}</w:rPr>` : '';
}
Direction has to be stated, never inferred
Word does not run the Unicode first-strong rule on your text. If you want a paragraph laid out right-to-left, you say so. There are three separate elements, at three different scopes, and they do different jobs:
| Element | Scope | What it does |
|---|---|---|
<w:bidi/> |
w:pPr |
Flips the paragraph's base direction: indentation, alignment, tab measurement and text flow. |
<w:rtl/> |
w:rPr |
Marks one run as complex-script, setting its reading order and switching it to the Cs property set. |
<w:bidiVisual/> |
w:tblPr |
Reverses column order so the first column is on the right. |
Emit w:bidi without w:rtl on the runs and the text lands in the right
place with its characters reading backwards. Emit w:rtl without w:bidi
and the characters read correctly inside a paragraph that is still indented and aligned from the
left. You need both, and they are specified in different sections of ECMA-376 — §17.3.1.6 and
§17.3.2.30.
The useful part is that the exporter does not reimplement the detection. It imports the same
detectDirection the on-screen editor uses and applies it per run:
// First-strong, matching how the editor decides paragraph direction, so a
// mostly-Latin run with an Arabic word in it is not flipped wholesale.
const rPr = rPrXml(props, hyperlink, detectDirection(cleaned) === 'rtl');
One implementation, two consumers. The file cannot disagree with the screen, because there is
nothing for it to disagree with. The screen side of that is in
why dir="auto" breaks a
mixed-language editor.
w:jc is physical, which changes when you emit it
CSS alignment has logical values — start and end resolve against the
element's direction. Word's w:jc does not: left means the left of the
page regardless of which way the paragraph runs.
So the exporter cannot translate CSS alignment straight through. Omitting w:jc
entirely leaves a paragraph on its start edge, which w:bidi has already moved to the
right — which is exactly what an unaligned RTL paragraph should do. An explicit
w:jc w:val="left" would drag it back to the left and undo the flip.
// w:jc left/right are physical in Word. Omitting it leaves the paragraph on
// its start edge, which w:bidi has already moved to the right — so an RTL
// paragraph only needs a w:jc when the user picked an alignment explicitly.
if (align && (align !== 'left' || rtl)) pr.push(`<w:jc w:val="${align}"/>`);
The same physical-versus-logical split applies to indentation:
w:ind w:left becomes w:ind w:right on an RTL paragraph, and a
blockquote's border moves from w:left to w:right, because in both cases
what the user meant was "the edge the text starts from".
Three ways to make the file unparseable
Whitespace that collapses across element boundaries
HTML collapses runs of whitespace, and it collapses them across element boundaries — the space
after </b> and the space before the next word are one space on screen. XML does
not collapse anything, and a literal newline inside <w:t> is a character Word
will render.
So the walk carries a small mutable flag — "are we at the start of a paragraph?" — through every run, collapses each text node against it, and trims a leading space at paragraph start. The flag is replaced rather than mutated when a new paragraph begins, so nested inline elements share one view of the whitespace state without leaking it across blocks.
Characters XML 1.0 does not allow
XML 1.0 forbids most control characters outright — there is no escape sequence for them, and a
raw 0x0B in a text node makes the part unparseable no matter how it is encoded.
Pasted content from PDFs and terminals contains them more often than you would expect.
The sanitiser drops those, and it drops one more character on purpose:
if (code === 0x200b) continue; // zero-width space from applyFontSize
U+200B is not illegal in XML — it is dropped because the editor injects it. Setting a font size with nothing selected inserts a zero-width space to keep the empty span alive, which is explained here. It is invisible on screen, invisible in the saved HTML, and would otherwise end up in the exported file where it serves no purpose at all.
Highlight markup leaking out of Find & Replace
Find & Replace wraps matches in <mark> elements to highlight them. Those are
transient UI, not content — but they are real DOM nodes in the editor, and a walk that reads
computed background colour will faithfully export a yellow highlight behind every search hit.
So <mark> is excluded from shading extraction explicitly. Notably the same leak
has to be blocked independently in three places: the DOCX walk, the PDF sandbox, and the autosave
path — which strips the marks before dispatching its change event so highlight markup is never
persisted into the tab in the first place.
A <w:tc> must contain at least one <w:p>. An empty table
cell is not an empty element — it is a cell containing an empty paragraph.
An empty <w:p/> has to be appended after every table, or two adjacent tables
merge into one when Word opens the file.
Bullets live in a private-use area
Word's stock list glyphs are not Unicode bullet characters. They are glyphs from the Symbol and Wingdings fonts, addressed at code points in the private-use area — which means the character you need cannot be typed, pasted, or reliably copied out of a reference document without a font substitution mangling it on the way.
// Word's stock bullet glyphs live in the Symbol and Wingdings private-use
// range, so they are built from char codes rather than pasted literals.
const BULLETS = [
{ text: String.fromCharCode(0xf0b7), font: 'Symbol' }, // filled round
{ text: 'o', font: 'Courier New' }, // hollow round
{ text: String.fromCharCode(0xf0a7), font: 'Wingdings' }, // filled square
];
Constructing them from char codes is not a style preference. A pasted U+F0B7 in a
source file is at the mercy of every editor, linter and build step it passes through, and the
failure is silent — you get a different bullet, or a replacement box, in the exported file only.
Was it worth it?
For this app, yes, but the reasoning is specific rather than general.
The whole DOCX pipeline is three files: 114 lines of ZIP writer, 472 lines of OOXML emitter, and 199 lines of package assembly. That is under 800 lines against 200–500 KB of dependency, and none of it is code that changes — the ZIP format is frozen and ECMA-376 has been stable for well over a decade. It is not the kind of dependency that pays rent in security patches and breaking upgrades.
It also matters that this is a write-only problem. The exporter emits documents; it never
reads them. Parsing arbitrary .docx files written by Word, Google Docs, LibreOffice
and twenty years of other tools is a completely different scale of problem, and there the answer
is unambiguously "use the library".
What it does not do is worth stating plainly. There is no image support — no
w:drawing, no media parts, no relationships to binary content. No footnotes, no
comments, no tracked changes, no headers and footers, no section breaks, no theme or font-table
parts. Tables are emitted at a fixed 100% width with no column sizing. For a notepad's export
button that is the right scope; for anything approaching a document editor it is not.
-
Use compression method
0. It is valid ZIP, and it removes the only dependency the problem genuinely has. -
Compute sizes up front and write into one
Uint8Array. Every field is little-endian. -
Read formatting from
getComputedStyle, not from markup — but OR text decorations down the tree, because they do not inherit. -
Respect
w:pPrandw:rPrchild order. The failure mode is an unopenable file with an error message that names nothing. -
Write the
Cstwin of every character property, or complex-script runs ignore your formatting entirely. -
State direction explicitly with
w:bidiandw:rtl, and remember thatw:jcandw:indare physical while CSS is logical. - Strip control characters, U+200B, and any transient UI markup before it reaches the file.