Skip to content

Stream .docx extraction - #15

Closed
gulatikrishav wants to merge 7 commits into
krishav/streaming-xlsx-truncationfrom
krishav/streaming-docx
Closed

gulatikrishav wants to merge 7 commits into
krishav/streaming-xlsx-truncationfrom
krishav/streaming-docx

Conversation

@gulatikrishav

@gulatikrishav gulatikrishav commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Stream .docx extraction

Phase C of the streaming-parsers spec. Phase D (API wiring of truncated) is not in this PR.

Stacked on #14. Based on krishav/streaming-xlsx-truncation so the diff is Phase C only — the
seven commits below, +1381/−50. GitHub retargets this to main automatically when #14 merges.
Merge #14 first.

.xlsx was one of two formats that can reach the 50 MB decompression ceiling. This is the other one. It still ran mammoth.extractRawText({ buffer }), which built an xmldom tree and a document model over the whole of word/document.xml before any cap could apply, then let the central trim throw almost all of it away.

The measurement

Through dist/ at a 1024 MB heap, on a 45 MB document.xml inside a 3.65 MB archive — every gate cleared, nothing skipped:

concurrency mammoth this
1 812 ms / 607 MB 68 ms / 175 MB
2 1552 ms / 970 MB 59 ms / 164 MB
4 3512 ms / 1217 MB 58 ms / 168 MB

mammoth built 43,420,044 characters every time and kept 250,000 — 0.6%. Its peak tracks the document and multiplies by concurrency; this is flat, because the cap now stops the read rather than trimming the result.

The sharpest case is not the large archive. A 0.41 MB attachment holding one 43 MB <w:t> peaked mammoth at 1270 MB, against 199 MB here.

Fidelity — the acceptance criterion

41 documents, 41 byte-identical, 0 divergences, 0 errors. That is the 17 vendored Word documents, the 3 repo fixtures, 8 real-world Word-authored templates, 10 textutil-dialect documents (tables, lists, unicode, 200-paragraph bodies), and 3 more from disk. ACCEPTED_DIVERGENCES is empty.

The harness pins one golden from two sides: the snapshot records what we produce, and mammoth is asserted to produce the same string. A regression fails the snapshot; vitest -u, which would launder it, then fails the mammoth assertion because mammoth still produces the old value. Verified by injecting a trim into the old handler and running with -u — 14 of 20 documents failed on the mammoth side with the snapshot already rewritten. The only way to change behaviour is an ACCEPTED_DIVERGENCES entry carrying a written reason.

The corpus is committed (tests/fixtures/docx-corpus/, 17 files vendored from mammoth 1.12.0 under BSD-2-Clause with the notice retained) rather than read from node_modules: mammoth declares no files field, so test/ ships by accident, and mammoth is now a devDependency.

Landed before the swap, in its own commit, so it is a real baseline rather than a suite that first appears alongside the code it validates.

Two deviations from the spec, called out deliberately

1. No unzipper/yauzl. The spec called for a zip library as a new dependency. zipEntries already walks the central directory and returns each entry's still-compressed subarray, and zlib is already imported and already stream-inflates one. The only new runtime dependency is saxes — already hoisted as a deduped transitive of both exceljs and word-extractor, so it installs nothing.

2. The mapping is ~205 lines, not "tens of lines sharing Phase B's SAX plumbing". Both halves of that estimate were wrong. Phase B shipped on exceljs's own reader and left no SAX plumbing to share, so this is the library's first hand-written SAX path. And mammoth dispatches from a whitelist (body-reader.js:46-57): an element it has no handler for has its entire subtree dropped, not recursed into. "Emit every w:t, newline on </w:p>" over-extracts on any document with a text box or a field.

Adopting the whitelist is also what deletes work: mammoth's 20-entry ignore list and all its emit-nothing leaves are just the default, and w:sdt→w:sdtContent and mc:AlternateContent→mc:Fallback fall out for free because w:sdtPr and mc:Choice are simply absent from the list.

