A great many “export to Word” buttons produce HTML with a .doc extension. Word opens it, so it ships. It also nags on save, round-trips badly, and is blocked outright by some corporate policies that quite reasonably object to HTML wearing a document extension.
Producing a real .docx is more work, but not as much as its reputation suggests. It is a ZIP of XML with a relationship graph bolted on, and modern browsers can write one with no library at all.
The package
A minimal document Word will open needs six parts:
[Content_Types].xml declares the MIME type of every part
_rels/.rels points at the main document
word/document.xml the content
word/_rels/document.xml.rels links, images, and the other parts
word/styles.xml heading styles etc.
word/numbering.xml list definitionsThen word/footnotes.xml, word/comments.xml, word/media/* and docProps/* as needed. Compression is the same primitive the import side already uses, in the other direction:
const cs = new CompressionStream("deflate-raw"); // DecompressionStream on the way in
cs.writable.getWriter().write(bytes);
const compressed = new Uint8Array(await new Response(cs.readable).arrayBuffer());You write the local file headers, the central directory and the end-of-central-directory record yourself. That is about sixty lines, including a CRC-32 table. One detail worth getting right: set general-purpose bit 11in both headers to declare entry names as UTF-8, or a non-ASCII filename is read in the archive’s legacy code page.
Everything interesting is indirect
The single most important structural idea in OOXML is that references do not appear where you expect them. A hyperlink does not contain its URL:
<!-- word/document.xml -->
<w:hyperlink r:id="rId7"><w:r><w:t>our documentation</w:t></w:r></w:hyperlink>
<!-- word/_rels/document.xml.rels -->
<Relationship Id="rId7"
Type=".../hyperlink"
Target="https://example.com/docs"
TargetMode="External"/>Images work the same way (r:embed pointing at word/media/image1.png), as do footnotes, comments, headers and footers. This is why so many naive Word importers drop every URL in a document while keeping the link text: they read document.xml and never open the rels part.
Writing has the matching hazard. A dangling r:id — referenced in the document, absent from the rels — is one of the most common reasons Word declares a hand-built file corrupt, and it says nothing about which id. It is worth asserting on directly:
const used = [...doc.matchAll(/r:(?:id|embed)="([^"]+)"/g)].map(m => m[1]);
const declared = [...rels.matchAll(/Id="([^"]+)"/g)].map(m => m[1]);
assert(used.every(id => declared.includes(id)));document.xml cannot tell a bullet from a number
Both list types are stored identically in the body:
<w:p>
<w:pPr><w:numPr>
<w:ilvl w:val="0"/> <!-- nesting depth -->
<w:numId w:val="3"/> <!-- which list -->
</w:numPr></w:pPr>
<w:r><w:t>An item</w:t></w:r>
</w:p>Nothing there says bullet or number. That lives in numbering.xml, reached through numId, in a w:numFmt per level. Skip that part on import and every ordered list in the document collapses to bullets. Skip it on export and Word has no definition to apply.
A small thing that produces a visible bug: give each list its own numId. Share one and two adjacent ordered lists continue each other’s numbering — the second starts at 5 because the first ended at 4.
Half-points, twips, EMUs
OOXML measures different things in different units, and one of them bites hard:
<w:sz w:val="22"/> font size in HALF-POINTS -> 11pt
<w:ind w:left="720"/> indent in twips (1/1440 in) -> 0.5in
<wp:extent cx="914400" cy="914400"/> image size in EMUs (914400/in)Write points into w:sz and the entire document renders at half its intended size. It is such a uniform error that it reads as a styling choice rather than a bug, which is exactly why it survives review. The same trap exists on import in the other direction: read w:sz as points and every imported document is twice its intended size.
One control character rejects the whole file
XML 1.0 permits only tab, newline and carriage return below 0x20. Word does not skip an illegal character, or substitute it, or warn. It refuses to open the document, and the message does not say why.
Editor content collects these more often than you would think — pasted from terminals, PDFs, or a database column that once held a NUL. So escape on the way out:
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")And write that character class as escapes. We first typed it with the literal characters. The code was correct and the file became unreadable to tooling — grep reported the plugin as a binary file, so no search matched it and no diff was reviewable. Six distinct control codepoints were sitting invisibly in the source. A one-line scanner in CI is cheap insurance:
[...src].filter(c => {
const n = c.codePointAt(0);
return n < 32 && n !== 9 && n !== 10 && n !== 13;
})A few more that produce “corrupt file”
- An empty table cell. A
<w:tc>must contain at least one paragraph. Emit<w:p/>when the cell is empty. - Footnote ids 0 and -1. Reserved for the separator rules Word draws above notes. They are not content, but
footnotes.xmlis malformed without them — and on import they must be skipped or you get two empty notes at the top of every document. - Deleted text is
w:delText, notw:t. A tracked deletion written asw:tis not a deletion; read asw:tit imports as an empty run. xml:space="preserve"on every run.Without it, leading and trailing spaces are collapsed and words run together across formatting boundaries — “theboldword”.sectPrcloses the body. Page size, orientation and margins go last, insidew:body, after all content.
Make the exporter emit what your importer reads
If you have both halves, this is the design decision that pays for itself. Emit precisely the conventions your reader already understands — the same hyperlink relationships, the same numbering ids, the same footnote and comment parts, the same revision markup.
That turns two features which happen to share a file extension into a loop you can assert on. Export a document, re-import it into a second editor instance, and compare:
const blob = await a.getDocxBlob();
await b.importFile(new File([blob], "rt.docx"));
// headings, bold/italic, colour, link URLs, nested lists, table cells,
// footnotes, tracked changes with authors, equations, accented characters
assert(b.getEditable().querySelector('a[href="https://example.com/docs"]'));Pair it with a structural check of the package itself — every XML part well-formed under a real parser, every r:id resolving, every numId defined, every part declared in [Content_Types].xml. The round trip proves the two halves agree with each other; the package check proves they agree with Word.
One footnote from our own run: two round-trip assertions failed at first, reporting an extra <ol> and an extra <li>. That was the footnotes section, which correctly renders as its own ordered list. The assertions were wrong, not the code — worth checking before you go looking for a bug, because a test that counts elements across a whole document will eventually count something you did not mean to include.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.