✷ OMNIVIEWER DOCX viewer ZIP viewer ← back

How Word stores your document — and what it remembers

A .docx is a ZIP full of XML, and it keeps more than it shows: the sentence someone cut, who cut it, when, and the comment they thought was resolved. This is a tour of the format — the package, the grammar, the tracked-change markup — and then the streaming algorithm that reads a 20 GB Word document without ever holding it, with real JavaScript you can lift.

On this page The package The grammar What it remembers Removing it properly Reading 20 GB of it The full algorithm Caveats

A .docx is a ZIP — the OPC package

Rename any .docx to .zip and it opens. That is not a trick: since Word 2007, the format has been Office Open XML, standardised as ECMA-376 and ISO/IEC 29500, and its container is an Open Packaging Convention package — a perfectly ordinary ZIP archive whose members are called parts.

A minimal Word document holds these:

PartWhat it holds
[Content_Types].xmlWhat every part is. Always the first entry in the archive.
_rels/.relsThe package’s root relationships — including which part is the document.
word/document.xmlThe body. Every paragraph, every run of text, every table.
word/_rels/document.xml.relsWhat the document points at: images, hyperlinks, comments, styles.
word/styles.xmlStyle definitions — and the inheritance chain between them.
word/numbering.xmlList formats: which lists are bulleted, which are numbered, at each level.
word/comments.xmlThe comment threads, with author, initials and timestamp.
word/media/The pictures, stored verbatim — a PNG in here is byte-for-byte the PNG that was inserted.
docProps/core.xmlTitle, author, last modified by, revision count, created and modified dates.
docProps/app.xmlApplication, company, manager, total editing time in minutes, page and word counts.

Two details matter for anything that reads the format properly. First, do not assume the document is at word/document.xml — resolve it through _rels/.rels, because a file written from a template or by a non-Microsoft library can legitimately name it something else:

// _rels/.rels
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
      Target="word/document.xml"/>   <!-- ...but Target is whatever it says -->
</Relationships>

Second, relationship targets are relative to the part that declared them. An image cited from word/document.xml as media/image1.png really lives at word/media/image1.png. Resolve once, up front, or every later lookup asks the package for a part name that is not in it.

The grammar, in one screen

WordprocessingML has a strict hierarchy, and only three levels of it carry text:

<w:p>
  <w:pPr>                               <!-- paragraph properties -->
    <w:pStyle w:val="Heading2"/>        <!-- ...its style -->
  </w:pPr>
  <w:r>
    <w:rPr><w:b/></w:rPr>               <!-- run properties: bold -->
    <w:t xml:space="preserve">Open risks</w:t>
  </w:r>
</w:p>

Note xml:space="preserve". Without it a leading or trailing space in a <w:t> is collapsible whitespace, which is how naive extractors end upwithwordsruntogether.

Headings are recorded three different ways

This is the single most common reason a .docx outline extractor comes back empty on a real corporate document. A paragraph is a heading if any of these holds:

  1. Its style id is a built-in Heading1Heading9.
  2. Its properties carry an explicit <w:outlineLvl w:val="1"/> (zero-based, so that is Heading 2).
  3. Its style is a house styleRiskHeading, ClauseTitle — whose <w:basedOn> chain eventually reaches a heading style. Nothing in the paragraph itself says “heading”; you have to walk styles.xml.

Miss the third and every document produced from a company template looks flat.

Lists are a two-table join

A list paragraph does not say “bullet” or “1.”. It cites a numId. That id resolves through <w:num> to an <w:abstractNum>, and only there does each level declare its <w:numFmt>bullet, decimal, lowerLetter. Resolve one table without the other and every numbered list in the document converts to bullets. The numbers themselves are never stored: Word computes them at render time, and so must you.

What a Word document remembers

Here is the part that catches people. Track Changes does not overwrite text. It wraps it.

<w:p>
  <w:r><w:t xml:space="preserve">We propose shipping in </w:t></w:r>

  <w:del w:id="101" w:author="Priya Raman" w:date="2026-02-11T09:24:00Z">
    <w:r><w:delText>Q3</w:delText></w:r>   <!-- deleted... and still here -->
  </w:del>

  <w:ins w:id="102" w:author="Priya Raman" w:date="2026-02-11T09:24:00Z">
    <w:r><w:t>Q4</w:t></w:r>
  </w:ins>
</w:p>

The deleted word is in the file, in plain text, with the name of the person who deleted it and the minute they did. Word renders it invisible when markup display is off. The bytes do not care.

This is the mechanism behind the leak stories. Redlined contracts circulated with the other side’s concessions still readable; press releases whose earlier, blunter wording is one toggle away; filings that name an internal reviewer nobody meant to disclose. None of it requires forensics — it requires unzipping a file.

The same applies to four more things, all of which travel with a document that looks clean on screen:

Removing it properly

“Accept all changes” in Word genuinely removes the markup — if somebody actually runs it. A document circulated for review usually has not. If you are stripping it programmatically, the operation is a rewrite of the XML, and the direction of each edit matters:

MarkupAccepting it means
<w:ins>Unwrap — drop the tags, keep the runs. Inserted text becomes ordinary text.
<w:del>Delete whole — tags and contents. This is the one that actually removes the secret.
<w:moveTo>Unwrap: the text belongs where it now is.
<w:moveFrom>Delete whole: the record of where it used to be.
<w:rPrChange>, <w:pPrChange>Delete whole — these store the previous formatting.