Also not authorised by the spec: mammoth moves to devDependencies. It follows from the malformed-XML decision — with no fallback, nothing calls it — and removes 10 packages / 3.61 MB from the runtime tree (mammoth, underscore, @xmldom/xmldom, dingbat-to-unicode, argparse, xmlbuilder, lop, sprintf-js, duck, option). jszip and bluebird stay; exceljs needs them regardless.

Malformed XML: the line moves in both directions

saxes is conformant where xmldom recovered, and xmldom threw where saxes copes. Measured on twelve cases: eight agree, two we are stricter on, two we are more available on.

  • Stricter: a raw U+000B in a w:t, and the same character as &#11;. Neither is a legal XML 1.0 character. Both test cases put it in the first text node, so nothing had been read and the result is failed; the same character further into a document keeps everything before it.
  • More available: trailing junk after the root, and an unclosed root. mammoth threw away the whole document for both.

The policy: text read then a broken parse → extracted + truncated: true; nothing read → failed. The loss is bounded, not a cliff — the same illegal character moved off the first text node keeps everything before it. Deliberately not a saxes error handler that reports and parses on: measured, that recovery emits close-tag text as content and descends into elements mammoth drops, i.e. silent wrong output.

Undeclared namespace prefixes are handled as a class, not an allowlist. saxes fails the whole parse on an unbound prefix — for attributes as well as elements — and Word 2013+ stamps w15:paraId on every w:p with w16cid:durableId beside it, so a document carrying those without their xmlns lost everything after its first paragraph. resolvePrefix resolves anything unclaimed to a sentinel URI that OOXML_PREFIXES doesn't map, so the element names {urn:agentextract:unbound}local, misses the whitelist, and its subtree drops — exactly what mammoth does with an unmapped namespace. saxes consults it last, after scope and additionalNamespaces, so the two compose; the assumed-prefix map keeps only w/mc/v, the three URIs where guessing beats dropping.

Zero saxes errors were raised on any of the 41 real documents. That was the evidence that mattered most for shipping without a fallback.

Testing

385 tests green; tsc --noEmit and the build clean.

  • 56 new tests across three files. Every contract claim was measured against mammoth, not read off the spec: 21 of 22 synthetic constructs byte-identical, including w:pict hoisting (the picture's text lands after the paragraph's break), the deleted paragraph mark, the deleted table row, w:instrText suppression, the w:fldSimple drop, CDATA vanishing, and NEL/U+2028 normalisation. The one divergence is the declared w:sym one.
  • Mutation-tested, not just green. Removing the NEL/U+2028 normalisation fails exactly one test; making unknown elements recurse fails exactly one test; emitting one newline per paragraph instead of two fails 34 of 53. The first two failing precisely one test each is the point — each regression is caught by its own test.
  • Fuzz: 1000 seeded iterations, two archives (deflated exercises the zip walk and preflight; stored puts mutated bytes straight in front of saxes). Coverage measured rather than assumed: 516/1000 reach extracted, the stored half alone 357/500; failure reasons span the pipeline — 352 from the zip walk, 21 from the handler's own w:body guard, 19 from routing. The property is that the degraded state is always reachable, always labeled, always inside the deadline, and never ''.
  • tests/attachment.test.ts is untouched — its 9 docx sites are routing and result-shape tests and stay green as the compatibility statement.

Known limits, now stated in the README

Inherited from mammoth exactly, so none is a regression — but all are easy to assume away:

  • Footnote, endnote and comment bodies are not extracted (separate zip parts); headers and footers are never opened.
  • Table structure is not preserved. A 2×2 table is byte-identical to four consecutive paragraphs. For a model reading an invoice, the column a figure sat in is gone.
  • w:fldSimple and w:ruby text is dropped — genuine mammoth text loss (cross-references, page numbers, DOCPROPERTY, merge-field results), replicated on purpose so this stays a pure port. Recovering them is one array entry each and belongs in its own change, once the corpus says how often they matter.
  • Peak is not independent of input size (the API takes a Buffer), and saxes buffers one text node whole, so a single enormous run still costs about twice its own size.

Knock-on comment rewrites

