From 4a24a47265249674f9cec8397351913ed8abda47 Mon Sep 17 00:00:00 2001 From: gulatikrishav Date: Tue, 4 Aug 2026 17:30:10 -0700 Subject: [PATCH 1/7] Vendor a docx fidelity corpus and pin mammoth's raw-text output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__snapshots__/docx-fidelity.test.ts.snap | 115 ++++++++++++++++++ tests/docx-fidelity.test.ts | 96 +++++++++++++++ tests/fixtures/docx-corpus/README.md | 62 ++++++++++ tests/fixtures/docx-corpus/comments.docx | Bin 0 -> 15526 bytes .../docx-corpus/embedded-style-map.docx | Bin 0 -> 49709 bytes tests/fixtures/docx-corpus/empty.docx | Bin 0 -> 3306 bytes tests/fixtures/docx-corpus/endnotes.docx | Bin 0 -> 4431 bytes .../docx-corpus/external-picture.docx | Bin 0 -> 4120 bytes .../docx-corpus/footnote-hyperlink.docx | Bin 0 -> 16271 bytes tests/fixtures/docx-corpus/footnotes.docx | Bin 0 -> 15936 bytes tests/fixtures/docx-corpus/simple-list.docx | Bin 0 -> 13887 bytes .../docx-corpus/single-paragraph.docx | Bin 0 -> 12773 bytes tests/fixtures/docx-corpus/strict-format.docx | Bin 0 -> 11692 bytes tests/fixtures/docx-corpus/strikethrough.docx | Bin 0 -> 12652 bytes tests/fixtures/docx-corpus/tables.docx | Bin 0 -> 13087 bytes tests/fixtures/docx-corpus/text-box.docx | Bin 0 -> 15850 bytes .../tiny-picture-target-base-relative.docx | Bin 0 -> 10881 bytes tests/fixtures/docx-corpus/tiny-picture.docx | Bin 0 -> 13699 bytes tests/fixtures/docx-corpus/underline.docx | Bin 0 -> 12797 bytes tests/fixtures/docx-corpus/utf8-bom.docx | Bin 0 -> 2836 bytes 20 files changed, 273 insertions(+) create mode 100644 tests/__snapshots__/docx-fidelity.test.ts.snap create mode 100644 tests/docx-fidelity.test.ts create mode 100644 tests/fixtures/docx-corpus/README.md create mode 100755 tests/fixtures/docx-corpus/comments.docx create mode 100644 tests/fixtures/docx-corpus/embedded-style-map.docx create mode 100644 tests/fixtures/docx-corpus/empty.docx create mode 100644 tests/fixtures/docx-corpus/endnotes.docx create mode 100644 tests/fixtures/docx-corpus/external-picture.docx create mode 100755 tests/fixtures/docx-corpus/footnote-hyperlink.docx create mode 100755 tests/fixtures/docx-corpus/footnotes.docx create mode 100755 tests/fixtures/docx-corpus/simple-list.docx create mode 100755 tests/fixtures/docx-corpus/single-paragraph.docx create mode 100644 tests/fixtures/docx-corpus/strict-format.docx create mode 100755 tests/fixtures/docx-corpus/strikethrough.docx create mode 100755 tests/fixtures/docx-corpus/tables.docx create mode 100755 tests/fixtures/docx-corpus/text-box.docx create mode 100644 tests/fixtures/docx-corpus/tiny-picture-target-base-relative.docx create mode 100755 tests/fixtures/docx-corpus/tiny-picture.docx create mode 100755 tests/fixtures/docx-corpus/underline.docx create mode 100644 tests/fixtures/docx-corpus/utf8-bom.docx diff --git a/tests/__snapshots__/docx-fidelity.test.ts.snap b/tests/__snapshots__/docx-fidelity.test.ts.snap new file mode 100644 index 0000000..adcb67e --- /dev/null +++ b/tests/__snapshots__/docx-fidelity.test.ts.snap @@ -0,0 +1,115 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`docx fidelity — our reader against mammoth > comments.docx 1`] = ` +"Ouch. + +" +`; + +exports[`docx fidelity — our reader against mammoth > embedded-style-map.docx 1`] = ` +"Walking on imported air + +" +`; + +exports[`docx fidelity — our reader against mammoth > empty.docx 1`] = `null`; + +exports[`docx fidelity — our reader against mammoth > endnotes.docx 1`] = ` +"Ouch. + +" +`; + +exports[`docx fidelity — our reader against mammoth > external-picture.docx 1`] = `null`; + +exports[`docx fidelity — our reader against mammoth > fixtures/empty.docx 1`] = `null`; + +exports[`docx fidelity — our reader against mammoth > fixtures/image.docx 1`] = ` +"Report + +" +`; + +exports[`docx fidelity — our reader against mammoth > fixtures/sample.docx 1`] = ` +"First paragraph of the sample document. + + + +Second paragraph with more words here. + +" +`; + +exports[`docx fidelity — our reader against mammoth > footnote-hyperlink.docx 1`] = `null`; + +exports[`docx fidelity — our reader against mammoth > footnotes.docx 1`] = ` +"Ouch. + +" +`; + +exports[`docx fidelity — our reader against mammoth > simple-list.docx 1`] = ` +"Apple + +Banana + +" +`; + +exports[`docx fidelity — our reader against mammoth > single-paragraph.docx 1`] = ` +"Walking on imported air + +" +`; + +exports[`docx fidelity — our reader against mammoth > strict-format.docx 1`] = ` +"Test + +" +`; + +exports[`docx fidelity — our reader against mammoth > strikethrough.docx 1`] = ` +"Today's Special: Salmon Sold out + +" +`; + +exports[`docx fidelity — our reader against mammoth > tables.docx 1`] = ` +"Above + +Top left + +Top right + +Bottom left + +Bottom right + +Below + +" +`; + +exports[`docx fidelity — our reader against mammoth > text-box.docx 1`] = ` +" + +Datum plane + +" +`; + +exports[`docx fidelity — our reader against mammoth > tiny-picture.docx 1`] = `null`; + +exports[`docx fidelity — our reader against mammoth > tiny-picture-target-base-relative.docx 1`] = `null`; + +exports[`docx fidelity — our reader against mammoth > underline.docx 1`] = ` +"The Sunset Tree + +" +`; + +exports[`docx fidelity — our reader against mammoth > utf8-bom.docx 1`] = ` +"This XML has a byte order mark. + +" +`; diff --git a/tests/docx-fidelity.test.ts b/tests/docx-fidelity.test.ts new file mode 100644 index 0000000..556cd19 --- /dev/null +++ b/tests/docx-fidelity.test.ts @@ -0,0 +1,96 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, it, expect } from 'vitest' +import mammoth from 'mammoth' + +import { extractAttachment } from '../attachment' + +// --------------------------------------------------------------------------- +// .docx fidelity — our reader against mammoth, over a real Word corpus +// --------------------------------------------------------------------------- +// This library read .docx through mammoth.extractRawText until the streaming rewrite. That reader +// materialised an xmldom tree AND a document model before any cap could apply; the replacement walks +// word/document.xml with a SAX parser and stops at the cap. The output contract is unchanged, and +// this file is what says so. +// +// THE INTERLOCK. Each case pins ONE golden from TWO sides: the snapshot records what we produce, and +// mammoth is then asserted to produce the same string. Neither side can move alone — +// - a regression in our reader fails the snapshot; +// - `vitest -u`, which would launder that regression by rewriting the snapshot, then fails the +// mammoth assertion, because mammoth still produces the old value. +// The only way to change behaviour is to add an ACCEPTED_DIVERGENCES entry with a written reason, +// which is a reviewed, committed edit. That is the mechanical form of "any structural regressions +// understood and accepted". +// +// NO skipIf ON THE ORACLE. `import mammoth` at the top is deliberate: if mammoth is missing the +// suite fails loudly rather than degrading to a snapshot pinned only by the code that generated it, +// which is self-referential and reports green. mammoth is a devDependency, so it is always present; +// retiring the oracle should be a reviewed deletion of this file, not a condition that skips itself. + +// Vendored from mammoth 1.12.0 test/test-data — see docx-corpus/README.md for why they are copied +// rather than read out of node_modules, and what each one covers. +const CORPUS_DIR = join(process.cwd(), 'tests', 'fixtures', 'docx-corpus') +// The three fixtures the rest of the suite already uses. They are macOS textutil output, a dialect +// the vendored corpus (all Word) does not otherwise represent; elsewhere they are only asserted with +// `toContain`, which cannot catch a paragraph-break regression. +const FIXTURE_DIR = join(process.cwd(), 'tests', 'fixtures') +const LOCAL_FIXTURES = ['sample.docx', 'empty.docx', 'image.docx'] + +const DOCX_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + +// Documents where our reader deliberately diverges from mammoth. Each entry must say WHY, and holds +// what mammoth produces — the snapshot holds ours. An empty table means the port is exact. +const ACCEPTED_DIVERGENCES: Record = {} + +// The entry point omits `extraction` entirely rather than ever returning '' (a ran-but-empty +// extraction is `{ status: 'extracted' }` with no text). That collapse is a library-wide contract +// applied identically to whatever reader is installed, so the oracle gets it too — otherwise +// empty.docx, whose text is exactly '\n\n', reads as a divergence in every run. +const asExtraction = (text: string): string | null => (text.trim().length === 0 ? null : text) + +const corpusFiles = () => readdirSync(CORPUS_DIR).filter((name) => name.endsWith('.docx')).sort() + +const cases = [ + ...corpusFiles().map((name) => [name, join(CORPUS_DIR, name)] as const), + ...LOCAL_FIXTURES.map((name) => [`fixtures/${name}`, join(FIXTURE_DIR, name)] as const), +] + +describe('docx fidelity — our reader against mammoth', () => { + it.each(cases)('%s', async (name, path) => { + const content = readFileSync(path) + + const result = await extractAttachment({ content, contentType: DOCX_TYPE }) + // No corpus document is anywhere near MAX_OUTPUT_CHARS, so anything but a complete + // extraction means a gate fired that should not have — worth failing on directly rather + // than letting it show up as a confusing snapshot diff. + expect(result.status).toBe('extracted') + expect(result.truncated).toBe(false) + + const ours = result.extraction ?? null + expect(ours).toMatchSnapshot() + + const { value } = await mammoth.extractRawText({ buffer: content }) + const theirs = asExtraction(value) + + const divergence = ACCEPTED_DIVERGENCES[name] + if (divergence) { + expect(theirs).toBe(divergence.mammoth) + return + } + // Paragraph array first: vitest diffs it element-wise, so a lost or hoisted paragraph reads + // as one changed line instead of a wall of escaped text. Then the exact string, which is + // what catches trailing breaks and whitespace the split would swallow. + expect((theirs ?? '').split('\n\n')).toEqual((ours ?? '').split('\n\n')) + expect(theirs).toBe(ours) + }) + + // A fixture added without a golden, or deleted while its golden lingers, are both silent holes: + // it.each simply enumerates fewer cases and the suite still passes. Counting them here is what + // makes the corpus itself part of the contract. + it('runs every corpus document, and only documents that exist', () => { + expect(corpusFiles()).toHaveLength(17) + expect(cases).toHaveLength(20) + for (const [, path] of cases) expect(readFileSync(path).length).toBeGreaterThan(0) + }) +}) diff --git a/tests/fixtures/docx-corpus/README.md b/tests/fixtures/docx-corpus/README.md new file mode 100644 index 0000000..fdb7026 --- /dev/null +++ b/tests/fixtures/docx-corpus/README.md @@ -0,0 +1,62 @@ +# docx fidelity corpus + +Seventeen real Word-authored `.docx`, vendored verbatim from **mammoth 1.12.0**, `test/test-data/`. + +## Why these files are here rather than read from `node_modules` + +`tests/docx-fidelity.test.ts` pins our `.docx` output against `mammoth.extractRawText` — the reader +this library used until the streaming rewrite. That suite needs a corpus that is: + +1. **Reviewable.** The acceptance criterion for the rewrite is "output diffed against mammoth across + a document corpus, with any structural regressions understood and accepted". That is only + checkable if the corpus and its expected output are in the diff. +2. **Stable.** mammoth declares no `files` field in its `package.json`, so `test/` ships by accident + rather than by contract. A future release that adds one would delete the regression suite + silently. mammoth is also a `devDependency` now, kept solely as the fidelity oracle. +3. **Extensible.** The corpus grows with redacted real-world documents; splitting it between + `node_modules` and here would be worse than one directory. + +## What they cover + +They are chosen for coverage of the WordprocessingML constructs that change the extracted text, not +for size. Notably: `strict-format.docx` binds the ISO-strict namespace URI (matching a literal `w:` +prefix would silently return nothing for it); `text-box.docx` carries a real `mc:AlternateContent` +with both a `mc:Choice` (DrawingML) and a `mc:Fallback` (VML `w:pict`) branch, which is the +double-count hazard and the paragraph-hoisting case at once; `tables.docx`, `footnotes.docx`, +`endnotes.docx` and `comments.docx` pin what is deliberately NOT extracted. + +Five of them — `embedded-style-map`, `strikethrough`, `underline`, `external-picture`, +`tiny-picture-target-base-relative` — add no construct the others don't already have. They are kept +anyway: the value of a real corpus is the cases nobody thought to enumerate, and they cost ~90 KB +and no measurable runtime. + +## Licence + +mammoth is BSD-2-Clause, which permits redistribution in source form provided the notice and +disclaimer are retained. They are, in full: + +> Copyright (c) 2013, Michael Williamson +> All rights reserved. +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions are met: +> +> 1. Redistributions of source code must retain the above copyright notice, this +> list of conditions and the following disclaimer. +> 2. Redistributions in binary form must reproduce the above copyright notice, +> this list of conditions and the following disclaimer in the documentation +> and/or other materials provided with the distribution. +> +> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +> DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +> ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +> (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +> ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +> (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +> SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Their contents are trivial sample prose ("Walking on imported air", "Ouch.") with no third-party +material. diff --git a/tests/fixtures/docx-corpus/comments.docx b/tests/fixtures/docx-corpus/comments.docx new file mode 100755 index 0000000000000000000000000000000000000000..32cdae1c9aba5750fe4946fb5d2d655a5e83019f GIT binary patch literal 15526 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 aKaNjX4gx5P`{5Lk0eHaeSFYSYzWpCtk9uza literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/embedded-style-map.docx b/tests/fixtures/docx-corpus/embedded-style-map.docx new file mode 100644 index 0000000000000000000000000000000000000000..bb0a5e2d7dbc9b51d2e9e8295b4ef7c03e2a6e2b GIT binary patch literal 49709 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/empty.docx b/tests/fixtures/docx-corpus/empty.docx new file mode 100644 index 0000000000000000000000000000000000000000..e10b86995fd8b7d3930f4609916aa6cd83238a3a GIT binary patch literal 3306 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/endnotes.docx b/tests/fixtures/docx-corpus/endnotes.docx new file mode 100644 index 0000000000000000000000000000000000000000..1ae4f93e7384d467d40dfcebaba3905e6a2ca670 GIT binary patch literal 4431 zcmaJ^2RzjO|35SHY)SUYs7ppN%ibd+^O6;ZL)MwuB0HR}kev~c(XdBGwkR?pbVi6| zeLH@iOCt6EzaNi#+&vzj*YjSV_w)69z2DE5jyfJbEglF&1cIdrEAgg$oD{?Xfu`_5 zAQ}(|WGe6M)Uqjndb<@8@SP6(UKMX zl$sDrd+Lu5SQv9zsoismei*#|J{l}&V{Q4RM)d4$pHpL!CNH^XyEVh}&e$Nzf< zKK{x0DWc6%2Nr#{>xeeW;dSsdg5b`{VQru3WH>?e4ta@RM6);%*x z&Nmgc>F))83kO>~m5Gef?w>PwYp>YPgPyiqA6@p|&Je?7`Q}Qi#Uyp0v+U2%XP-&< zL8S9V0Y^Pi=(W!&>Jv#4Yec7(BxCUxt7=c|_V!_-3#xkTCSE09t6tG~U&bDvAun4R z2}8ZCGL}jvQD#w%_Uxz|^w`!ENq#a_(YNE8DMB_ep!bY5C@Xa>D@4bArbob{2OYia z1QXHz(c#DKIT^c{-JKOOa(eyp0Rw-tL<}%s?QEsv>g;lysQG48TsH8S6i_VX(J%`a z7pzp%dM}WM2vQPE*4E2Cb8x()%e9~@fhZl9ceQUAQ+1`oY-;ii)=p11w)qx9F-hkr zvpAo;F5^`sv}>1OvR|EZ+E%nq5H*pek~?=XA`1>)V6h8t09zvQqbnIuXtg(XN?#@D z3t9))X=^^yHnSD1qy-6}QchTV^LLhqCgYqN_S+5mByf#%FUmydk^kxl+-;3 zoR@g&l~ELC2tPqadv{RWyKYVHvox)ZQNr|Q@ss7e1L?%3*>EDH#h2Igw3~`t?q}mhHeU_fQ7tei-(yI-xP4)ihNJQ)#tuD3yro&bHh{^DH`x>oGbbj>mp90gD z)Llxpbq78EVc=zd({*K5BWBttI;tgin{U_awoh8!1NF3P+#u1RtKFe^EE%0yff#a6 z`-zhjaZ7}v+?fdROmS$X`)en4B_sj+Bf2^r5f~cmDU?>oPA3z00m_oIO>jWo-=ZM^ z$U`{0TL12>zeBK{^)uvOEjYFoz4ZMg*5xbmMs z(-{0!QH09d+K5uag3D}qZ$*Z+XM(f&}lPvO<2A&b*(H!&F9XvUEkVW_7-*i%bbIgq%S+pK6 z`ks&Q@Gtsy4`+!rPhy>LE%RgWLM-uKb8IsulZ?XVMa~WsNw;fiJZb;UILk>9!!W!Z zKa5kiPfmK)SpuyNo3KSux^R_&krd(p=G$57)M5^5f_L%Pq9py!n3FtII?w$AgMYy7 zzA$Za4EkyTQK|v=0#=wmi95dcg(D9n$A@9XE?vS~?lX&QD9746*$UEO>rAk4q8E#X+6&)T!1+f*q zns`fwu(%ZGv>&$wC+`)*RrZ-n_N#3oZp{w;4w_>X1QxX@u1)!o_P){le zTbbab)#mixXp2Z+7C6QH*Yac!sf^vNaE7jW*n88~%b!7}MqFM&)J#UFIttrIrrUQE ze7~EAibV;s+Rn%hm|wsvE%v@B0<-(lP@6$jrR~z3>mY1sHI=5772HlrGPJldSEWrqfE?I~ZAi6m9viyI8FvRMcR zsxBxy^vDKmpg z4r`qXTv5v<9_$1_trGz4|E{%-leLqxyNw%G^_6kGYJlKdS7a31$MZ}Tb1I-UUyRNC z1IxOdLgqD8T8sUC6Co855gcD^cF~*u1y!sebZ3@A*q_YubmsOA>)L)b>P$h8f3Kwt z!jUWueT7T}yN;R}SQV7XTQVzf-QaCA+2|_K=dY+`VY8kQX&n#x0b()@(C6Il@wWFM zx_YCuu>G5)XrS!K6O{|dydoQjRbygQp{(TBh8eVQO0c#CswhwQn?|=oWoez2m_QY` zb;wFXxXWHhOUlE5b~=Gx=^gPU?`JT7+|%{Q2cNF-%(%LO3$CGSEFZ1Q<|0q8vN3$P z%|XjK{G5$Uh?w%}WchmRp3~ZAR~`gmfLr;y%5v(L4Vu?ypz6# zrQ^?&+cBmVdvdoS(wo+_m?hX~hn8B~L9;j|J(XoM)lq)eG$vT?^bxo2p;ugXvaD}* zF^1LpfADz6cH$Jr#~O@Oy-b}^Hi1_lSM&rVf5YAH(%>$?*ILtvbWXE}3`}_Y~tx@{JQaWqpbR^-nOH`S27@9aZq^1V5zr zEQF_r-?eEVc&#*4=5FQ!tU`!X@PvK zWN8Pac8}XunugIUv!TG4+3nnYU05VP{ymA)47v~Oi*rv48jXAn)qf`0c0(_r>^&;` zAqRq2H?MIKYOv%{$|r*umh>l)uYNVcfY9Fa=o3Uf-bW(3m z=R4Z-8{wZD!yy&yN&^b~mIMT%I4H84ySL-7wzvk@9u=hAtuv?E_Er5L#2XR#%86Me z|KY_Aypj&lGUPC_H#f=rvLp<5UGY-d7IhoC95eQ9tEcXjH-FS5mFO)#%XgO}jRH9m z_)Z#k?Mi9*a?;1+B*?K7v^vJLIF($67DGCokmfY>oOFMA*Vrlg6`8!6>)9Z!965_N zdfk*8j)t~VY76=>6k7tc?rf7RZCV(&OJm7Hf)GvLl-4IZk@rPWmCt+y`r=_a-m{Pl z_lY>27RS$?d`3nis~v+IH^V-Z+_r!J#EajMhA$eaI9(MOT)H77_*m0HnKYEvqbrnl z&V^U?f@B=TNVvQq>=sRP3aUb1jkDhiHnU_h%bUM?(I+w)5vAn(pnzazERed#YNJ6t z=SFzZWXf6D7faOD-}E_m31v&-uH4jMvfMCGPbZ9#W+nXq%>+Xd!UoA1O1JEVrNycm zVOFTVs$x)r@o4JfvAf;Ai1O!x>E9---%*ETZDQ1!9hj|OP;HbBV0zE%o)NcGAzNTB zsE^cF32Dq(&0roGoox#L1mW#@l>tha(Pfo$*&qpGz5;J0=v7#((9h@VH$%m0r2Tgz zRShO}B|U()J>k5HwzP8Fy!Vve6(=gQjv~Z;Jc}K9N4f+|KB03_r@4Y?xlD2lEmw^i zqY;Cl($2flTuMleQi^ecp(uj`Sn)k$U)QMWYJWN{t4E^eq(KrZlc5``Wbfh7X2e)- znVyLMBfDjBRC5p+SZTnSQ#^ki`HZ=eVJqWNppe;J&LoD4Epv0HO|wZyD@uvDH44FZdTRav#XkVy6iE$GQoZF{L z`5Trq2R%i4UCYnIpC`p`@G>vW=3ufH19a4Ja4+Ni--8|?XJD}f<^*BC|1tI*{=wf? z3OmT`FUr6rSqtFqIPg?|COtmb>|?M=!+v9aTB`pq8Fn7nU)W@KfPOqO9sHT^_`u$ z|x2yX=>_&-z)JI(IQOkCW>36 NAYDKZXmV`${{UN&`Q`us literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/external-picture.docx b/tests/fixtures/docx-corpus/external-picture.docx new file mode 100644 index 0000000000000000000000000000000000000000..34756262cfd0f9c35b89dd0d6085c4e51a6dcbe7 GIT binary patch literal 4120 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 b+`#X{qk=Rz5JG=|CJLYk2%IzAKfe7x*WkO~ literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/footnotes.docx b/tests/fixtures/docx-corpus/footnotes.docx new file mode 100755 index 0000000000000000000000000000000000000000..3228e74b1c5b596a3b4d46b5110a57fb8105c4d5 GIT binary patch literal 15936 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/simple-list.docx b/tests/fixtures/docx-corpus/simple-list.docx new file mode 100755 index 0000000000000000000000000000000000000000..c2cd5ae96ebaf909db41c7c852a643cbcc9d9950 GIT binary patch literal 13887 zcmeHubyS?m^7i2FuE9OHy9Kud2^!qp-QC^Y-61#$5G1(EKyVB08sMAk-Mzb;yXQN< z|L=XznYU}syw5Y;Jyl)RT{TLw5RjMvXaFn#03ZdNksf)+fB^s)(4ZFp7FH+6-F>eTmuIQ!BuUo$5t~$l$15IaU<#q#2$@l9u(EQe}3|c$7}Ge2U=HA zp9tEsWre*MSxfLV)X?d`Yfr_vNO!b?jR?U73SxMpDZ{S%QJHkR8l`uTWIG6nPg3Dr zaB#Gh0^G{Twy=TcU2r?Y8-CFz$ue82&{I-(XJqX9XT{fe6IEH3ykZV3CMcz2j+FUR zsW#(UaA7KrIuv6O*uz%$C=O|DIeyeTJ0kdpYOsih;Aa<*&8Ok#ujocY+>sNWTW@5i za8r{N55kD7J@JEtuLUW$JzssX=87fQQwyS$TYZi;80^HND19c3lm??#`H|XhR;5J> zjzqLR*XYS9Y3#_50Ce207Q;7qMj2dHD1S$!yvBDXBEnXBbM2le1#xzCDZ*pGHgscj zX;F0h38aB9FAxBwznL*{Jbue1=o1Byl_G%5Sl7YW%8`lj=ly@?`(JFce;ImI!mY|Spazn;>8Wt@pgO1SJhBcKz7M>~>EcK@ z))0)o?GmE_ot5-}L$n3$H3PmC^8{sfyg?N_=el{p+_m_NZe&a_16)2__{61`a~6c= z2Nb8J0{6jMzE`9g4A|bDk79w1OlE>0hxVT~GATY2$^iOA5E1|&1i*s1+Bz69{mW2{ zYz>{QL0<2tyZggvz(76^^z?uCRh8JI(8ml?8mUgPWIlO5Ju$OmM!zO{M0*A0(rU8= zx*NYPfvJk>*YpC{`)e1c6ETgeKlXX=z(bQc;3E6Np!ABy=#?psyl)*YKZ8@~JG|9` zfa3!+$a{D<`vhyoG4(cBFrP6(W2m8X#AX#Th#(+ZLkyTEIcvtD==2bf3cUgILVF{7 z@<7Z$woD92CK`odIDZr$ z?+1i`(l$P;M&}67YWyAs06+sN(Qn%JS3Mh5{c5w!jM{~>F9h2;m_XQPA3P*7!!F{7 z&AfS|J&8$)whNumaNy$|I%`mh9Ket&3KP-T>E0OE+4=C=t*LN;mC`Xu0Uh(Lv`UXr za$&BJ%>*`01M&#CBc*xF&1^yC&LB#vwHnPdES(M{>aj0U0BJ7xc4ab|-g z)#6N4N(0rDsL58n4F4rq%Nb4yc&JaEW5k0O;l>4 z3uWivw7@Zs`6j^VHdzJQmYdmPMq`^A{q;t}Vwu6|d!lAMZO(qNn1pRFdzP-?LSUqg zSvbK-=dOoOyh3T9i8BQ}2|Y$i^RgrSD&MH%RkTJ+FZ6rpL;U_#Va|d^BdZZr8?J>n8C88Eiy@;{{o+>tKkNL4w>z^XH;v^8sh%?th~e}}mIYCiYng=L!?G0I>d0TqiSQYh$M0?kqnS$s-L#+htD7PbiD7Bu=)5)Gqii&Jm0C znXlyuzSx8kn}???DisJ%OM4RlxBS5R+Vw-R7Og||wfwOciyzsSc&&&gYwIN6CG@L* zNZ?$Is1~F^zu$U%A%0a}xo|RwNQ6#Gs|ft?p;i3Ya>Yrd#4ApRO#$fI@C1*~rUrS^7^cBJ&@j%?amq`^;6w)V zH-3trV?(yPFHrm5^@uN5rsuXa**nA4nntE+)_rWMvGrMMw-T|dvvP~DQ(>;YC8(y? zg7SvYvmjnLyl(IE5Sakfod%YurLc}k_*SoeHBS+~vCna7nXlr|_6mO<&Kj*&D4nq~ zZcs0PHhM+ic-IWHFmzn%A&Bee+B=ILH)-iOSVtP#e7|@0L|;7@63LYpHnpvXTulT# zHJwEmN^3lpxx@dwV1efq5@1GCAlrz0DgHX58Fy`~00n?#q&@{%ZZ0#h7)o=p=|({_ zk9fx0Q;f&AJ<>)xh9IpwsnXnqqKt^gQh0lCe_NX)^kxtxhe+t|``t$07L1Ul=y-*7 zR?v8V2! z>rCEX()ccJK)fJx8}|4T7n4oRo|2l}z8k9#cIF~tj3SACn;RXE9*GDw)trD}uh_Z_`sG%7YD8=LT4Ny9k0LN#%Z)~}Z`>pWF=MsLDJyI@@# z_+8sz_m_3ETh1t!$m38iwPrguf7Eb$zT9ti(&RFAQ%qDaW14-7T$EUD`gY!1<0*$n zP)1A6Rb<{tc_bk63i_JYag(B-mLYomttl(Bon8KC6C`wPZ*f)jFgtj8&u=s0qUrI- zlrn8WeFbd_`SOQtO9Pq3X6_bQ!juP}4pEc5}wZDc1 z*wpq_)dcghwX~y+VZI5~%5UJT=Sd3uiZG{FS4YLp0jo(`cR`nR&tb`Zq$dGx?Rr?a z@_GOP=Q|nRhM$q(w8z9_nN3#G;BXe1CJF)K!@66v@Ek9gjQCLN{AsS{HY8p8v&%Pm$GmV|r5?JLttv14Sh;ZVB&`ljxP01Je zX~O)dfX8u(r_C*XV4UG-5g7BDox|H|Xr^-!Yk^v6GXzjj7{Lx7MV#Y_rUb z>diFii+ZIknaZm``h_;5=mg4MtE%CZuym7r5EmjX{rG~bRK(jrw?KnPL3j34Hnyz3 z{E4+Sgq$N<+>&k9*9sTx`3iOa?hr^gmIjPg;G4dmL=?Q$ryMW;+3KJmVy6RUp1 zl}HlAo@|!-eOyz=INjV;1=XW$l;7G{0+X(X@ z;-c$mh{w9xc!fgeRt})k**$erwnfRPp{W-jp3#-rrLNzUtHtvRU~1^&c`-fnWeI91^o^TXHUeT zP)Q=Fa+7_Ld=Wz3Vx@}XOXZ8du-?D(?gSnIW$9~owtB%It!qWa) zWL=DxDXWnwH4g$Gp3E1L>O;gX$sQg^v@Qw#>8|MZmPBM%U2+Q@(gl@+#hCdDXvgP{1u2>L@Ss zf{q&}M(aQDm@P)s5aBLBPb?U^D zs8%T&?K5(5bKAr*c^84z^`TO@d!~mmCA{Z6QAI-UE%lOwb>lRUs2`ZE&}Ur%k#5ZV zoqPDWUzBou^r}^#MopxGt&5XMk2P_nFq{LbuQc>di}3yfVb+ILDqgHgSnN|9kE2ii z$q3G83N`(Vtm#i_ABy`2H!+^?@-PzJT}#OjlG;Nkp&;tPwQ|Q3J^W>FpNqJjh=`2M60>DhZ0HvhhhtMq9-*Lj2Iu51&=!?zPd0)@g z?w1GVr#jX<2#gS`5#D9Egbe9$c+!9oGHzwJGu$9YU|iMyP%Kj#SA_5~M?Vr2DT`OB zNQYSbjj6vm)2LAN{#1gBD;@Dwt?!H9S6y|)D@K;}=dXl~B*VS8xBe@@Ca9V4I<#jC z7~xG}ZaP9Ivr^)&uS~OLZs(uSdBzM|Qyb!YPwY^hP9Zm{Tmwbe_zDjoCmBZ;3OA@? zLNncXRv@WP>zdP0Zo!?r&BQNO%8V!l$|PXiFH{#c_EjZ^foRbT2Sig+8LMc~gx^6z zI7mR;Cz^tFI}MZY@|0xnUE(1_rP01-;O(6wu|BdiCNm#&g`afFo5d;~=s+)TmG5`i zTI$tw?T}?i?MTjVi*R`Gs@Kj?p>FA-2cKKA`DSW)@#xlpmLAYjlvA9Gr2Sy|IKSmB zr@9#Fkxs2^sBmY{&=REchAfVND46xSX2+ZO?Z90vP;1u~uWK9$@zB({gJsnrkg^5G zWx8Sim_B~_rHWNMaD2>Znl)K)s|YuzY1_EtoQYMXqg5NfcX>#;5zl;k?Q|ulQ{SX)IY9#TqwMpVP9^8B@iHGUJ z?Er7X`JlZhQvP){iG|$7&!P%1kVDQAkL2V2bKIw-r%c=y+&q~M*Rj=Ouh|B0r2whZ z$u(Et)hDqDP4-xsHo5f)%JeIf+3 zwtSIT2#zFlr0kW zm7P!~e>Q+A8*tc^*V`b^GNMsWTgj4+CSMK01Kl7exk2FH1iSN-&?xFvNNg>FtP-{U zOn|{iAcX4A*0S%z`M}uS;7>}JGTK#0cs^bZ5ocaWy?O{0LmtpjnRTHfxxj*A`EkCa zPk|WIe%}61v;r5u(t`T=8}09t0E^*R=Y}DCH^e5~8p*AXFWCvl_VbpvvH1-JPq7n@ z;Ug!ve02mD*q0O-n2Mk6JhbOH_IPAMY^j1)f}h8D9{-#$^tfh#I21O<57GtJU@H10 zUtS}Ux-8Pb#RTG6+8oQLnL?r?K?`YAR+(zD;&Z86h@9zib~3^-K3=$*PdaY77tm<~ z+;Zim)V6t^WkANDW9g6fus_&Qw9J;4Qm99&Im}QSymj6npne?E5fxLKnm2@57JTJ~ znpqJsa_74o0RIUB4Tke=541X%lelwU1jOkHB08~aZJ?Gye<6A7YrNvj{_l5#u4O;k zs7=}Vo;YtP+(IYZ-gP=g^LtUmTFi);jf&;Z^I`yjLuN5EK3TeSMdS_f1Eh4DF~`yP?r1y}4p+keaZ0o{&H!7cx2^ z%sQRi$0I%lP8*)5qp_EN45+1gN&(oQU|Lxa0Kosd3x99!WcJ3y#Msd3cW zxaMl|DNy3HJl1H-y2OTBGIsPz>d@#WJ!L-)LwfcgEb-|IAp!lhYzmb6fwG~-7Zj4u zLTO1}w;Pm*S*AHfq7sg-7bZu%{1-(`1%qvmb}!$zZ5O23uj;1{oEgfe(zz%#{{Ya840TpNb!9>nh51oxe*it%Iz&Y$TpuC#a{r zXq=K0Bqu&UYOk!Et*wsYeemc=cDdM&5$Q8;Q%G$o`&u@@bjz{pq9VVT0wTJ&L%lJr zLt$TMMNooOe+0k}lp7~hjX&{>gV*)o^6I}Jk)0ddW~3Fw&b<=KHA*TT-wi0vLA@m* z)uY7JI~y@hg2(CJU>IXm?z!=4Q?osO8jx*_Xr|vla4ib^?yGm14&~^|`#M*f9>paG ziot^+_k4f*Qgtq|4I!2o`(O(FXj~^Hi-9q@J|4O8M+>@5CkDUQbj_F1z+@PcMY=5# z(L94;GZGb-QZ^;_%Z>LnRH-}Z3?x9n=L(YzL+b&NOyP2DiTVw#?gzRq>Jui`JA)Go zY<{oSF4-VCVTrwy<(3FI6dZm`-Jny)Ig$udlqt=-AGsW{#P3dQb6sXzq()`Xv8jZY_v$)yI+L@D$3*=)vU6tVA*0x%60(X+$kA zN~tYMQWgGGS;073bisGRsj_&GkgyLD{6IpGYmC`;i=#MsHjJd39K!pq2!(5_JnWmU zA@Kb~A{nmlw9aoT?N8Kui7~KiiRRP~ZC;^|yy+gbtFhn__`rapK{n^oXeg1#S6S@Y zMCFUP#UTrIG~6L9Q2PAA+B<6^-0#>D=734rD?06Jz{nP%10ij2f%VLNbLA2z@on$j z>9j_gjzp=Wz|4axe+?UqOK`=GOYgWARhKta)Ke|loTFV`W^PN+v@v0F-(^a>71wk0 zBN@s_xyX^kcytcNw6>TT4vOMby3AWgXJ?6gbU(PBCIk;ypkS}>jQ0MGY7EPBrkYcc zchF0TJ(0QY!r;b_gydc5=c>)Tl!d5Hms(?zL{;J>%RFUblWi<3v{uxi$VH%J_?M{8 z0N3rAwk!U-F(l>EZEY*(Bw^ChKBX&zzClq18TlKkps*phiuw{rHKA%szoCN1NFju4 zNj(d;NjmG>jO>y7CS&M4sQjBrA;)y*)BMuc8KK215@&k}NqpQu^O51c=?D+jrjRlwXd2hdSvPDMs(yITWlDH}jmp#Oyc3}2!X zE$^prz_j`;ZsQ9<51D1)+-!v#x$pzlaP0%O3YINoSQV1Qal|LQWN$?+o*yH}fX(Q< zIE-0LThKRUbDm4oUC}4Wl+{wW_3F|Nc+OM0^ZC+SC*@uOZW|4 z=!yJnujny<1`{50FD~a2N<(lK5nKVH;|lUh$5dE7WYlE?4V%$=-`~^pE7dvKD+m1S zE*cW~D(oclW;EcZpyYdg5`sv*3X82lPz4Gm2*Aij0-;oYwm*Z3`N;E@ND!q0DD*+Y zba1fs8n|pB;5n%AeDLHuZm{wcP%dwjVTgKl}SGRPSfM;uuF)L0$a* zJ_hhzRa|-oL>y*@3mWl5q$`&ZH)zs^fO;IDpbZvPgP5W0x0Ya*M~j;&LNJHdoEH3u zC-6cA0-xJj8Xa35@-DL04ZH>3{q}oY&SzFp@cLr@I?@l)_uK2pF1a5uVeu(b{No;^ zZYu?gmD|GXc=_xS*PjQQFZ;Fq%%f%~n>{a}ZBSHe5xyTvA8exDrFRY38B;SVrqEm3 ziAa4rq^kjZ=gV)gBQ0y4FNCi26g(^FR#D}jf3ek4+At+K@Rv_&flh(pDnR}jPuw6T z2%da^X~~LMh_R|QO0vN&-D>fqxm+E`8w>O?J)pP)%e|I94|Lq#e=nz&iQK!xaQrSc zG_Ju|uO_Vrr6nE(%_d@5gDU+Qfvsfy(UKT)Ev-8)L<*`u}pWfK)JCk(8 zi&4dca{Yt*C_3NTHhK> zQROw%y^YR4iUi#O7+)nX7S@AR^8D^elhy&>5m_FmoLm+GEG_F zDue8?Q{6*ZKJBIA)FF=>O5qzypmQ=_Qc?O2swW|RBpWZcE>~`wj1Xs@9aT+Ex!d3F zev`SV`gVnxpP~tVZZtOFKF{qo`I){V>*%RpXUUy)H2=#yL44e&YnM^&wpnlj4H_Nv zlG)c6P*dc7&1Eb%D@vb35e~va!PTN+MX82knQHvxN%-Atn=r!zc4{@DadeU!ZVe$d zdd%rC~vy9DLB~I))fbv)8TAB-TiTNbXlRq zlQNNrn<iIao|Yzz^{LpATVpTS!=97V;EE;UyHo zw4mun;X_obn0ix7rCF2$(ZS7W?l)qH-{7@|Ih~-~q=p34P-#m2H}TVeF<~)M}r6}oNn!YoJLGAiMZwO?+<;8?{x~#L~xo$^y-IuXfM9Q^D`HHY{2`RkW7}W|#R&r*r6Z zFxvoeml*;4;c7e8^`*mu``_JJ#CGD03X`IgSv8GZ@6)z}yyix(PZs8z;hYICU%pf+ z@`GvSKCCtWfcmGLKF+up6*ov-dqKyo=%8Qcw_+3%P^GVmzJb+mIy;*z8w+H{48Bc% zctv&^%1z48!Hhp8a!?ZKQc)9a|GDB-d@X6;r&H%%!;)6;qIe0Y_#neaJzw5ihnKSg z)dno&A`XlMXBz3C4Hw!m3sE1ziPI1Yu!+$GTFWm%N04!$-}e-6uUnf2!~zbHqBwHn zns4X0<^5}eO(yG;nYq<9OVbByZIUf^eQYcEY@GW$wWTPZ7{_Rj6T6~22KrNqw>Kkq z2wq3o&X+3CI5k1fHv|M~c}Htme`|0^K>+YKs#WKjOcPo+Y>rCFo-iyz(XMG%YUtmA?kTIZpKVF zDY)wCBs{J6d12K5Y%tuNK2=V(nXhcz!UX6q z#NDlO+tjh$7HjnmQMXzY$~$Jf^{GjI952kY9ExI1Jlo6Gg_!DxJcT5@H}DNQl#hcK z-KO~dBWjP;^bdP(ZZvmq&LS7G0Ox7DD0K7j+_63V@oRAtm#rc@e`)qtJi7(5l0&3M zJ!^0y&Xp#dXmw$5$@}N+xn&gA?DP z@?>+pg^j0SCUlDq48w+cS zl>d@ri<<0_fnY&}Uhi1vV`ytxN+xS?Y9V=dP8RS0Fn4hw7&YpZv#3(E1y+!J@ED;; zk4^V&dl1&`f`}}fEI0X32_I106I`xb%XGLDT|tq~jH?L7+YIxz^7+CbeHk&+RI2<( zQU#5OW>Yq>1XHWJ-v%stlvb1`mYgL+MJ9(G-)Ff$oid85E&7u2_*zv*x6(Fb;;k8B zb}(N9Ur=1kQ$`O?e1Mfs(yQ0Gg~pqttQx4ak9k7wX4-eg8T~@A*~aMjGo1{DiWuJ^ zRDqcfZlbkxO~-whVPgZSkdBQ?-r=zZ4*P=o@?IlJQ0lL0qHmLOiBDzoUyN3KL9_Co z^)`+AyJ3u=q+}2%2l8**v~jjJFm?c?C4c&p(f4CEKw;GG_Xm6=Yk}6xL7MgjG_+W% z1gbI>IXI2$syBl`o#l;;)bHe1^%wBj*Seo$3Ks3w1N2>PmeXzb2vBg|D@Gx|W57b= zuUpACL}Etik2sUpd-6%k9jo%64y#4QB7~X8rawHmxGaqv-GkFIfQy0nygod!$y`TP zZMgPP6<0rGHZEQXo^sv;7v3cDIwNRtJV<+qe9-yAwjPsI4c^WMRWZInhI?KDgJsul zUQ;Th2_L?#9xowQ+6#K0W!gxZW=iX`O9@sKY?ZjfC&c|KNxQGR=b?O(*No7A;xjWd%bEf~FqE+MOy-N!a7ZxYY% zmR;04T`<5>S2*d#(&*8QFw={!v8A55A8NL0Os7;ye8t^=rBARmObukD)OcdIoUOv@ z&ggHtup2nJGP#0O>75#$U%;_8MoMC&Br{% zKL-<%bPG$TlGsJqA24t|MG86|SZK3wMjKR-jEvuK=+> z-c&Nq!sP>+BRRTs9pTD`RcLkyN05VUT7gYfv}Toj%mi@ca;>H_TAPMM8A}J@03|8c z{je{xvZmZyiz^_442XkEa#)z6iqaEp(&z(8q-g)4(nUeUe$xE`Z3gq*R4o2{AJ+SQ z%a%L^G5gh4Q-rnm?|XpV)tQ@m+tyvvpeHd(P3eVlHpjJQvjR4$_5?hg#k6)HC+mo7 zxbF@ZXv$`kR%Q>EdT;0EB<-0+j}aS8k^(kR)dLjS2C);TV+MQWJnn_HqMg^%V_nY} zo&|KDLB8!@)&&Os8npWS^FLhvh)w=z|Ka~GO0s_^_rAHbjOAjoY0QfdDy@b3l3 ze+3?b>g)c0<;TA={aQ@=Czl4?Ka`gK3jejF?@u^7;_vWZ3;cei__cK6Pl{vEqV?NY z{#r=!EBx17@1Jl_>_6cD&iwug{`HjkPp}~I-@w0|IseM@_x|+u$wGnD6aWAnm;$6hn!e8Z^WFag7RrZM literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/single-paragraph.docx b/tests/fixtures/docx-corpus/single-paragraph.docx new file mode 100755 index 0000000000000000000000000000000000000000..09caf9a2e3c12900479ffd18706f204677447307 GIT binary patch literal 12773 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 F{|`VLytDuS literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/tiny-picture-target-base-relative.docx b/tests/fixtures/docx-corpus/tiny-picture-target-base-relative.docx new file mode 100644 index 0000000000000000000000000000000000000000..1d61988c13b904b94517e28a5500e7ce61917c50 GIT binary patch literal 10881 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 AA^-pY literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/tiny-picture.docx b/tests/fixtures/docx-corpus/tiny-picture.docx new file mode 100755 index 0000000000000000000000000000000000000000..d45bb9e1bf21081332384acbc891ffdaddee05fe GIT binary patch literal 13699 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 z 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/docx-corpus/utf8-bom.docx b/tests/fixtures/docx-corpus/utf8-bom.docx new file mode 100644 index 0000000000000000000000000000000000000000..388123843ca3e8c41413d61ca8ff03f11a8112c5 GIT binary patch literal 2836 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 literal 0 HcmV?d00001 From 9a1208ba456622a03605d52c299d5fad2e5b7c7c Mon Sep 17 00:00:00 2001 From: gulatikrishav Date: Tue, 4 Aug 2026 17:39:19 -0700 Subject: [PATCH 2/7] Stream .docx extraction over a single inflated zip entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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. --- attachment.ts | 452 +++++++++++++++++++++++++++++++++++++++++++++----- package.json | 1 + 2 files changed, 410 insertions(+), 43 deletions(-) diff --git a/attachment.ts b/attachment.ts index e24e56f..ef5b942 100644 --- a/attachment.ts +++ b/attachment.ts @@ -168,7 +168,7 @@ const decodeText = (content: Buffer, hint?: string): string => { // HANDLER REGISTRY // Handlers lazy-load their parsers: a Lambda that only ever sees text never pays to load pdf.js -// or mammoth. +// or saxes. // Text — txt, csv, calendar, vcard, json, xml, yaml const textHandler: Handler = { @@ -284,19 +284,69 @@ const pdfHandler: Handler = { }, } -// DOCX — modern OOXML Word +// DOCX — modern OOXML Word. Streams word/document.xml; see DOCX STREAMING READER for the machinery, +// and for why the output contract is mammoth's, reproduced rather than invented. const docxHandler: Handler = { kind: 'docx', contentTypes: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], extensions: ['.docx'], - extract: async ({ content }) => { - const { default: mammoth } = await import('mammoth') - const { value } = await mammoth.extractRawText({ buffer: content }) - return { text: value } + extract: async ({ content, maxOutputChars, deadline }) => { + // Fail closed, as the xlsx handler does on !rewritten. Both bails are unreachable rather + // than defensive: an archive whose central directory can't be walked never gets past + // checkDecompressionBudget to reach this (Invariant 2 makes budget-ok imply zipEntries-ok + // over the same records), and routing pins the second — ooxmlKind only answers 'docx' for an + // archive whose directory NAMES word/document.xml. Costs nothing, and keeps neither of those + // proofs load-bearing. + const entries = zipEntries(content) + if (!entries) throw new Error('docx central directory could not be read for streaming') + const part = entries.find((entry) => entry.name === DOCX_MAIN_PART) + if (!part) throw new Error(`docx archive has no ${DOCX_MAIN_PART} entry`) + + const reader = await createDocxReader() + // Decode ACROSS inflate chunks, not per chunk: a 16 KB boundary lands mid-sequence in any + // document with a non-ASCII character, and chunk.toString('utf8') would turn that one + // character into two U+FFFD. StringDecoder carries the partial bytes forward. (saxes handles + // a surrogate pair split across write() calls itself; this is the layer below that.) + const { StringDecoder } = await import('node:string_decoder') + const decoder = new StringDecoder('utf8') + + let truncated = false + try { + for await (const chunk of docxMainPartChunks(part)) { + // Both guards here, ahead of the work, at one inflate chunk of granularity. Finer + // than the pdf per-page and xlsx per-row checks, and the only place a stop is + // possible: saxes has no abort, so the way to stop parsing is to stop feeding it. + // Per CHUNK rather than per emitted character, for the same reason the xlsx deadline + // sits above its empty-row skip — tens of MB of w:pPr/w:rPr markup produces no text + // at all, so a cap-only check would never fire on precisely the cheapest loop to + // spin. Breaking a `for await` destroys the inflate stream, so the rest of the + // document is never decompressed either. + if (Date.now() > deadline || reader.chars() > maxOutputChars) { + truncated = true + break + } + reader.write(decoder.write(chunk)) + } + // Only a read that ran to the end may assert the document ended cleanly. + if (!truncated) reader.end(decoder.end()) + } catch (error) { + // saxes is conformant where mammoth's DOM parser recovered, so a document the old reader + // read to the end can stop short here. Text already extracted is still text, and the + // contract has a word for "the document continues past this point" — so keep it and say + // so. Nothing read means nothing to label: that stays a failure, which a caller can see + // and retry. What this deliberately does NOT do is install a saxes error handler and + // parse on; measured, that emits close-tag text as content and descends into elements + // mammoth drops — silent wrong output, the one outcome this file fails over everywhere else. + if (reader.text().trim().length === 0) throw error + truncated = true + } + + // Emptiness is left to the entry point: an empty document is exactly '\n\n', which trims to ''. + return { text: reader.text(), truncated } }, } -// DOC — legacy Word 97–2003. mammoth only reads the modern .docx zip, so the OLE binary needs +// DOC — legacy Word 97–2003. The .docx reader above is an OOXML zip reader, so the OLE binary needs // word-extractor instead. const docHandler: Handler = { kind: 'doc', @@ -506,17 +556,32 @@ const ooxmlKind = (content: Buffer): HandlerKind | undefined => { // declared size is attacker-controlled. So measure it: stream-inflate each entry (peak stays ~one // zlib chunk), count REAL bytes, abort once the total crosses the cap. Unmeasurable = fail closed. // -// WHICH READER THIS BINDS — the two formats no longer share one: +// WHICH READER THIS BINDS — since the .docx handler stopped using mammoth, one answer for both +// formats: OUR OWN zipEntries walk. Neither handler hands the original bytes to a third-party zip +// reader any more, so there is no foreign directory choice left to second-guess. // -// .docx -> mammoth -> jszip reads the CENTRAL DIRECTORY, so measuring it measures what jszip will -// inflate. The invariants below pin both walks to the same records; their reasoning is this case. +// .docx -> zipEntries locates word/document.xml and the handler inflates exactly that region of +// exactly this buffer. Nothing else in the archive is decompressed at read time. // // .xlsx -> exceljs -> unzipper dispatches on LOCAL file-header signatures front-to-back -// (unzipper/lib/parse.js:51), only skipping PAST directory records — so the invariants do not -// bind it, and a local entry the directory omits would still be inflated unmeasured. -// reorderForStreaming binds it instead: it re-emits one local header per measured entry and -// nothing else, so both walks see the same set by construction. Hence the xlsx handler FAILS -// when the rewrite can't be produced; the original bytes would drop the budget on the floor. +// (unzipper/lib/parse.js:51), only skipping PAST directory records — so a central-directory +// measurement would not bind it at all. reorderForStreaming binds it instead: it re-emits one +// local header per measured entry and nothing else, so both walks see the same set by +// construction. Hence the xlsx handler FAILS when the rewrite can't be produced; the original +// bytes would drop the budget on the floor. +// +// So the two invariants below no longer exist to keep a foreign reader honest. Invariant 2 has +// instead become the proof that this measurement and zipEntries can never disagree — now +// load-bearing for BOTH formats. Invariant 1 is a structural check that no longer prevents anything. +// See each. +// +// STALE, AND KNOWN TO BE: this measures EVERY entry, while the .docx handler now inflates one. On +// real documents word/document.xml is 2–28% of the archive (a Word template measured 66 KB of 3.1 MB), +// so a document with ≥50 MB of compressible non-document parts — EMF/WMF vector art, embedded OLE +// objects — is declined over bytes that would never be read. Narrow: ordinary media is PNG/JPEG, +// already compressed, so 50 MB of it needs >10 MB of archive and MAX_INPUT_BYTES turns it away +// first. Left alone deliberately — loosening a zip-bomb gate is a permissiveness change that belongs +// with the input caps, not smuggled in behind a parser swap. const EOCD_MAGIC = Buffer.from([0x50, 0x4b, 0x05, 0x06]) // End-of-Central-Directory const CD_SIG = 0x02014b50 // Central-Directory file header @@ -555,11 +620,12 @@ const inflateCounting = (comp: Buffer, runningTotal: number, cap: number): Promi inflate.end(comp) }) -// Pick the EOCD the way jszip (inside mammoth, and so the .docx reader) does: the LAST signature in -// the buffer, no comment-length check. Matching its choice is the point — measuring a different -// directory than the parser reads is a bomb-bypass. An invariant here would diverge: jszip -// follows a second EOCD planted after the real one, so rejecting that leaves the parser inflating a -// directory we never measured. Bonus: last-match doesn't false-skip zips with bytes after the EOCD. +// The LAST signature in the buffer, no comment-length check. Chosen originally to match jszip, which +// read .docx through mammoth: measuring a different directory than the parser reads is a bomb-bypass, +// and jszip follows a second EOCD planted after the real one. jszip is out of the read path now, so +// the choice is purely internal — this function is shared by BOTH walks (zipEntries and the budget), +// so any deterministic pick is self-consistent. Kept as last-match because it still has to agree with +// unzipper on the .xlsx path, and because it doesn't false-skip zips with bytes after the EOCD. const findEocd = (buf: Buffer): number => { const eocd = buf.lastIndexOf(EOCD_MAGIC) return eocd >= 0 && eocd + 22 <= buf.length ? eocd : -1 // need room for the 22-byte fixed record @@ -580,10 +646,12 @@ interface ZipEntry { // two callers do with that differs, because they ask for different things: // ooxmlKind — identification, so it degrades to a raw byte scan. // reorderForStreaming — the entry set unzipper will be given, so it refuses and its caller fails. +// docxHandler — the entry it will inflate, so it refuses and the handler fails. // -// That second caller makes this walk load-bearing for the .xlsx zip-bomb guard, not merely a lenient -// identifier: the archive it emits is what makes the budget's measurement bind (see DECOMPRESSION -// BUDGET). Still deliberately NOT merged with checkDecompressionBudget — that one is the measurement +// Those last two make this walk load-bearing for the zip-bomb guard on BOTH formats, not merely a +// lenient identifier: on .xlsx the archive it emits is what makes the budget's measurement bind, and +// on .docx it is what picks the one region the handler decompresses (see DECOMPRESSION BUDGET). +// Still deliberately NOT merged with checkDecompressionBudget — that one is the measurement // itself, and its two invariants must reject archives this one accepts. Merging would force a single // contract onto both. What keeps the split safe is the one-way relation pinned at Invariant 2: this // walk can never be the more permissive of the two. @@ -623,10 +691,10 @@ const zipEntries = (buf: Buffer): ZipEntry[] | undefined => { const zipEntryNames = (buf: Buffer): string[] | undefined => zipEntries(buf)?.map((entry) => entry.name) // Measure a zip's ACTUAL decompressed size, capped, from each entry's real (structural, not -// self-declared) compressed region. The two invariants below pin us to the records jszip will read -// — the .docx path; the .xlsx path is bound differently, see the section header. Both assert the -// FILE is self-consistent rather than mirroring jszip, so neither rots if it changes. Every real -// archive satisfies them (73 measured, 0 failures). +// self-declared) compressed region. The two invariants below were written to pin us to the records +// jszip would read on the old .docx path; jszip is gone, and what they now do is stated at each. +// Both assert the FILE is self-consistent rather than mirroring any particular reader, which is why +// neither rotted when the reader changed. Every real archive satisfies them (73 measured, 0 failures). const checkDecompressionBudget = async (buf: Buffer, cap: number): Promise => { const eocd = findEocd(buf) if (eocd < 0) return corrupt('malformed zip: no end-of-central-directory record') @@ -640,9 +708,13 @@ const checkDecompressionBudget = async (buf: Buffer, cap: number): Promise raw text) +// +// Reads word/document.xml straight out of the archive with a SAX parser, so the largest live object +// is one inflate chunk plus the text kept so far — never a DOM. mammoth, which this replaces, built +// an xmldom tree AND a document model on top of it, then let the central cap 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 +// +// mammoth built 43,420,044 characters every time and kept 250,000 — 0.6%. Its peak tracks the +// document and multiplies by concurrency; this one is flat, because the cap now stops the READ +// rather than trimming the result. The sharpest case isn't even the big archive: a 0.41 MB +// attachment holding one 43 MB w:t peaked mammoth at 1270 MB, versus 199 MB here. +// +// Same failure mode as the .xlsx reader, one format over, and reachable from half a megabyte. +// +// THE OUTPUT CONTRACT is mammoth's extractRawText, reproduced deliberately rather than invented +// (raw-text.js is 13 lines): w:t text verbatim, w:tab -> '\t', a paragraph's children then TWO +// newlines, everything else its children only. Nothing is trimmed, so a document ends with '\n\n'. +// +// THE SHAPE is a WHITELIST, mirroring body-reader.js:46-57 — an element mammoth has no handler for +// has its ENTIRE SUBTREE dropped rather than recursed into, and bare text outside a w:t is dropped +// too. So the tables below are what we KEEP; anything absent disappears with its children. That is +// the load-bearing decision: "emit every w:t, newline on " over-extracts on any document with +// a text box or a field. It also DELETES work rather than adding it — +// - mammoth's 20-entry ignore list (w:pPr, w:rPr, w:sectPr, w:proofErr, w:tblGrid, ...) has no +// counterpart here. It exists only to suppress a warning; for raw text "deliberately ignored" +// and "unrecognised" produce the identical empty result, and the default already IS that result. +// - every emit-nothing leaf mammoth spells out (w:br, w:cr, w:fldChar, w:instrText, +// w:footnoteReference, w:bookmarkStart) is likewise just the default. +// - w:sdt keeping only w:sdtContent, and mc:AlternateContent keeping only mc:Fallback, fall out +// for free: w:sdtPr and mc:Choice simply aren't on the list. +// +// Fidelity is pinned by tests/docx-fidelity.test.ts against mammoth itself over a real Word corpus. +// The deliberate divergences are listed at DOCX_CONTAINERS. + +const DOCX_MAIN_PART = 'word/document.xml' + +// Namespace URI -> the prefix mammoth's element names carry (office-xml-reader.js:10-36). Matching a +// literal `w:` prefix instead would be a shippable bug three times over: the ISO-strict format binds +// w to a different URI (mammoth's own corpus carries strict-format.docx), a producer may bind +// wordprocessingml as the DEFAULT namespace — giving //

