DEV Community

René
René

Posted on

A .docx file is just a ZIP. Word documents in the browser with zero dependencies

I needed a "Download as Word" button for a free legal-text generator I built. The obvious route is a library like docx (~500 KB) or server-side generation. But the documents I produce are dead simple. A heading, some bold sub-headings, lines of text. Shipping half a megabyte of dependency for that felt wrong.

So I looked into what a .docx actually is. It turns out to be a ZIP archive containing a handful of XML files. And if you accept two constraints, you can build one in about 50 lines of vanilla TypeScript.

The two constraints that make it easy

1. Store, don't compress. The ZIP format supports an uncompressed "store" mode (method 0). Your text gets a bit bigger, but you skip implementing DEFLATE entirely. For a two-page document nobody cares about 8 KB vs 3 KB.

2. Minimal OOXML. Word is surprisingly forgiving. A valid .docx needs exactly three files.

  • [Content_Types].xml — declares what's in the package
  • _rels/.rels — points to the main document part
  • word/document.xml — your actual content

No styles.xml, no fontTable.xml, no theme. Word, LibreOffice and Google Docs all open it fine and apply their defaults.

The ZIP writer

The only non-trivial part of the ZIP spec is the CRC-32 checksum. That's a well-known 8-line bit-twiddling loop.

function crc32(data: Uint8Array) {
  let crc = 0xffffffff;
  for (let i = 0; i < data.length; i++) {
    crc ^= data[i];
    for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
  }
  return (crc ^ 0xffffffff) >>> 0;
}
Enter fullscreen mode Exit fullscreen mode

The rest is writing three record types with the right magic numbers. A local file header (0x04034b50) followed by the raw bytes comes first for each file, then one central directory entry (0x02014b50) per file, then a single end-of-central-directory record (0x06054b50). With store mode, compressed size equals uncompressed size, and all the "advanced" fields stay zero.

function makeZip(files: [string, string][]) {
  const enc = new TextEncoder();
  const u16 = (n: number) => [n & 255, (n >>> 8) & 255];
  const u32 = (n: number) => [...u16(n), ...u16(n >>> 16)];
  const body: number[] = [], central: number[] = [];
  for (const [name, content] of files) {
    const nameB = [...enc.encode(name)], data = [...enc.encode(content)];
    const common = [...u32(crc32(new Uint8Array(data))), ...u32(data.length),
                    ...u32(data.length), ...u16(nameB.length), ...u16(0)];
    central.push(...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0), ...u16(0),
                 ...u16(0), ...u16(0), ...common, ...u16(0), ...u16(0), ...u16(0),
                 ...u32(0), ...u32(body.length), ...nameB);
    body.push(...u32(0x04034b50), ...u16(20), ...u16(0), ...u16(0), ...u16(0),
              ...u16(0), ...common, ...nameB, ...data);
  }
  return new Uint8Array([...body, ...central, ...u32(0x06054b50), ...u16(0),
    ...u16(0), ...u16(files.length), ...u16(files.length),
    ...u32(central.length), ...u32(body.length), ...u16(0)]);
}
Enter fullscreen mode Exit fullscreen mode

That's the entire "ZIP library".

The Word part

word/document.xml is WordprocessingML. A paragraph is <w:p>, a run is <w:r>, text is <w:t>. Three gotchas cost me the most time.

  1. Half-points. Font sizes in <w:sz w:val="36"/> are half-points, so 36 means 18 pt. Everyone hits this once.
  2. xml:space="preserve". Without it, Word trims leading and trailing whitespace in your <w:t> elements, which silently eats spaces around line breaks.
  3. Escape everything. You're concatenating XML, so &, <, > and quotes in user data must be escaped or the file won't open at all. Word's error message ("unreadable content") tells you nothing.

Line breaks within a paragraph are <w:br/> between <w:t> elements, no need for separate paragraphs per line.

Finally, wrap the bytes in a Blob with the right MIME type and trigger the download.

const blob = new Blob([bytes], {
  type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.docx';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
Enter fullscreen mode Exit fullscreen mode

Verifying without opening Word

Two command-line tricks saved me a lot of clicking. unzip -l file.docx confirms the archive structure is valid, and on macOS textutil -convert txt file.docx round-trips the document through Apple's own OOXML parser, so if that prints your text, Word will open it. xmllint catches malformed XML before you even zip it.

Where it runs in production

The whole thing powers the Word export of a free legal-notice generator for Austrian websites I built. It pulls company data from public registers and outputs the mandatory site imprint, and you can try it at https://webgaudi.at/impressum-generator-oesterreich/

The export feature adds about 1.5 KB minified to the bundle. The docx npm package would have been ~300x that, for documents this simple.

Would I recommend this for reports with tables, images and headers? No, take the library. But for "text with some bold lines as a .docx download", the format is far less scary than it looks. It's just a ZIP.

Top comments (0)