In commit 2 because they would otherwise be actively wrong. DECOMPRESSION BUDGET no longer has two foreign readers to reason about — both OOXML formats now read through our own zipEntries walk — so Invariant 2 becomes the proof that budget-ok implies zipEntries-ok for both, and Invariant 1 is demoted to a structural check. findEocd's last-match choice is now internal rather than a match for jszip's.

The budget is also noted as knowingly stale: it measures every entry while the handler inflates one, and on real documents word/document.xml is 2–28% of the archive (a Word template measured 66 KB of 3.1 MB). Loosening it is a permissiveness change on a zip-bomb gate that belongs with the input caps, not smuggled in behind a parser swap.

Corpus gap

LibreOffice and pandoc are not installed locally, so that dialect is untested. Production attachment bytes are unreachable from this account (the manifest is metadata only).

Follow-ups

The one-line w:fldSimple / two-line w:ruby recovery, once there is frequency data. The API bump to 0.4.0 and the createLimit(1) comment correction — its evidence is an .xlsx measurement Phase B invalidated, and the .docx half is now fixed too; the limit itself stays at 1 per spec §5. Then Phase D.

The .docx handler is about to stop using mammoth.extractRawText and read
word/document.xml with a streaming SAX parser instead. This is the harness
that says the output did not change, landed BEFORE the swap so it is a real
baseline: a fidelity suite that first appears alongside a new implementation
proves nothing about either.

Seventeen real Word documents vendored from mammoth 1.12.0's test/test-data,
plus the three textutil-generated fixtures the suite already carries. Copied
rather than read out of node_modules: the corpus and its expected output have
to be visible in a diff for "no change" to be checkable, mammoth declares no
`files` field so test/ ships by accident rather than by contract, and mammoth
is about to become a devDependency kept solely as this oracle.

Each case pins one golden from two sides. The snapshot records what we
produce; mammoth is then asserted to produce the same string. A regression in
our reader fails the snapshot, and `vitest -u` — which would launder that
regression by rewriting the snapshot — then fails the mammoth assertion,
because mammoth still produces the old value. Verified by injecting a trim
into the current handler and running with -u: 14 of the 20 documents failed
on the mammoth side with the snapshot already rewritten. The only way to
change behaviour is an ACCEPTED_DIVERGENCES entry carrying a written reason,
which is a reviewed, committed edit. That table is empty today.

The oracle is imported at the top of the file with no skipIf. Guarding it
would disarm the interlock silently, and green, in exactly the case it exists
for: with mammoth absent the snapshot is pinned only by the code that
generated it.

Comparison is paragraph-array first, then exact string. vitest diffs the
array element-wise, so a lost break reads as one changed line rather than a
wall of escaped text; the string assertion then catches the trailing
whitespace the split swallows.
mammoth.extractRawText built an xmldom tree and a document model over the
whole of word/document.xml before any cap could apply, then let the central
trim throw almost all of it away. Measured through dist/ at a 1024 MB heap on
a 45 MB document.xml inside a 3.65 MB archive — every gate cleared, nothing
skipped:

                  mammoth              this
  concurrency 1   812 ms /  607 MB     68 ms / 175 MB
  concurrency 2  1552 ms /  970 MB     59 ms / 164 MB
  concurrency 4  3512 ms / 1217 MB     58 ms / 168 MB

It built 43,420,044 characters every time and kept 250,000. Peak tracked the
document and multiplied by concurrency; this is flat, because the cap now
stops the read rather than trimming the result. The sharpest case is not the
big archive: a 0.41 MB attachment holding one 43 MB w:t peaked mammoth at
1270 MB against 199 MB here. That is the .xlsx OOM one format over, reachable
from half a megabyte of email attachment.

No new zip dependency. zipEntries already walks the central directory and
hands back each entry's still-compressed subarray, and zlib is already
imported and already stream-inflates one in inflateCounting, so the handler
locates word/document.xml and inflates exactly that region of exactly this
buffer. The only addition is saxes, which is already hoisted as a deduped
transitive of exceljs and word-extractor, so it costs nothing to install.

