DEV Community

Notepad Neo
Notepad Neo

Posted on Originally published at notepadneo.com

Writing a .docx in the Browser With No Dependencies

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.

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:

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:

// 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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

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.

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');
Enter fullscreen mode Exit fullscreen mode

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:

Diagram: Converting browser pixels into the units OOXML uses

A twip is a twentieth of a point, and a point is a seventy-second of an inch. Everything else on this diagram is a consequence of those two definitions and the browser's fixed 96 dpi reference.

(The interactive diagram is in the original article.)

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.

Diagram: Child element order inside paragraph properties

The order is defined in ECMA-376 part 1. There is no lenient mode, and the error message never names the offending element — so the practical debugging technique is to bisect the properties you emit until the file opens.

(The interactive diagram is in the original article.)

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.

Diagram: Latin character properties and their complex-script twins

This is the single most surprising thing about writing OOXML by hand. Every property gets written twice, and nothing in the file format hints that it should be.

(The interactive diagram is in the original article.)

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>` : '';
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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}"/>`);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Two small Word-specific rules that are not in any tutorial

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
];
Enter fullscreen mode Exit fullscreen mode

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.

Takeaways

  • 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:pPr and w:rPr child order. The failure mode is an unopenable file with an error message that names nothing.
  • Write the Cs twin of every character property, or complex-script runs ignore your formatting entirely.
  • State direction explicitly with w:bidi and w:rtl, and remember that w:jc and w:ind are physical while CSS is logical.
  • Strip control characters, U+200B, and any transient UI markup before it reaches the file.

I build Notepad Neo, a free, offline-first online notepad with tabs, rich text and DOCX/PDF export that keeps every note on your own device. The original version of this article has interactive diagrams, and there are more write-ups on the engineering blog.

Top comments (0)