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.
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:
| Part | What it holds |
|---|---|
[Content_Types].xml | What every part is. Always the first entry in the archive. |
_rels/.rels | The package’s root relationships — including which part is the document. |
word/document.xml | The body. Every paragraph, every run of text, every table. |
word/_rels/document.xml.rels | What the document points at: images, hyperlinks, comments, styles. |
word/styles.xml | Style definitions — and the inheritance chain between them. |
word/numbering.xml | List formats: which lists are bulleted, which are numbered, at each level. |
word/comments.xml | The 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.xml | Title, author, last modified by, revision count, created and modified dates. |
docProps/app.xml | Application, 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>— a paragraph. The unit of block structure. Everything a reader thinks of as a line, a heading or a list item is one of these.<w:r>— a run. A stretch of text with uniform formatting. Bolding one word splits a paragraph into three runs.<w:t>— the text itself. The only place actual characters live.
<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:
- Its style id is a built-in
Heading1…Heading9. - Its properties carry an explicit
<w:outlineLvl w:val="1"/>(zero-based, so that is Heading 2). - Its style is a house style —
RiskHeading,ClauseTitle— whose<w:basedOn>chain eventually reaches a heading style. Nothing in the paragraph itself says “heading”; you have to walkstyles.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:
- Moves. Cutting a sentence and pasting it elsewhere records
<w:moveFrom>and<w:moveTo>— the text is stored twice, and the “from” half marks where it used to be. - Comments. They live in their own part,
word/comments.xml, with author and initials. Hiding comments in the UI does not remove them, andword/people.xmlcan carry the full roster of reviewers besides. - Hidden text.
<w:vanish/>is a display instruction, nothing more. The words are stored as ordinary text. - Editing fingerprints.
docProps/app.xmlrecords<TotalTime>— cumulative editing minutes — plus<Company>and<Manager>. And every element can carryw:rsid*attributes: revision-session ids that group edits made in the same sitting, which is enough to tell which paragraphs were written together.
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:
| Markup | Accepting 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 <. 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
- Cross-references resolve late. A footnote or comment reference in paragraph 12 points into a part you may not have read yet. Read the small side parts first — they are small by construction — and the body last.
- Table structure lives between paragraphs.
<w:tbl>,<w:tr>and<w:tc>tags sit in the markup separating paragraphs, so a splitter has to keep and inspect the inter-paragraph text rather than discard it, or every table cell flattens into a stray line. - Random access needs an index. To reach paragraph 9,000,000 you either stream past the first 8,999,999 or pre-build a table of their offsets.
- A single pathological paragraph still bounds you. Memory is O(longest paragraph). That is fine for prose; a generated file with one 500 MB paragraph is not prose.
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.
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.