The output contract is mammoth's, reproduced rather than invented, and the
shape that makes it reproducible is a WHITELIST: an element mammoth has no
handler for has its entire subtree dropped, not recursed into. "Emit every
w:t, newline on </w:p>" over-extracts on any document with a text box or a
field. Adopting the whitelist also deletes work — mammoth's 20-entry ignore
list and its emit-nothing leaves are all just the default, and w:sdt keeping
only w:sdtContent and mc:AlternateContent keeping only mc:Fallback fall out
for free because w:sdtPr and mc:Choice are simply absent from the list.

Verified byte-identical to mammoth across all 20 documents of the fidelity
corpus on the first run, with no snapshot churn and an empty
ACCEPTED_DIVERGENCES table. That includes strict-format.docx (the ISO-strict
namespace URI, which a literal w: prefix match would silently return nothing
for), text-box.docx (mc:Fallback selection and w:pict hoisting, where the
picture's text has to land after the paragraph's break rather than inside
it), tables.docx and utf8-bom.docx.

Two things the port needs that the old reader got for free. Text is decoded
across inflate chunks with StringDecoder, since a chunk boundary lands
mid-sequence in any document with a non-ASCII character and a per-chunk
toString would emit U+FFFD pairs. And NEL/U+2028 are normalised to \n on the
way out: xmldom did that to the whole source before mammoth saw it, while
saxes does only the XML-standard \r\n and \r.

Malformed XML now stops the read rather than being recovered. saxes is
conformant where xmldom salvaged, so a document the old reader finished can
end short here; text already extracted is returned with truncated set, and
only a read that produced nothing stays a failure. Deliberately not a saxes
error handler that parses on — measured, that emits close-tag text as content
and descends into elements mammoth drops, which is silent wrong output. To
keep the common case off that path entirely, the OOXML prefixes are pre-bound
via additionalNamespaces: real producers emit stray o:/w10:/wps: markup
undeclared, and an in-document xmlns still shadows the pre-binding.

The comment rewrites are part of this commit because they would otherwise be
actively wrong. DECOMPRESSION BUDGET no longer has two foreign readers to
reason about: both OOXML formats now read through our own zipEntries walk, so
Invariant 2 becomes the proof that budget-ok implies zipEntries-ok for both,
and Invariant 1 is demoted to a structural check that no longer prevents
anything. findEocd's last-match choice is now internal rather than a match for
jszip's. The budget is also noted as knowingly stale: it measures every entry
while the handler inflates one, and on real documents word/document.xml is
2-28% of the archive. Loosening it is a permissiveness change that belongs
with the input caps, not smuggled in behind a parser swap.
The fidelity corpus proves the reader agrees with mammoth on real Word
documents, but real Word documents do not carry every construct: across all
20 of them there is no w:tab, no w:br, no w:cr, no tracked change, no field,
no w:sdt and no w:hyperlink, and none comes near the output cap or the
deadline. This is what the corpus cannot reach.

Every claim these tests pin was measured against mammoth, not read off the
spec. Running all 22 synthetic constructs through both readers: 21 byte-
identical, including w:pict hoisting (the picture's text lands after the
paragraph's break, not inside it), the deleted paragraph mark (no break, text
merges into the next paragraph), the deleted table row, w:instrText
suppression across a complete complex field, the w:fldSimple subtree drop,
CDATA vanishing, and NEL/U+2028 normalisation. The one divergence is w:sym,
which is the declared one: mammoth maps it through dingbat-to-unicode and we
drop it, since that table would be the only reason to keep any of mammoth's
tree and the only place this reader would read an attribute at all.

The load-bearing test is that an unrecognised element is dropped WITH its
children rather than recursed into. A reader written the obvious way passes
every other test in this file and still over-extracts on any document with a
text box or a field.

Three mutations confirm the tests are not vacuous: removing the NEL/U+2028
normalisation fails exactly one test, making unknown elements recurse fails
exactly one test, and emitting one newline per paragraph instead of two fails
34 of 53. The first two failing precisely one test each is the point — each
regression is caught by its own test rather than incidentally by another.