with no prefix at all — +// and a producer may pick any prefix it likes. Only these four URIs are ever matched; mammoth's +// other eleven mappings feed its HTML converter and its ignore list, neither of which survives here. +const OOXML_PREFIXES: Record = { + 'http://schemas.openxmlformats.org/wordprocessingml/2006/main': 'w', // transitional + 'http://purl.oclc.org/ooxml/wordprocessingml/main': 'w', // ISO strict + 'http://schemas.openxmlformats.org/markup-compatibility/2006': 'mc', + 'urn:schemas-microsoft-com:vml': 'v', +} + +// Pre-bound so an undeclared prefix doesn't end the parse. saxes is stricter than the DOM parser +// this replaces and fails with `unbound namespace prefix` where xmldom shrugs, and real producers do +// emit stray o:/w10:/wne:/wps: markup without declaring it. Binding them up front costs nothing — +// the elements are dropped either way, only the throw is avoided — and it converts the likeliest +// strictness regression into a non-event. Verified: an in-document xmlns still shadows these, so a +// strict-format file resolves to the strict URI and routes correctly. +const OOXML_ASSUMED_PREFIXES: Record = { + w: 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + mc: 'http://schemas.openxmlformats.org/markup-compatibility/2006', + v: 'urn:schemas-microsoft-com:vml', + o: 'urn:schemas-microsoft-com:office:office', + w10: 'urn:schemas-microsoft-com:office:word', + r: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', + wp: 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing', + a: 'http://schemas.openxmlformats.org/drawingml/2006/main', + pic: 'http://schemas.openxmlformats.org/drawingml/2006/picture', + wps: 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape', + w14: 'http://schemas.microsoft.com/office/word/2010/wordml', +} + +// Recurse into these; emit nothing of their own. Absent from the list = subtree dropped. +const DOCX_CONTAINERS = new Set([ + 'w:document', + 'w:body', + 'w:r', + 'w:hyperlink', + 'w:ins', + 'w:smartTag', + 'w:tbl', + 'w:tr', + 'w:tc', + 'w:sdt', // w:sdtPr isn't here, which is the whole of mammoth's firstOrEmpty("w:sdtContent") + 'w:sdtContent', + 'mc:AlternateContent', + 'mc:Fallback', // likewise mc:Choice: dropped by omission + 'w:object', + 'w:drawing', + 'w:txbxContent', + 'v:group', + 'v:rect', + 'v:roundrect', + 'v:shape', + 'v:textbox', +]) +// Deliberately NOT here, each matching mammoth: +// wp:inline / wp:anchor — mammoth's readDrawingElement digs for a:blip images and never reads +// arbitrary children, so the DrawingML text-box path (wps:txbx) yields nothing. Text boxes reach +// us through the VML fallback above, which is the branch mc:Fallback selects anyway. +// v:imagedata, v:shapetype, v:shadow — images and shape defs; no text either way. +// w:fldSimple, w:delText, w:ruby — mammoth DROPS these with their children. w:delText is right +// (deleted text does not belong in an extract); w:fldSimple and w:ruby are real text loss in +// mammoth, replicated on purpose so this change stays a pure port. Recovering them is one array +// entry each, deliberately left to its own change once the corpus says how often they matter. +// +// Deviations from mammoth, all four in tests/docx-fidelity.test.ts's ACCEPTED_DIVERGENCES if they +// ever show up on a real document: a w:sdt carrying wordml:checkbox has its first text character +// REPLACED by a checkbox node in mammoth (we keep the character — strictly better, and costs nothing +// here); w:sym needs mammoth's dingbat-to-unicode table to map (we drop it, and it is the only +// element that would make this reader read an attribute at all); a w:t holding a comment or a nested +// element makes mammoth's text() throw "Not implemented" (we extract); and the main part is read at +// its conventional path rather than resolved through _rels/.rels, which routing already requires. + +// Emit a literal, then drop any children — mammoth's handlers for these ignore children entirely. +const DOCX_LITERALS: Record = { + 'w:tab': '\t', + 'w:noBreakHyphen': '\u2011', + 'w:softHyphen': '\u00ad', +} + +// xmldom normalised these to '\n' before mammoth ever saw the markup (dom-parser.js:34-38). saxes +// does the XML-standard \r\n and \r itself — correctly, across chunk boundaries — but leaves NEL and +// LINE SEPARATOR alone. Both are single characters, so matching xmldom on the way out needs no +// cross-chunk state, unlike normalising the raw markup would. +const XML_EXTRA_SEPARATORS = /[\u0085\u2028]/g + +// The two markers that change what an ANCESTOR emits, so they have to be spotted even though +// they sit inside an already-dropped w:pPr / w:trPr subtree. ECMA-376 17.13.5.15 (deleted paragraph +// mark: the mark is gone, so the paragraph merges into the next one) and 17.13.5.12 (deleted table +// row: the whole row goes). +const PARAGRAPH_DELETED = ['w:p', 'w:pPr', 'w:rPr', 'w:del'] +const ROW_DELETED = ['w:tr', 'w:trPr', 'w:del'] + +const endsWith = (stack: string[], path: string[]): boolean => + path.every((name, i) => stack[stack.length - path.length + i] === name) + +// A paragraph's own text, and the text hoisted out of any w:pict inside it. mammoth returns a +// (value, extra) pair from every element it reads: w:pict moves its whole result into `extra` +// (body-reader.js:439 .toExtra()), extras bubble up through every container, and only w:p reinserts +// them — as a SIBLING AFTER the paragraph (:296 .insertExtra()). So VML text-box text lands after +// the paragraph's '\n\n' rather than glued into the middle of it, and an extra that never reaches a +// w:p is silently lost. Two string fields per open w:p / w:pict reproduce all of that. +interface DocxFrame { + value: string + extra: string +} + +interface DocxReader { + write: (chunk: string) => void // one decoded slice of document.xml; THROWS on malformed XML + chars: () => number // characters emitted so far — what the handler's cap check reads + end: (tail: string) => void // flush, close, and run the end-of-document checks + text: () => string // the text, complete or partial +} + +const createDocxReader = async (): Promise => { + // Lazy like every other parser here: a Lambda that only ever sees text never loads saxes. + const { SaxesParser } = await import('saxes') + + const stack: string[] = [] // qualified names of every open element, innermost last + const frames: DocxFrame[] = [{ value: '', extra: '' }] // index 0 is the document; extras reaching it are lost + const deleted: boolean[] = [false] // one flag per open w:p; index 0 pairs with the root frame + let top = frames[frames.length - 1] + let skip = -1 // stack index where the dropped subtree began, or -1 when we're reading + let rowDeleted = false // a w:trPr said its row is deleted; act on it once that w:trPr closes + let sawBody = false + let chars = 0 + + const emit = (text: string): void => { + top.value += text + chars += text.length + } + + const parser = new SaxesParser({ xmlns: true, additionalNamespaces: OOXML_ASSUMED_PREFIXES }) + + parser.on('opentag', (tag) => { + // A direct port of mammoth's convertName (xml/reader.js:53-66): mapped URI -> `w:t`, + // unmapped -> `{uri}local`, no namespace -> the bare local name. Only the first form can + // match a table, so the other two exist to guarantee a miss — but they keep the stack + // readable in a debugger, and they are what makes an unmapped namespace fail closed. + const uri = tag.uri ?? '' + const prefix = OOXML_PREFIXES[uri] + const name = uri === '' ? tag.local : prefix === undefined ? `{${uri}}${tag.local}` : `${prefix}:${tag.local}` + stack.push(name) + + if (skip >= 0) { + // Inside a dropped subtree nothing is emitted, and only the two ancestor-affecting + // markers are still looked for. The `skip ===` tests prove the marker really sits in the + // properties element of a LIVE w:p / w:tr — i.e. that the drop began at that w:pPr / + // w:trPr — rather than somewhere deeper in unrelated debris. + if (name === 'w:del') { + if (skip === stack.length - 3 && endsWith(stack, PARAGRAPH_DELETED)) deleted[deleted.length - 1] = true + else if (skip === stack.length - 2 && endsWith(stack, ROW_DELETED)) rowDeleted = true + } + return + } + + // w:t is the ONE text-bearing element; the text handler recognizes it off the stack top, so + // it needs no flag of its own. Deliberately before the container test, so its children — + // illegal anyway — fall through to the drop below exactly as they do in mammoth. + if (name === 'w:t') return + + if (name === 'w:p' || name === 'w:pict') { + top = { value: '', extra: '' } + frames.push(top) + if (name === 'w:p') deleted.push(false) + return + } + if (DOCX_CONTAINERS.has(name)) { + if (name === 'w:body') sawBody = true + return + } + const literal = DOCX_LITERALS[name] + if (literal !== undefined) emit(literal) + // The whitelist default: this element and everything under it is gone. + skip = stack.length - 1 + }) + + parser.on('text', (text) => { + if (skip < 0 && stack[stack.length - 1] === 'w:t') emit(text.replace(XML_EXTRA_SEPARATORS, '\n')) + }) + + parser.on('closetag', () => { + const name = stack.pop() + + if (skip >= 0) { + if (stack.length !== skip) return // still inside the dropped subtree + skip = -1 + // A deleted row's properties have just closed; drop the rest of the row with them. + if (rowDeleted && name === 'w:trPr') { + rowDeleted = false + skip = stack.length - 1 + } + return + } + + if (name !== 'w:p' && name !== 'w:pict') return + const frame = top + frames.pop() + top = frames[frames.length - 1] + if (name === 'w:pict') { + // Hoist: the picture's own text becomes the parent's `extra`, behind any extra it + // already carried. It reaches the output only if some ancestor w:p reinserts it. + top.extra += frame.extra + frame.value + } else if (deleted.pop()) { + // The paragraph mark was deleted, so there is no paragraph break here: this text runs + // straight into the next paragraph. mammoth re-reads the stashed children in the next + // paragraph's context and lands on the same string; suppressing the tail is the + // streaming form of that, and is equivalent because this reader is context-free. + top.value += frame.value + top.extra += frame.extra + } else { + top.value += `${frame.value}\n\n${frame.extra}` + chars += 2 + } + }) + + return { + write: (chunk) => void parser.write(chunk), + chars: () => chars, + end: (tail) => { + parser.write(tail).close() // close() is the well-formedness check: it throws on an unclosed element + // mammoth throws "Could not find the body element: are you sure this is a docx file?" + // here — for a foreign root AND for a w:document with no w:body, both verified. Keeping + // it means a zip whose main part is not WordprocessingML stays a labeled failure instead + // of becoming an 'extracted' with no text. + if (!sawBody) throw new Error('docx main part has no w:body element') + }, + // Frames above the root are only still open on the truncated path; concatenating every + // frame's `value` in order is exactly the partial text in document order, so a document that + // is one enormous paragraph still returns what was read instead of ''. On the complete path + // there is only the root frame and this is just frames[0].value. Pending `extra` is dropped + // either way — mammoth's own behaviour for an extra that never reaches a w:p. + text: () => frames.reduce((text, frame) => text + frame.value, ''), + } +} + +// One shape for both storage methods, so the handler's loop has a single form. Method 0 is its own +// single chunk (bounded by MAX_INPUT_BYTES, and no real producer stores document.xml); method 8 +// yields a chunk at a time under backpressure, which is what keeps peak memory flat. +const docxMainPartChunks = (part: ZipEntry): AsyncIterable | Iterable => { + if (part.method === 0) return [part.data] + const inflate = zlib.createInflateRaw() + inflate.end(part.data) // pushes the compressed bytes; the readable side inflates only on demand + return inflate +} + ///////////////////////////////////////////////////////////// // ROUTING (continued) — claim verification + the final decision @@ -1035,8 +1400,8 @@ export const extractAttachment = async ( return { status: 'skipped', reason: type ? `unsupported type ${type}` : 'unrecognized attachment' } } - // Zip bombs: measure the real decompressed size before exceljs/mammoth touch the bytes. The - // preflight also decides which refusal this is — over budget skips, malformed fails. + // Zip bombs: measure the real decompressed size before either OOXML handler inflates anything. + // The preflight also decides which refusal this is — over budget skips, malformed fails. // (pdf/doc/text aren't zips.) if (kind === 'docx' || kind === 'xlsx') { const check = await checkDecompressionBudget(input.content, MAX_UNCOMPRESSED_BYTES) @@ -1060,9 +1425,10 @@ export const extractAttachment = async ( ) // Central cap, so a pathological document can't dump megabytes into S3 and the search index. // Handlers that build incrementally overshoot by one unit, so for them this is the final - // precise trim; docx and html return a full string, so for those it's POST-materialization — - // peak memory follows the whole document, and hard containment is the host memory limit - // (see README). Don't split a surrogate pair at the boundary: a lone half serializes as U+FFFD. + // precise trim; html alone still returns a full string, so for that one it's + // POST-materialization — peak memory follows the whole document, and hard containment is the + // host memory limit (see README). Don't split a surrogate pair at the boundary: a lone half + // serializes as U+FFFD. const overCap = output.text.length > maxOutputChars const capEnd = overCap && output.text.charCodeAt(maxOutputChars - 1) >= 0xd800 && output.text.charCodeAt(maxOutputChars - 1) <= 0xdbff diff --git a/package.json b/package.json index 796935a..f57d2a9 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "iconv-lite": "^0.7.2", "jschardet": "^3.1.4", "mammoth": "^1.12.0", + "saxes": "^5.0.1", "unpdf": "^1.6.2", "word-extractor": "^1.0.4" } From 78a85bdc928740eef955cb2c7b2c4bf58efb4e7d Mon Sep 17 00:00:00 2001 From: gulatikrishav Date: Tue, 4 Aug 2026 17:43:07 -0700 Subject: [PATCH 3/7] Cover the docx whitelist, break and truncation contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/docx-streaming.test.ts | 374 +++++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 tests/docx-streaming.test.ts diff --git a/tests/docx-streaming.test.ts b/tests/docx-streaming.test.ts new file mode 100644 index 0000000..8534dad --- /dev/null +++ b/tests/docx-streaming.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, vi } from 'vitest' +import JSZip from 'jszip' + +import { extractAttachment, HANDLER_TIMEOUT_MS, MAX_OUTPUT_CHARS } from '../attachment' + +// --------------------------------------------------------------------------- +// .docx streaming reader — element semantics, caps, and the deadline +// --------------------------------------------------------------------------- +// tests/docx-fidelity.test.ts proves the reader agrees with mammoth on a real Word corpus. This file +// covers what that corpus cannot: the corpus contains no w:tab, no w:br, no w:cr, no tracked +// changes, no fields, no w:sdt and no w:hyperlink at all — real Word documents simply don't carry +// every construct — and no corpus document is anywhere near the output cap or the deadline. +// +// THE CONTRACT BEING PINNED, in one place so the tests below can just reference it (mammoth's +// raw-text.js is 13 lines, and each of these was measured against it, not read off the spec): +// +// w:t -> its text, verbatim, never trimmed +// w:tab -> '\t'; w:noBreakHyphen -> U+2011; w:softHyphen -> U+00AD +// w:p -> its children, then TWO newlines. Including the last paragraph, so a document +// ends with '\n\n'. +// w:br, w:cr -> NOTHING. Two different mechanisms in mammoth (br is a document node with no raw +// mapping, cr isn't whitelisted at all) reaching the same result. +// anything else with no handler -> the WHOLE SUBTREE disappears, children included. This is the +// one that makes a naive "emit every w:t" reader wrong rather than merely different. +// bare text outside a w:t -> dropped. +// tables -> one paragraph per cell paragraph. No tabs, no row markers: a 2x2 table is +// byte-identical to four consecutive paragraphs. +// +// Everything here builds its own archive, because the point is to reach constructs no real document +// in the corpus contains. + +const DOCX_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + +const NS = [ + 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"', + 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"', + 'xmlns:v="urn:schemas-microsoft-com:vml"', + 'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"', + 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"', +].join(' ') + +// JSZip output satisfies both decompression-budget invariants, so a fixture built here reaches the +// handler rather than being turned away in front of it. STORE covers the stored-entry path. +const docxFrom = async (documentXml: string, compression: 'DEFLATE' | 'STORE' = 'DEFLATE') => { + const zip = new JSZip() + zip.file( + '[Content_Types].xml', + '' + ) + zip.file( + '_rels/.rels', + '' + ) + zip.file('word/document.xml', documentXml) + return Buffer.from(await zip.generateAsync({ type: 'nodebuffer', compression })) +} + +const buildDocx = (body: string, compression: 'DEFLATE' | 'STORE' = 'DEFLATE') => + docxFrom(`${body}`, compression) + +const extract = async (body: string, options?: { maxOutputChars?: number }) => + extractAttachment({ content: await buildDocx(body), contentType: DOCX_TYPE }, options) + +const para = (inner: string) => `${inner}` +const text = (s: string) => para(`${s}`) + +describe('docx — the whitelist drops unrecognised subtrees', () => { + // mc:Choice is the DrawingML branch and mc:Fallback the VML one; Word emits both for the same + // text box. Taking both would DOUBLE the text, and taking Choice instead of Fallback would take + // the branch mammoth discards. Corpus-covered by text-box.docx, kept here because a 20-document + // snapshot failure names the document, not the branch. + it('takes only the mc:Fallback branch of mc:AlternateContent', async () => { + const r = await extract( + `` + + `${text('CHOICE')}` + + `${text('FALLBACK')}` + + `` + ) + expect(r.extraction).toContain('FALLBACK') + expect(r.extraction).not.toContain('CHOICE') + }) + + // A w:pict's text is hoisted out and reinserted as a SIBLING of the enclosing paragraph, so it + // lands AFTER that paragraph's break rather than inside it. Emitting it inline would glue two + // words into one nonword token — 'HelloBoxed' — which is exactly the shape an LLM reads wrong. + it('emits w:pict content after the enclosing paragraph, not inside it', async () => { + const r = await extract( + `Hello${text('Boxed')}` + ) + expect(r.extraction).toBe('Hello\n\nBoxed\n\n') + }) + + // The other half, and the counter-intuitive one: an extra that never reaches a w:p is silently + // discarded. It looks like a bug worth "fixing", so it is pinned as the contract instead. + it('drops a w:pict that sits outside any paragraph', async () => { + const r = await extract( + `${text('Orphan')}${text('after')}` + ) + expect(r.extraction).toBe('after\n\n') + }) + + // Over-tightening the whitelist loses real body text silently. None of these containers appears + // anywhere in the fidelity corpus, so nothing else would catch it. + it.each([ + ['w:ins', `KEEP`], + ['w:smartTag', `KEEP`], + ['w:hyperlink', `KEEP`], + ['w:sdt', `KEEP`], + ['w:object', `KEEP`], + ])('recurses into %s', async (_name, body) => { + const r = await extract(body) + expect(r.extraction).toContain('KEEP') + expect(r.extraction).not.toContain('DROP') + }) + + // The load-bearing one. A reader that recursed into unknown elements — the obvious way to write + // this — would pass every other test in this file and still over-extract on real documents. + it('drops an unrecognised element WITH its children rather than recursing', async () => { + const r = await extract(`${text('kept')}${text('hidden')}`) + // Not just the text: no stray '\n\n' from the paragraph inside the dropped subtree either. + expect(r.extraction).toBe('kept\n\n') + }) + + // Character data lives only inside w:t. Whitespace between elements is common in + // pretty-printed markup, so a reader that accumulated all text would leak indentation. + it('drops bare character data outside a w:t', async () => { + const r = await extract('straykeptmore') + expect(r.extraction).toBe('kept\n\n') + }) + + // Field instructions are markup, not content. Leaking them puts ` HYPERLINK "http://..." ` into + // the body of an extract that an agent then reads as prose. + it('suppresses w:instrText across a complex field, keeping only the display run', async () => { + const r = await extract( + `` + + `` + + ` HYPERLINK "http://example.com" ` + + `` + + `link` + + `` + + `` + ) + expect(r.extraction).toBe('link\n\n') + }) + + // KNOWN mammoth DEFECT, MATCHED ON PURPOSE. w:fldSimple has no handler in mammoth and is not on + // its ignore list, so the whole subtree — including the cached result a reader sees on screen — + // is dropped. Cross-references, page numbers, DOCPROPERTY and merge fields all land here. This + // change is a pure port, so it keeps the defect; recovering it is one entry in DOCX_CONTAINERS + // and belongs in its own change, once the corpus says how many documents it affects. + it('drops a w:fldSimple subtree, matching mammoth rather than fixing it here', async () => { + const r = await extract( + `page 7` + ) + expect(r.extraction).toBe('page \n\n') + }) + + // Deleted text must not resurface in an extract; inserted text must. + it('drops tracked deletions and keeps tracked insertions', async () => { + const r = await extract( + `` + + `GONE` + + `ADDED` + + `` + ) + expect(r.extraction).toBe('ADDED\n\n') + }) + + // The subtlest paragraph rule. A deleted paragraph MARK means the break is gone, not the text: + // the content runs into the next paragraph. Emitting the usual '\n\n' would invent a break the + // author deleted, splitting one sentence into two. + it('merges a paragraph whose mark was deleted into the next one', async () => { + const r = await extract( + `first half ${text('second half')}` + ) + expect(r.extraction).toBe('first half second half\n\n') + }) + + // Same marker, one level out: a deleted table row takes the whole row with it. + it('drops a table row marked deleted, keeping the rows around it', async () => { + const r = await extract( + `` + + `${text('dropped cell')}` + + `${text('live cell')}` + + `` + ) + expect(r.extraction).toBe('live cell\n\n') + }) +}) + +describe('docx — leaf and character contract', () => { + // TWO newlines, and a trailing one on the last paragraph. A reader that joined paragraphs with + // '\n\n' instead of suffixing each would differ only in the final break — invisible in a + // `toContain` assertion, and different from mammoth on every document ever extracted. + it('suffixes every paragraph with two newlines, including the last', async () => { + expect((await extract(`${text('a')}${text('b')}`)).extraction).toBe('a\n\nb\n\n') + }) + + // An empty paragraph is exactly '\n\n', which the entry point then trims to nothing rather than + // storing ''. Both halves matter: the break is produced, and the result still omits `extraction`. + it('produces only breaks for an empty paragraph, and omits the extraction', async () => { + const r = await extract('') + expect(r.status).toBe('extracted') + expect(r.extraction).toBeUndefined() + }) + + // The "obvious improvement" that would silently change every document ever extracted. mammoth + // emits nothing for either — w:br is a document node with no raw-text mapping, w:cr is not + // whitelisted at all — so a line break inside a paragraph simply is not represented. + it('emits nothing for w:br or w:cr', async () => { + const r = await extract(para('ab')) + expect(r.extraction).toBe('ab\n\n') + }) + + it('maps w:tab, w:noBreakHyphen and w:softHyphen to their literals', async () => { + const r = await extract(para('abc')) + expect(r.extraction).toBe(`a\tb‑­c\n\n`) + }) + + // ACCEPTED DIVERGENCE. mammoth maps w:sym through the dingbat-to-unicode package; we drop it, + // because that table would be the only reason to keep any of mammoth's dependency tree and the + // only place this reader would read an attribute at all. Pinned so the choice is visible rather + // than incidental — if the corpus ever shows it matters, the fix is a small inline table. + it('drops w:sym rather than taking a dependency to map it', async () => { + const r = await extract(para('ab')) + expect(r.extraction).toBe('ab\n\n') + }) + + // Leading and trailing spaces inside a w:t are content. A "helpful" trim would silently join + // words across run boundaries, which is how formatted text is stored. + it('never trims whitespace inside or around a run', async () => { + const r = await extract(para(' spaced out ')) + expect(r.extraction).toBe(' spaced out \n\n') + }) + + // Entities decode; CDATA does NOT. The second is counter-intuitive: mammoth's DOM walk only + // handles element and text nodes, so a CDATA section vanishes. We reproduce it by NOT wiring + // saxes' cdata event — i.e. by absence, which is exactly the kind of thing a later reader + // "fixes" without realising it is a contract. + it('decodes XML entities but drops CDATA, as mammoth does', async () => { + expect((await extract(text('a & b <c>'))).extraction).toBe('a & b \n\n') + expect((await extract(para(''))).extraction).toBeUndefined() + }) + + // xmldom normalised all four to '\n' before mammoth ever saw the markup. saxes does the + // XML-standard \r\n and \r itself but leaves NEL (U+0085) and LINE SEPARATOR (U+2028) alone, so + // without an explicit normalisation this is the one test that fails. + it('normalises CRLF, CR, NEL and LINE SEPARATOR to a plain newline', async () => { + const CR = String.fromCharCode(13) + const r = await extract(text(`a${CR}\nb${CR}c…d
e`)) + expect(r.extraction).toBe('a\nb\nc\nd\ne\n\n') + }) + + // Catches decoding each inflate chunk independently. A chunk boundary lands mid-sequence in any + // document with non-ASCII text, and chunk.toString('utf8') turns that one character into two + // U+FFFD. Deliberately not tuned to the chunk size: a long run of 4-byte characters puts a + // boundary inside one of them whatever the chunking turns out to be. + it('decodes multi-byte UTF-8 across inflate chunk boundaries', async () => { + const emoji = '\u{1F600}'.repeat(20_000) // 80 KB of 4-byte sequences, several chunks deep + const r = await extract(text(emoji)) + expect(r.extraction).not.toContain('�') + expect(r.extraction).toBe(`${emoji}\n\n`) + }) + + // The entry point's surrogate guard only fires when the handler OVERSHOOTS the cap; a handler + // that stopped exactly on it would slip a lone half through, which serialises as U+FFFD. The + // existing cap tests go through text/plain and cannot reach this path. + it('never splits a surrogate pair when the cap lands mid-character', async () => { + const r = await extract(text('\u{1F600}'.repeat(4_000)), { maxOutputChars: 1_001 }) + expect(r.truncated).toBe(true) + expect(r.extraction).not.toContain('�') + expect([...(r.extraction ?? '')].every((c) => c === '\u{1F600}')).toBe(true) + }) +}) + +describe('docx — the cap and the deadline stop the read', () => { + // Long enough to cross the default cap, with a marker last so "did it stop reading" is + // observable from outside rather than inferred from a length. + const oversized = (marker = 'TRAILING-MARKER') => + `${Array.from({ length: 6_000 }, (_, i) => text(`paragraph ${i} ${'x'.repeat(50)}`)).join('')}${text(marker)}` + + it('reports truncated when the output cap stops it, and not when it does not', async () => { + const cut = await extract(oversized()) + expect(cut.status).toBe('extracted') + expect(cut.truncated).toBe(true) + expect(cut.extraction?.length).toBeLessThanOrEqual(MAX_OUTPUT_CHARS) + + const whole = await extract(text('short document')) + expect(whole.truncated).toBe(false) + expect(whole.extraction).toBe('short document\n\n') + }) + + // The claim is that the cap stops the READ, not that it trims the result. If it only trimmed, + // the marker would still have been parsed — and on a ceiling-sized document that is the whole + // difference between 68 ms and 812 ms, and between 175 MB and 607 MB. + it('stops parsing at the cap rather than reading on and trimming', async () => { + const started = Date.now() + const r = await extract(oversized()) + expect(r.extraction).not.toContain('TRAILING-MARKER') + expect(Date.now() - started).toBeLessThan(HANDLER_TIMEOUT_MS) + }) + + it('omits the extraction entirely, never returning an empty string, at a zero cap', async () => { + const r = await extract(text('some real text'), { maxOutputChars: 0 }) + expect(r.status).toBe('extracted') + expect(r.extraction).toBeUndefined() + expect(r.truncated).toBe(true) + }) + + // A single paragraph of many runs, deliberately: with the deadline checked per PARAGRAPH rather + // than per chunk, a one-paragraph document would spin unbudgeted, which is the docx analogue of + // the xlsx sheet-of-blank-rows case. + it('reports truncated when the deadline stops it', async () => { + const body = `${Array.from({ length: 4_000 }, (_, i) => `run ${i} `).join('')}` + const content = await buildDocx(body) + + // extractAttachment's FIRST Date.now() sets the deadline; every later call lands past it, so + // the read stops on its first check. Deliberately blunt — zlib and saxes may read the clock + // themselves, so a mock letting N chunks through would depend on how often they do. + const base = Date.now() + let calls = 0 + const clock = vi.spyOn(Date, 'now').mockImplementation(() => { + calls += 1 + return calls === 1 ? base : base + HANDLER_TIMEOUT_MS + 1 + }) + const stopped = await extractAttachment({ content, contentType: DOCX_TYPE }) + clock.mockRestore() + + expect(stopped.status).toBe('extracted') // a deadline stop is not a failure + expect(stopped.truncated).toBe(true) + + // Control: the same bytes on a real clock are complete and NOT flagged — without this the + // assertion above would also pass if `truncated` were hardcoded true. + const whole = await extractAttachment({ content, contentType: DOCX_TYPE }) + expect(whole.truncated).toBe(false) + expect(whole.extraction).toContain('run 3999') + }) + + // The memory claim in one assertion: a single text node far larger than the cap must not be + // accumulated whole before the cap applies. saxes buffers one text node, so this is also the + // bound on that claim — it is the shape that costs the most, and it still stops at the cap. + it('caps a document that is one enormous run', async () => { + const r = await extract(text('lorem ipsum dolor sit amet '.repeat(200_000))) // ~5.4M chars + expect(r.status).toBe('extracted') + expect(r.truncated).toBe(true) + expect(r.extraction?.length).toBeLessThanOrEqual(MAX_OUTPUT_CHARS) + expect(r.extraction?.startsWith('lorem ipsum')).toBe(true) + }) +}) + +describe('docx — archive-level reads', () => { + // Some producers store small parts uncompressed. Full fidelity is required on that path, and it + // is a different branch of the handler (no inflate stream at all). + it('reads a stored, uncompressed word/document.xml', async () => { + const content = await buildDocx(`${text('stored entry')}`, 'STORE') + const r = await extractAttachment({ content, contentType: DOCX_TYPE }) + expect(r.status).toBe('extracted') + expect(r.extraction).toBe('stored entry\n\n') + }) + + // A table flattens to one paragraph per cell paragraph: no tabs, no row markers, no column + // structure. Documented as a limit rather than a bug — it is what mammoth did, and what the + // README now states — but pinned here because it is the thing most likely to be "improved" + // without realising it changes every extracted invoice. + it('flattens a table to bare paragraphs, losing row and column structure', async () => { + const cell = (s: string) => `${text(s)}` + const r = await extract( + `${cell('r1c1')}${cell('r1c2')}${cell('r2c1')}${cell('r2c2')}` + ) + expect(r.extraction).toBe('r1c1\n\nr1c2\n\nr2c1\n\nr2c2\n\n') + // ...i.e. indistinguishable from four consecutive paragraphs: + expect(r.extraction).toBe((await extract(['r1c1', 'r1c2', 'r2c1', 'r2c2'].map(text).join(''))).extraction) + }) +}) From 806b92a2929a67ed923969ed9acd413fcba3a440 Mon Sep 17 00:00:00 2001 From: gulatikrishav Date: Tue, 4 Aug 2026 17:46:01 -0700 Subject: [PATCH 4/7] Pin docx behaviour on malformed and hostile archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/docx-hostile.test.ts | 195 +++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 tests/docx-hostile.test.ts diff --git a/tests/docx-hostile.test.ts b/tests/docx-hostile.test.ts new file mode 100644 index 0000000..b965e4e --- /dev/null +++ b/tests/docx-hostile.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest' +import JSZip from 'jszip' + +import { extractAttachment, HANDLER_TIMEOUT_MS } from '../attachment' + +// --------------------------------------------------------------------------- +// .docx on malformed and hostile archives +// --------------------------------------------------------------------------- +// THE ERROR POLICY, stated once. saxes is a conformant parser; the DOM parser it replaced recovered +// from some malformedness and threw on other. So the swap moves the line in BOTH directions, and +// this file is where that is pinned rather than discovered. The policy: +// +// text was read, then the parse broke -> `extracted` + `truncated: true`. A prefix is honest, and +// the contract already has a word for "the document +// continues past this point". +// nothing was read -> `failed`, which a caller can see and retry. +// +// 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 — silent wrong output, which is +// the one outcome this library fails over everywhere else. +// +// Measured against mammoth on the twelve cases below: eight agree, two we are STRICTER on (a raw +// 0x0B, and the same character as a numeric reference — U+000B is not a legal XML character, and +// xmldom passed it through where saxes refuses), and two we are MORE AVAILABLE on (trailing junk +// after the root, and an unclosed root, both of which made mammoth throw away the whole document). +// Each case below names which. + +const DOCX_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' + +const zipWith = async (name: string, content: string | Buffer, compression: 'DEFLATE' | 'STORE' = 'DEFLATE') => { + const zip = new JSZip() + zip.file( + '[Content_Types].xml', + '' + ) + zip.file( + '_rels/.rels', + '' + ) + zip.file(name, content) + return Buffer.from(await zip.generateAsync({ type: 'nodebuffer', compression })) +} + +const docxOf = (documentXml: string) => zipWith('word/document.xml', documentXml) +const body = (inner: string) => `${inner}` +const para = (s: string) => `${s}` + +const extract = async (documentXml: string) => + extractAttachment({ content: await docxOf(documentXml), contentType: DOCX_TYPE }) + +const VT = String.fromCharCode(11) // U+000B — not a legal XML 1.0 character + +describe('docx — malformed XML', () => { + // The two we are STRICTER on. Both are the same illegal character by two spellings, and both + // sit in the FIRST text node — so nothing was read before the parse broke, and the policy says + // `failed`. The same character further into a document would keep everything before it. Pinned + // as a deliberate coverage loss, not an oversight: a document that is not well-formed XML now + // gets a labeled refusal instead of text the previous parser guessed at. + it.each([ + ['a raw control character in a w:t', body(para(`a${VT}b`))], + ['the same character as a numeric reference', body(para('a b'))], + ])('fails on %s, where the DOM parser passed it through', async (_name, documentXml) => { + const r = await extract(documentXml) + expect(r.status).toBe('failed') + expect(r.extraction).toBeUndefined() + }) + + // The two we are MORE AVAILABLE on: mammoth threw away the entire document for both of these, + // and the text before the fault is perfectly good. + it.each([ + ['trailing junk after the root element', `${body(para('one'))}`], + ['an unclosed root element', `${para('one')}`], + ])('keeps the text read before %s, and flags it truncated', async (_name, documentXml) => { + const r = await extract(documentXml) + expect(r.status).toBe('extracted') + expect(r.extraction).toBe('one\n\n') + expect(r.truncated).toBe(true) + }) + + // Same policy, cases where mammoth also recovered. The text matches it exactly; what differs is + // that we say the document was cut and it did not. + it.each([ + ['an unbound namespace prefix', `${body(para('ok'))}`], + ['a mismatched close tag', `${para('one')}`], + ])('keeps the text and flags truncated on %s', async (_name, documentXml) => { + const r = await extract(documentXml) + expect(r.status).toBe('extracted') + expect(r.truncated).toBe(true) + }) + + // mammoth threw on both of these too, so this is agreement rather than a change. + it.each([ + ['an undefined entity', body(para('a b'))], + ['a duplicate attribute', `x`], + ])('fails on %s', async (_name, documentXml) => { + expect((await extract(documentXml)).status).toBe('failed') + }) + + // mammoth threw "Could not find the body element: are you sure this is a docx file?" for both. + // Without this check a zip whose main part is not WordprocessingML would come back `extracted` + // with no text — a silent nothing rather than a labeled refusal. + it.each([ + ['a w:document with no w:body', `${para('x')}`], + ['a foreign root element', '

hi

'], + ])('fails on %s rather than extracting nothing', async (_name, documentXml) => { + const r = await extract(documentXml) + expect(r.status).toBe('failed') + expect(r.reason).toMatch(/w:body/) + }) +}) + +describe('docx — hostile payloads', () => { + // saxes never parses entity declarations: its entity table is the five predefined ones on a null + // prototype, and nothing ever writes to it. So the expansion cannot happen — the reference is + // simply undefined and the parse fails. Asserted on TIME as well as status, because "bounded" + // is the actual property; a parser that expanded this would not return at all. + it('refuses a billion-laughs payload without expanding it', async () => { + const entities = + '' + + [1, 2, 3, 4].map((n) => ``).join('') + const started = Date.now() + const r = await extract( + `${para('&l4;')}` + ) + expect(Date.now() - started).toBeLessThan(HANDLER_TIMEOUT_MS) + expect(r.status).toBe('failed') + expect(r.extraction).toBeUndefined() + }) + + // 1000 seeded iterations. The property is not "produces good text" — it is that the degraded + // state is always REACHABLE and always LABELED, which is what makes shipping without a fallback + // reader safe. Seeded so a failure is reproducible; the long unseeded runs live in eval/. + it('never throws, always labels, and always returns in time on mutated bytes', async () => { + let seed = 0x5eed + const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff + + // Two archives, because they fuzz different layers. The deflated one mostly exercises the + // zip walk and the decompression preflight; the stored one puts the mutated bytes straight + // in front of saxes, which is where the interesting failures are. + const deflated = await docxOf(body([...Array(20)].map((_, i) => para(`paragraph ${i}`)).join(''))) + const stored = await zipWith( + 'word/document.xml', + body([...Array(20)].map((_, i) => para(`paragraph ${i}`)).join('')), + 'STORE' + ) + + for (let i = 0; i < 1000; i++) { + const source = i % 2 === 0 ? deflated : stored + const mutated = Buffer.from(source) + for (let f = 0; f < 1 + ((rnd() * 4) | 0); f++) { + mutated[(rnd() * mutated.length) | 0] ^= 1 << ((rnd() * 8) | 0) + } + + const started = Date.now() + const r = await extractAttachment({ content: mutated, contentType: DOCX_TYPE }) + expect(Date.now() - started).toBeLessThan(HANDLER_TIMEOUT_MS) + expect(['extracted', 'skipped', 'failed']).toContain(r.status) + // The contract everywhere else in this library: text is present or the field is absent, + // never ''. A mutation must not be able to produce the third state. + expect(r.extraction).not.toBe('') + } + }) +}) + +describe('docx — archives the handler never sees', () => { + // Routing refuses this before the handler: ooxmlKind answers 'docx' only for an archive whose + // central directory NAMES word/document.xml, so a renamed main part is unroutable. mammoth would + // have followed _rels/.rels here — this is the one place we read the conventional path instead — + // but routing already declined such a package before this change, so it is a pinned agreement + // rather than a regression. If it ever needs fixing, the fix belongs in ooxmlKind. + it('declines an archive whose main part is not at the conventional path', async () => { + const content = await zipWith('word/document2.xml', body(para('unreachable'))) + const r = await extractAttachment({ content, contentType: DOCX_TYPE }) + expect(r.status).toBe('skipped') + expect(r.reason).toMatch(/unsupported type/) + }) + + // The decompression preflight inflates every entry to measure it, so a corrupt deflate stream is + // caught there — before the handler's own inflate can see it. The value of pinning it is that + // the handler's inflate-error path is proven unreachable-but-safe: if a future preflight change + // stops catching this, the reason string moves and this test says so. + it('fails a corrupt deflate stream at the preflight, not in the handler', async () => { + const good = await docxOf(body(para('x'))) + const corrupt = Buffer.from(good) + const at = corrupt.indexOf('word/document.xml') + 'word/document.xml'.length + 10 + corrupt[at] ^= 0xff + corrupt[at + 1] ^= 0xff + corrupt[at + 2] ^= 0xff + + const r = await extractAttachment({ content: corrupt, contentType: DOCX_TYPE }) + expect(r.status).toBe('failed') + expect(r.reason).toBe('malformed zip: unreadable compressed data') + }) +}) From bc7d80f50cf799f0e98e3e7b1d50438239152f1d Mon Sep 17 00:00:00 2001 From: gulatikrishav Date: Tue, 4 Aug 2026 17:49:46 -0700 Subject: [PATCH 5/7] Demote mammoth to a devDependency and restate the docx limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 36 +++++++++++++++++++++++++++++++----- package-lock.json | 13 ++++++++++++- package.json | 2 +- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8c68de3..ca2a062 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ const result = await extractAttachment({ // result.truncated === false ``` -The heavy parsers (`unpdf`, `mammoth`, `exceljs`, …) are lazy-loaded per handler, so importing +The heavy parsers (`unpdf`, `exceljs`, `saxes`, …) are lazy-loaded per handler, so importing `extractAttachment` costs nothing until you actually call it on a matching attachment. It's also available on its own subpath — `import { extractAttachment } from 'agentextract/attachment'` — if you want to reach it without touching the body-extraction entry point. @@ -90,15 +90,41 @@ guards reduce blast radius; they are **not** a sandbox. or ZIP64 metadata is treated as over-budget (fail-closed), not trusted. - **Output** — extracted text is capped at `MAX_OUTPUT_CHARS` (250k), or lower via `maxOutputChars`. Cutting sets `truncated` on the result, so a partial extraction is never mistaken for a complete - one. The PDF and `.xlsx` handlers apply the cap **incrementally** as they build — and stop reading - the document once they reach it — so neither ever materializes in full. The `.docx` (mammoth) and - HTML (html-to-text) handlers return a complete string that is then trimmed, so for those the cap is + one. The PDF, `.docx` and `.xlsx` handlers apply the cap **incrementally** as they build — and stop + reading the document once they reach it — so none of them ever materializes in full. Only the HTML + handler (html-to-text) returns a complete string that is then trimmed, so for that one the cap is **post-materialization** and peak memory follows the whole document. - **Timeout** — `HANDLER_TIMEOUT_MS` (10 s) stops *awaiting* a slow async parse. It cannot cancel synchronous CPU already running inside a parser, so handlers that yield between units of work (PDF - per page, `.xlsx` per row) also check the deadline themselves and stop; the others cannot. + per page, `.docx` per inflate chunk, `.xlsx` per row) also check the deadline themselves and return + what they have with `truncated` set. The HTML, `.doc` and text handlers cannot. - **PDF** — page count and accumulated output are bounded (`MAX_PDF_PAGES`, `MAX_OUTPUT_CHARS`), but pdf.js's internal per-page decompression is **not** bounded in-library (no hook exists). +- **`.docx`** — `word/document.xml` is located in the archive's central directory, inflated on its + own, and read with a streaming SAX parser (`saxes`) rather than loaded into a DOM. Peak memory + therefore tracks one inflate chunk plus the text kept so far, independent of document size. + Measured at a 1024 MB heap on a 45 MB `document.xml` inside a 3.65 MB archive, against the previous + DOM-based reader: + + | concurrency | before | after | + |---|---|---| + | 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** | + + The old reader built 43.4M characters and kept 250k. The sharpest case is not the large archive: a + 0.41 MB attachment holding one 43 MB `` peaked the old reader at 1270 MB, against 199 MB here. + Two residual bounds, stated rather than glossed: 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. Malformed XML is also stricter than before — a document the old reader + silently half-read now comes back either `truncated` or `failed`. +- **`.docx` scope** — text comes from `word/document.xml` only. **Footnote, endnote and comment + bodies are not extracted** (they are separate zip parts), and neither are headers or footers. + **Table structure is not preserved**: each cell's paragraphs are emitted in reading order with the + same blank-line separator as body paragraphs, so a 2×2 table is indistinguishable from four + consecutive paragraphs. List bullets and numbers are dropped; the item text remains. All of this + matches the previous reader exactly — it is a documented limit, not a regression — but a consumer + reading an invoice or a contract should know the column a figure sat in is gone. - **`.xlsx`** — read row-by-row through `exceljs`'s streaming reader rather than loaded whole, so peak memory tracks the shared-string table plus one row instead of a live object per cell (measured: 294 MB → 171 MB, and 3.7x faster, on a 5 MB / 38 MB-uncompressed workbook). It is not diff --git a/package-lock.json b/package-lock.json index 2ad0f6f..f61f039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "html-to-text": "^10.0.0", "iconv-lite": "^0.7.2", "jschardet": "^3.1.4", - "mammoth": "^1.12.0", + "saxes": "^5.0.1", "unpdf": "^1.6.2", "word-extractor": "^1.0.4" }, @@ -21,6 +21,7 @@ "@types/html-to-text": "^9.0.4", "@types/node": "^22.20.0", "@types/word-extractor": "^1.0.6", + "mammoth": "^1.12.0", "typescript": "^6.0.3", "vitest": "^4.1.2" }, @@ -606,6 +607,7 @@ "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -668,6 +670,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -966,6 +969,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/dom-serializer": { @@ -1027,6 +1031,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "dev": true, "license": "BSD", "dependencies": { "underscore": "^1.13.1" @@ -1749,6 +1754,7 @@ "version": "0.4.2", "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "duck": "^0.1.12", @@ -1770,6 +1776,7 @@ "version": "1.12.0", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "@xmldom/xmldom": "^0.8.6", @@ -1878,6 +1885,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/pako": { @@ -2140,6 +2148,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/stackback": { @@ -2283,6 +2292,7 @@ "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, "license": "MIT" }, "node_modules/undici-types": { @@ -2545,6 +2555,7 @@ "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "dev": true, "license": "MIT", "engines": { "node": ">=4.0" diff --git a/package.json b/package.json index f57d2a9..1c68843 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@types/html-to-text": "^9.0.4", "@types/node": "^22.20.0", "@types/word-extractor": "^1.0.6", + "mammoth": "^1.12.0", "typescript": "^6.0.3", "vitest": "^4.1.2" }, @@ -69,7 +70,6 @@ "html-to-text": "^10.0.0", "iconv-lite": "^0.7.2", "jschardet": "^3.1.4", - "mammoth": "^1.12.0", "saxes": "^5.0.1", "unpdf": "^1.6.2", "word-extractor": "^1.0.4" From 84b5bae4765127a7cad3e626f5a6050b47a82dbd Mon Sep 17 00:00:00 2001 From: gulatikrishav Date: Tue, 4 Aug 2026 18:11:33 -0700 Subject: [PATCH 6/7] Resolve any undeclared prefix instead of allowlisting known ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ours "before\n\n" mammoth the whole document ours "before\n\n" mammoth the whole document ours "before\n\n" mammoth "before\n\nafter\n\n" 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. --- attachment.ts | 44 ++++++++++++++++++---------- tests/docx-streaming.test.ts | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/attachment.ts b/attachment.ts index ef5b942..07242fe 100644 --- a/attachment.ts +++ b/attachment.ts @@ -959,24 +959,31 @@ const OOXML_PREFIXES: Record = { 'urn:schemas-microsoft-com:vml': 'v', } -// Pre-bound so an undeclared prefix doesn't end the parse. saxes is stricter than the DOM parser -// this replaces and fails with `unbound namespace prefix` where xmldom shrugs, and real producers do -// emit stray o:/w10:/wne:/wps: markup without declaring it. Binding them up front costs nothing — -// the elements are dropped either way, only the throw is avoided — and it converts the likeliest -// strictness regression into a non-event. Verified: an in-document xmlns still shadows these, so a -// strict-format file resolves to the strict URI and routes correctly. +// Any prefix that reaches saxes undeclared resolves HERE, and this URI is deliberately absent from +// OOXML_PREFIXES — so the element names `{urn:agentextract:unbound}local`, misses the whitelist, and +// its subtree is dropped. Which is precisely what mammoth does with an unmapped namespace +// (xml/reader.js:53-66 emits the same `{uri}local` shape, and no handler matches it). +// +// It exists because saxes fails the whole parse on an unbound prefix — for ATTRIBUTES too +// (saxes.js:1920-1925) — where xmldom shrugged. That is not a rare shape: Word 2013 and later stamp +// w15:paraId on EVERY w:p, and w16cid:durableId alongside it, so a document that carries those +// without their xmlns (a fragment assembled by a templating tool, a repaired file) used to lose +// everything after its first paragraph. An allowlist cannot close that — there is always another +// prefix — so this resolves the open class instead, and the map below shrinks to the only entries +// that do something an allowlist has to do. +const UNBOUND_NAMESPACE = 'urn:agentextract:unbound' + +// The three prefixes where GUESSING is better than dropping, because these are the only URIs +// OOXML_PREFIXES maps: an undeclared `w:` resolved to the sentinel above would take the whole +// document body with it. Every other prefix a real document leaves undeclared — o:, w10:, wne:, r:, +// wp:, a:, pic:, wps:, w14: — resolves to a URI this reader does not map either way, so guessing it +// and sentinelling it are the same outcome, and the sentinel needs no maintenance. +// An in-document xmlns still shadows these (saxes.resolve checks scope first, then this map, then +// the sentinel), so a strict-format file resolves to the strict URI and routes correctly. const OOXML_ASSUMED_PREFIXES: Record = { w: 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', mc: 'http://schemas.openxmlformats.org/markup-compatibility/2006', v: 'urn:schemas-microsoft-com:vml', - o: 'urn:schemas-microsoft-com:office:office', - w10: 'urn:schemas-microsoft-com:office:word', - r: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', - wp: 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing', - a: 'http://schemas.openxmlformats.org/drawingml/2006/main', - pic: 'http://schemas.openxmlformats.org/drawingml/2006/picture', - wps: 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape', - w14: 'http://schemas.microsoft.com/office/word/2010/wordml', } // Recurse into these; emit nothing of their own. Absent from the list = subtree dropped. @@ -1080,7 +1087,14 @@ const createDocxReader = async (): Promise => { chars += text.length } - const parser = new SaxesParser({ xmlns: true, additionalNamespaces: OOXML_ASSUMED_PREFIXES }) + const parser = new SaxesParser({ + xmlns: true, + additionalNamespaces: OOXML_ASSUMED_PREFIXES, + // Consulted only after scope and the map above (saxes.js:1845-1862), so this catches exactly + // the prefixes nothing else claimed, and turns "unbound prefix" from a fatal parse error into + // an unmapped namespace — which this reader already knows how to drop. + resolvePrefix: () => UNBOUND_NAMESPACE, + }) parser.on('opentag', (tag) => { // A direct port of mammoth's convertName (xml/reader.js:53-66): mapped URI -> `w:t`, diff --git a/tests/docx-streaming.test.ts b/tests/docx-streaming.test.ts index 8534dad..4e96f2c 100644 --- a/tests/docx-streaming.test.ts +++ b/tests/docx-streaming.test.ts @@ -273,6 +273,62 @@ describe('docx — leaf and character contract', () => { }) }) +// Undeclared namespace prefixes. saxes fails the whole parse on one — for ATTRIBUTES as well as +// elements — where the DOM parser this replaced shrugged. Under the error policy that turns into +// `extracted` + `truncated`, i.e. everything after the offending element is silently gone. These are +// not exotic: Word 2013 and later stamp w15:paraId on EVERY w:p, with w16cid:durableId beside it, so +// a document carrying those without their xmlns loses everything after its first paragraph. +// +// An allowlist of known prefixes cannot close that — there is always another prefix, and the first +// version of this reader proved the point by naming wne: in a comment while omitting it from the +// map. resolvePrefix closes the open class instead: anything unclaimed resolves to a sentinel URI +// that OOXML_PREFIXES does not map, which is exactly the shape mammoth gives an unmapped namespace. +describe('docx — an undeclared prefix must not end the parse', () => { + const withAttr = (attr: string) => + `${text('before')}middle${text('after')}` + + // Attributes, which is the common case and the one that reads as impossible until you hit it: + // the element is perfectly well-formed WordprocessingML and only its attribute is unbound. + it.each([ + ['w15:paraId', 'w15:paraId="12AB34CD"'], + ['w16cid:durableId', 'w16cid:durableId="99"'], + ['a prefix no allowlist would think to carry', 'zz:whatever="1"'], + ])('reads the whole document despite an undeclared %s attribute', async (_name, attr) => { + const r = await extract(withAttr(attr)) + expect(r.extraction).toBe('before\n\nmiddle\n\nafter\n\n') + expect(r.truncated).toBe(false) + }) + + // Elements, where the prefix being unmapped must still mean "drop this subtree" — the fix must + // not turn an unknown namespace into a recursed-into container. + it('drops an undeclared-prefix element with its children, and keeps reading after it', async () => { + const r = await extract(`${text('before')}${text('hidden')}${text('after')}`) + expect(r.extraction).toBe('before\n\nafter\n\n') + expect(r.truncated).toBe(false) + }) + + // Why the assumed-prefix map still has to exist alongside the sentinel: `w` is one of the three + // prefixes OOXML_PREFIXES actually maps, so resolving an undeclared one to the sentinel would + // drop the entire document body. mammoth throws outright on this document; we recover it. + it('still recovers a document that does not declare w: at all', async () => { + const content = await docxFrom( + `${text('recovered')}` + ) + const r = await extractAttachment({ content, contentType: DOCX_TYPE }) + expect(r.extraction).toBe('recovered\n\n') + }) + + // ...and the assumed map must never win over a real declaration, or a strict-format document + // would be read against the transitional URI. + it('lets an in-document xmlns shadow the assumed prefix', async () => { + const content = await docxFrom( + `${text('strict')}` + ) + const r = await extractAttachment({ content, contentType: DOCX_TYPE }) + expect(r.extraction).toBe('strict\n\n') + }) +}) + describe('docx — the cap and the deadline stop the read', () => { // Long enough to cross the default cap, with a marker last so "did it stop reading" is // observable from outside rather than inferred from a length. From 5538b7ca07ba2581622c88e4885b803d099f1cb6 Mon Sep 17 00:00:00 2001 From: gulatikrishav Date: Tue, 4 Aug 2026 18:13:09 -0700 Subject: [PATCH 7/7] Fix a hostile-case test that measured the wrong thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row named "an unbound namespace prefix" built its fixture with the offending element after ``, 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. --- tests/docx-hostile.test.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/docx-hostile.test.ts b/tests/docx-hostile.test.ts index b965e4e..7b15c97 100644 --- a/tests/docx-hostile.test.ts +++ b/tests/docx-hostile.test.ts @@ -19,11 +19,17 @@ import { extractAttachment, HANDLER_TIMEOUT_MS } from '../attachment' // close-tag text as content and descends into elements mammoth drops — silent wrong output, which is // the one outcome this library fails over everywhere else. // -// Measured against mammoth on the twelve cases below: eight agree, two we are STRICTER on (a raw +// Measured against mammoth on the eleven cases below: seven agree, two we are STRICTER on (a raw // 0x0B, and the same character as a numeric reference — U+000B is not a legal XML character, and // xmldom passed it through where saxes refuses), and two we are MORE AVAILABLE on (trailing junk // after the root, and an unclosed root, both of which made mammoth throw away the whole document). // Each case below names which. +// +// An undeclared namespace prefix is NOT in this file, deliberately. It used to be, asserting +// `truncated` — but the fixture put the offending element after ``, so it was measuring +// trailing junk under a misleading name and duplicating the case above it. Undeclared prefixes no +// longer break the parse at all (see the resolvePrefix block in docx-streaming.test.ts), which is +// what a test measuring the right thing would have said. const DOCX_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' @@ -78,15 +84,24 @@ describe('docx — malformed XML', () => { expect(r.truncated).toBe(true) }) - // Same policy, cases where mammoth also recovered. The text matches it exactly; what differs is + // Same policy, a case where mammoth also recovered. The text matches it exactly; what differs is // that we say the document was cut and it did not. - it.each([ - ['an unbound namespace prefix', `${body(para('ok'))}`], - ['a mismatched close tag', `${para('one')}`], - ])('keeps the text and flags truncated on %s', async (_name, documentXml) => { - const r = await extract(documentXml) + it('keeps the text and flags truncated on a mismatched close tag', async () => { + const r = await extract( + `${para('one')}` + ) + expect(r.status).toBe('extracted') + expect(r.truncated).toBe(true) + }) + + // The degradation the two `failed` cases above only show the worst end of. The same illegal + // character, moved off the first text node, costs everything after it and nothing before — + // which is what makes "stricter than mammoth" a bounded loss rather than a cliff. + it('keeps everything before an illegal character that appears partway through', async () => { + const r = await extract(body(`${para('first')}${para('second')}${para(`third${VT}broken`)}${para('fourth')}`)) expect(r.status).toBe('extracted') expect(r.truncated).toBe(true) + expect(r.extraction).toBe('first\n\nsecond\n\n') }) // mammoth threw on both of these too, so this is agreement rather than a change.