From 830ba2b1e4e640148a8ac893ed9befa64dda348f Mon Sep 17 00:00:00 2001 From: Dolan Miu Date: Tue, 20 Oct 2026 11:29:58 +0100 Subject: [PATCH 1/4] feat: embed files in a document as OLE objects, from docx/embedded-files (#719) EmbeddedFileRun, in a new entry, docx/embedded-files, embeds a file in a document as an OLE object, as Word's Insert > Object > Create from File does: the document shows a picture of the file, and the file opens when it is double-clicked. An Excel workbook, Word document or PowerPoint presentation (.xlsx, .xlsm, .docx, .docm, .pptx, .pptm) is embedded as itself, with Word's ProgIDs, so Office opens it. Any other file, such as a sound, a video or a PDF, is embedded in an OLE package (ProgID Package), which Word for Windows opens with the application the computer opens its type with. The package is written as Word 2016 and later write one: its CompObj and ObjInfo streams are byte for byte Word's, and its Ole10Native stream holds the file and its name. The picture is given as the preview, and the picture frame's shape type is written once in each part, as Word writes it. The compound file writer moves from docx/macros to src/compound-file, which both entries bundle. It now gives the root a class id, and writes DIFAT sectors, so it writes files of more than about 7 MB, such as a video. The oleObject relationship is a type of its own, and PackagePart is generic over its relationship type, defaulting to RelationshipType, so the released types stay compatible. Checked in Word for Mac 16.114: each object is an embedded OLE object with its ProgID, in the body, a table, a header and a footnote; an embedded .docx and .docm open in Word, and a change to one is saved in the document; a package is kept unchanged when Word saves. LibreOffice imports a workbook as a spreadsheet and a document as text, and draws every picture. oletools extracts a packaged file unchanged, and the demo passes the Open XML SDK validator for Office 2010 to Microsoft 365. Word for Windows wasn't checked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X5zGDL3sgGVBMXPfny376c --- .cspell.json | 2 + README.md | 31 ++ demo/assets/embedded-files/budget.png | Bin 0 -> 15229 bytes demo/assets/embedded-files/budget.xlsx | Bin 0 -> 6068 bytes demo/assets/embedded-files/letter-icon.png | Bin 0 -> 3561 bytes demo/assets/embedded-files/tone-icon.png | Bin 0 -> 3209 bytes demo/assets/embedded-files/tone.wav | Bin 0 -> 8044 bytes demo/embedded-files/embedded-files.ts | 63 ++++ docs/_sidebar.md | 1 + docs/live-examples/live-examples.js | 10 +- docs/usage/embedded-files.md | 151 ++++++++ eslint.config.ts | 11 +- package.json | 17 +- .../compound-file.spec.ts | 41 ++- .../compound-file.ts | 62 +++- src/embedded-files/embedded-file-run.spec.ts | 322 ++++++++++++++++++ src/embedded-files/embedded-file-run.ts | 257 ++++++++++++++ src/embedded-files/embedded-file-type.spec.ts | 79 +++++ src/embedded-files/embedded-file-type.ts | 95 ++++++ src/embedded-files/index.ts | 38 +++ src/embedded-files/ole-package.spec.ts | 111 ++++++ src/embedded-files/ole-package.ts | 144 ++++++++ .../picture-frame-shape-type.ts | 93 +++++ src/export/packer/package-part-writer.ts | 9 +- src/file/index.ts | 2 +- src/file/package-part/index.ts | 2 +- src/file/package-part/package-part.spec.ts | 26 +- src/file/package-part/package-part.ts | 27 +- .../relationship/relationship.ts | 11 +- src/file/relationships/relationships.ts | 10 +- src/macros/create-macros.spec.ts | 3 +- src/macros/create-macros.ts | 2 +- src/tests/compound-file.ts | 189 ++++++++++ src/tests/macros.ts | 143 -------- vite.entries.config.ts | 1 + 35 files changed, 1764 insertions(+), 189 deletions(-) create mode 100644 demo/assets/embedded-files/budget.png create mode 100644 demo/assets/embedded-files/budget.xlsx create mode 100644 demo/assets/embedded-files/letter-icon.png create mode 100644 demo/assets/embedded-files/tone-icon.png create mode 100644 demo/assets/embedded-files/tone.wav create mode 100644 demo/embedded-files/embedded-files.ts create mode 100644 docs/usage/embedded-files.md rename src/{macros => compound-file}/compound-file.spec.ts (75%) rename src/{macros => compound-file}/compound-file.ts (79%) create mode 100644 src/embedded-files/embedded-file-run.spec.ts create mode 100644 src/embedded-files/embedded-file-run.ts create mode 100644 src/embedded-files/embedded-file-type.spec.ts create mode 100644 src/embedded-files/embedded-file-type.ts create mode 100644 src/embedded-files/index.ts create mode 100644 src/embedded-files/ole-package.spec.ts create mode 100644 src/embedded-files/ole-package.ts create mode 100644 src/embedded-files/picture-frame-shape-type.ts create mode 100644 src/tests/compound-file.ts diff --git a/.cspell.json b/.cspell.json index 85da6375231..1a15cd50554 100644 --- a/.cspell.json +++ b/.cspell.json @@ -24,6 +24,7 @@ "coordsize", "datas", "DCMI", + "DIFAT", "dcmitype", "dcterms", "docsify", @@ -43,6 +44,7 @@ "jszip", "NUMPAGES", "odttf", + "OLEDS", "OMML", "ondata", "onfile", diff --git a/README.md b/README.md index 42819fb8f0f..cb29f71843e 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,37 @@ const doc = new Document({ In a page without a bundler, load `dist/macros.umd.cjs` after `dist/index.umd.cjs`. It adds the `docxMacros` global. See the [macros](https://docx.js.org/#/usage/macros) documentation. +# Embedded Files + +Workbooks, documents, presentations and any other file, such as a sound, a video or a PDF, are embedded in a document as OLE objects, which open from it, with `docx/embedded-files`: + +```ts +import * as fs from "fs"; +import { Document, Paragraph } from "docx"; +import { EmbeddedFileRun } from "docx/embedded-files"; + +const doc = new Document({ + sections: [ + { + children: [ + new Paragraph({ + children: [ + new EmbeddedFileRun({ + fileName: "budget.xlsx", + data: fs.readFileSync("./budget.xlsx"), + preview: { type: "png", data: fs.readFileSync("./budget.png") }, + transformation: { width: 240, height: 120 }, + }), + ], + }), + ], + }, + ], +}); +``` + +In a page without a bundler, load `dist/embedded-files.umd.cjs` after `dist/index.umd.cjs`. It adds the `docxEmbeddedFiles` global. See the [embedded files](https://docx.js.org/#/usage/embedded-files) documentation. + # Playground Experience `docx` in action through [Docx.js Editor][docxjs-editor-url], an interactive playground where you can code and preview the results in real-time. diff --git a/demo/assets/embedded-files/budget.png b/demo/assets/embedded-files/budget.png new file mode 100644 index 0000000000000000000000000000000000000000..30852d5bf70ce30b695cd6402ea803a3891df219 GIT binary patch literal 15229 zcma)j1z1&Yy6-|oPze#01`z>~?h;TC1Syg36r{UTDHV|JGH9f`1w z&dixJ=ia*?9v@}7*IwWEz4428e|RbN9P>Kibp$~$#YCUILJ*W``1?5eCHT|qSy%*u z@IDiJ_EgSccy-KKO=9sJ+34(L8w-;YY)QHid{ZGg=#^ELsTikN)9Y~CYnlUNcbZ?1 z4Y-coPaf>P^C9f3Xt#R0jcop+TXf0~FslJSt3lg*T<7C? z#{}!IlvGrg1ku0bgrmEmqLc5ie+xa|?Zfw;K)vvk7#LkF0pz{xLsCP(Sxw%sHq%V!GuM`2V&3Q*wR9BAd?s3L0ts0jxTdEz$ z@sf9s;@J<)o*Z#&>z;8<4o2Httc_C&M(WHtA$Grpqvg|zB(GATaRJ^DB`V6FQz&Qh zk&DhU+M{BoMlM?mRm|r#ctMtL;iGib(Q3*oVGIM@@;j7WFmY{5fi)Nd{ z?%N&T-j~ido|WornF#h5D1GPSP46?)s^7AX@C$T>`6Z zDF=(Q%s3w(Cr8Hy8Qq$KJnR1cu2~Fm{|ZeFl}m`qa6P*(R)x#l{zBSs`|vwevq%1Y ziIdZF{92!{pC=Z?w^zIE)@2c$%_dV;IkyI|GcB$0ewH?iVVB`W{2IRYoVz!&7R1SK ze~F>nU+eMRmUpjm$Ft_0-p!25un#xks3?gW{ai~(B})&%b#CjIMNeP*)~zeJ<_w%wpKC1zS6715()iMMuU*ZC1sS_JNzy=UX-Lqg_p8 zLq`K6Ue;LTMwlcCvGFK~XKyd#RpFPDh{Tg?*4c z6B9>paCj9iY4;Sb7K%5cRqWx|$m8_eZw!}R%$MIVSz3`fHZW-1Iy0CIuHuc2jV z`}mQ;R+H1cR%gKlJ+HD*@7r<6*LN=214hgKqxL6ij0DPCx%U|DcVAI2J%1NEZhzG| zFd^CRgKO*|&%&v-=w~wJamUHhyk@y(@?(VvV7sYy0E&exH*T743CU3CZ3-D zDr&8i_8vo@%;-X1o>niFuRHP!toj*bTCzZ&iD9igw!f-nMm@w|3^QUu=4c-?xAt3nafv>)KB!c!{hL!?+e1tc`i9C6~vOoDV1pC`|9!s>@|^KRNc_@TOlDNMU50` zRK;)en)`w(WUevpox5pBX}taZ*#6|+I{GUip(?nPOcXqYEo*l`oZ?2VS?zEYsdgX# z$)IsB+UUT_~rLfS}ZOA#Sh216YtNowhS=h88C5eMmPVLs%S6H*$VKOOGi(Zrj!o^O>+*Ihzx;^5fskKWqbP}Y(gRrB@U zu)N|OlNhvjLZgh&qo(*YFQ>HhC)Z_NW7X&RTV0;c6Z-jsE5-TU(>2bI<>U_6pDgtM z{anv37F}(KgVRNGm-SMP#uX3el6okwBmMn*4>@9%jOb`-=3M3VnlSS}*U61IPI4{Y zC(ab8H9p^z&$dfS_$4xtV77aCKJ&o?$`WU?u&|IzNN9S*{?|Iv@d`|2eWL2mc8}W; z@4YWfEX+Mb<{Jh-!jDTRL{kZp)AKfu!fzOMCX({FDys?JLfYsI0$G{vOBym?{j+{o z?Hq-TFd%F_<(Cv%rjYqTDC)7dxXDVKSg6J9@Ocuys5#GUzpSPA8c)v;Wh}P z;-*F|e`v0YKTM5? z98|R>-Y%|8PL8g%AKB+JT(4#};uhj(d_{dKO)ecXM1+ zu@Fl0kAo{5MrORJwzO(z8BWh*_<3Eh?Dz4{e=?rOw!}|vr?k_ZlZsZ_^FCfX8>>;2 ziXCdc_JY@aeYm5ib}LX?^s>r+#T))zlHb%X930$(SLwDUdKv@IWN?1|T#1YH@8i6W zo$j+SRmF?FGMtbYJJz9}$%58-G;TL!)Y+qG{)*(DxPG9>Drt<0Hj?IhQ;Cqhf40 z_Q=v3Xg+rJZ_{{Se6+WmlgXJ~ueGSbGCh53#y%%({-gF1p^l@sbPRc;nSrk4$@O%W zzO>oNWkt*Y$v5XGtKTzED=xpt4t&hrRefj2zGmnI)h{w@@4^1j8*_7W-6Gp6dPnc` z#2NdeCCRKW_2*TzBIN_mWc@y*+)e77S*VVm(HVv)-s%o{YP2+=)uMmSB(T*#LDJrc z?Xc|MOA-3;AvM-&as;&$Gu{FIdVgf6vxhrH8Ez$eR-GIXi-fOrKzc(PgIPSQ+WvZa z%>I3$p%so#+^-D1bGFYu5pWaouaYtsv9r_N%~VZoUa;=zCrD>mSTC5Q4kR^GuGo6L zQ~>ZSB4RGCZAX(MyW@p4Mdv5ZICKQseJ?4C@Z z7tYT};iODpQc%#&Ji=#L61C_{F>kaO>!OVOy16g55 zLu2t)XgjI{Szduvze3yTP!I4ie7KaGnk5ohXL+q%NtQ+lTruCU4IgD`^nMnutH-xj zJdO_iCB;fy*48|CHYS>{A)?SfwE8?lQql`mle!?o@A?+@+F7*9ME|cP2i5!c@3Lk! zZzUHr)}K{soK|NPB~x

K-4-&kXL3SEC?4ZuU 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 av_}ryuJpgc-@?~w5iwz@XW4>U@BR<@QGEyi literal 0 HcmV?d00001 diff --git a/demo/assets/embedded-files/budget.xlsx b/demo/assets/embedded-files/budget.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f279fbdfc2fff9c6607fc619cb4d76a2aa12baeb GIT binary patch literal 6068 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%0AO1xnP;$k3)lgmsVdI_RvOomEt|LgiG3wjRF1 zfAs6~wl_KWDBd8)Zv)Fa)18YLo&6mW{Z@PzJ)a6BFjhFZeB` z8#-=ntn%bsIXA!IxUY*I7=P&SVXlIL0z?7-|6(3tH2aa@lz)##izjSv)LR)i1dmjr30OY2s#8 zSdg*iA+@XAid6U=@~V-qKcI?z1(~nxXo$CitU(OfLKyLGv1%^+ndlp-NaZ7~tlf6# zjmDGS$6mJb9vcu<)gq1no5XwoLWra$6H!!s&yI{s%eLZmGYcEhFXKdcRi3;m6ADsz z+3M&tPr1Q;zYZ(Uw*s?fE;owVUrCrApZ^kw;ah}wo-eJge%}zFVMgfs5HY`j^qtf9 z_0J%$mXLA3-Ybx4SS>bkz#nz=wP-z1#oVl+SNW?S6|tZE;EW|SV500`9%ZaOhp|J&-^ZlJ zRyV1RP6{ABpXpDc)jTCw6bU``6*c73Qhj%_l-$|quQadM{OQysu&smdNYXb+dB`R3 zJ=S-byC(B^iqBAR0VtHmbOrh~qj44#^sKJ2*|Co47*2N?2D^s%v-CI7^7jWq)V0B& z2TI*9jB)0gxr<6PMlEjDVZ0pcLGn+kC{xnUVwYDryLsc>WIZxx#*>w(fiCbB(iwsU z%l`5E48uSvPcT)CI!R+_CI{JV>JhmRLjoP18Sa6=CSfKKi7g`w$bkg$kF8)+30fQo zse~p(Nb3dNT-MgfUlB|<^d&rN&9t?fPk`Qc^c4afBEkdKDgtsUbWTrXkHhLsFwn(^ zd^^3kO7@|7rgz;amPP+U_6(M32XYAqCD_f0NEcT2oQgiRRKM#TeL_){OiR+?ovd%m z{S^GbLBu&XNje4nO9FgK?Vk8%^sbqk+#(S4f{crMI>(!gaTN!np^-fG)3BzT#BmE} zFTi)su6%529?l;gqtwzV45|;wNw%8APq?>cUPH<~`VHy*sxElC0QfvuFkQ~?FuoU% zcxwkXp)nHk-bwK=$0_J9d^^BmE-tTGkBwzt)t1KH6U9R}o~)an>Tc+1ZZHJt3vTh* zvx|B1!MK{1s;9))RCD%%$|BvOhU(&x;K0VuN*T@YHbJ|TNC;XVsE{J*NvoCP7)_n5 z&Y-%pHv?`>e8!zEvcJqbR7Kn1Lv#`qp*vnAG+{CmZ%(G<3zHg-v&5L%%pvEBqzub|0o z-5Mnv{czL&oRfU7!;CwLvGZS;;awE6szXQ5Gpi}?= literal 0 HcmV?d00001 diff --git a/demo/assets/embedded-files/tone.wav b/demo/assets/embedded-files/tone.wav new file mode 100644 index 0000000000000000000000000000000000000000..c5a2228405ec9bddd319ec30f46912decc8ddefd GIT binary patch literal 8044 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 { + const doc = new Document({ + sections: [ + { + children: [ + new Paragraph({ text: "Quarterly report", heading: HeadingLevel.HEADING_1 }), + new Paragraph("The budget, which opens in Excel when double-clicked:"), + new Paragraph({ + children: [ + new EmbeddedFileRun({ + fileName: "budget.xlsx", + data: fs.readFileSync("./demo/assets/embedded-files/budget.xlsx"), + preview: { type: "png", data: fs.readFileSync("./demo/assets/embedded-files/budget.png") }, + transformation: { width: 241, height: 111 }, + altText: "Sales and costs for January to March", + }), + ], + }), + new Paragraph("A letter, which opens in Word, and a sound, which Word for Windows plays:"), + new Paragraph({ + children: [ + new EmbeddedFileRun({ + fileName: "letter.docx", + data: letterData, + preview: { type: "png", data: fs.readFileSync("./demo/assets/embedded-files/letter-icon.png") }, + transformation: { width: 96, height: 96 }, + displayAsIcon: true, + }), + new EmbeddedFileRun({ + fileName: "tone.wav", + data: fs.readFileSync("./demo/assets/embedded-files/tone.wav"), + preview: { type: "png", data: fs.readFileSync("./demo/assets/embedded-files/tone-icon.png") }, + transformation: { width: 96, height: 96 }, + }), + ], + }), + ], + }, + ], + }); + + Packer.toBuffer(doc).then((buffer) => { + fs.writeFileSync("My Document.docx", buffer); + }); +}); diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 3d83ca01cc4..eb936741f45 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -69,6 +69,7 @@ - [Math from LaTeX](usage/math-latex.md) - [HTML](usage/html.md) - [Macros](usage/macros.md) + - [Embedded Files](usage/embedded-files.md) - [Comments](usage/comments.md) - [Footnotes](usage/footnotes.md) - [Endnotes](usage/endnotes.md) diff --git a/docs/live-examples/live-examples.js b/docs/live-examples/live-examples.js index 19079fee01f..b129af9b19a 100644 --- a/docs/live-examples/live-examples.js +++ b/docs/live-examples/live-examples.js @@ -35,6 +35,7 @@ // docx/html writes math with docx/math, so it is loaded after it "docx/html": { file: "html", global: "docxHtml", after: ["docx/math"] }, "docx/macros": { file: "macros", global: "docxMacros" }, + "docx/embedded-files": { file: "embedded-files", global: "docxEmbeddedFiles" }, }; // Packages from npm an example can import too, from jsDelivr at the version the docs are written for: where each is, and @@ -46,8 +47,13 @@ }, }; - // docx-preview draws these as empty space - const NOT_DRAWN = { "docx/shapes": "shapes", "docx/charts": "charts", "docx/watermarks": "watermarks" }; + // docx-preview draws these as empty space, or, for embedded files, not at all + const NOT_DRAWN = { + "docx/shapes": "shapes", + "docx/charts": "charts", + "docx/watermarks": "watermarks", + "docx/embedded-files": "embedded files", + }; // The type of a document with macros, a .docm, which Word doesn't open as a .docx. A blob's type is in lower case const DOCM = "application/vnd.ms-word.document.macroenabled.12"; diff --git a/docs/usage/embedded-files.md b/docs/usage/embedded-files.md new file mode 100644 index 00000000000..68493128198 --- /dev/null +++ b/docs/usage/embedded-files.md @@ -0,0 +1,151 @@ +# Embedded Files + + + +A document can have files embedded in it: a workbook, another document, a presentation, or any other file, such as a sound, a video or a PDF. Each is an OLE object, which the document shows as a picture, and which opens when it is double-clicked, as a file embedded with Word's **Insert > Object > Create from File** does. + +`EmbeddedFileRun` embeds a file. It comes with the `docx` package, from `docx/embedded-files`, and goes in a paragraph, as an `ImageRun` does: + +```ts +import { Document, Packer, Paragraph } from "docx"; +import { EmbeddedFileRun } from "docx/embedded-files"; +``` + +In a page without a bundler, load `dist/embedded-files.umd.cjs` after `dist/index.umd.cjs` (or `dist/embedded-files.iife.js` after `dist/index.iife.js`). It adds a `docxEmbeddedFiles` global, such as `new docxEmbeddedFiles.EmbeddedFileRun({ ... })`. + +## Example + +```ts live +import * as fs from "fs"; +import { Document, Packer, Paragraph } from "docx"; +import { EmbeddedFileRun } from "docx/embedded-files"; + +const doc = new Document({ + sections: [ + { + children: [ + new Paragraph("The budget, which opens in Excel when it is double-clicked:"), + new Paragraph({ + children: [ + new EmbeddedFileRun({ + fileName: "budget.xlsx", + data: fs.readFileSync("./demo/assets/embedded-files/budget.xlsx"), + preview: { type: "png", data: fs.readFileSync("./demo/assets/embedded-files/budget.png") }, + transformation: { width: 241, height: 111 }, + }), + ], + }), + ], + }, + ], +}); + +Packer.toBuffer(doc).then((buffer) => { + fs.writeFileSync("My Document.docx", buffer); +}); +``` + +## Options + +| Property | Type | Notes | Description | +| ---------------- | ----------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `fileName` | `string` | Required | The file's name, such as `budget.xlsx`. Its extension says how the file is embedded, below | +| `data` | `Buffer \| string \| Uint8Array \| ArrayBuffer` | Required | The file, or a base64 data URI of it | +| `preview` | `{ type, data }` | Required | The picture the document shows for the file: a `"png"`, `"jpg"`, `"gif"` or `"bmp"`, as `type`, and its `data`, as an `ImageRun`'s | +| `transformation` | `{ width, height }` | Required | The size the picture is drawn at, in pixels, as an `ImageRun`'s | +| `displayAsIcon` | `boolean` | Optional | Shows the file as an icon, as Word's **Display as icon** does, rather than as its content. Default is `false` | +| `altText` | `string` | Optional | A description of the file for screen readers, as Word's **Alt Text** | + +## How each file is embedded + +An Excel workbook, a Word document or a PowerPoint presentation is embedded as itself, as Word embeds it, so Office opens it from the document, and changes made to it are saved in the document: + +| Extension | Opens in | Its OLE ProgID | +| ---------------- | ---------- | ------------------------------------------------------ | +| `.xlsx`, `.xlsm` | Excel | `Excel.Sheet.12`, `Excel.SheetMacroEnabled.12` | +| `.docx`, `.docm` | Word | `Word.Document.12`, `Word.DocumentMacroEnabled.12` | +| `.pptx`, `.pptm` | PowerPoint | `PowerPoint.Show.12`, `PowerPoint.ShowMacroEnabled.12` | + +`EmbeddedFileRun` throws for one of these that isn't a zip file, as Office's files are, such as an older Excel workbook (`.xls`) named `.xlsx`, as Office couldn't open it. + +Any other file, such as a sound (`.mp3`, `.wav`), a video (`.mp4`), a PDF, or an older Office file (`.xls`, `.doc`, `.ppt`), is embedded in an OLE package (the `Package` ProgID), as Word for Windows embeds a file no application on the computer embeds itself. Word for Windows opens it with the application Windows opens its type with, after asking whether to. Word for Mac and LibreOffice show its picture and keep it in the document, but Word for Mac can't open it: it says the application can't be found. A packaged file is always shown as an icon. + +```ts live +import * as fs from "fs"; +import { Document, Packer, Paragraph } from "docx"; +import { EmbeddedFileRun } from "docx/embedded-files"; + +const doc = new Document({ + sections: [ + { + children: [ + new Paragraph("A sound, which Word for Windows plays when it is double-clicked:"), + new Paragraph({ + children: [ + new EmbeddedFileRun({ + fileName: "tone.wav", + data: fs.readFileSync("./demo/assets/embedded-files/tone.wav"), + preview: { type: "png", data: fs.readFileSync("./demo/assets/embedded-files/tone-icon.png") }, + transformation: { width: 96, height: 96 }, + altText: "A one-second chime", + }), + ], + }), + ], + }, + ], +}); + +Packer.toBuffer(doc).then((buffer) => { + fs.writeFileSync("My Document.docx", buffer); +}); +``` + +## The picture + +docx doesn't draw a file's picture: give it one in `preview`, such as a screenshot of a workbook's table, or an icon with the file's name, as Word shows a file with **Display as icon**. Word shows the picture until the file is opened and changed in its application, which then draws the file again: its content, or, with `displayAsIcon`, its application's icon. + +```ts live +import * as fs from "fs"; +import { Document, Packer, Paragraph } from "docx"; +import { EmbeddedFileRun } from "docx/embedded-files"; + +// A Word document to embed, made with docx too +const letter = new Document({ sections: [{ children: [new Paragraph("Dear reader, this letter is in the report.")] }] }); +const letterData = await Packer.toBuffer(letter); + +const doc = new Document({ + sections: [ + { + children: [ + new Paragraph("A letter, which opens in Word when it is double-clicked:"), + new Paragraph({ + children: [ + new EmbeddedFileRun({ + fileName: "letter.docx", + data: letterData, + preview: { type: "png", data: fs.readFileSync("./demo/assets/embedded-files/letter-icon.png") }, + transformation: { width: 96, height: 96 }, + displayAsIcon: true, + }), + ], + }), + ], + }, + ], +}); + +Packer.toBuffer(doc).then((buffer) => { + fs.writeFileSync("My Document.docx", buffer); +}); +``` + +An embedded file goes anywhere a run does: in a table, a header, a footer, a footnote or a comment, and in a document `patchDocument` fills in. + +## Examples + +### A workbook, a document and a sound + +[Example](https://raw.githubusercontent.com/dolanmiu/docx/master/demo/embedded-files/embedded-files.ts ":include") + +_Source: https://github.com/dolanmiu/docx/blob/master/demo/embedded-files/embedded-files.ts_ diff --git a/eslint.config.ts b/eslint.config.ts index 2b158cab54f..bd85041cc1b 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -398,7 +398,8 @@ const config: Linter.Config[] = [ // internals, such as the Formatter. docx/math (src/math) re-exports docx's math, which docx still exports and keeps in // src/file/paragraph/math. src/text-layout measures text for docx/shapes and docx/layout, and each bundles a copy. // docx/html (src/html) imports docx/math by name too, for the math in HTML. docx/macros (src/macros) writes VBA - // projects for docx's macros + // projects for docx's macros, and docx/embedded-files (src/embedded-files) embeds files as OLE objects. + // src/compound-file writes the compound files both keep them in, and each bundles a copy { files: ["src/**/*.ts"], ignores: [ @@ -409,6 +410,8 @@ const config: Linter.Config[] = [ "src/layout/**", "src/html/**", "src/macros/**", + "src/embedded-files/**", + "src/compound-file/**", "src/text-layout/**", "**/*.spec.ts", ], @@ -420,7 +423,7 @@ const config: Linter.Config[] = [ patterns: [ { group: ["docx/*"], message: "docx doesn't import its optional entries, such as docx/shapes" }, { - regex: "(^|/)(shapes|watermarks|charts|layout|html|macros|text-layout)(/|$)", + regex: "(^|/)(shapes|watermarks|charts|layout|html|macros|embedded-files|compound-file|text-layout)(/|$)", message: "docx doesn't import its optional entries, such as docx/shapes", }, ], @@ -437,6 +440,8 @@ const config: Linter.Config[] = [ "src/layout/*.ts", "src/html/*.ts", "src/macros/*.ts", + "src/embedded-files/*.ts", + "src/compound-file/*.ts", "src/text-layout/*.ts", ], ignores: ["**/*.spec.ts"], @@ -449,7 +454,7 @@ const config: Linter.Config[] = [ group: ["@file/*", "@util/*", "@export/*", "@shared", "@shared/*"], message: 'Optional entries import docx from "docx"', }, - { regex: "^\\.\\./(?!text-layout(/|$))", message: 'Optional entries import docx from "docx"' }, + { regex: "^\\.\\./(?!(text-layout|compound-file)(/|$))", message: 'Optional entries import docx from "docx"' }, ], }, ], diff --git a/package.json b/package.json index f716c28e33f..03612b61af4 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,16 @@ "types": "./dist/macros.d.cts", "default": "./dist/macros.cjs" } + }, + "./embedded-files": { + "import": { + "types": "./dist/embedded-files.d.ts", + "default": "./dist/embedded-files.mjs" + }, + "require": { + "types": "./dist/embedded-files.d.cts", + "default": "./dist/embedded-files.cjs" + } } }, "typesVersions": { @@ -110,6 +120,9 @@ ], "macros": [ "./dist/macros.d.ts" + ], + "embedded-files": [ + "./dist/embedded-files.d.ts" ] } }, @@ -117,14 +130,14 @@ "dist" ], "scripts": { - "build": "tsc && vite build && vite build --config vite.entries.config.ts --mode shapes && vite build --config vite.entries.config.ts --mode watermarks && vite build --config vite.entries.config.ts --mode charts && vite build --config vite.entries.config.ts --mode math && vite build --config vite.entries.config.ts --mode layout && vite build --config vite.entries.config.ts --mode html && vite build --config vite.entries.config.ts --mode macros", + "build": "tsc && vite build && vite build --config vite.entries.config.ts --mode shapes && vite build --config vite.entries.config.ts --mode watermarks && vite build --config vite.entries.config.ts --mode charts && vite build --config vite.entries.config.ts --mode math && vite build --config vite.entries.config.ts --mode layout && vite build --config vite.entries.config.ts --mode html && vite build --config vite.entries.config.ts --mode macros && vite build --config vite.entries.config.ts --mode embedded-files", "test": "vitest --ui --coverage", "test:ci": "vitest run --coverage", "prepublishOnly": "npm run build --omit=dev", "lint": "eslint --config eslint.config.ts", "predemo": "npm run build", "demo": "tsx ./demo/index.ts", - "typedoc": "typedoc src/index.ts src/shapes/index.ts src/watermarks/index.ts src/charts/index.ts src/math/index.ts src/layout/index.ts src/html/index.ts src/macros/index.ts --tsconfig tsconfig.typedoc.json", + "typedoc": "typedoc src/index.ts src/shapes/index.ts src/watermarks/index.ts src/charts/index.ts src/math/index.ts src/layout/index.ts src/html/index.ts src/macros/index.ts src/embedded-files/index.ts --tsconfig tsconfig.typedoc.json", "prettier": "prettier -l \"{src,scripts,demo,docs}/**/*.{ts,html,md}\"", "prettier:fix": "npm run prettier -- --write", "cspell": "cspell \"{src,demo,docs,scripts}/**/*.{ts,scss,html,md}\" && cspell \"./*.*\"", diff --git a/src/macros/compound-file.spec.ts b/src/compound-file/compound-file.spec.ts similarity index 75% rename from src/macros/compound-file.spec.ts rename to src/compound-file/compound-file.spec.ts index 6ae2a816a2c..c084ed7877a 100644 --- a/src/macros/compound-file.spec.ts +++ b/src/compound-file/compound-file.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { type CompoundFileDirectoryEntry, readCompoundFile } from "tests/macros"; +import { type CompoundFileDirectoryEntry, readCompoundFile } from "tests/compound-file"; import { createCompoundFile } from "./compound-file"; @@ -128,10 +128,41 @@ describe("createCompoundFile", () => { expect(file.entries.find((entry) => entry.name === "VBA")).toMatchObject({ type: 1, child: NO_STREAM }); }); - it("should throw for a file too large for the sectors of the FAT the header lists", () => { - // 109 sectors of the FAT, of 128 sector numbers each, are 109 × 128 sectors of 512 bytes - expect(() => createCompoundFile([{ name: "Large", data: new Uint8Array(109 * 128 * 512) }])).toThrow( - "A compound file of more than 7143424 bytes isn't supported", + it("should list the FAT's sectors after the first 109 in DIFAT sectors, for a file of more than about 7 MB", () => { + // 109 sectors of the FAT, of 128 sector numbers each, are 109 × 128 sectors of 512 bytes, and this stream needs + // 128 × 127 more, so the FAT has more than the 109 + 127 sectors one DIFAT sector lists + const data = new Uint8Array((109 + 128) * 128 * 512).map((_, index) => index % 251); + const compoundFile = createCompoundFile([ + { name: "Large", data }, + { name: "Small", data: bytes(10, 1) }, + ]); + const view = new DataView(compoundFile.buffer); + const file = readCompoundFile(compoundFile); + + // Two DIFAT sectors, the second ending the chain + expect(view.getUint32(72, true)).toBe(2); + const second = view.getUint32(512 + view.getUint32(68, true) * 512 + 127 * 4, true); + expect(view.getUint32(512 + second * 512 + 127 * 4, true)).toBe(0xfffffffe); + // Compared by hand, as comparing 15 MB with toEqual takes half a minute + const large = file.streams.get("Large")!; + expect(large.length).toBe(data.length); + expect(large.findIndex((byte, index) => byte !== data[index])).toBe(-1); + expect(file.streams.get("Small")).toEqual(bytes(10, 1)); + }); + + it("should give the root the class id, and none without one", () => { + const withClassId = readCompoundFile( + createCompoundFile([{ name: "Data", data: bytes(1, 0) }], { classId: "0003000c-0000-0000-c000-000000000046" }), + ); + const without = readCompoundFile(createCompoundFile([{ name: "Data", data: bytes(1, 0) }])); + + expect(withClassId.entries[0].classId).toBe("0003000C-0000-0000-C000-000000000046"); + expect(without.entries[0].classId).toBeUndefined(); + }); + + it("should throw for a class id that isn't a GUID", () => { + expect(() => createCompoundFile([], { classId: "{0003000C-0000-0000-C000-000000000046}" })).toThrow( + 'Invalid class id "{0003000C-0000-0000-C000-000000000046}". Expected a GUID such as "0003000C-0000-0000-C000-000000000046"', ); }); diff --git a/src/macros/compound-file.ts b/src/compound-file/compound-file.ts similarity index 79% rename from src/macros/compound-file.ts rename to src/compound-file/compound-file.ts index 31ba242072e..4b41a975b2a 100644 --- a/src/macros/compound-file.ts +++ b/src/compound-file/compound-file.ts @@ -1,6 +1,8 @@ /** - * Writes a compound file, the format a VBA project is kept in: a file system in a file, with storages, its folders, and - * streams, its files. + * Writes a compound file, the format a VBA project and an OLE object are kept in: a file system in a file, with + * storages, its folders, and streams, its files. + * + * docx/macros and docx/embedded-files each bundle a copy. * * Reference: [MS-CFB] Compound File Binary File Format * @@ -31,13 +33,16 @@ const SECTOR_SIZE = 512; const MINI_SECTOR_SIZE = 64; const MINI_STREAM_CUTOFF = 4096; const DIRECTORY_ENTRY_SIZE = 128; -// The number of sector numbers a sector of the FAT holds, and the number of FAT sectors the header lists +// The number of sector numbers a sector of the FAT holds, the number of FAT sectors the header lists, and the number a +// DIFAT sector lists, before the number of the next DIFAT sector const SECTOR_NUMBERS_PER_SECTOR = SECTOR_SIZE / 4; const HEADER_FAT_SECTORS = 109; +const DIFAT_SECTOR_FAT_SECTORS = SECTOR_NUMBERS_PER_SECTOR - 1; const FREE_SECTOR = 0xffffffff; const END_OF_CHAIN = 0xfffffffe; const FAT_SECTOR = 0xfffffffd; +const DIFAT_SECTOR = 0xfffffffc; const NO_STREAM = 0xffffffff; const STORAGE = 1; @@ -164,13 +169,32 @@ const addChain = (table: number[], count: number, value?: number): number => { }; /** - * Writes a compound file of version 3, as Office writes VBA projects. + * Writes a class id, a GUID such as "0003000C-0000-0000-C000-000000000046", as a directory entry holds it: its first + * three fields little-endian, and the rest in order. + */ +const writeClassId = (view: DataView, offset: number, classId: string): void => { + const match = /^([\da-f]{8})-([\da-f]{4})-([\da-f]{4})-([\da-f]{4})-([\da-f]{12})$/i.exec(classId); + if (match === null) { + throw new Error(`Invalid class id "${classId}". Expected a GUID such as "0003000C-0000-0000-C000-000000000046"`); + } + view.setUint32(offset, parseInt(match[1], 16), true); + view.setUint16(offset + 4, parseInt(match[2], 16), true); + view.setUint16(offset + 6, parseInt(match[3], 16), true); + const rest = match[4] + match[5]; + for (let index = 0; index < 8; index++) { + view.setUint8(offset + 8 + index, parseInt(rest.slice(index * 2, index * 2 + 2), 16)); + } +}; + +/** + * Writes a compound file of version 3, as Office writes VBA projects and OLE objects. * * @param children - The root storage's streams and storages + * @param options - The root storage's class id, which says what application an OLE object's data is for * @throws If two entries of a storage have the same name, ignoring case, or a name is empty, longer than 31 characters or * has "/", "\", ":" or "!" */ -export const createCompoundFile = (children: readonly CompoundFileEntry[]): Uint8Array => { +export const createCompoundFile = (children: readonly CompoundFileEntry[], { classId }: { readonly classId?: string } = {}): Uint8Array => { const entries = createDirectory(children); const streams = entries.filter((entry) => entry.type === STREAM); const miniStreams = streams.filter((entry) => entry.data!.length < MINI_STREAM_CUTOFF); @@ -203,16 +227,16 @@ export const createCompoundFile = (children: readonly CompoundFileEntry[]): Uint // eslint-disable-next-line functional/immutable-data stream.size = stream.data!.length; } + // The FAT has a number for each sector, its own and the DIFAT's too. The header lists its first 109 sectors, and DIFAT + // sectors list the rest, for a file of more than about 7 MB let fatSectors = 0; - while (fatSectors * SECTOR_NUMBERS_PER_SECTOR < fat.length + fatSectors) { + const difatSectorsFor = (count: number): number => Math.max(0, Math.ceil((count - HEADER_FAT_SECTORS) / DIFAT_SECTOR_FAT_SECTORS)); + while (fatSectors * SECTOR_NUMBERS_PER_SECTOR < fat.length + fatSectors + difatSectorsFor(fatSectors)) { fatSectors++; } - if (fatSectors > HEADER_FAT_SECTORS) { - throw new Error( - `A compound file of more than ${HEADER_FAT_SECTORS * SECTOR_NUMBERS_PER_SECTOR * SECTOR_SIZE} bytes isn't supported`, - ); - } + const difatSectors = difatSectorsFor(fatSectors); const fatStart = addChain(fat, fatSectors, FAT_SECTOR); + const difatStart = addChain(fat, difatSectors, DIFAT_SECTOR); const file = new Uint8Array(SECTOR_SIZE + fat.length * SECTOR_SIZE); const view = new DataView(file.buffer); @@ -230,11 +254,22 @@ export const createCompoundFile = (children: readonly CompoundFileEntry[]): Uint view.setUint32(56, MINI_STREAM_CUTOFF, true); view.setUint32(60, miniFatStart, true); view.setUint32(64, miniFatSectors, true); - view.setUint32(68, END_OF_CHAIN, true); + view.setUint32(68, difatStart, true); + view.setUint32(72, difatSectors, true); for (let index = 0; index < HEADER_FAT_SECTORS; index++) { view.setUint32(76 + index * 4, index < fatSectors ? fatStart + index : FREE_SECTOR, true); } + // The DIFAT, each sector ending with the next's number + for (let sector = 0; sector < difatSectors; sector++) { + const offset = sectorOffset(difatStart + sector); + for (let index = 0; index < DIFAT_SECTOR_FAT_SECTORS; index++) { + const fatSector = HEADER_FAT_SECTORS + sector * DIFAT_SECTOR_FAT_SECTORS + index; + view.setUint32(offset + index * 4, fatSector < fatSectors ? fatStart + fatSector : FREE_SECTOR, true); + } + view.setUint32(offset + DIFAT_SECTOR_FAT_SECTORS * 4, sector < difatSectors - 1 ? difatStart + sector + 1 : END_OF_CHAIN, true); + } + // The directory. Its unused entries are zeros, but for their links for (let index = 0; index < Math.ceil(entries.length / 4) * 4; index++) { const offset = sectorOffset(directoryStart) + index * DIRECTORY_ENTRY_SIZE; @@ -251,6 +286,9 @@ export const createCompoundFile = (children: readonly CompoundFileEntry[]): Uint view.setUint16(offset + 64, (entry.name.length + 1) * 2, true); view.setUint8(offset + 66, entry.type); view.setUint8(offset + 67, entry.color); + if (index === 0 && classId !== undefined) { + writeClassId(view, offset + 80, classId); + } view.setUint32(offset + 68, entry.left, true); view.setUint32(offset + 72, entry.right, true); view.setUint32(offset + 76, entry.child, true); diff --git a/src/embedded-files/embedded-file-run.spec.ts b/src/embedded-files/embedded-file-run.spec.ts new file mode 100644 index 00000000000..e4ff58f751e --- /dev/null +++ b/src/embedded-files/embedded-file-run.spec.ts @@ -0,0 +1,322 @@ +import JSZip from "jszip"; +import { describe, expect, it, vi } from "vitest"; + +import { Formatter } from "@export/formatter"; +import { File } from "@file/file"; +import { Document, FootnoteReferenceRun, Header, Packer, Paragraph, PatchType, Run, patchDocument } from "docx"; +import { readCompoundFile } from "tests/compound-file"; + +import { EmbeddedFileRun, type EmbeddedFileRunOptions } from "./embedded-file-run"; + +// A file that starts as a zip file does, as Office's files do +const WORKBOOK = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3]); +const PICTURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 4, 5, 6]); + +const workbook: EmbeddedFileRunOptions = { + fileName: "budget.xlsx", + data: WORKBOOK, + preview: { type: "png", data: PICTURE }, + transformation: { width: 180, height: 100 }, +}; + +const format = (run: EmbeddedFileRun, file = new File({ sections: [] })): Record => + new Formatter().format(run, { file, viewWrapper: file.Document, stack: [] }) as Record; + +type Element = Record; + +// The attributes of an element of the object (`w:object`), by its name +const attributesOf = (run: EmbeddedFileRun, name: string): Record => { + const [object] = format(run)["w:r"] as readonly Element[]; + const children = object["w:object"] as readonly Element[]; + const shape = children.find((child) => "v:shape" in child)!["v:shape"] as readonly Element[]; + // An element with only attributes is formatted as an object, and one with children as an array + const value = name === "w:object" ? children : [...children, ...shape].find((child) => name in child)![name]; + const element = Array.isArray(value) ? value[0] : value; + return (element as { readonly _attr: Record })._attr; +}; + +const pack = async (doc: Document): Promise => JSZip.loadAsync(await Packer.toBuffer(doc)); + +const read = async (zip: JSZip, path: string): Promise => (await zip.file(path)?.async("text")) ?? ""; + +describe("EmbeddedFileRun", () => { + it("should be a run from docx", () => { + expect(new EmbeddedFileRun(workbook)).toBeInstanceOf(Run); + }); + + describe("#constructor()", () => { + it("should write the object as Word does: its picture frame, its picture and its OLE data", () => { + const run = new EmbeddedFileRun(workbook); + const tree = format(run); + const { ShapeID: shapeId } = attributesOf(run, "o:OLEObject"); + + expect(tree).toStrictEqual({ + "w:r": [ + { + "w:object": [ + { _attr: { "w:dxaOrig": 2700, "w:dyaOrig": 1500 } }, + { + "v:shapetype": expect.arrayContaining([ + { _attr: expect.objectContaining({ id: "_x0000_t75", "o:spt": 75 }) }, + ]), + }, + { + "v:shape": [ + { _attr: { id: shapeId, type: "#_x0000_t75", style: "width:135pt;height:75pt", "o:ole": "" } }, + { + "v:imagedata": { + _attr: { "r:id": expect.stringMatching(/^rId\{[0-9a-f]+\.png\}$/), "o:title": "" }, + }, + }, + ], + }, + { + "o:OLEObject": { + _attr: { + Type: "Embed", + ProgID: "Excel.Sheet.12", + ShapeID: shapeId, + DrawAspect: "Content", + ObjectID: expect.stringMatching(/^_\d{10}$/), + "r:id": expect.stringMatching(/^rId/), + }, + }, + }, + ], + }, + ], + }); + expect(shapeId).toMatch(/^EmbeddedFile/); + }); + + it("should give each object its own shape and object ids", () => { + const [first, second] = [new EmbeddedFileRun(workbook), new EmbeddedFileRun(workbook)].map((run) => + attributesOf(run, "o:OLEObject"), + ); + + expect(first.ShapeID).not.toBe(second.ShapeID); + expect(first.ObjectID).not.toBe(second.ObjectID); + }); + + it("should draw the object at its size in points, rounded to hundredths, with its original size in twentieths of a point", () => { + const run = new EmbeddedFileRun({ ...workbook, transformation: { width: 101, height: 33.3 } }); + + expect(attributesOf(run, "v:shape").style).toBe("width:75.75pt;height:24.98pt"); + expect(attributesOf(run, "w:object")).toEqual({ "w:dxaOrig": 1515, "w:dyaOrig": 500 }); + }); + + it("should show the file as an icon with displayAsIcon", () => { + expect(attributesOf(new EmbeddedFileRun({ ...workbook, displayAsIcon: true }), "o:OLEObject").DrawAspect).toBe("Icon"); + expect(attributesOf(new EmbeddedFileRun({ ...workbook, displayAsIcon: false }), "o:OLEObject").DrawAspect).toBe("Content"); + }); + + it("should package any other file, and always show it as an icon", () => { + const run = new EmbeddedFileRun({ ...workbook, fileName: "song.mp3", data: new Uint8Array([1, 2, 3]), displayAsIcon: false }); + + expect(attributesOf(run, "o:OLEObject")).toMatchObject({ ProgID: "Package", DrawAspect: "Icon" }); + }); + + it("should describe the file for screen readers with altText", () => { + expect(attributesOf(new EmbeddedFileRun({ ...workbook, altText: "Sales by month" }), "v:shape").alt).toBe("Sales by month"); + expect(attributesOf(new EmbeddedFileRun(workbook), "v:shape")).not.toHaveProperty("alt"); + }); + + it("should throw for an Office file that isn't a zip file, which Office couldn't open", () => { + expect( + () => new EmbeddedFileRun({ ...workbook, fileName: "old.xlsx", data: new Uint8Array([0xd0, 0xcf, 0x11, 0xe0]) }), + ).toThrow( + "EmbeddedFileRun: \"old.xlsx\" isn't an Office Open XML file, which is a zip file, so Office couldn't open it. Name it with its own extension, such as .xls, to embed it in an OLE package", + ); + expect( + () => new EmbeddedFileRun({ ...workbook, fileName: "old.xls", data: new Uint8Array([0xd0, 0xcf, 0x11, 0xe0]) }), + ).not.toThrow(); + }); + + it("should throw for a picture type Word doesn't draw an embedded file in, or none", () => { + for (const type of ["svg", undefined]) { + expect( + () => + new EmbeddedFileRun({ + ...workbook, + preview: { type, data: PICTURE } as unknown as EmbeddedFileRunOptions["preview"], + }), + ).toThrow(`EmbeddedFileRun: the preview's type is "${type}". Expected "jpg", "png", "gif" or "bmp"`); + } + }); + }); + + describe("#prepForXml()", () => { + it("should add the picture to the document's media, named by its content", () => { + const file = new File({ sections: [] }); + const addImage = vi.spyOn(file.Media, "addImage"); + format(new EmbeddedFileRun(workbook), file); + + expect(addImage).toHaveBeenCalledWith( + expect.stringMatching(/^[0-9a-f]+\.png$/), + expect.objectContaining({ + type: "png", + data: PICTURE, + transformation: { pixels: { x: 180, y: 100 }, emus: { x: 1714500, y: 952500 } }, + }), + ); + }); + }); + + describe("when packed", () => { + it("should embed an Office file as itself, with its picture and the relationships to both", async () => { + const zip = await pack( + new Document({ sections: [{ children: [new Paragraph({ children: [new EmbeddedFileRun(workbook)] })] }] }), + ); + const document = await read(zip, "word/document.xml"); + const relationships = await read(zip, "word/_rels/document.xml.rels"); + const [, imageId] = /]*r:id="([^"]+)"/.exec(document)!; + + expect(await zip.file("word/embeddings/Microsoft_Excel_Worksheet1.xlsx")?.async("uint8array")).toEqual(WORKBOOK); + expect(relationships).toContain( + ``, + ); + expect(relationships).toMatch( + new RegExp( + ``, + ), + ); + expect(await read(zip, "[Content_Types].xml")).toContain( + '', + ); + }); + + it("should write the shape type once in each part, in its first object, and again when packed again", async () => { + const doc = new Document({ + footnotes: { + 1: { children: [new Paragraph({ children: [new EmbeddedFileRun(workbook), new EmbeddedFileRun(workbook)] })] }, + }, + sections: [ + { + headers: { default: new Header({ children: [new Paragraph({ children: [new EmbeddedFileRun(workbook)] })] }) }, + children: [ + new Paragraph({ children: [new EmbeddedFileRun(workbook), new FootnoteReferenceRun(1)] }), + new Paragraph({ children: [new EmbeddedFileRun(workbook), new EmbeddedFileRun(workbook)] }), + ], + }, + ], + }); + await pack(doc); + const zip = await pack(doc); + + for (const part of ["document", "header1", "footnotes"]) { + const xml = await read(zip, `word/${part}.xml`); + expect(xml.split(" { + const zip = await pack( + new Document({ sections: [{ children: [new Paragraph({ children: [new EmbeddedFileRun(workbook)] })] }] }), + ); + + expect(await read(zip, "word/document.xml")).toContain( + '' + + '' + + '' + + '' + + '' + + '', + ); + }); + + it("should embed any other file in an OLE package", async () => { + const data = new Uint8Array([1, 2, 3]); + const zip = await pack( + new Document({ + sections: [ + { children: [new Paragraph({ children: [new EmbeddedFileRun({ ...workbook, fileName: "song.mp3", data })] })] }, + ], + }), + ); + const ole = readCompoundFile((await zip.file("word/embeddings/oleObject1.bin")?.async("uint8array"))!); + + expect(ole.entries[0].classId).toBe("0003000C-0000-0000-C000-000000000046"); + expect(ole.streams.get("\u0001Ole10Native")).toBeDefined(); + expect(await read(zip, "word/_rels/document.xml.rels")).toContain( + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="embeddings/oleObject1.bin"', + ); + expect(await read(zip, "[Content_Types].xml")).toContain( + '', + ); + }); + + it("should embed each file once, and a picture used twice once", async () => { + const zip = await pack( + new Document({ + sections: [{ children: [new Paragraph({ children: [new EmbeddedFileRun(workbook), new EmbeddedFileRun(workbook)] })] }], + }), + ); + + expect( + Object.keys(zip.files) + .filter((path) => /^word\/(embeddings|media)\/./.test(path)) + .sort(), + ).toEqual([ + "word/embeddings/Microsoft_Excel_Worksheet1.xlsx", + "word/embeddings/Microsoft_Excel_Worksheet2.xlsx", + expect.stringMatching(/^word\/media\/[0-9a-f]+\.png$/), + ]); + }); + + it("should embed a file in a header or a footnote, with the relationships from its part", async () => { + const zip = await pack( + new Document({ + footnotes: { + 1: { children: [new Paragraph({ children: [new EmbeddedFileRun({ ...workbook, fileName: "note.docx" })] })] }, + }, + sections: [ + { + headers: { default: new Header({ children: [new Paragraph({ children: [new EmbeddedFileRun(workbook)] })] }) }, + children: [new Paragraph({ children: [new FootnoteReferenceRun(1)] })], + }, + ], + }), + ); + + for (const [part, target] of [ + ["header1", "embeddings/Microsoft_Excel_Worksheet1.xlsx"], + ["footnotes", "embeddings/Microsoft_Word_Document1.docx"], + ]) { + const relationships = await read(zip, `word/_rels/${part}.xml.rels`); + expect(relationships).toContain(`Target="${target}"`); + expect(relationships).toMatch(/Target="media\/[0-9a-f]+\.png"/); + } + }); + + it("should embed a file given as an ArrayBuffer, a data URI or a slice of a larger buffer", async () => { + const larger = Buffer.from([9, 9, ...WORKBOOK, 9]); + for (const data of [ + WORKBOOK.slice().buffer, + `data:application/octet-stream;base64,${Buffer.from(WORKBOOK).toString("base64")}`, + larger.subarray(2, -1), + ]) { + const zip = await pack( + new Document({ sections: [{ children: [new Paragraph({ children: [new EmbeddedFileRun({ ...workbook, data })] })] }] }), + ); + expect(await zip.file("word/embeddings/Microsoft_Excel_Worksheet1.xlsx")?.async("uint8array")).toEqual(WORKBOOK); + } + }); + + it("should embed a file in a document patchDocument fills in", async () => { + const template = await Packer.toBuffer(new Document({ sections: [{ children: [new Paragraph("Before {{file}} after")] }] })); + const patched = await patchDocument({ + outputType: "uint8array", + data: template, + patches: { file: { type: PatchType.PARAGRAPH, children: [new EmbeddedFileRun(workbook)] } }, + }); + const zip = await JSZip.loadAsync(patched); + + expect(await read(zip, "word/document.xml")).toContain('ProgID="Excel.Sheet.12"'); + expect(await zip.file("word/embeddings/Microsoft_Excel_Worksheet1.xlsx")?.async("uint8array")).toEqual(WORKBOOK); + expect(Object.keys(zip.files).some((path) => /^word\/media\/[0-9a-f]+\.png$/.test(path))).toBe(true); + }); + }); +}); diff --git a/src/embedded-files/embedded-file-run.ts b/src/embedded-files/embedded-file-run.ts new file mode 100644 index 00000000000..73763307f52 --- /dev/null +++ b/src/embedded-files/embedded-file-run.ts @@ -0,0 +1,257 @@ +/** + * Embedded file run module: a file embedded in a document as an OLE object, such as a workbook, a presentation, a sound + * or a video, which opens from the document. + * + * Reference: http://officeopenxml.com/WPembeddedObjects.php + * + * @module + */ +import { + BuilderElement, + type IContext, + type IMediaData, + type IXmlableObject, + NextAttributeComponent, + PackagePart, + type PackagePartRelationshipType, + Run, + XmlComponent, + hashedId, + standardizeData, + uniqueId, + uniqueNumericIdCreator, +} from "docx"; + +import { PACKAGE_FILE_TYPE, embeddedFileTypeOf } from "./embedded-file-type"; +import { createOlePackage } from "./ole-package"; +import { PictureFrameShapeType } from "./picture-frame-shape-type"; + +/** The image formats Word draws an embedded file's picture in */ +const PREVIEW_TYPES: ReadonlySet = new Set(["jpg", "png", "gif", "bmp"]); + +/** Number of English Metric Units in one pixel at 96 dpi. */ +const EMUS_PER_PIXEL = 9525; + +/** Number of points in one pixel at 96 dpi. */ +const POINTS_PER_PIXEL = 0.75; + +/** + * The picture a document shows for an embedded file. + */ +export type EmbeddedFilePreview = { + /** The image format. Word draws an embedded file's picture as a legacy picture, which can't be an SVG */ + readonly type: "jpg" | "png" | "gif" | "bmp"; + /** The image data. Accepts a Buffer, Uint8Array, ArrayBuffer, or a base64-encoded data URI string */ + readonly data: Buffer | string | Uint8Array | ArrayBuffer; +}; + +/** + * Options for an {@link EmbeddedFileRun}. + */ +export type EmbeddedFileRunOptions = { + /** + * The file's name, such as "budget.xlsx". Its extension says how the file is embedded: an Excel workbook (.xlsx, + * .xlsm), a Word document (.docx, .docm) or a PowerPoint presentation (.pptx, .pptm) as itself, which Word opens in + * Excel, Word or PowerPoint, and any other file, such as a sound, a video or a PDF, in an OLE package, which Word + * for Windows opens with the application the computer opens its type with. A packaged file keeps the name + */ + readonly fileName: string; + /** The file. Accepts a Buffer, Uint8Array, ArrayBuffer, or a base64-encoded data URI string */ + readonly data: Buffer | string | Uint8Array | ArrayBuffer; + /** + * The picture the document shows for the file: of its content, such as a workbook's table, or an icon. Word draws + * it until the file is opened and changed in its application, which then draws the file again + */ + readonly preview: EmbeddedFilePreview; + /** The size the picture is drawn at, in pixels, at 96 to the inch, as an ImageRun's */ + readonly transformation: { + /** Width in pixels */ + readonly width: number; + /** Height in pixels */ + readonly height: number; + }; + /** + * Whether the file is shown as an icon, as Word's "Display as icon" does, rather than as its content. When the file is + * changed in its application, Word draws its application's icon instead of its content. A packaged file is always + * shown as an icon. Default is false + */ + readonly displayAsIcon?: boolean; + /** A description of the file for screen readers, as Word's "Alt Text" */ + readonly altText?: string; +}; + +// Each object in a document has an id of its own (`ObjectID`), a number Word writes with ten digits +const objectNumber = uniqueNumericIdCreator(1_000_000_000); + +/** + * The object's OLE data (`o:OLEObject`): the application it is for, the shape it is drawn as, and the part its file is + * in, which is added to the package, with the relationship to it, when it is written. + */ +class OleObject extends XmlComponent { + public constructor( + private readonly part: PackagePart, + { progId, shapeId, displayAsIcon }: { readonly progId: string; readonly shapeId: string; readonly displayAsIcon: boolean }, + ) { + super("o:OLEObject"); + this.root.push( + new NextAttributeComponent({ + type: { key: "Type", value: "Embed" }, + progId: { key: "ProgID", value: progId }, + shapeId: { key: "ShapeID", value: shapeId }, + drawAspect: { key: "DrawAspect", value: displayAsIcon ? "Icon" : "Content" }, + objectId: { key: "ObjectID", value: `_${objectNumber()}` }, + id: { key: "r:id", value: part.relationshipId }, + }), + ); + } + + public prepForXml(context: IContext): IXmlableObject | undefined { + this.part.addTo(context); + return super.prepForXml(context); + } +} + +/** + * The bytes of data given as a Buffer, Uint8Array, ArrayBuffer or base64-encoded data URI string. + */ +const bytesOf = (data: Buffer | string | Uint8Array | ArrayBuffer): Uint8Array => { + const standardized = standardizeData(data); + return ArrayBuffer.isView(standardized) + ? new Uint8Array(standardized.buffer, standardized.byteOffset, standardized.byteLength) + : new Uint8Array(standardized); +}; + +/** + * Represents a file embedded in a WordprocessingML document, as Word's Insert > Object > "Create from File" embeds one: + * an OLE object, drawn as a picture of it, that opens the file from the document. + * + * An Excel workbook, Word document or PowerPoint presentation is embedded as itself, so Word opens it in its + * application, which can change it in the document. Any other file, such as a sound, a video or a PDF, is embedded in an + * OLE package, which Word for Windows opens with the application the computer opens its type with, as it does a file + * embedded there. Word for Mac shows it, but can't open it. + * + * The document shows the picture given in `preview`, of the file's content or an icon, until the file is opened. The + * object sits inline with text, in a paragraph, including in a table, header, footer or footnote. + * + * Reference: http://officeopenxml.com/WPembeddedObjects.php + * + * @publicApi + * + * ## XSD Schema + * The embedded file combines several elements: + * - w:r (run container) + * - w:object (the embedded object, with its original size) + * - v:shapetype and v:shape (the picture frame it's drawn in) + * - v:imagedata (the picture) + * - o:OLEObject (the application it is for, and the part the file is in) + * + * @example + * ```typescript + * new Paragraph({ + * children: [ + * new EmbeddedFileRun({ + * fileName: "budget.xlsx", + * data: fs.readFileSync("./budget.xlsx"), + * preview: { type: "png", data: fs.readFileSync("./budget.png") }, + * transformation: { width: 240, height: 120 }, + * }), + * ], + * }); + * ``` + */ +export class EmbeddedFileRun extends Run { + private readonly preview: IMediaData; + + /** + * @throws If the file is an Excel workbook, Word document or PowerPoint presentation that isn't a zip file, as Office's + * files are, so Office couldn't open it, such as an older Excel workbook (.xls) named .xlsx + * @throws If the preview's type isn't "jpg", "png", "gif" or "bmp", such as when JavaScript leaves it out + */ + public constructor({ fileName, data, preview, transformation, displayAsIcon = false, altText }: EmbeddedFileRunOptions) { + // A run, so that it can be one of a paragraph's children. It has no formatting, so only the `w:r` is written + super({}); + + if (!PREVIEW_TYPES.has(preview.type)) { + throw new Error(`EmbeddedFileRun: the preview's type is "${preview.type}". Expected "jpg", "png", "gif" or "bmp"`); + } + const fileType = embeddedFileTypeOf(fileName); + const file = bytesOf(data); + const packaged = fileType === PACKAGE_FILE_TYPE; + // Office Open XML files start as a zip file does, with "PK\x03\x04" + if (!packaged && !(file[0] === 0x50 && file[1] === 0x4b && file[2] === 0x03 && file[3] === 0x04)) { + throw new Error( + `EmbeddedFileRun: "${fileName}" isn't an Office Open XML file, which is a zip file, so Office couldn't open it. Name it with its own extension, such as .xls, to embed it in an OLE package`, + ); + } + const part = new PackagePart({ + folder: "embeddings", + name: fileType.partName, + extension: fileType.extension, + contentType: fileType.contentType, + relationshipType: fileType.relationshipType, + content: packaged ? createOlePackage({ fileName, data: file }) : file, + }); + + const previewFileName = `${hashedId(preview.data)}.${preview.type}`; + this.preview = { + type: preview.type, + data: standardizeData(preview.data), + fileName: previewFileName, + transformation: { + pixels: { x: Math.round(transformation.width), y: Math.round(transformation.height) }, + emus: { x: Math.round(transformation.width * EMUS_PER_PIXEL), y: Math.round(transformation.height * EMUS_PER_PIXEL) }, + }, + }; + + // Its size in points, which Word rounds to hundredths, and in twentieths of a point + const width = Math.round(transformation.width * POINTS_PER_PIXEL * 100) / 100; + const height = Math.round(transformation.height * POINTS_PER_PIXEL * 100) / 100; + const shapeId = `EmbeddedFile${uniqueId()}`; + + this.root.push( + new BuilderElement({ + name: "w:object", + attributes: { + width: { key: "w:dxaOrig", value: Math.round(width * 20) }, + height: { key: "w:dyaOrig", value: Math.round(height * 20) }, + }, + children: [ + new PictureFrameShapeType(), + new BuilderElement<{ + readonly id: string; + readonly type: string; + readonly alt?: string; + readonly style: string; + readonly ole: string; + }>({ + name: "v:shape", + attributes: { + id: { key: "id", value: shapeId }, + type: { key: "type", value: "#_x0000_t75" }, + alt: { key: "alt", value: altText }, + style: { key: "style", value: `width:${width}pt;height:${height}pt` }, + ole: { key: "o:ole", value: "" }, + }, + children: [ + new BuilderElement({ + name: "v:imagedata", + attributes: { + // Placeholder resolved to the relationship id of the part it's in by the packer + id: { key: "r:id", value: `rId{${previewFileName}}` }, + title: { key: "o:title", value: "" }, + }, + }), + ], + }), + new OleObject(part, { progId: fileType.progId, shapeId, displayAsIcon: packaged || displayAsIcon }), + ], + }), + ); + } + + public prepForXml(context: IContext): IXmlableObject | undefined { + context.file.Media.addImage(this.preview.fileName, this.preview); + + return super.prepForXml(context); + } +} diff --git a/src/embedded-files/embedded-file-type.spec.ts b/src/embedded-files/embedded-file-type.spec.ts new file mode 100644 index 00000000000..1fc13eaaf93 --- /dev/null +++ b/src/embedded-files/embedded-file-type.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { PACKAGE_FILE_TYPE, embeddedFileTypeOf } from "./embedded-file-type"; + +describe("embeddedFileTypeOf", () => { + it.each([ + [ + "budget.xlsx", + "Excel.Sheet.12", + "Microsoft_Excel_Worksheet", + "xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + [ + "tools.xlsm", + "Excel.SheetMacroEnabled.12", + "Microsoft_Excel_Macro-enabled_Worksheet", + "xlsm", + "application/vnd.ms-excel.sheet.macroEnabled.12", + ], + [ + "letter.docx", + "Word.Document.12", + "Microsoft_Word_Document", + "docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + [ + "tools.docm", + "Word.DocumentMacroEnabled.12", + "Microsoft_Word_Macro-enabled_Document", + "docm", + "application/vnd.ms-word.document.macroEnabled.12", + ], + [ + "slides.pptx", + "PowerPoint.Show.12", + "Microsoft_PowerPoint_Presentation", + "pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ], + [ + "slides.pptm", + "PowerPoint.ShowMacroEnabled.12", + "Microsoft_PowerPoint_Macro-enabled_Presentation", + "pptm", + "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ], + ])("should embed %s as itself, for Office to open", (fileName, progId, partName, extension, contentType) => { + expect(embeddedFileTypeOf(fileName)).toEqual({ + progId, + partName, + extension, + contentType, + relationshipType: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package", + }); + }); + + it("should read the extension in any case, after the last dot", () => { + expect(embeddedFileTypeOf("Q3 Budget.v2.XLSX").progId).toBe("Excel.Sheet.12"); + }); + + it.each(["song.mp3", "video.mp4", "manual.pdf", "old.xls", "README", "xlsx", "budget.xlsx.zip", "a.constructor", "a.toString"])( + "should embed %s in an OLE package", + (fileName) => { + expect(embeddedFileTypeOf(fileName)).toBe(PACKAGE_FILE_TYPE); + }, + ); + + it("should keep a package in an OLE object part", () => { + expect(PACKAGE_FILE_TYPE).toEqual({ + progId: "Package", + partName: "oleObject", + extension: "bin", + contentType: "application/vnd.openxmlformats-officedocument.oleObject", + relationshipType: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject", + }); + }); +}); diff --git a/src/embedded-files/embedded-file-type.ts b/src/embedded-files/embedded-file-type.ts new file mode 100644 index 00000000000..e6b7f279cee --- /dev/null +++ b/src/embedded-files/embedded-file-type.ts @@ -0,0 +1,95 @@ +/** + * How each kind of file is embedded: Office's own files as themselves, and any other file in an OLE package. + * + * @module + */ + +/** + * How a file is embedded in a document: the application Word opens it with, and the part it is kept in. + */ +export type EmbeddedFileType = { + /** The OLE ProgID of the application Word opens the file with, such as "Excel.Sheet.12" */ + readonly progId: string; + /** The start of the part's file name, as Word names it, such as "Microsoft_Excel_Worksheet" */ + readonly partName: string; + /** The extension of the part's file name, such as "xlsx" */ + readonly extension: string; + /** The part's content type */ + readonly contentType: string; + /** The type of the relationship to the part from the part the file is in, such as the document */ + readonly relationshipType: + | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" + | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject"; +}; + +const PACKAGE_RELATIONSHIP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"; + +/** + * Office's own files, by extension, which are embedded as themselves, as Word embeds them, so Word opens each in its + * application: Excel, Word or PowerPoint. Their ProgIDs are Office 2007's, which every version since opens. + */ +const OFFICE_FILE_TYPES: Readonly> = { + xlsx: { + progId: "Excel.Sheet.12", + partName: "Microsoft_Excel_Worksheet", + extension: "xlsx", + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + relationshipType: PACKAGE_RELATIONSHIP, + }, + xlsm: { + progId: "Excel.SheetMacroEnabled.12", + partName: "Microsoft_Excel_Macro-enabled_Worksheet", + extension: "xlsm", + contentType: "application/vnd.ms-excel.sheet.macroEnabled.12", + relationshipType: PACKAGE_RELATIONSHIP, + }, + docx: { + progId: "Word.Document.12", + partName: "Microsoft_Word_Document", + extension: "docx", + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + relationshipType: PACKAGE_RELATIONSHIP, + }, + docm: { + progId: "Word.DocumentMacroEnabled.12", + partName: "Microsoft_Word_Macro-enabled_Document", + extension: "docm", + contentType: "application/vnd.ms-word.document.macroEnabled.12", + relationshipType: PACKAGE_RELATIONSHIP, + }, + pptx: { + progId: "PowerPoint.Show.12", + partName: "Microsoft_PowerPoint_Presentation", + extension: "pptx", + contentType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + relationshipType: PACKAGE_RELATIONSHIP, + }, + pptm: { + progId: "PowerPoint.ShowMacroEnabled.12", + partName: "Microsoft_PowerPoint_Macro-enabled_Presentation", + extension: "pptm", + contentType: "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + relationshipType: PACKAGE_RELATIONSHIP, + }, +}; + +/** + * Any other file, which is embedded in an OLE package, as Word embeds a file no application on the computer embeds + * itself, such as a sound, a video or a PDF. + */ +export const PACKAGE_FILE_TYPE: EmbeddedFileType = { + progId: "Package", + partName: "oleObject", + extension: "bin", + contentType: "application/vnd.openxmlformats-officedocument.oleObject", + relationshipType: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject", +}; + +/** + * How a file is embedded, from the extension of its name: as itself for Office's own files, such as "budget.xlsx", and + * in an OLE package for any other. + */ +export const embeddedFileTypeOf = (fileName: string): EmbeddedFileType => { + const extension = /\.([^.]+)$/.exec(fileName)?.[1].toLowerCase(); + return extension !== undefined && Object.hasOwn(OFFICE_FILE_TYPES, extension) ? OFFICE_FILE_TYPES[extension] : PACKAGE_FILE_TYPE; +}; diff --git a/src/embedded-files/index.ts b/src/embedded-files/index.ts new file mode 100644 index 00000000000..ee1aa15147d --- /dev/null +++ b/src/embedded-files/index.ts @@ -0,0 +1,38 @@ +/** + * Embedded files for docx documents: a workbook, a document, a presentation or any other file, such as a sound, a video + * or a PDF, embedded in the document as an OLE object, which opens from it, as Word's Insert > Object embeds a file. + * + * Embedded files come with the `docx` package, from `docx/embedded-files`. Everything else, such as the document and its + * paragraphs, comes from `docx`. + * + * @module + * + * @example + * ```typescript + * import * as fs from "fs"; + * import { Document, Packer, Paragraph } from "docx"; + * import { EmbeddedFileRun } from "docx/embedded-files"; + * + * const doc = new Document({ + * sections: [ + * { + * children: [ + * new Paragraph({ + * children: [ + * new EmbeddedFileRun({ + * fileName: "budget.xlsx", + * data: fs.readFileSync("./budget.xlsx"), + * preview: { type: "png", data: fs.readFileSync("./budget.png") }, + * transformation: { width: 240, height: 120 }, + * }), + * ], + * }), + * ], + * }, + * ], + * }); + * + * const buffer = await Packer.toBuffer(doc); + * ``` + */ +export * from "./embedded-file-run"; diff --git a/src/embedded-files/ole-package.spec.ts b/src/embedded-files/ole-package.spec.ts new file mode 100644 index 00000000000..7a63cab3d3e --- /dev/null +++ b/src/embedded-files/ole-package.spec.ts @@ -0,0 +1,111 @@ +// cspell:ignore Résumé +import { describe, expect, it } from "vitest"; + +import { readCompoundFile } from "tests/compound-file"; + +import { PACKAGE_CLASS_ID, createOlePackage } from "./ole-package"; + +const hex = (bytes: Uint8Array): string => [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(" "); + +/** + * Reads the packager's native data as Word writes it, field by field: the file's label, its paths and its data, then the + * Unicode copies of its strings. + */ +const readNativeData = (stream: Uint8Array) => { + const view = new DataView(stream.buffer, stream.byteOffset, stream.byteLength); + let offset = 0; + const u16 = (): number => { + offset += 2; + return view.getUint16(offset - 2, true); + }; + const u32 = (): number => { + offset += 4; + return view.getUint32(offset - 4, true); + }; + const bytes = (length: number): Uint8Array => { + offset += length; + return stream.subarray(offset - length, offset); + }; + const ansi = (): string => { + const end = stream.indexOf(0, offset); + const text = String.fromCharCode(...stream.subarray(offset, end)); + offset = end + 1; + return text; + }; + const unicode = (): string => { + const length = u32(); + return String.fromCharCode(...Array.from({ length }, () => u16())); + }; + + const size = u32(); + const type = u16(); + const label = ansi(); + const path = ansi(); + const flags = hex(bytes(4)); + const tempPathLength = u32(); + const tempPath = ansi(); + const data = bytes(u32()); + const unicodeStrings = [unicode(), unicode(), unicode()]; + return { size, type, label, path, flags, tempPathLength, tempPath, data, unicodeStrings, rest: stream.length - offset }; +}; + +describe("createOlePackage", () => { + it("should write a compound file of the packager's class, with Word's three streams", () => { + const file = readCompoundFile(createOlePackage({ fileName: "song.mp3", data: new Uint8Array([1, 2, 3]) })); + + expect(file.entries[0].classId).toBe(PACKAGE_CLASS_ID); + expect([...file.streams.keys()].sort()).toEqual(["\u0001CompObj", "\u0001Ole10Native", "\u0003ObjInfo"]); + }); + + it("should name the packager in its CompObj stream, byte for byte as Word 2010 and later do", () => { + const file = readCompoundFile(createOlePackage({ fileName: "song.mp3", data: new Uint8Array([1, 2, 3]) })); + + expect(hex(file.streams.get("\u0001CompObj")!)).toBe( + // The header, with the packager's class after four bytes of 0xFF + "01 00 fe ff 03 0a 00 00 ff ff ff ff 0c 00 03 00 00 00 00 00 c0 00 00 00 00 00 00 46 " + + // "OLE Package", no clipboard format and "Package", then the Unicode marker and three empty strings + "0c 00 00 00 4f 4c 45 20 50 61 63 6b 61 67 65 00 00 00 00 00 08 00 00 00 50 61 63 6b 61 67 65 00 " + + "f4 39 b2 71 00 00 00 00 00 00 00 00 00 00 00 00", + ); + }); + + it("should tell Word to show the package as an icon, as Word does", () => { + const file = readCompoundFile(createOlePackage({ fileName: "song.mp3", data: new Uint8Array([1, 2, 3]) })); + + expect(hex(file.streams.get("\u0003ObjInfo")!)).toBe("40 00 03 00 01 00"); + }); + + it("should keep the file, with its name as its label and both its paths, in ANSI and in Unicode", () => { + const data = new Uint8Array(5000).map((_, index) => index % 256); + const file = readCompoundFile(createOlePackage({ fileName: "song.mp3", data })); + const stream = file.streams.get("\u0001Ole10Native")!; + + expect(readNativeData(stream)).toEqual({ + size: stream.length - 4, + type: 2, + label: "song.mp3", + path: "song.mp3", + flags: "00 00 03 00", + tempPathLength: 9, + tempPath: "song.mp3", + data, + unicodeStrings: ["song.mp3", "song.mp3", "song.mp3"], + rest: 0, + }); + }); + + it("should write a character beyond ASCII as _ in the ANSI strings, and as itself in the Unicode ones", () => { + const file = readCompoundFile(createOlePackage({ fileName: "Résumé 🎵.wav", data: new Uint8Array([1]) })); + const native = readNativeData(file.streams.get("\u0001Ole10Native")!); + + // The emoji is two UTF-16 code units, but one character + expect(native).toMatchObject({ label: "R_sum_ _.wav", path: "R_sum_ _.wav", tempPath: "R_sum_ _.wav", tempPathLength: 13 }); + expect(native.unicodeStrings).toEqual(["Résumé 🎵.wav", "Résumé 🎵.wav", "Résumé 🎵.wav"]); + }); + + it("should package an empty file", () => { + const file = readCompoundFile(createOlePackage({ fileName: "empty.txt", data: new Uint8Array() })); + + expect(readNativeData(file.streams.get("\u0001Ole10Native")!)).toMatchObject({ data: new Uint8Array(), rest: 0 }); + }); +}); diff --git a/src/embedded-files/ole-package.ts b/src/embedded-files/ole-package.ts new file mode 100644 index 00000000000..f5dfb18f89f --- /dev/null +++ b/src/embedded-files/ole-package.ts @@ -0,0 +1,144 @@ +/** + * Writes an OLE package, the OLE object Windows' Object Packager keeps a file in, so Word can embed a file no + * application embeds itself, such as a sound, a video or a PDF. + * + * The package is a compound file, as Word writes one: its root's class is the packager's, and it has three streams. + * "\1Ole10Native" holds the file, with its name. "\1CompObj" names the packager. "\3ObjInfo" tells Word to show + * the package as an icon. + * + The packager's data in "\1Ole10Native" isn't documented, so it is written as Word writes it, which Apache POI's + * Ole10Native writes too, and LibreOffice's ownview.cxx reads. + * + * References: + * - [MS-OLEDS] 2.3.6 OLENativeStream and 2.3.8 CompObjStream + * - [MS-DOC] ODT, the structure "\3ObjInfo" holds: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/641974b2-ba6c-4615-81a9-285f7d82cf67 + * + * @module + */ +import { createCompoundFile } from "../compound-file/compound-file"; + +/** The class of the packager's objects, which Windows names Package */ +export const PACKAGE_CLASS_ID = "0003000C-0000-0000-C000-000000000046"; + +/** + * Writes the bytes of a run of fields: unsigned 16 and 32-bit little-endian numbers, and bytes. + */ +const concat = (fields: readonly (readonly ["u16" | "u32", number] | Uint8Array)[]): Uint8Array => { + const length = fields.reduce((total, field) => total + (field instanceof Uint8Array ? field.length : field[0] === "u16" ? 2 : 4), 0); + const bytes = new Uint8Array(length); + const view = new DataView(bytes.buffer); + let offset = 0; + for (const field of fields) { + if (field instanceof Uint8Array) { + bytes.set(field, offset); + offset += field.length; + } else if (field[0] === "u16") { + view.setUint16(offset, field[1], true); + offset += 2; + } else { + view.setUint32(offset, field[1], true); + offset += 4; + } + } + return bytes; +}; + +/** + * A string in the computer's ANSI code page, with a terminating NUL. Only ASCII is the same in every code page, so any + * other character is "_": Word takes the file's name from the package's Unicode copies of its strings. + */ +const ansi = (text: string): Uint8Array => + Uint8Array.from([ + ...Array.from(text, (character) => (character.length === 1 && character.charCodeAt(0) < 0x80 ? character.charCodeAt(0) : 0x5f)), + 0, + ]); + +/** + * A string in UTF-16, little-endian, without a terminator. + */ +const unicode = (text: string): Uint8Array => { + const bytes = new Uint8Array(text.length * 2); + const view = new DataView(bytes.buffer); + for (let index = 0; index < text.length; index++) { + view.setUint16(index * 2, text.charCodeAt(index), true); + } + return bytes; +}; + +/** + * The packager's native data, in the "\1Ole10Native" stream: the file's label, the path it was packaged from, the path + * it is unpacked to, each in ANSI, then the file, then the three strings again in Unicode. The packager unpacks the file + * to a file named as the last of the paths, so its extension says what the file opens with. Each is the file's name, as + * a document made with docx has no path for it. + */ +const createNativeData = (fileName: string, data: Uint8Array): Uint8Array => { + const name = ansi(fileName); + // Each Unicode copy has its length in UTF-16 code units + const unicodeName = [["u32", fileName.length] as const, unicode(fileName)]; + const body = concat([ + // The type of the package's content: a file + ["u16", 2], + // Its label, and the path it was packaged from + name, + name, + // Always these, in every package Office writes + ["u16", 0], + ["u16", 3], + // The path it is unpacked to + ["u32", name.length], + name, + ["u32", data.length], + data, + // The Unicode copies: the path it is unpacked to, its label, and the path it was packaged from + ...unicodeName, + ...unicodeName, + ...unicodeName, + ]); + return concat([["u32", body.length], body]); +}; + +/** + * The "\1CompObj" stream, as Word writes it for a package: the header with the packager's class, its type's name, no + * clipboard format, and its ProgID, then empty Unicode copies. + */ +const createCompObj = (): Uint8Array => + concat([ + // The header: a version and byte order mark, and the class after four bytes of 0xFF + ["u32", 0xfffe0001], + ["u32", 0x00000a03], + ["u32", 0xffffffff], + // The packager's class, its first three fields little-endian + ["u32", 0x0003000c], + ["u16", 0], + ["u16", 0], + Uint8Array.from([0xc0, 0, 0, 0, 0, 0, 0, 0x46]), + ["u32", "OLE Package".length + 1], + ansi("OLE Package"), + // No clipboard format + ["u32", 0], + ["u32", "Package".length + 1], + ansi("Package"), + // The marker of the Unicode copies, which are empty + ["u32", 0x71b239f4], + ["u32", 0], + ["u32", 0], + ["u32", 0], + ]); + +/** + * The "\3ObjInfo" stream ([MS-DOC] ODT): the object is shown as an icon, in a metafile, as Word writes it for a package. + */ +const OBJECT_INFO = Uint8Array.from([0x40, 0x00, 0x03, 0x00, 0x01, 0x00]); + +/** + * Writes an OLE package of a file, as Word embeds one in word/embeddings/oleObject1.bin. + */ +export const createOlePackage = ({ fileName, data }: { readonly fileName: string; readonly data: Uint8Array }): Uint8Array => + createCompoundFile( + [ + { name: "\u0001Ole10Native", data: createNativeData(fileName, data) }, + { name: "\u0001CompObj", data: createCompObj() }, + { name: "\u0003ObjInfo", data: OBJECT_INFO }, + ], + { classId: PACKAGE_CLASS_ID }, + ); diff --git a/src/embedded-files/picture-frame-shape-type.ts b/src/embedded-files/picture-frame-shape-type.ts new file mode 100644 index 00000000000..21d32d85eed --- /dev/null +++ b/src/embedded-files/picture-frame-shape-type.ts @@ -0,0 +1,93 @@ +/** + * The picture frame shape type, which Word draws an embedded object's picture in. + * + * @module + */ +import { BuilderElement, type IContext, type IXmlableObject, XmlComponent } from "docx"; + +// The formulas of the picture frame, which inset it by half of its line's width +const FORMULAS = [ + "if lineDrawn pixelLineWidth 0", + "sum @0 1 0", + "sum 0 0 @1", + "prod @2 1 2", + "prod @3 21600 pixelWidth", + "prod @3 21600 pixelHeight", + "sum @0 0 1", + "prod @6 1 2", + "prod @7 21600 pixelWidth", + "sum @8 21600 0", + "prod @7 21600 pixelHeight", + "sum @10 21600 0", +]; + +/** + * The parts being written that have the shape type already. Each part is written with a context of its own, so a part + * written again, such as when a document is packed twice, has it again. + */ +const writtenIn = new WeakSet(); + +/** + * The picture frame shape type (`_x0000_t75`), Office's preset shape 75, as Word writes it in an embedded object. Its + * shape (`v:shape`) refers to it as `#_x0000_t75`. + * + * It is written in the first object of each part it is in, the document, a header or the footnotes, as Word writes it, + * and left out of the rest: a part can't have two elements with the same id. + * + * ```xml + * + * + * ... + * + * + * + * ``` + */ +export class PictureFrameShapeType extends XmlComponent { + private readonly shapeType = new BuilderElement({ + name: "v:shapetype", + attributes: { + id: { key: "id", value: "_x0000_t75" }, + coordinateSize: { key: "coordsize", value: "21600,21600" }, + presetShapeType: { key: "o:spt", value: 75 }, + preferRelative: { key: "o:preferrelative", value: "t" }, + path: { key: "path", value: "m@4@5l@4@11@9@11@9@5xe" }, + filled: { key: "filled", value: "f" }, + stroked: { key: "stroked", value: "f" }, + }, + children: [ + new BuilderElement({ name: "v:stroke", attributes: { joinStyle: { key: "joinstyle", value: "miter" } } }), + new BuilderElement({ + name: "v:formulas", + children: FORMULAS.map( + (equation) => new BuilderElement({ name: "v:f", attributes: { equation: { key: "eqn", value: equation } } }), + ), + }), + new BuilderElement({ + name: "v:path", + attributes: { + extrusionOk: { key: "o:extrusionok", value: "f" }, + gradientShapeOk: { key: "gradientshapeok", value: "t" }, + connectType: { key: "o:connecttype", value: "rect" }, + }, + }), + new BuilderElement({ + name: "o:lock", + attributes: { extension: { key: "v:ext", value: "edit" }, aspectRatio: { key: "aspectratio", value: "t" } }, + }), + ], + }); + + public constructor() { + super("v:shapetype"); + } + + public prepForXml(context: IContext): IXmlableObject | undefined { + if (writtenIn.has(context)) { + return undefined; + } + writtenIn.add(context); + return this.shapeType.prepForXml(context); + } +} diff --git a/src/export/packer/package-part-writer.ts b/src/export/packer/package-part-writer.ts index 17b3c82cf3f..26164db640d 100644 --- a/src/export/packer/package-part-writer.ts +++ b/src/export/packer/package-part-writer.ts @@ -7,7 +7,7 @@ import JSZip from "jszip"; import type { File } from "@file/file"; -import type { PackagePart } from "@file/package-part/package-part"; +import type { PackagePart, PackagePartRelationshipType } from "@file/package-part/package-part"; import { Relationships } from "@file/relationships"; import type { XmlComponent } from "@file/xml-components"; import { encodeUtf8 } from "@util/convenience-functions"; @@ -38,7 +38,12 @@ const xmlifyPart = (file: File, content: XmlComponent, prettify: Prettify, relat declaration: standalone ? { standalone: "yes", encoding: "UTF-8" } : { encoding: "UTF-8" }, }); -const xmlifyPackagePart = (file: File, part: PackagePart, path: string, prettify: Prettify): readonly PackagePartFile[] => { +const xmlifyPackagePart = ( + file: File, + part: PackagePart, + path: string, + prettify: Prettify, +): readonly PackagePartFile[] => { const { content } = part.options; if (content instanceof Uint8Array) { return [{ data: content, path: `word/${path}` }]; diff --git a/src/file/index.ts b/src/file/index.ts index d10ed79e007..ec7a9fbe737 100644 --- a/src/file/index.ts +++ b/src/file/index.ts @@ -38,7 +38,7 @@ export * from "./theme"; export * from "./document-macros"; // Parts that drawings add to the package when it is written, such as docx/charts' charts export * from "./package-part"; -export { type RelationshipType } from "./relationships/relationship/relationship"; +export { type OleObjectRelationshipType, type RelationshipType } from "./relationships/relationship/relationship"; // The VML shape that text boxes are drawn with, which docx/watermarks uses too export { type IVmlShapeOptions, type LengthUnit, type VmlShapeStyle, createVmlShape } from "./vml"; export { type IPropertiesOptions } from "./core-properties"; diff --git a/src/file/package-part/index.ts b/src/file/package-part/index.ts index 1a8d267dc2a..23395e4e267 100644 --- a/src/file/package-part/index.ts +++ b/src/file/package-part/index.ts @@ -3,4 +3,4 @@ * * @module */ -export { type EmbeddedPackageFile, PackagePart, type PackagePartOptions } from "./package-part"; +export { type EmbeddedPackageFile, PackagePart, type PackagePartOptions, type PackagePartRelationshipType } from "./package-part"; diff --git a/src/file/package-part/package-part.spec.ts b/src/file/package-part/package-part.spec.ts index ba8bb17dcff..06ed91913a4 100644 --- a/src/file/package-part/package-part.spec.ts +++ b/src/file/package-part/package-part.spec.ts @@ -8,7 +8,7 @@ import { Footer, Header } from "@file/header"; import { Paragraph, Run, TextRun } from "@file/paragraph"; import { BuilderElement, type IContext, type IXmlableObject, XmlComponent } from "@file/xml-components"; -import { PackagePart, type PackagePartOptions, PackageParts } from "./package-part"; +import { PackagePart, type PackagePartOptions, type PackagePartRelationshipType, PackageParts } from "./package-part"; const CHART_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; const CHART_RELATIONSHIP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"; @@ -16,7 +16,7 @@ const PACKAGE_RELATIONSHIP = "http://schemas.openxmlformats.org/officeDocument/2 // Refers to a part by its relationship id, as a chart's c:chart does, and adds it to the package when it is written class PartReference extends XmlComponent { - public constructor(private readonly part: PackagePart) { + public constructor(private readonly part: PackagePart) { super("c:chart"); this.root.push(part.relationshipId); } @@ -28,7 +28,7 @@ class PartReference extends XmlComponent { } class PartRun extends Run { - public constructor(part: PackagePart) { + public constructor(part: PackagePart) { super({}); this.root.push(new PartReference(part)); } @@ -54,7 +54,8 @@ const createWorkbook = (content: PackagePartOptions["content"] = new Uint8Array( content, }); -const paragraphWith = (...parts: readonly PackagePart[]): Paragraph => new Paragraph({ children: parts.map((part) => new PartRun(part)) }); +const paragraphWith = (...parts: readonly PackagePart[]): Paragraph => + new Paragraph({ children: parts.map((part) => new PartRun(part)) }); const pack = async (file: File): Promise => JSZip.loadAsync(await Packer.toBuffer(file)); @@ -108,6 +109,23 @@ describe("PackagePart", () => { expect(zip.file("word/charts/_rels/chart1.xml.rels")).to.equal(null); }); + it("should relate a part as an OLE object, which isn't one of the relationship types", async () => { + const oleObject = new PackagePart({ + folder: "embeddings", + name: "oleObject", + extension: "bin", + contentType: "application/vnd.openxmlformats-officedocument.oleObject", + relationshipType: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject", + content: new Uint8Array([1, 2, 3]), + }); + const zip = await pack(new File({ sections: [{ children: [paragraphWith(oleObject)] }] })); + + expect(await read(zip, "word/_rels/document.xml.rels")).to.contain( + ``, + ); + expect(await zip.file("word/embeddings/oleObject1.bin")?.async("uint8array")).to.deep.equal(new Uint8Array([1, 2, 3])); + }); + it("should give each part a relationship id of its own", () => { expect(createChart().relationshipId).to.match(/^rId/); expect(createChart().relationshipId).to.not.equal(createChart().relationshipId); diff --git a/src/file/package-part/package-part.ts b/src/file/package-part/package-part.ts index aa2dfd70812..65ea7f8b09f 100644 --- a/src/file/package-part/package-part.ts +++ b/src/file/package-part/package-part.ts @@ -7,7 +7,7 @@ */ import type { ContentTypes } from "@file/content-types/content-types"; import { Relationships } from "@file/relationships"; -import type { RelationshipType } from "@file/relationships/relationship/relationship"; +import type { OleObjectRelationshipType, RelationshipType } from "@file/relationships/relationship/relationship"; import type { IContext, XmlComponent } from "@file/xml-components"; import { uniqueId } from "@util/convenience-functions"; @@ -21,10 +21,18 @@ export type EmbeddedPackageFile = { readonly content: XmlComponent | Uint8Array; }; +/** + * The types of the relationships to package parts: a {@link RelationshipType}, or an OLE object's. + */ +export type PackagePartRelationshipType = RelationshipType | OleObjectRelationshipType; + /** * Options for a {@link PackagePart}. + * + * @typeParam Relationship - The type of the relationship to the part. A part related as an OLE object, such as a file + * docx/embedded-files embeds, gives it, so the options of a part made without it have a {@link RelationshipType} */ -export type PackagePartOptions = { +export type PackagePartOptions = { /** The folder under word/ the part is written in, such as "charts" */ readonly folder: string; /** The start of the part's file name, which is numbered in the order parts are added: "chart" for word/charts/chart1.xml */ @@ -34,7 +42,7 @@ export type PackagePartOptions = { /** The part's content type, such as "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" */ readonly contentType: string; /** The type of the relationship to the part from the part that refers to it, such as the chart relationship */ - readonly relationshipType: RelationshipType; + readonly relationshipType: Relationship; /** * The part's content: XML, formatted as the document's parts are, its bytes, or the files of a package, such as an * embedded workbook, which are zipped. XML can refer to other parts by calling their {@link PackagePart.addTo} when @@ -78,7 +86,7 @@ const RESERVED_FOLDERS = new Set(["_rels", "fonts", "media", "theme"]); * } * ``` */ -export class PackagePart { +export class PackagePart { private readonly id = uniqueId(); /** The id of the relationship to the part, such as a chart's `r:id` in the document */ public readonly relationshipId = `rId${this.id}`; @@ -89,7 +97,7 @@ export class PackagePart { * @throws If the folder isn't a single folder name, or is one of the folders docx writes parts of its own in * @throws If the name or the extension isn't a single part of a file name, which could lead out of the folder */ - public constructor(public readonly options: PackagePartOptions) { + public constructor(public readonly options: PackagePartOptions) { if (!PATH_PART.test(options.folder) || RESERVED_FOLDERS.has(options.folder)) { throw new Error(`Invalid package part folder "${options.folder}". Expected a folder name docx doesn't use, such as "charts"`); } @@ -121,7 +129,7 @@ export class PackagePart { * added. Not part of the public API: `File` holds one, and the compiler writes its parts, as `patchDocument` does. */ export class PackageParts { - private readonly paths = new Map(); + private readonly paths = new Map, string>(); // The folders of the parts whose XML is being written, by the relationships the XML is formatted with private readonly folders = new WeakMap(); @@ -140,7 +148,7 @@ export class PackageParts { * * @returns The part's path under word/, such as "charts/chart1.xml" */ - public add(part: PackagePart): string { + public add(part: PackagePart): string { const added = this.paths.get(part); if (added !== undefined) { return added; @@ -162,9 +170,12 @@ export class PackageParts { /** * Each part added, with its path under word/, in the order they were added. + * + * A part related as an OLE object is listed too, though the parts are typed as they were before a part could be: + * `File`, whose type is public, has these parts, and the compiler only writes their content. */ public get Array(): readonly { readonly part: PackagePart; readonly path: string }[] { - return [...this.paths].map(([part, path]) => ({ part, path })); + return [...this.paths].map(([part, path]) => ({ part: part as PackagePart, path })); } /** diff --git a/src/file/relationships/relationship/relationship.ts b/src/file/relationships/relationship/relationship.ts index fa88aaa5411..1161a3bed3d 100644 --- a/src/file/relationships/relationship/relationship.ts +++ b/src/file/relationships/relationship/relationship.ts @@ -38,6 +38,13 @@ export type MacroRelationshipType = | "http://schemas.microsoft.com/office/2006/relationships/vbaProject" | "http://schemas.microsoft.com/office/2006/relationships/wordVbaData"; +/** + * The type of the relationship to an OLE object that isn't a package, such as a file docx/embedded-files embeds in an + * OLE package. It isn't one of the {@link RelationshipType}s, as a {@link PackagePart} gives its relationship type in its + * options, which a part made with a relationship type of that union can be read for. + */ +export type OleObjectRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject"; + /** * Target mode types for relationships. * @@ -50,7 +57,7 @@ export const TargetModeType = { type IRelationshipAttributes = { readonly id: string; - readonly type: RelationshipType | MacroRelationshipType; + readonly type: RelationshipType | MacroRelationshipType | OleObjectRelationshipType; readonly target: string; readonly targetMode?: (typeof TargetModeType)[keyof typeof TargetModeType]; }; @@ -73,7 +80,7 @@ type IRelationshipAttributes = { export const createRelationship = ( id: string, - type: RelationshipType | MacroRelationshipType, + type: RelationshipType | MacroRelationshipType | OleObjectRelationshipType, target: string, targetMode?: (typeof TargetModeType)[keyof typeof TargetModeType], ): XmlComponent => diff --git a/src/file/relationships/relationships.ts b/src/file/relationships/relationships.ts index 21763d4b173..9e5001a5881 100644 --- a/src/file/relationships/relationships.ts +++ b/src/file/relationships/relationships.ts @@ -11,7 +11,13 @@ import { XmlComponent } from "@file/xml-components"; import { RelationshipsAttributes } from "./attributes"; -import { type MacroRelationshipType, type RelationshipType, type TargetModeType, createRelationship } from "./relationship/relationship"; +import { + type MacroRelationshipType, + type OleObjectRelationshipType, + type RelationshipType, + type TargetModeType, + createRelationship, +} from "./relationship/relationship"; /** * Represents a collection of relationships in an OPC package. @@ -51,7 +57,7 @@ export class Relationships extends XmlComponent { */ public addRelationship( id: number | string, - type: RelationshipType | MacroRelationshipType, + type: RelationshipType | MacroRelationshipType | OleObjectRelationshipType, target: string, targetMode?: (typeof TargetModeType)[keyof typeof TargetModeType], ): void { diff --git a/src/macros/create-macros.spec.ts b/src/macros/create-macros.spec.ts index ccce5e15a89..ba2c508810c 100644 --- a/src/macros/create-macros.spec.ts +++ b/src/macros/create-macros.spec.ts @@ -2,7 +2,8 @@ import JSZip from "jszip"; import { describe, expect, it } from "vitest"; import { Document, Packer, Paragraph } from "docx"; -import { decompress, readCompoundFile } from "tests/macros"; +import { readCompoundFile } from "tests/compound-file"; +import { decompress } from "tests/macros"; import { createMacros } from "./create-macros"; diff --git a/src/macros/create-macros.ts b/src/macros/create-macros.ts index 7868235c51e..eaa7015b5fa 100644 --- a/src/macros/create-macros.ts +++ b/src/macros/create-macros.ts @@ -9,8 +9,8 @@ import type { DocumentMacroEvent, DocumentMacros } from "docx"; import { CODE_PAGE, encodeWindows1252 } from "./code-page"; -import { createCompoundFile } from "./compound-file"; import { compress } from "./compression"; +import { createCompoundFile } from "../compound-file/compound-file"; /** * A module of VBA code, as the VBA editor shows it. diff --git a/src/tests/compound-file.ts b/src/tests/compound-file.ts new file mode 100644 index 00000000000..e243eb42219 --- /dev/null +++ b/src/tests/compound-file.ts @@ -0,0 +1,189 @@ +/** + * Helpers for tests of the compound files docx writes, for docx/macros' VBA projects and docx/embedded-files' OLE + * objects: they read them back as Office does, from the specification rather than from docx's own code. + */ +/* eslint-disable no-bitwise -- the sizes of sectors are powers of two */ + +/** + * An entry of a compound file's directory, as written. + */ +export type CompoundFileDirectoryEntry = { + readonly name: string; + /** 1 for a storage, 2 for a stream and 5 for the root. 0 is an unused entry */ + readonly type: number; + /** 0 for red and 1 for black */ + readonly color: number; + readonly left: number; + readonly right: number; + readonly child: number; + readonly start: number; + readonly size: number; + /** The class id of a storage or the root, such as "0003000C-0000-0000-C000-000000000046", or undefined for none */ + readonly classId?: string; +}; + +/** + * A compound file, as read: its header's fields, its directory, and each stream, by its path, such as "VBA/dir". + */ +export type ReadCompoundFile = { + readonly header: { + readonly minorVersion: number; + readonly majorVersion: number; + readonly byteOrder: number; + readonly sectorShift: number; + readonly miniSectorShift: number; + readonly miniStreamCutoff: number; + }; + readonly entries: readonly CompoundFileDirectoryEntry[]; + readonly streams: ReadonlyMap; +}; + +const END_OF_CHAIN = 0xfffffffe; + +const hex = (bytes: readonly number[]): string => + bytes + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .toUpperCase(); + +/** + * A class id as written in a directory entry, a GUID whose first three fields are little-endian, in its usual form. + */ +const classIdOf = (bytes: Uint8Array): string | undefined => { + if (bytes.every((byte) => byte === 0)) { + return undefined; + } + const [a, b, c, d] = [ + [...bytes.subarray(0, 4)].reverse(), + [...bytes.subarray(4, 6)].reverse(), + [...bytes.subarray(6, 8)].reverse(), + [...bytes.subarray(8)], + ]; + return `${hex(a)}-${hex(b)}-${hex(c)}-${hex(d.slice(0, 2))}-${hex(d.slice(2))}`; +}; +const NO_STREAM = 0xffffffff; + +/** + * Reads a compound file of version 3, following its FAT, its directory and its mini stream. + * + * Reference: [MS-CFB] Compound File Binary File Format + */ +export const readCompoundFile = (file: Uint8Array): ReadCompoundFile => { + const view = new DataView(file.buffer, file.byteOffset, file.byteLength); + const sectorSize = 1 << view.getUint16(30, true); + const miniSectorSize = 1 << view.getUint16(32, true); + const miniStreamCutoff = view.getUint32(56, true); + const sectorOffset = (sector: number): number => (sector + 1) * sectorSize; + + // The FAT's sectors: the first 109 are listed in the header, and the rest in a chain of DIFAT sectors, each listing + // as many as it has room for after the number of the next + const fatSectorCount = view.getUint32(44, true); + // eslint-disable-next-line functional/prefer-readonly-type + const fatSectors: number[] = []; + for (let index = 0; index < Math.min(fatSectorCount, 109); index++) { + // eslint-disable-next-line functional/immutable-data + fatSectors.push(view.getUint32(76 + index * 4, true)); + } + const perDifatSector = sectorSize / 4 - 1; + for (let sector = view.getUint32(68, true), count = 0; count < view.getUint32(72, true); count++) { + for (let index = 0; index < perDifatSector && fatSectors.length < fatSectorCount; index++) { + // eslint-disable-next-line functional/immutable-data + fatSectors.push(view.getUint32(sectorOffset(sector) + index * 4, true)); + } + sector = view.getUint32(sectorOffset(sector) + perDifatSector * 4, true); + } + const fat = fatSectors.flatMap((sector) => + Array.from({ length: sectorSize / 4 }, (_, index) => view.getUint32(sectorOffset(sector) + index * 4, true)), + ); + const chain = (table: readonly number[], start: number): readonly number[] => { + // eslint-disable-next-line functional/prefer-readonly-type + const sectors: number[] = []; + // No longer than the table, so a chain that loops ends + for (let sector = start; sector !== END_OF_CHAIN && sectors.length < table.length; sector = table[sector]) { + // eslint-disable-next-line functional/immutable-data + sectors.push(sector); + } + return sectors; + }; + const readChain = (start: number): Uint8Array => { + const sectors = chain(fat, start); + const bytes = new Uint8Array(sectors.length * sectorSize); + for (const [index, sector] of sectors.entries()) { + bytes.set(file.subarray(sectorOffset(sector), sectorOffset(sector) + sectorSize), index * sectorSize); + } + return bytes; + }; + + const directory = readChain(view.getUint32(48, true)); + const directoryView = new DataView(directory.buffer); + const entries = Array.from({ length: directory.length / 128 }, (_, index): CompoundFileDirectoryEntry => { + const offset = index * 128; + const nameLength = directoryView.getUint16(offset + 64, true); + const name = String.fromCharCode( + ...Array.from({ length: Math.max(nameLength / 2 - 1, 0) }, (__, position) => + directoryView.getUint16(offset + position * 2, true), + ), + ); + return { + name, + type: directory[offset + 66], + color: directory[offset + 67], + left: directoryView.getUint32(offset + 68, true), + right: directoryView.getUint32(offset + 72, true), + child: directoryView.getUint32(offset + 76, true), + start: directoryView.getUint32(offset + 116, true), + size: directoryView.getUint32(offset + 120, true), + classId: classIdOf(directory.subarray(offset + 80, offset + 96)), + }; + }); + + const miniFatStart = view.getUint32(60, true); + const miniFatBytes = miniFatStart === END_OF_CHAIN ? new Uint8Array() : readChain(miniFatStart); + const miniFat = Array.from({ length: miniFatBytes.length / 4 }, (_, index) => + new DataView(miniFatBytes.buffer).getUint32(index * 4, true), + ); + const miniStream = entries[0].start === END_OF_CHAIN ? new Uint8Array() : readChain(entries[0].start); + + const readStream = (entry: CompoundFileDirectoryEntry): Uint8Array => { + if (entry.size >= miniStreamCutoff) { + return readChain(entry.start).subarray(0, entry.size); + } + const bytes = new Uint8Array(entry.size); + for (const [index, sector] of chain(miniFat, entry.start).entries()) { + const part = miniStream.subarray(sector * miniSectorSize, (sector + 1) * miniSectorSize); + bytes.set(part.subarray(0, Math.min(miniSectorSize, entry.size - index * miniSectorSize)), index * miniSectorSize); + } + return bytes; + }; + + // Each storage's entries are a tree, read in order + const streams = new Map(); + const walk = (id: number, folder: string): void => { + if (id === NO_STREAM) { + return; + } + const entry = entries[id]; + walk(entry.left, folder); + if (entry.type === 1) { + walk(entry.child, `${folder}${entry.name}/`); + } else { + // eslint-disable-next-line functional/immutable-data + streams.set(`${folder}${entry.name}`, readStream(entry)); + } + walk(entry.right, folder); + }; + walk(entries[0].child, ""); + + return { + header: { + minorVersion: view.getUint16(24, true), + majorVersion: view.getUint16(26, true), + byteOrder: view.getUint16(28, true), + sectorShift: view.getUint16(30, true), + miniSectorShift: view.getUint16(32, true), + miniStreamCutoff, + }, + entries, + streams, + }; +}; diff --git a/src/tests/macros.ts b/src/tests/macros.ts index 696bd27d40d..7968f2bf097 100644 --- a/src/tests/macros.ts +++ b/src/tests/macros.ts @@ -48,146 +48,3 @@ export const decompress = (data: Uint8Array): Uint8Array => { } return Uint8Array.from(output); }; - -/** - * An entry of a compound file's directory, as written. - */ -export type CompoundFileDirectoryEntry = { - readonly name: string; - /** 1 for a storage, 2 for a stream and 5 for the root. 0 is an unused entry */ - readonly type: number; - /** 0 for red and 1 for black */ - readonly color: number; - readonly left: number; - readonly right: number; - readonly child: number; - readonly start: number; - readonly size: number; -}; - -/** - * A compound file, as read: its header's fields, its directory, and each stream, by its path, such as "VBA/dir". - */ -export type ReadCompoundFile = { - readonly header: { - readonly minorVersion: number; - readonly majorVersion: number; - readonly byteOrder: number; - readonly sectorShift: number; - readonly miniSectorShift: number; - readonly miniStreamCutoff: number; - }; - readonly entries: readonly CompoundFileDirectoryEntry[]; - readonly streams: ReadonlyMap; -}; - -const END_OF_CHAIN = 0xfffffffe; -const NO_STREAM = 0xffffffff; - -/** - * Reads a compound file of version 3, following its FAT, its directory and its mini stream. - * - * Reference: [MS-CFB] Compound File Binary File Format - */ -export const readCompoundFile = (file: Uint8Array): ReadCompoundFile => { - const view = new DataView(file.buffer, file.byteOffset, file.byteLength); - const sectorSize = 1 << view.getUint16(30, true); - const miniSectorSize = 1 << view.getUint16(32, true); - const miniStreamCutoff = view.getUint32(56, true); - const sectorOffset = (sector: number): number => (sector + 1) * sectorSize; - - const fatSectors = Array.from({ length: view.getUint32(44, true) }, (_, index) => view.getUint32(76 + index * 4, true)); - const fat = fatSectors.flatMap((sector) => - Array.from({ length: sectorSize / 4 }, (_, index) => view.getUint32(sectorOffset(sector) + index * 4, true)), - ); - const chain = (table: readonly number[], start: number): readonly number[] => { - // eslint-disable-next-line functional/prefer-readonly-type - const sectors: number[] = []; - // No longer than the table, so a chain that loops ends - for (let sector = start; sector !== END_OF_CHAIN && sectors.length < table.length; sector = table[sector]) { - // eslint-disable-next-line functional/immutable-data - sectors.push(sector); - } - return sectors; - }; - const readChain = (start: number): Uint8Array => { - const sectors = chain(fat, start); - const bytes = new Uint8Array(sectors.length * sectorSize); - for (const [index, sector] of sectors.entries()) { - bytes.set(file.subarray(sectorOffset(sector), sectorOffset(sector) + sectorSize), index * sectorSize); - } - return bytes; - }; - - const directory = readChain(view.getUint32(48, true)); - const directoryView = new DataView(directory.buffer); - const entries = Array.from({ length: directory.length / 128 }, (_, index): CompoundFileDirectoryEntry => { - const offset = index * 128; - const nameLength = directoryView.getUint16(offset + 64, true); - const name = String.fromCharCode( - ...Array.from({ length: Math.max(nameLength / 2 - 1, 0) }, (__, position) => - directoryView.getUint16(offset + position * 2, true), - ), - ); - return { - name, - type: directory[offset + 66], - color: directory[offset + 67], - left: directoryView.getUint32(offset + 68, true), - right: directoryView.getUint32(offset + 72, true), - child: directoryView.getUint32(offset + 76, true), - start: directoryView.getUint32(offset + 116, true), - size: directoryView.getUint32(offset + 120, true), - }; - }); - - const miniFatStart = view.getUint32(60, true); - const miniFatBytes = miniFatStart === END_OF_CHAIN ? new Uint8Array() : readChain(miniFatStart); - const miniFat = Array.from({ length: miniFatBytes.length / 4 }, (_, index) => - new DataView(miniFatBytes.buffer).getUint32(index * 4, true), - ); - const miniStream = entries[0].start === END_OF_CHAIN ? new Uint8Array() : readChain(entries[0].start); - - const readStream = (entry: CompoundFileDirectoryEntry): Uint8Array => { - if (entry.size >= miniStreamCutoff) { - return readChain(entry.start).subarray(0, entry.size); - } - const bytes = new Uint8Array(entry.size); - for (const [index, sector] of chain(miniFat, entry.start).entries()) { - const part = miniStream.subarray(sector * miniSectorSize, (sector + 1) * miniSectorSize); - bytes.set(part.subarray(0, Math.min(miniSectorSize, entry.size - index * miniSectorSize)), index * miniSectorSize); - } - return bytes; - }; - - // Each storage's entries are a tree, read in order - const streams = new Map(); - const walk = (id: number, folder: string): void => { - if (id === NO_STREAM) { - return; - } - const entry = entries[id]; - walk(entry.left, folder); - if (entry.type === 1) { - walk(entry.child, `${folder}${entry.name}/`); - } else { - // eslint-disable-next-line functional/immutable-data - streams.set(`${folder}${entry.name}`, readStream(entry)); - } - walk(entry.right, folder); - }; - walk(entries[0].child, ""); - - return { - header: { - minorVersion: view.getUint16(24, true), - majorVersion: view.getUint16(26, true), - byteOrder: view.getUint16(28, true), - sectorShift: view.getUint16(30, true), - miniSectorShift: view.getUint16(32, true), - miniStreamCutoff, - }, - entries, - streams, - }; -}; diff --git a/vite.entries.config.ts b/vite.entries.config.ts index 7da4f776fc3..173025f64a3 100644 --- a/vite.entries.config.ts +++ b/vite.entries.config.ts @@ -27,6 +27,7 @@ const ENTRIES: Readonly> = { layout: { name: "layout", global: "docxLayout" }, html: { name: "html", global: "docxHtml" }, macros: { name: "macros", global: "docxMacros" }, + "embedded-files": { name: "embedded-files", global: "docxEmbeddedFiles" }, }; // API Extractor, which bundles the declarations, copies in those of any module that isn't a library, so it would copy From e22caceda400de961fa4b9f4922a3b9b4be4f2d5 Mon Sep 17 00:00:00 2001 From: Dolan Miu Date: Tue, 20 Oct 2026 11:51:45 +0100 Subject: [PATCH 2/4] fix: don't insert a VML shape type a patched part already has An embedded file writes the picture frame's shape type (_x0000_t75) in the first object of each part, but patchDocument inserts content into a template's part without knowing which shape types it has. A template with an embedded file of its own, or another legacy picture, got a second shape type with the same id, which the Open XML SDK validator rejects. patchDocument now leaves out of the content it inserts each v:shapetype whose id the part has already, as it renumbers drawings and bookmarks so they keep clear of the template's. The inserted shapes use the part's own shape type, which Word and LibreOffice find wherever it is in the part, even after the shape. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X5zGDL3sgGVBMXPfny376c --- src/embedded-files/embedded-file-run.spec.ts | 18 ++++++ src/patcher/from-docx.ts | 6 +- src/patcher/vml-shape-types.spec.ts | 62 ++++++++++++++++++++ src/patcher/vml-shape-types.ts | 57 ++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/patcher/vml-shape-types.spec.ts create mode 100644 src/patcher/vml-shape-types.ts diff --git a/src/embedded-files/embedded-file-run.spec.ts b/src/embedded-files/embedded-file-run.spec.ts index e4ff58f751e..dd9446bcf46 100644 --- a/src/embedded-files/embedded-file-run.spec.ts +++ b/src/embedded-files/embedded-file-run.spec.ts @@ -305,6 +305,24 @@ describe("EmbeddedFileRun", () => { } }); + it("should leave out the shape type when patchDocument inserts a file into a part that has it", async () => { + // A template with an embedded file of its own, so its body has the picture frame's shape type + const template = await Packer.toBuffer( + new Document({ + sections: [{ children: [new Paragraph({ children: [new EmbeddedFileRun(workbook)] }), new Paragraph("{{file}}")] }], + }), + ); + const patched = await patchDocument({ + outputType: "uint8array", + data: template, + patches: { file: { type: PatchType.PARAGRAPH, children: [new EmbeddedFileRun(workbook)] } }, + }); + const document = await read(await JSZip.loadAsync(patched), "word/document.xml"); + + expect(document.split(' { const template = await Packer.toBuffer(new Document({ sections: [{ children: [new Paragraph("Before {{file}} after")] }] })); const patched = await patchDocument({ diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 54c9452c6b6..1d40269a761 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -42,6 +42,7 @@ import { patchStyles } from "./styles"; import { patchTableRows } from "./table-rows"; import { readThemeColors } from "./theme-colors"; import { toJson } from "./util"; +import { removeShapeTypesIn } from "./vml-shape-types"; /** * Supported input data types for document patching. @@ -486,6 +487,8 @@ export const patchDocument = async { @@ -547,7 +550,8 @@ export const patchDocument = async styles.use(lists.renumber(notes.renumber(renumberBookmarks(renumberDrawings(elements))))), + renumberIds: (elements) => + styles.use(lists.renumber(notes.renumber(renumberBookmarks(renumberDrawings(removeShapeTypes(elements)))))), }); if (didFindOccurrence) { diff --git a/src/patcher/vml-shape-types.spec.ts b/src/patcher/vml-shape-types.spec.ts new file mode 100644 index 00000000000..a37b8671ac0 --- /dev/null +++ b/src/patcher/vml-shape-types.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { type XmlElement as Element, stringifyElement } from "@util/xml"; + +import { toJson } from "./util"; +import { findShapeTypeIds, removeShapeTypesIn } from "./vml-shape-types"; + +const elementsOf = (xml: string): readonly Element[] => toJson(`${xml}`).elements![0].elements!; + +// The XML of elements, written as the patcher writes a part +const xmlOf = (elements: readonly Element[]): string => stringifyElement({ elements: [...elements] }); + +// The XML of an element, normalized as it is written +const normalized = (xml: string): string => xmlOf(elementsOf(xml)); + +// An embedded object, with the shape type of its picture frame when it is the first in its part +const object = (shapeType?: string): string => + `${shapeType === undefined ? "" : ``}` + + ``; + +describe("vml-shape-types", () => { + describe("findShapeTypeIds", () => { + it("should find the id of every shape type, at any depth", () => { + const document = toJson( + `${object("_x0000_t75")}` + + `${object("_x0000_t202")}`, + ); + + expect(findShapeTypeIds(document)).toEqual(["_x0000_t75", "_x0000_t202"]); + }); + }); + + describe("removeShapeTypesIn", () => { + it("should leave out a shape type the part has, and keep the shape that uses it", () => { + const removeShapeTypes = removeShapeTypesIn( + toJson(`${object("_x0000_t75")}`), + ); + + expect(xmlOf(removeShapeTypes(elementsOf(`${object("_x0000_t75")}`)))).toBe(normalized(`${object()}`)); + }); + + it("should keep a shape type the part doesn't have, and leave it out of what is inserted after", () => { + const removeShapeTypes = removeShapeTypesIn( + toJson(`${object("_x0000_t202")}`), + ); + + expect(xmlOf(removeShapeTypes(elementsOf(`${object("_x0000_t75")}`)))).toBe( + normalized(`${object("_x0000_t75")}`), + ); + expect(xmlOf(removeShapeTypes(elementsOf(`${object("_x0000_t75")}`)))).toBe(normalized(`${object()}`)); + }); + + it("should leave content without shape types as it is", () => { + const removeShapeTypes = removeShapeTypesIn( + toJson(`${object("_x0000_t75")}`), + ); + const elements = elementsOf(`Text`); + + expect(removeShapeTypes(elements)).toEqual(elements); + }); + }); +}); diff --git a/src/patcher/vml-shape-types.ts b/src/patcher/vml-shape-types.ts new file mode 100644 index 00000000000..8aa2b696994 --- /dev/null +++ b/src/patcher/vml-shape-types.ts @@ -0,0 +1,57 @@ +/** + * VML shape types in content inserted into an existing part. + * + * @module + */ +import type { XmlElement as Element } from "@util/xml"; + +/** + * Leaves out of content that is about to be inserted into a part the VML shape types the part has already. + */ +export type RemoveShapeTypes = (elements: readonly Element[]) => readonly Element[]; + +const shapeTypeIdOf = (element: Element): string | undefined => + element.name === "v:shapetype" && element.attributes?.id !== undefined ? String(element.attributes.id) : undefined; + +/** + * Finds the id of every VML shape type (`v:shapetype`) in an element, at any depth. + * + * @param element - The element to search, such as a part's root + * @returns The ids, once for each shape type + */ +export const findShapeTypeIds = (element: Element): readonly string[] => { + const id = shapeTypeIdOf(element); + return [...(id === undefined ? [] : [id]), ...(element.elements ?? []).flatMap(findShapeTypeIds)]; +}; + +/** + * Creates a function that leaves out of the content inserted into a part each VML shape type (`v:shapetype`) the part + * has already, such as the picture frame (`_x0000_t75`) an embedded file or a picture watermark is drawn in. Content is + * written with the shape types its shapes use, without knowing which the template has, but a part can't have two + * elements with the same id. The shapes inserted use the part's own, which Word and LibreOffice find anywhere in the + * part, as Word writes each shape type once in a part, in its first shape. + * + * A shape type with the id of one the part has is the same shape type: docx writes Office's preset ones, whose ids, such + * as `_x0000_t75` for preset 75, name the preset. + * + * @param part - The root of the part the content is inserted into + * @returns A function that returns the elements without the shape types the part has, including those inserted before + */ +export const removeShapeTypesIn = (part: Element): RemoveShapeTypes => { + const idsInPart = new Set(findShapeTypeIds(part)); + + const remove = (element: Element): readonly Element[] => { + const id = shapeTypeIdOf(element); + if (id !== undefined) { + if (idsInPart.has(id)) { + return []; + } + // eslint-disable-next-line functional/immutable-data + idsInPart.add(id); + return [element]; + } + return [element.elements === undefined ? element : { ...element, elements: element.elements.flatMap(remove) }]; + }; + + return (elements) => elements.flatMap(remove); +}; From eddc3ac4617f15189ece7114c59e5216c590ed87 Mon Sep 17 00:00:00 2001 From: Dolan Miu Date: Tue, 20 Oct 2026 12:24:42 +0100 Subject: [PATCH 3/4] fix: declare the VML and Office namespaces in a document patchDocument fills in patchDocument declares on the template's document the namespaces of the elements patches insert, so they are bound even when the template doesn't declare them. VML's (v) and Office's (o) weren't among them, so an embedded file, which is drawn as a VML shape with Office's OLE data (o:OLEObject), inserted into a template that doesn't declare them made document.xml not well-formed, with an unbound prefix, and Word couldn't open it. Text boxes, which are VML shapes too, had the same problem. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X5zGDL3sgGVBMXPfny376c --- src/embedded-files/embedded-file-run.spec.ts | 19 +++++++++++++++++++ src/patcher/from-docx.ts | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/embedded-files/embedded-file-run.spec.ts b/src/embedded-files/embedded-file-run.spec.ts index dd9446bcf46..000ecdeb4b2 100644 --- a/src/embedded-files/embedded-file-run.spec.ts +++ b/src/embedded-files/embedded-file-run.spec.ts @@ -323,6 +323,25 @@ describe("EmbeddedFileRun", () => { expect(document.split(' { + // A template whose document doesn't declare them, as some applications write one + const zip = await JSZip.loadAsync( + await Packer.toBuffer(new Document({ sections: [{ children: [new Paragraph("{{file}}")] }] })), + ); + const xml = (await read(zip, "word/document.xml")).replace(/ xmlns:[vo]="[^"]*"/g, ""); + zip.file("word/document.xml", xml); + const patched = await patchDocument({ + outputType: "uint8array", + data: await zip.generateAsync({ type: "uint8array" }), + patches: { file: { type: PatchType.PARAGRAPH, children: [new EmbeddedFileRun(workbook)] } }, + }); + const root = /]*>/.exec(await read(await JSZip.loadAsync(patched), "word/document.xml"))![0]; + + expect(xml).not.toMatch(/xmlns:[vo]=/); + expect(root).toContain('xmlns:v="urn:schemas-microsoft-com:vml"'); + expect(root).toContain('xmlns:o="urn:schemas-microsoft-com:office:office"'); + }); + it("should embed a file in a document patchDocument fills in", async () => { const template = await Packer.toBuffer(new Document({ sections: [{ children: [new Paragraph("Before {{file}} after")] }] })); const patched = await patchDocument({ diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 1d40269a761..399007e00ed 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -391,9 +391,10 @@ export const patchDocument = async i.name === "w:document"); if (document && document.attributes) { // We could check all namespaces from Document, but we'll instead - // check only those that may be used by our element types. + // check only those that may be used by our element types. VML (v) and Office's (o) are for legacy + // shapes, such as text boxes and the embedded files of docx/embedded-files - for (const ns of ["mc", "wp", "r", "w15", "m"] as const) { + for (const ns of ["mc", "wp", "r", "w15", "m", "v", "o"] as const) { // eslint-disable-next-line functional/immutable-data document.attributes[`xmlns:${ns}`] = DocumentAttributeNamespaces[ns]; } From d70cb7c6e982e035ec8d832e499121117f0e9aae Mon Sep 17 00:00:00 2001 From: Dolan Miu Date: Tue, 20 Oct 2026 12:39:42 +0100 Subject: [PATCH 4/4] fix: declare the namespaces of patched content in every part patchDocument writes it into patchDocument declared the namespaces of the elements patches insert, such as VML's and Office's, on the document only. Patches are written into headers, footers, notes and comments too, so an embedded file, an image or a text box patched into a header whose template doesn't declare them had unbound prefixes, and the part wasn't well-formed. The same namespaces are now declared on the root of each part patches are written into: the document, headers, footers, footnotes, endnotes and comments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X5zGDL3sgGVBMXPfny376c --- src/embedded-files/embedded-file-run.spec.ts | 48 ++++++++++++++------ src/patcher/from-docx.ts | 24 +++++----- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/embedded-files/embedded-file-run.spec.ts b/src/embedded-files/embedded-file-run.spec.ts index 000ecdeb4b2..264e7f91662 100644 --- a/src/embedded-files/embedded-file-run.spec.ts +++ b/src/embedded-files/embedded-file-run.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { Formatter } from "@export/formatter"; import { File } from "@file/file"; -import { Document, FootnoteReferenceRun, Header, Packer, Paragraph, PatchType, Run, patchDocument } from "docx"; +import { Document, Footer, FootnoteReferenceRun, Header, Packer, Paragraph, PatchType, Run, TextRun, patchDocument } from "docx"; import { readCompoundFile } from "tests/compound-file"; import { EmbeddedFileRun, type EmbeddedFileRunOptions } from "./embedded-file-run"; @@ -323,23 +323,41 @@ describe("EmbeddedFileRun", () => { expect(document.split(' { - // A template whose document doesn't declare them, as some applications write one + it("should declare the VML and Office namespaces in each part patchDocument inserts a file into, when it doesn't", async () => { + // A template whose parts don't declare them, as some applications write one + const parts = { document: "w:document", header1: "w:hdr", footer1: "w:ftr", footnotes: "w:footnotes" }; const zip = await JSZip.loadAsync( - await Packer.toBuffer(new Document({ sections: [{ children: [new Paragraph("{{file}}")] }] })), + await Packer.toBuffer( + new Document({ + footnotes: { 1: { children: [new Paragraph("{{file}}")] } }, + sections: [ + { + headers: { default: new Header({ children: [new Paragraph("{{file}}")] }) }, + footers: { default: new Footer({ children: [new Paragraph("{{file}}")] }) }, + children: [new Paragraph({ children: [new TextRun("{{file}}"), new FootnoteReferenceRun(1)] })], + }, + ], + }), + ), + ); + for (const part of Object.keys(parts)) { + zip.file(`word/${part}.xml`, (await read(zip, `word/${part}.xml`)).replace(/ xmlns:[vo]="[^"]*"/g, "")); + } + const patched = await JSZip.loadAsync( + await patchDocument({ + outputType: "uint8array", + data: await zip.generateAsync({ type: "uint8array" }), + patches: { file: { type: PatchType.PARAGRAPH, children: [new EmbeddedFileRun(workbook)] } }, + }), ); - const xml = (await read(zip, "word/document.xml")).replace(/ xmlns:[vo]="[^"]*"/g, ""); - zip.file("word/document.xml", xml); - const patched = await patchDocument({ - outputType: "uint8array", - data: await zip.generateAsync({ type: "uint8array" }), - patches: { file: { type: PatchType.PARAGRAPH, children: [new EmbeddedFileRun(workbook)] } }, - }); - const root = /]*>/.exec(await read(await JSZip.loadAsync(patched), "word/document.xml"))![0]; - expect(xml).not.toMatch(/xmlns:[vo]=/); - expect(root).toContain('xmlns:v="urn:schemas-microsoft-com:vml"'); - expect(root).toContain('xmlns:o="urn:schemas-microsoft-com:office:office"'); + for (const [part, rootName] of Object.entries(parts)) { + const xml = await read(patched, `word/${part}.xml`); + const root = new RegExp(`<${rootName} [^>]*>`).exec(xml)![0]; + expect(xml, part).toContain('ProgID="Excel.Sheet.12"'); + expect(root, part).toContain('xmlns:v="urn:schemas-microsoft-com:vml"'); + expect(root, part).toContain('xmlns:o="urn:schemas-microsoft-com:office:office"'); + } }); it("should embed a file in a document patchDocument fills in", async () => { diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 399007e00ed..2283413c118 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -259,6 +259,9 @@ const imageReplacer = new ImageReplacer(); const UTF16LE = new Uint8Array([0xff, 0xfe]); const UTF16BE = new Uint8Array([0xfe, 0xff]); +// The root elements of the parts patches are written into, which are given the namespaces of what patches insert +const PATCHED_PART_ROOTS: ReadonlySet = new Set(["w:document", "w:hdr", "w:ftr", "w:footnotes", "w:endnotes", "w:comments"]); + const compareByteArrays = (a: Uint8Array, b: Uint8Array): boolean => { if (a.length !== b.length) { return false; @@ -387,20 +390,17 @@ export const patchDocument = async i.name === "w:document"); - if (document && document.attributes) { - // We could check all namespaces from Document, but we'll instead - // check only those that may be used by our element types. VML (v) and Office's (o) are for legacy - // shapes, such as text boxes and the embedded files of docx/embedded-files + // The root of a part patches are written into: the document, a header, a footer, the notes or the comments + const root = json.elements?.find((i) => PATCHED_PART_ROOTS.has(i.name ?? "")); + if (root && root.attributes) { + // We could check all namespaces from Document, but we'll instead + // check only those that may be used by our element types. VML (v) and Office's (o) are for legacy + // shapes, such as text boxes and the embedded files of docx/embedded-files - for (const ns of ["mc", "wp", "r", "w15", "m", "v", "o"] as const) { - // eslint-disable-next-line functional/immutable-data - document.attributes[`xmlns:${ns}`] = DocumentAttributeNamespaces[ns]; - } - // eslint-disable-next-line functional/immutable-data - document.attributes["mc:Ignorable"] = `${document.attributes["mc:Ignorable"] || ""} w15`.trim(); + for (const ns of ["mc", "wp", "r", "w15", "m", "v", "o"] as const) { + root.attributes[`xmlns:${ns}`] = DocumentAttributeNamespaces[ns]; } + root.attributes["mc:Ignorable"] = `${root.attributes["mc:Ignorable"] || ""} w15`.trim(); } // eslint-disable-next-line functional/immutable-data