The identifier Word stamps on every paragraph
While building a .docx viewer that runs entirely in the browser, we put the document’s raw bytes next to its parsed structure. The thing that kept catching our eye was not the tracked changes everybody worries about. It was a four‑byte number, repeated 135 times in a three‑page file, that no word processor ever shows you and that survives most of the ways people try to clean a document.
First, we had to open a .docx without Word
A .docx is not a document file in the way a .txt is. It is a ZIP archive — an Open Packaging Convention package — whose members are XML parts. You can see that in the first sixteen bytes of any Word document, and this is a real dump of the sample we ship with the viewer:
00000000 50 4b 03 04 14 00 00 08 08 00 00 00 21 00 f0 7a |PK..........!..z|
00000010 c0 c3 59 01 00 00 61 05 00 00 13 00 00 00 5b 43 |..Y...a.......[C|
00000020 6f 6e 74 65 6e 74 5f 54 79 70 65 73 5d 2e 78 6d |ontent_Types].xm|
00000030 6c b5 94 cb 6e c2 30 10 45 f7 fd 8a c8 db 2a 31 |l...n.0.E.....*1|
50 4b 03 04 is a ZIP local file header — PK, for Phil Katz. The name that follows it is [Content_Types].xml, which every OPC package stores first. The body text lives a few entries later in word/document.xml, deflate‑compressed like everything else.
Our reader is plain JavaScript in a Web Worker. It reads the archive index from the tail of the file, seeks to the one part it needs, and inflates it with the browser’s own DecompressionStream. Nothing is uploaded, and no compression library ships:
// Only word/document.xml is decompressed. The rest of the package is never read.
const compressed = file.slice(dataStart, dataStart + compressedLength);
const xml = await new Response(
compressed.stream().pipeThrough(new DecompressionStream('deflate-raw'))
).text();
The hex pane made the invisible visible
Once the document rendered, we did what we do with every format: put the parsed view beside the bytes. And the bytes were noisier than the document. Here is the very first paragraph of a three‑page brief — the title — as it actually appears in word/document.xml:
00000130 64 79 3e 3c 77 3a 70 20 77 3a 72 73 69 64 52 3d |dy><w:p w:rsidR=|
00000140 22 30 30 41 32 31 46 33 43 22 20 77 3a 72 73 69 |"00A21F3C" w:rsi|
00000150 64 52 44 65 66 61 75 6c 74 3d 22 30 30 41 32 31 |dRDefault="00A21|
00000160 46 33 43 22 20 77 3a 72 73 69 64 50 3d 22 30 30 |F3C" w:rsidP="00|
00000170 42 37 34 45 31 39 22 3e 3c 77 3a 70 50 72 3e 3c |B74E19"><w:pPr><|
Three attributes, before a single character of the title. Counted across the whole document:
total rsid attributes: 135
distinct values: 00A21F3C, 00B74E19, 00C93D57
Three distinct values, sprinkled over every paragraph in the file. And they are not only on the paragraphs — the same values are listed together in word/settings.xml, in a part of the document nobody opens:
<w:rsids>
<w:rsidRoot w:val="00A21F3C"/>
<w:rsid w:val="00A21F3C"/>
<w:rsid w:val="00B74E19"/>
<w:rsid w:val="00C93D57"/>
</w:rsids>
What an rsid is
Unlike the reverse‑engineered corners of some formats, this one is documented. rsid stands for Revision Save ID, and it is specified in ECMA‑376, the Office Open XML standard.
The mechanism is simple. When you open a document and start editing, Word generates a random four‑byte value for that editing session. Every paragraph and every run you touch during that session gets stamped with it. Save, close, reopen tomorrow, and you get a new value; the old ones stay on the paragraphs they were stamped on, and the new one joins the list in settings.xml.
The point of it is document comparison. If two files share rsid values, Word can tell that a given paragraph in one is the same paragraph as in the other — not merely similar text — which makes merge and compare far more accurate than diffing strings. It is a genuinely useful feature.
| Attribute | Stamped on |
|---|---|
w:rsidR | The session in which the paragraph was added |
w:rsidRDefault | The default session for runs in that paragraph |
w:rsidP | The session in which the paragraph’s properties last changed |
w:rsidRPr | The session in which a run’s formatting last changed |
w:rsidTr | The session in which a table row changed |
So the set of values in a document is, in effect, a count of the editing sessions it has lived through, and their distribution across paragraphs is a map of which parts were written together.
Does an rsid identify you?
Not by itself, and not in the way people fear. An rsid is not a username, a machine ID, a licence key or a serial number. It is a random value generated per editing session. Reading one tells you nothing about who was typing or on what computer.
What it does is correlate. Two documents that share rsid values were not written independently — one is descended from the other, or both from a common ancestor. That is a real inference, and it is the reason the field is worth showing:
- A contract you sent to three counterparties, each “starting from a blank document”, that all carry your template’s rsids.
- An anonymous submission whose rsids match a document published under a name.
- Two press releases that were supposed to be drafted separately.
- The shape of the editing itself: a paragraph carrying a session id that appears nowhere else was written at a different time from everything around it.
Correlation is still disclosure. A value does not have to name you to matter. Tracked changes and author metadata are the more direct risks, and they get all the attention — but an identifier that links two files you meant to keep separate belongs in the list a privacy tool shows you and removes. Whether your copy of Word offers to strip these is a separate question; what is certain is that they are in the file you are about to attach.
Reading them yourself
You do not need our viewer for this. Unzip the document and look:
unzip -o report.docx -d report/
grep -o 'w:rsid[A-Za-z]*="[0-9A-F]*"' report/word/document.xml | sort | uniq -c
cat report/word/settings.xml | tr '>' '>\n' | grep rsid
Or, in the browser, the same scan the viewer runs — count the attributes and collapse them to the set of sessions:
const rsids = [...xml.matchAll(/w:rsid[A-Za-z]*="([0-9A-Fa-f]+)"/g)].map((m) => m[1]);
console.log('stamps: ', rsids.length);
console.log('sessions:', new Set(rsids).size);
// Which paragraphs were written in the same sitting?
const perParagraph = [...xml.matchAll(/<w:p\b[^>]*w:rsidR="([0-9A-Fa-f]+)"/g)]
.map((m, i) => ({ paragraph: i, session: m[1] }));
That last one is the interesting query. Group the paragraphs by session and you get the document’s edit history in outline — which parts are original, which were added later, and which single paragraph was dropped in at the end.
The neighbours
Once you are reading the raw part, the rsids turn out to have company. This is the same file, a few hundred bytes further in — a sentence that was deleted, with the name of the person who deleted it and the minute they did:
000006c0 3a 64 65 6c 20 77 3a 69 64 3d 22 31 30 31 22 20 |:del w:id="101" |
000006d0 77 3a 61 75 74 68 6f 72 3d 22 50 72 69 79 61 20 |w:author="Priya |
000006e0 52 61 6d 61 6e 22 20 77 3a 64 61 74 65 3d 22 32 |Raman" w:date="2|
000006f0 30 32 36 2d 30 32 2d 31 31 54 30 39 3a 32 34 3a |026-02-11T09:24:|
00000700 30 30 5a 22 3e 3c 77 3a 72 3e 3c 77 3a 64 65 6c |00Z"><w:r><w:del|
00000710 54 65 78 74 20 78 6d 6c 3a 73 70 61 63 65 3d 22 |Text xml:space="|
00000720 70 72 65 73 65 72 76 65 22 3e 51 33 3c 2f 77 3a |preserve">Q3</w:|
00000730 64 65 6c 54 65 78 74 3e 3c 2f 77 3a 72 3e 3c 2f |delText></w:r></|
Read the right‑hand column. The word Q3 was deleted from that sentence, and it is right there, in <w:delText>, along with w:author and w:date. Track Changes does not remove text; it wraps it. Word simply stops drawing it.
Three more fields sit in the same neighbourhood, none of them visible while you read the document:
<TotalTime>indocProps/app.xml— cumulative minutes spent editing. A number that says how long a one‑page letter really took.<cp:lastModifiedBy>and<dc:creator>indocProps/core.xml— names, verbatim, whether or not they appear in the text.<w:vanish/>on a run — hidden text. Not printed, not shown, stored in full.
Removing them safely
The REVISIONS tab shows all of it and offers a cleaned copy. Stripping the rsids themselves is the easy part — they are attributes, and removing an attribute cannot change how a document renders:
const stripRsids = (xml) => xml.replace(/\s+w:rsid[A-Za-z]*="[^"]*"/g, '');
The tracked changes need more care, because accepting an edit means two opposite operations depending on which kind it is:
// An insertion becomes ordinary text: drop the tags, keep the runs.
xml = unwrapElement(xml, 'w:ins');
// A deletion disappears entirely: tags AND contents.
// This is the one that actually removes the secret.
xml = stripElement(xml, 'w:del');
// The record of where moved text used to be, and what formatting used to be.
xml = stripElement(xml, 'w:moveFrom');
xml = stripElement(xml, 'w:rPrChange');
Write a new package; do not patch the old one. A ZIP can hold bytes that no central‑directory entry points at, so “removing” a part by editing the index leaves its contents sitting in the file, recoverable by anyone who scans past the index. Our cleaner rebuilds the archive from the parts it is keeping, which is the difference between a cleaner and a display setting.
The result is a valid Word document — same styles, same numbering, same tables and pictures — that no longer carries the earlier draft, the reviewer names, the comment threads, the editing minutes or the session identifiers. And because all of this runs in your tab, neither the original nor the cleaned copy is ever uploaded.
OmniViewer opens every file format in your browser — Word, PDF, ZIP, PNG and more — powered by the same windowed engine as fastjsonviewer.com and hugecsv.com. Every byte stays on your machine.