← Writing
Build Tooling

Writing a real Word document in the browser with no library

A .docx looks like a format you need a dependency for. It is a ZIP of XML, and 219 lines of plain JavaScript is enough to build one Word will open without complaining.

If you have ever needed to hand someone a Word document out of a web page, you probably reached for a library, found one that is 400 KB, and moved on with your day. That's a reasonable thing to do. I couldn't do it, and the constraint turned out to be worth the trouble.

The appendix builder takes your messy indicators and timeline lines and gives you back two formatted tables in a .docx you can drop into a report. Its entire proposition is that nothing leaves your browser, and the audience for it will open the network tab to check. A script tag pointing at a CDN breaks that claim in one line. The Content Security Policy on this site is script-src 'self', so it would not have loaded anyway.

So: no library. Here is what you actually need to know.

A .docx is a ZIP with four files in it

That's the whole trick, and once you know it the rest is typing. Rename any .docx to .zip, open it, and you'll find a directory tree of XML. A .docx Word saved for me held 12 parts. You need four:

[Content_Types].xml            what kind of thing each part is
_rels/.rels                    points at the main document
word/document.xml              the actual content
word/_rels/document.xml.rels   the document's own relationships

No styles.xml, no theme, no settings, no fontTable. Word fills in sensible defaults for all of it. I spent an hour adding parts I didn't need before working that out, so you want to start with four and only add a part when something you want does not work without it.

The XML is boring, which is the good news

The wordprocessing namespace nests exactly the way you would guess. A document holds a body, a body holds paragraphs, a paragraph holds runs, and a run holds text:

<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
  <w:body>
    <w:p><w:r><w:t>Appendix A. Indicators of compromise</w:t></w:r></w:p>
  </w:body>
</w:document>

Tables are the same shape one level down: a w:tbl holds rows, a row holds cells, and a cell holds paragraphs. Widths are in twentieths of a point, which the spec calls dxa. A US Letter page with one inch margins gives you 9,360 dxa of content width, and if your column widths do not add up to that, the table will not sit where you expect.

One thing that is genuinely useful and not obvious: put <w:tblHeader/> in the first row's properties and Word repeats that row at the top of every page the table spills onto. On a two page indicator appendix that is the difference between something readable and something you apologise for.

The gotcha that cost me an evening

TWO TABLES WITH NOTHING BETWEEN THEM BECOME ONE TABLE. Word merges adjacent <w:tbl> elements silently, so your indicator table and your timeline table arrive fused together with mismatched columns, and nothing in your code looks wrong.

The fix is one empty paragraph between them:

body += table(...);
body += '<w:p/>';   // load-bearing, not cosmetic
body += table(...);

It is in my source with that comment on it, because I won't remember why it is there in six months and neither will you. If you take one thing from this post, take that line.

Building the ZIP by hand

Here is where you would normally reach for a library, and here is why you do not have to. A ZIP file supports more than one compression method, and one of them is method 0, stored, which means no compression at all. Word doesn't care which you use, and the four parts here come to about 6 KB. So you get to skip implementing DEFLATE, which is the only genuinely hard part of writing a ZIP.

What is left is three structures, laid out in the order they appear in the file:

  • a local file header before each file, 30 bytes plus the name
  • a central directory entry per file, 46 bytes plus the name
  • one end of central directory record to finish

Every field is little endian. Set bit 11 of the general purpose flag, 0x0800, to say the filenames are UTF-8. I also set a fixed modification date of 1980-01-01 rather than the current time, so that the same input produces a byte-identical file every run. That costs nothing and I recommend it: it means you can diff two outputs and get a meaningful answer.

CRC32 is fifteen lines

The one piece of real computation you need is a CRC32 of each file's bytes, and it is a table-driven loop you can write from the polynomial:

const CRC_TABLE = (() => {
  const t = 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);
    t[i] = c >>> 0;
  }
  return t;
})();

function crc32(b) {
  let c = 0xFFFFFFFF;
  for (let i = 0; i < b.length; i++) c = CRC_TABLE[(c ^ b[i]) & 0xFF] ^ (c >>> 8);
  return (c ^ 0xFFFFFFFF) >>> 0;
}

0xEDB88320 is the reversed form of the standard CRC-32 polynomial. The >>> 0 matters, because JavaScript bitwise operators give you a signed 32 bit integer and you want the unsigned value.

What it adds up to

219 lines from the CRC table to the finished Blob, no dependencies, no build step. It runs in a browser tab with the network disconnected. The output opens in Word, in LibreOffice and in Google Docs, and it carries no branding of mine, because the file is going into your deliverable and not into my marketing.

I prefer this to a dependency for a document this simple, and not only for the privacy claim. A library would have been more code shipped to the browser than the entire tool, to solve a problem that turned out to be four XML files and a byte layout.

Where I would stop

Be honest about the limits before you copy this approach:

  • Images and embedded fonts are a real jump. Both mean more parts, more relationships, and media files that are not text. If you need them, take the library.
  • Store-only means bigger files. 6 KB of XML is nothing. A hundred page document with a thousand rows is a different conversation and you will want DEFLATE.
  • Complex styling gets tedious quickly. Direct formatting on every run works and is what I do. A real style sheet is the right answer past a certain point.
  • You are on your own for validation. Word is forgiving but not infinitely so, and when it refuses a file it doesn't tell you anything useful. Change one thing at a time.

Where to read the actual specs

None of this is my discovery and all of it is documented for free, which surprised me:

  • ECMA-376 is the Office Open XML standard. It is enormous, and you only ever need the WordprocessingML part.
  • Microsoft's [MS-DOCX] documents what Word actually implements, which is the more useful question.
  • PKWARE's APPNOTE.TXT is the ZIP format itself, and every byte offset above comes from it.

The fastest way in is still to take a .docx Word made, rename it to .zip, and read what is inside. That is what I did, and it answered more questions faster than the specification did.

The tool this came out of is at benchnotes.io/tools/appendix, free and with nothing uploaded. Paste your indicators and timeline lines in whatever shape they came out of your notes, and you get the two appendix tables back as a Word file.

If you go and build one of these yourself, the four parts and the empty paragraph between tables are the two things I wish somebody had told me on day one. That is most of what took me an evening, and it is yours for free.