Then drop word/comments.xml and its siblings, remove the runs marked <w:vanish/>, blank the identifying fields in docProps, and strip the w:rsid attributes.

Write a new package, do not patch the old one. A ZIP can hold data that no central-directory entry points at — so “removing” a part by editing the index leaves its bytes sitting in the file, recoverable by anyone who scans past the index. Rebuild the archive from the parts you are keeping. That is the difference between a cleaner and a display setting.

Reading 20 GB of it

Word documents get enormous the way any document does: a merged contract set, a generated report, an export of a case management system. The body is one XML part, and a DOM parse of it costs several times its size in memory. A 20 GB document.xml is not going to be a tree.

But it does not have to be, because WordprocessingML has a record boundary, and it is </w:p>.

That is a stronger claim than it looks. XML forbids a literal < inside character data and inside attribute values — it must be written &lt;. So the six characters </w:p> cannot occur anywhere in a well-formed document except at the end of a paragraph. Splitting on them is not a heuristic; it is sound, in the same way splitting JSON Lines on \n is sound.

So the algorithm is: read the ZIP index from the tail, seek to the one part that matters, stream it through the browser’s own DEFLATE decompressor, cut at </w:p>, and parse each paragraph on its own — a few hundred bytes — then throw it away. Peak memory becomes the size of the longest single paragraph, not the size of the document.

The full algorithm

A resumable splitter first. It takes arbitrary chunks and emits complete paragraphs, carrying only the partial trailing one across a chunk edge:

function createParagraphScanner() {
  let buf = '';

  // <w:p must be followed by '>', '/' or whitespace — otherwise <w:pPr>,
  // <w:pStyle> and <w:proofErr> all read as paragraph starts.
  const findOpen = (s) => {
    let from = 0;
    for (;;) {
      const i = s.indexOf('<w:p', from);
      if (i === -1) return -1;
      const c = s[i + 4];
      if (c === undefined) return -1;              // partial tag: wait for more
      if (c === '>' || c === '/' || /\s/.test(c)) return i;
      from = i + 4;
    }
  };

  return {
    push(text) {
      buf += text;
      const out = [];
      for (;;) {
        const open = findOpen(buf);
        if (open === -1) return out;               // nothing complete yet
        if (open > 0) buf = buf.slice(open);       // skip inter-paragraph markup
        const selfClose = /^<w:p(?:\s[^<>]*)?\/>/.exec(buf);
        if (selfClose) {                           // an empty paragraph
          out.push(selfClose[0]);
          buf = buf.slice(selfClose[0].length);
          continue;
        }
        const close = buf.indexOf('</w:p>');
        if (close === -1) return out;              // incomplete: keep for next chunk
        out.push(buf.slice(0, close + 6));
        buf = buf.slice(close + 6);
      }
    },
  };
}

Then the driver. The ZIP index gives the byte range of the document part; everything after that is a pipe:

async function streamDocx(file, onParagraph) {
  // 1. Index: read the ZIP tail, find word/document.xml, get its byte range.
  //    Costs two small reads whether the file is 20 KB or 20 GB.
  const { start, length, method } = await locateDocumentPart(file);

  // 2. A stream over just those bytes.
  let pos = start;
  const raw = new ReadableStream({
    async pull(c) {
      if (pos >= start + length) { c.close(); return; }
      const n = Math.min(1 << 20, start + length - pos);
      c.enqueue(new Uint8Array(await file.slice(pos, pos + n).arrayBuffer()));
      pos += n;
    },
  });

  // 3. Inflate with the platform decompressor — no compression library needed.
  const bytes = method === 8
    ? raw.pipeThrough(new DecompressionStream('deflate-raw'))
    : raw;

  // 4. Decode and split. { stream: true } is what makes a UTF-8 character
  //    split across a block boundary survive.
  const scanner = createParagraphScanner();
  const decoder = new TextDecoder('utf-8');
  const reader = bytes.getReader();
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    for (const xml of scanner.push(decoder.decode(value, { stream: true }))) {
      onParagraph(xml);                            // parse it, use it, drop it
    }
  }
}

// Example: count the words in a 20 GB document without building anything.
let words = 0;
await streamDocx(file, (xml) => {
  const text = [...xml.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join('');
  words += text.trim() ? text.trim().split(/\s+/).length : 0;
});
console.log(words, 'words — peak memory: one block + one paragraph');

This is what OmniViewer’s DOCX toolkit does. The outline, the revision audit and the statistics are counted paragraph by paragraph as the document streams, so their numbers cover every paragraph in the file at any size. Only the tabs that build a second document — the rendered view, the Markdown and HTML conversions — retain anything, and they stop at a bounded window and say so on screen.

What the streaming approach gives up

It is the same split every format on OmniViewer uses: window what you view, bound what you parse, and count everything else as it goes past.

Open the DOCX viewer →Read the document, audit its tracked changes and comments, convert it to Markdown — locally, no upload. Inside a ZIP file →The central directory and DEFLATE — the container a .docx is built on.

OmniViewer opens every file format in your browser — Word, PDF, ZIP, JSON, CSV and more — powered by the same windowed engine as fastjsonviewer.com and hugecsv.com.