Two contracts here are pinned specifically because they look like defects
someone would later fix: a w:pict outside any paragraph is silently dropped,
and a table flattens to one paragraph per cell with no tabs or row markers,
so a 2x2 table is byte-identical to four consecutive paragraphs. Both are
mammoth's behaviour and both are now stated in the README as limits.
Replacing a lenient DOM parser with a conformant streaming one moves the
malformedness line in BOTH directions, so this measures where it lands rather
than leaving it to be discovered. Against mammoth on twelve cases: eight
agree, two we are stricter on, two we are more available on.

Stricter: a raw U+000B in a w:t, and the same character written as &#11;.
Neither is a legal XML 1.0 character; xmldom passed them through and saxes
refuses. Both test cases put it in the FIRST text node, so nothing had been
read and the result is `failed` — the same character further into a document
keeps everything before it. Recorded as a deliberate coverage loss.

More available: trailing junk after the root, and an unclosed root. mammoth
threw away the whole document for both; we return the text read before the
fault with truncated set.

The policy itself is stated once at the top of the file: text read then a
broken parse is `extracted` + `truncated`, nothing read is `failed`. What it
deliberately is not is a saxes error handler that reports and parses on —
measured, that recovery emits close-tag text as content and descends into
elements mammoth drops, which is silent wrong output.

The billion-laughs case asserts on TIME as much as status: saxes never parses
entity declarations, its table is the five predefined ones on a null
prototype and nothing writes to it, so the expansion is not representable and
the reference is simply undefined. A parser that did expand it would not
return at all.

The fuzz is 1000 seeded iterations across two archives — a deflated one that
mostly exercises the zip walk and preflight, and a stored one that puts the
mutated bytes straight in front of saxes. Coverage measured rather than
assumed: 516 of 1000 reach `extracted`, so the parser genuinely runs; the
stored half alone reaches it 357 of 500 times; failure reasons span the whole
pipeline, 352 from the zip walk, 21 from the handler's own w:body guard, 19
from routing. The property is not that the text is good but that the degraded
state is always reachable, always labeled, always inside the deadline, and
never the empty string — which is what makes shipping without a fallback
reader safe.

Two archives never reach the handler at all, pinned so that stays true: a
renamed main part is unroutable because ooxmlKind requires the literal
word/document.xml entry name (this is the one place we read the conventional
path where mammoth resolved _rels/.rels, and routing already declined such a
package before this change), and a corrupt deflate stream is caught by the
preflight, which proves the handler's own inflate-error path is
unreachable-but-safe.
Nothing in the shipped source calls mammoth any more — verified against the
built output, where the 31 remaining occurrences in dist/attachment.js are all
preserved comments and there is no require. It stays as the fidelity oracle,
which is a test-time job, so it belongs in devDependencies.

Deliberately NOT kept as a runtime fallback. A fallback would fire precisely
on malformed input — the most likely to be adversarial — and hand it to the
reader measured at 1270 MB on a 0.41 MB attachment, re-arming the exact hazard
this change exists to remove. It would also forfeit the dependency win, keep
an XML parser with CVE history loaded, and make "which reader ran" unobservable
from the result. The contract already has a safe degraded state: `failed` with
a reason, which a caller can see and retry.

Ten packages leave the runtime tree, 3.61 MB: mammoth, underscore,
@xmldom/xmldom, dingbat-to-unicode, argparse, xmlbuilder, lop, sprintf-js,
duck, option. jszip, bluebird, base64-js and path-is-absolute stay, since
exceljs and word-extractor need them regardless. Net dependency change for the
package is one added (saxes, already hoisted as a deduped transitive of both
exceljs and word-extractor, so it installs nothing new) and one moved.

The README's resource-limits section claimed .docx was post-materialization
with peak memory following the whole document, and listed it among the
handlers that cannot honour the deadline. Both are now false. It gains the
measured before/after table, and two residual bounds are stated rather than
glossed: peak is not independent of INPUT size, since the API takes a Buffer,
and saxes buffers one text node whole, so a single enormous run still costs
about twice its own size.

It also gains a scope bullet, because the port inherits limits that are easy
to assume away: footnote, endnote and comment bodies are not extracted (they
are separate zip parts), headers and footers are never opened, and a table
flattens to one paragraph per cell — a 2x2 table is byte-identical to four
consecutive paragraphs. All of that matched the previous reader exactly, so
none of it is a regression, but a consumer reading an invoice should know the
column a figure sat in is gone.

