Repository navigation
Stream .docx extraction - #15
Closed
gulatikrishav wants to merge 7 commits into
Closed
gulatikrishav wants to merge 7 commits into
gulatikrishav wants to merge 7 commits into
Conversation
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 . 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
changed the base branch from
main
to
krishav/streaming-xlsx-truncation
August 5, 2026 01:16
gulatikrishav
marked this pull request as ready for review
August 5, 2026 01:39
Contributor
|
Superseded by #20, which integrates the XLSX and DOCX streaming work on current main and includes the consolidated review fixes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stream
.docxextractionPhase C of the streaming-parsers spec. Phase D (API wiring of
truncated) is not in this PR..xlsxwas one of two formats that can reach the 50 MB decompression ceiling. This is the other one. It still ranmammoth.extractRawText({ buffer }), which built an xmldom tree and a document model over the whole ofword/document.xmlbefore 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 MBdocument.xmlinside a 3.65 MB archive — every gate cleared, nothing skipped: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_DIVERGENCESis 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 anACCEPTED_DIVERGENCESentry 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 fromnode_modules: mammoth declares nofilesfield, sotest/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.zipEntriesalready walks the central directory and returns each entry's still-compressed subarray, andzlibis already imported and already stream-inflates one. The only new runtime dependency issaxes— already hoisted as a deduped transitive of bothexceljsandword-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 everyw: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:sdtContentandmc:AlternateContent→mc:Fallbackfall out for free becausew:sdtPrandmc:Choiceare 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).jszipandbluebirdstay; 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.
w:t, and the same character as. 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 isfailed; the same character further into a document keeps everything before it.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:paraIdon everyw:pwithw16cid:durableIdbeside it, so a document carrying those without theirxmlnslost everything after its first paragraph.resolvePrefixresolves anything unclaimed to a sentinel URI thatOOXML_PREFIXESdoesn'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 andadditionalNamespaces, so the two compose; the assumed-prefix map keeps onlyw/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 --noEmitand the build clean.w:picthoisting (the picture's text lands after the paragraph's break), the deleted paragraph mark, the deleted table row,w:instrTextsuppression, thew:fldSimpledrop, CDATA vanishing, and NEL/U+2028 normalisation. The one divergence is the declaredw:symone.extracted, the stored half alone 357/500; failure reasons span the pipeline — 352 from the zip walk, 21 from the handler's ownw:bodyguard, 19 from routing. The property is that the degraded state is always reachable, always labeled, always inside the deadline, and never''.tests/attachment.test.tsis 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:
w:fldSimpleandw:rubytext 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.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 BUDGETno longer has two foreign readers to reason about — both OOXML formats now read through our ownzipEntrieswalk — 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.xmlis 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-linew:rubyrecovery, once there is frequency data. The API bump to 0.4.0 and thecreateLimit(1)comment correction — its evidence is an.xlsxmeasurement Phase B invalidated, and the.docxhalf is now fixed too; the limit itself stays at 1 per spec §5. Then Phase D.