Fidelity across everything available: 41 documents, 41 byte-identical, 0
divergences, 0 errors. That is the 17 vendored Word documents, the 3 repo
fixtures, 8 real-world Word-authored templates, 10 textutil-dialect documents
covering tables, lists, unicode and 200-paragraph bodies, and 3 more from
disk. No saxes error was raised on any of them, which is the evidence that
mattered most for shipping without a fallback.
OOXML_ASSUMED_PREFIXES was a hand-enumerated allowlist, and saxes fails the
whole parse on a prefix outside it — for ATTRIBUTES as well as elements
(saxes.js:1920-1925). Under the error policy that becomes `extracted` +
`truncated: true`, so everything after the offending element is silently gone.

That is not an exotic shape. Word 2013 and later stamp w15:paraId on EVERY
w:p, with w16cid:durableId beside it, so a document carrying those without
their xmlns — a fragment assembled by a templating tool, a repaired file —
lost everything after its first paragraph. Measured before the fix:

  <w:p w15:paraId="12AB34CD">      ours "before\n\n"   mammoth the whole document
  <w:p w16cid:durableId="99">      ours "before\n\n"   mammoth the whole document
  <wne:acd/>                       ours "before\n\n"   mammoth "before\n\nafter\n\n"
  <w:p w14:paraId="1">             ours the whole document (w14 was in the map)

The w14 row is the tell: the allowlist was the entire difference. So was the
comment above the constant, which named wne: as a real-world stray prefix
while the map omitted it.

An allowlist cannot close this — there is always another prefix. resolvePrefix
closes the class: anything unclaimed resolves to a sentinel URI that
OOXML_PREFIXES does not map, so the element names {urn:agentextract:unbound}
local, misses the whitelist, and its subtree is dropped. That is exactly what
mammoth does with an unmapped namespace (xml/reader.js:53-66 produces the same
{uri}local shape, and no handler matches it), so this is the port getting
closer to its reference, not further.

saxes consults it last, after scope and after additionalNamespaces
(saxes.js:1845-1862), which is what lets the two compose. The map shrinks to
the three prefixes that still do something an allowlist has to do: w, mc and v
are the only URIs OOXML_PREFIXES maps, so an undeclared `w:` resolved to the
sentinel would drop the whole document body. The other eight entries resolved
to URIs this reader does not map either way, so guessing them and sentinelling
them were always the same outcome — they were maintenance with no behaviour
attached.

Fidelity is unmoved: 385 existing tests green with no snapshot churn, and the
41-document corpus still 41 byte-identical, 0 divergences, 0 errors. Six new
tests, and removing resolvePrefix fails five of them — including one that was
not written for this at all: the w:hyperlink container test uses r:id, an
undeclared prefix that had been passing only because `r` happened to sit in
the old allowlist. It now passes through the general mechanism.
The row named "an unbound namespace prefix" built its fixture with the
offending element after `</w:document>`, so it was measuring trailing junk
after the root — duplicating the case directly above it and passing for a
reason unrelated to its name. It survived the resolvePrefix change for the
same reason, which is how it surfaced.

Replaced with the case that was actually missing: the same illegal character
those two `failed` rows use, moved off the first text node. It returns
"first\n\nsecond\n\n" with truncated set — everything before the fault, nothing
after. That is what makes "stricter than mammoth" a bounded loss rather than a
cliff, and until now it was asserted only in prose.

Undeclared prefixes are covered in docx-streaming.test.ts, where they belong:
they no longer break the parse, so the interesting assertion is that the whole
document comes back.
@gulatikrishav
gulatikrishav changed the base branch from main to krishav/streaming-xlsx-truncation August 5, 2026 01:16
@gulatikrishav
gulatikrishav marked this pull request as ready for review August 5, 2026 01:39
@Haakam21

Copy link
Copy Markdown
Contributor

Superseded by #20, which integrates the XLSX and DOCX streaming work on current main and includes the consolidated review fixes.

@Haakam21 Haakam21 closed this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants