@opengeni/artifact-tool 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +104 -0
  3. package/dist/bounded-zip.d.ts +28 -0
  4. package/dist/chunk-4BSENMAE.js +39 -0
  5. package/dist/chunk-4BSENMAE.js.map +1 -0
  6. package/dist/chunk-4FRFNNBM.js +276 -0
  7. package/dist/chunk-4FRFNNBM.js.map +1 -0
  8. package/dist/chunk-5XIA6KHZ.js +49 -0
  9. package/dist/chunk-5XIA6KHZ.js.map +1 -0
  10. package/dist/chunk-66CT2ZRP.js +2229 -0
  11. package/dist/chunk-66CT2ZRP.js.map +1 -0
  12. package/dist/chunk-72SYDU4O.js +319 -0
  13. package/dist/chunk-72SYDU4O.js.map +1 -0
  14. package/dist/chunk-B2ZJRZDA.js +279 -0
  15. package/dist/chunk-B2ZJRZDA.js.map +1 -0
  16. package/dist/chunk-DCTLTNPW.js +2458 -0
  17. package/dist/chunk-DCTLTNPW.js.map +1 -0
  18. package/dist/chunk-FGZM4FQ6.js +473 -0
  19. package/dist/chunk-FGZM4FQ6.js.map +1 -0
  20. package/dist/chunk-HTJBLBDE.js +591 -0
  21. package/dist/chunk-HTJBLBDE.js.map +1 -0
  22. package/dist/chunk-IYFXZQ34.js +5306 -0
  23. package/dist/chunk-IYFXZQ34.js.map +1 -0
  24. package/dist/chunk-J6N7IR44.js +172 -0
  25. package/dist/chunk-J6N7IR44.js.map +1 -0
  26. package/dist/chunk-N2IHNWBS.js +1002 -0
  27. package/dist/chunk-N2IHNWBS.js.map +1 -0
  28. package/dist/chunk-NAG44K67.js +549 -0
  29. package/dist/chunk-NAG44K67.js.map +1 -0
  30. package/dist/chunk-O24KTEGP.js +33 -0
  31. package/dist/chunk-O24KTEGP.js.map +1 -0
  32. package/dist/chunk-PI2AZVGK.js +2074 -0
  33. package/dist/chunk-PI2AZVGK.js.map +1 -0
  34. package/dist/chunk-Q2FFHEY7.js +40 -0
  35. package/dist/chunk-Q2FFHEY7.js.map +1 -0
  36. package/dist/chunk-RBGQADXA.js +57 -0
  37. package/dist/chunk-RBGQADXA.js.map +1 -0
  38. package/dist/chunk-U5G2BKM2.js +613 -0
  39. package/dist/chunk-U5G2BKM2.js.map +1 -0
  40. package/dist/chunk-VOJHMSM3.js +39 -0
  41. package/dist/chunk-VOJHMSM3.js.map +1 -0
  42. package/dist/chunk-VUNV25KB.js +16 -0
  43. package/dist/chunk-VUNV25KB.js.map +1 -0
  44. package/dist/chunk-XIFUNVBO.js +101 -0
  45. package/dist/chunk-XIFUNVBO.js.map +1 -0
  46. package/dist/chunk-ZCJYRJGB.js +489 -0
  47. package/dist/chunk-ZCJYRJGB.js.map +1 -0
  48. package/dist/chunk-ZZXPEE4S.js +2260 -0
  49. package/dist/chunk-ZZXPEE4S.js.map +1 -0
  50. package/dist/document-docx-api.d.ts +49 -0
  51. package/dist/document-docx-codec.d.ts +9 -0
  52. package/dist/document-docx-codec.js +4765 -0
  53. package/dist/document-docx-codec.js.map +1 -0
  54. package/dist/document-docx-import.d.ts +261 -0
  55. package/dist/document-docx-packer.d.ts +18 -0
  56. package/dist/document-docx-state.d.ts +12 -0
  57. package/dist/document-render.d.ts +3 -0
  58. package/dist/document-render.js +492 -0
  59. package/dist/document-render.js.map +1 -0
  60. package/dist/document.d.ts +432 -0
  61. package/dist/document.js +37 -0
  62. package/dist/document.js.map +1 -0
  63. package/dist/errors.d.ts +23 -0
  64. package/dist/file-blob.d.ts +11 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +186 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/inspection.d.ts +12 -0
  69. package/dist/kernel.d.ts +101 -0
  70. package/dist/lib-GQLPACFK.js +9746 -0
  71. package/dist/lib-GQLPACFK.js.map +1 -0
  72. package/dist/materializer-cli-entry.d.ts +2 -0
  73. package/dist/materializer-cli-entry.js +29 -0
  74. package/dist/materializer-cli-entry.js.map +1 -0
  75. package/dist/materializer-cli.d.ts +24 -0
  76. package/dist/materializer-cli.js +24 -0
  77. package/dist/materializer-cli.js.map +1 -0
  78. package/dist/native-raster.d.ts +10 -0
  79. package/dist/native.d.ts +129 -0
  80. package/dist/native.js +31 -0
  81. package/dist/native.js.map +1 -0
  82. package/dist/office-import.d.ts +48 -0
  83. package/dist/office-import.js +154 -0
  84. package/dist/office-import.js.map +1 -0
  85. package/dist/presentation-pptx-api.d.ts +60 -0
  86. package/dist/presentation-pptx-import.d.ts +5 -0
  87. package/dist/presentation-pptx-state-digest-765CZZED.js +10 -0
  88. package/dist/presentation-pptx-state-digest-765CZZED.js.map +1 -0
  89. package/dist/presentation-pptx-state-digest.d.ts +3 -0
  90. package/dist/presentation-pptx-state.d.ts +4 -0
  91. package/dist/presentation-pptx-xml.d.ts +29 -0
  92. package/dist/presentation-pptx.d.ts +5 -0
  93. package/dist/presentation-pptx.js +2365 -0
  94. package/dist/presentation-pptx.js.map +1 -0
  95. package/dist/presentation-render.d.ts +29 -0
  96. package/dist/presentation-render.js +375 -0
  97. package/dist/presentation-render.js.map +1 -0
  98. package/dist/presentation.d.ts +565 -0
  99. package/dist/presentation.js +71 -0
  100. package/dist/presentation.js.map +1 -0
  101. package/dist/production-composite.d.ts +68 -0
  102. package/dist/production-document.d.ts +2 -0
  103. package/dist/production-document.js +176 -0
  104. package/dist/production-document.js.map +1 -0
  105. package/dist/production-native-codecs.d.ts +94 -0
  106. package/dist/production-presentation.d.ts +2 -0
  107. package/dist/production-presentation.js +176 -0
  108. package/dist/production-presentation.js.map +1 -0
  109. package/dist/production-sha256.d.ts +2 -0
  110. package/dist/production-spreadsheet-native.d.ts +12 -0
  111. package/dist/production-spreadsheet.d.ts +2 -0
  112. package/dist/production-spreadsheet.js +176 -0
  113. package/dist/production-spreadsheet.js.map +1 -0
  114. package/dist/production.d.ts +96 -0
  115. package/dist/raster-image.d.ts +34 -0
  116. package/dist/reference.d.ts +17 -0
  117. package/dist/reference.js +574 -0
  118. package/dist/reference.js.map +1 -0
  119. package/dist/runtime-cli-entry.d.ts +2 -0
  120. package/dist/runtime-cli-entry.js +21 -0
  121. package/dist/runtime-cli-entry.js.map +1 -0
  122. package/dist/runtime-cli.d.ts +76 -0
  123. package/dist/runtime-cli.js +26 -0
  124. package/dist/runtime-cli.js.map +1 -0
  125. package/dist/runtime-development.d.ts +73 -0
  126. package/dist/runtime-development.js +30 -0
  127. package/dist/runtime-development.js.map +1 -0
  128. package/dist/runtime-receipt.d.ts +19 -0
  129. package/dist/runtime.d.ts +355 -0
  130. package/dist/runtime.js +42 -0
  131. package/dist/runtime.js.map +1 -0
  132. package/dist/snapshot.d.ts +26 -0
  133. package/dist/snapshot.js +61 -0
  134. package/dist/snapshot.js.map +1 -0
  135. package/dist/spreadsheet-address.d.ts +18 -0
  136. package/dist/spreadsheet-file.d.ts +17 -0
  137. package/dist/spreadsheet-formula.d.ts +67 -0
  138. package/dist/spreadsheet-image.d.ts +17 -0
  139. package/dist/spreadsheet-render.d.ts +5 -0
  140. package/dist/spreadsheet-render.js +752 -0
  141. package/dist/spreadsheet-render.js.map +1 -0
  142. package/dist/spreadsheet-types.d.ts +155 -0
  143. package/dist/spreadsheet-xlsx-api.d.ts +88 -0
  144. package/dist/spreadsheet-xlsx-codec.d.ts +16 -0
  145. package/dist/spreadsheet-xlsx-codec.js +16 -0
  146. package/dist/spreadsheet-xlsx-codec.js.map +1 -0
  147. package/dist/spreadsheet-xlsx-state.d.ts +16 -0
  148. package/dist/spreadsheet.d.ts +724 -0
  149. package/dist/spreadsheet.js +67 -0
  150. package/dist/spreadsheet.js.map +1 -0
  151. package/package.json +129 -0
  152. package/src/bounded-zip.ts +450 -0
  153. package/src/document-docx-api.ts +64 -0
  154. package/src/document-docx-codec.ts +2253 -0
  155. package/src/document-docx-import.ts +3814 -0
  156. package/src/document-docx-packer.ts +72 -0
  157. package/src/document-docx-state.ts +59 -0
  158. package/src/document-render.ts +615 -0
  159. package/src/document.ts +2800 -0
  160. package/src/errors.ts +45 -0
  161. package/src/file-blob.ts +43 -0
  162. package/src/index.ts +4 -0
  163. package/src/inspection.ts +50 -0
  164. package/src/kernel.ts +525 -0
  165. package/src/materializer-cli-entry.ts +11 -0
  166. package/src/materializer-cli.ts +743 -0
  167. package/src/native-raster.ts +141 -0
  168. package/src/native.ts +390 -0
  169. package/src/office-import.ts +243 -0
  170. package/src/presentation-pptx-api.ts +107 -0
  171. package/src/presentation-pptx-import.ts +2065 -0
  172. package/src/presentation-pptx-state-digest.ts +46 -0
  173. package/src/presentation-pptx-state.ts +18 -0
  174. package/src/presentation-pptx-xml.ts +473 -0
  175. package/src/presentation-pptx.ts +447 -0
  176. package/src/presentation-render.ts +532 -0
  177. package/src/presentation.ts +2599 -0
  178. package/src/production-composite.ts +805 -0
  179. package/src/production-document.ts +2 -0
  180. package/src/production-native-codecs.ts +1141 -0
  181. package/src/production-presentation.ts +2 -0
  182. package/src/production-sha256.ts +150 -0
  183. package/src/production-spreadsheet-native.ts +686 -0
  184. package/src/production-spreadsheet.ts +2 -0
  185. package/src/production.ts +1022 -0
  186. package/src/raster-image.ts +586 -0
  187. package/src/reference.ts +17 -0
  188. package/src/runtime-cli-entry.ts +13 -0
  189. package/src/runtime-cli.ts +670 -0
  190. package/src/runtime-development.ts +689 -0
  191. package/src/runtime-receipt.ts +169 -0
  192. package/src/runtime.ts +1463 -0
  193. package/src/snapshot.ts +88 -0
  194. package/src/spreadsheet-address.ts +90 -0
  195. package/src/spreadsheet-file.ts +71 -0
  196. package/src/spreadsheet-formula.ts +1212 -0
  197. package/src/spreadsheet-image.ts +242 -0
  198. package/src/spreadsheet-render.ts +985 -0
  199. package/src/spreadsheet-types.ts +171 -0
  200. package/src/spreadsheet-xlsx-api.ts +123 -0
  201. package/src/spreadsheet-xlsx-codec.ts +3063 -0
  202. package/src/spreadsheet-xlsx-state.ts +191 -0
  203. package/src/spreadsheet.ts +5195 -0
@@ -0,0 +1,3814 @@
1
+ /**
2
+ * A dependency-free, browser-safe DOCX reader.
3
+ *
4
+ * This module deliberately returns a neutral DTO. The editable document model
5
+ * can consume that DTO without making the ZIP/XML reader depend on it (or on a
6
+ * Node-only OOXML library). Unsupported fidelity-bearing content fails closed.
7
+ */
8
+
9
+ import {
10
+ inflateBoundedZipEntry,
11
+ parseBoundedZip,
12
+ type BoundedZipEntry,
13
+ type BoundedZipFailure,
14
+ } from "./bounded-zip";
15
+ import type {
16
+ DocumentOpaqueContent,
17
+ DocumentOpaqueContentType,
18
+ DocumentOpaqueRelationship,
19
+ } from "./document-docx-api";
20
+
21
+ export type DocxImportLimits = {
22
+ maxCompressedBytes: number;
23
+ maxEntries: number;
24
+ maxEntryCompressedBytes: number;
25
+ maxEntryUncompressedBytes: number;
26
+ maxTotalUncompressedBytes: number;
27
+ maxCompressionRatio: number;
28
+ maxXmlBytes: number;
29
+ maxTotalXmlBytes: number;
30
+ maxRetainedXmlCharacters: number;
31
+ maxXmlNodes: number;
32
+ maxXmlDepth: number;
33
+ maxXmlAttributesPerElement: number;
34
+ maxRelationshipsPerPart: number;
35
+ maxTotalRelationships: number;
36
+ maxBlocks: number;
37
+ maxTextCharacters: number;
38
+ maxSections: number;
39
+ maxProjectedStoryBlocks: number;
40
+ maxProjectedStoryCharacters: number;
41
+ maxStyles: number;
42
+ maxStyleInheritanceDepth: number;
43
+ maxNumberingDefinitions: number;
44
+ maxComments: number;
45
+ maxProjectedCommentWork: number;
46
+ };
47
+
48
+ export type DocxImportErrorCode =
49
+ | "invalid_input"
50
+ | "invalid_zip"
51
+ | "limit_exceeded"
52
+ | "invalid_xml"
53
+ | "invalid_package"
54
+ | "unsupported_feature"
55
+ | "unsupported_platform";
56
+
57
+ export class DocxImportError extends Error {
58
+ readonly name = "DocxImportError";
59
+ constructor(
60
+ readonly code: DocxImportErrorCode,
61
+ message: string,
62
+ readonly partName?: string,
63
+ ) {
64
+ super(partName ? `${message} (${partName})` : message);
65
+ }
66
+ }
67
+
68
+ export type ImportedDocument = {
69
+ format: "docx";
70
+ schemaVersion: 1;
71
+ evenAndOddHeaders: boolean;
72
+ trackRevisions: boolean;
73
+ blocks: ImportedBlock[];
74
+ sections: ImportedSection[];
75
+ styles: ImportedStyle[];
76
+ lists: ImportedListDefinition[];
77
+ headers: ImportedHeaderFooter[];
78
+ footers: ImportedHeaderFooter[];
79
+ comments: ImportedComment[];
80
+ trackedChanges: ImportedTrackedChange[];
81
+ opaqueContent: DocumentOpaqueContent;
82
+ };
83
+
84
+ export type ImportedBlock = ImportedParagraph | ImportedTable | ImportedPageBreak;
85
+
86
+ export type ImportedParagraph = {
87
+ kind: "paragraph";
88
+ id: string;
89
+ styleId?: string;
90
+ style: ImportedParagraphStyle;
91
+ inlines: ImportedInline[];
92
+ commentAnchors: ImportedCommentAnchor[];
93
+ };
94
+
95
+ export type ImportedInline = ImportedRun | ImportedPageBreak;
96
+
97
+ export type ImportedRun = {
98
+ kind: "run";
99
+ text: string;
100
+ styleId?: string;
101
+ style: ImportedRunStyle;
102
+ changeId?: string;
103
+ };
104
+
105
+ export type ImportedPageBreak = { kind: "pageBreak" };
106
+
107
+ export type ImportedTable = {
108
+ kind: "table";
109
+ id: string;
110
+ styleId?: string;
111
+ width?: ImportedMeasure;
112
+ indent?: ImportedMeasure;
113
+ alignment?: string;
114
+ layout?: "fixed" | "autofit";
115
+ cellMargins?: ImportedBoxMeasures;
116
+ borders?: ImportedTableBorders;
117
+ gridColumnWidthsPt: number[];
118
+ rows: ImportedTableRow[];
119
+ };
120
+
121
+ export type ImportedTableRow = {
122
+ header: boolean;
123
+ cannotSplit: boolean;
124
+ heightPt?: number;
125
+ heightRule?: "auto" | "atLeast" | "exact";
126
+ cells: ImportedTableCell[];
127
+ };
128
+
129
+ export type ImportedTableCell = {
130
+ width?: ImportedMeasure;
131
+ margins?: ImportedBoxMeasures;
132
+ columnSpan: number;
133
+ verticalMerge?: "restart" | "continue";
134
+ verticalAlignment?: string;
135
+ fill?: string;
136
+ blocks: ImportedBlock[];
137
+ };
138
+
139
+ export type ImportedMeasure = { value: number; unit: "pt" | "percent" | "auto" };
140
+
141
+ export type ImportedBoxMeasures = {
142
+ top?: ImportedMeasure;
143
+ right?: ImportedMeasure;
144
+ bottom?: ImportedMeasure;
145
+ left?: ImportedMeasure;
146
+ start?: ImportedMeasure;
147
+ end?: ImportedMeasure;
148
+ };
149
+
150
+ export type ImportedBorder = {
151
+ style: string;
152
+ color?: string;
153
+ sizePt?: number;
154
+ spacePt?: number;
155
+ shadow?: boolean;
156
+ frame?: boolean;
157
+ };
158
+
159
+ export type ImportedTableBorders = {
160
+ top?: ImportedBorder;
161
+ right?: ImportedBorder;
162
+ bottom?: ImportedBorder;
163
+ left?: ImportedBorder;
164
+ insideHorizontal?: ImportedBorder;
165
+ insideVertical?: ImportedBorder;
166
+ };
167
+
168
+ export type ImportedParagraphStyle = {
169
+ alignment?: string;
170
+ spaceBeforePt?: number;
171
+ spaceAfterPt?: number;
172
+ line?: number;
173
+ lineRule?: string;
174
+ indentLeftPt?: number;
175
+ indentRightPt?: number;
176
+ firstLinePt?: number;
177
+ hangingPt?: number;
178
+ keepNext?: boolean;
179
+ keepLines?: boolean;
180
+ pageBreakBefore?: boolean;
181
+ outlineLevel?: number;
182
+ bidirectional?: boolean;
183
+ list?: { numId: string; level: number };
184
+ };
185
+
186
+ export type ImportedRunStyle = {
187
+ fontFamily?: string;
188
+ fontSizePt?: number;
189
+ fontSizeComplexScriptPt?: number;
190
+ color?: string;
191
+ bold?: boolean;
192
+ boldComplexScript?: boolean;
193
+ italic?: boolean;
194
+ italicComplexScript?: boolean;
195
+ underline?: string;
196
+ strike?: boolean;
197
+ highlight?: string;
198
+ verticalAlign?: string;
199
+ language?: string;
200
+ };
201
+
202
+ export type ImportedStyle = {
203
+ styleId: string;
204
+ kind: string;
205
+ name?: string;
206
+ basedOn?: string;
207
+ next?: string;
208
+ isDefault: boolean;
209
+ paragraph: ImportedParagraphStyle;
210
+ run: ImportedRunStyle;
211
+ };
212
+
213
+ export type ImportedListLevel = {
214
+ level: number;
215
+ start: number;
216
+ restart?: number;
217
+ legal?: boolean;
218
+ alignment?: string;
219
+ format?: string;
220
+ text?: string;
221
+ suffix?: string;
222
+ paragraphStyleId?: string;
223
+ paragraph: ImportedParagraphStyle;
224
+ run: ImportedRunStyle;
225
+ };
226
+
227
+ export type ImportedListDefinition = {
228
+ numId: string;
229
+ abstractNumId: string;
230
+ levels: ImportedListLevel[];
231
+ overrides: Array<{ level: number; start?: number; definition?: ImportedListLevel }>;
232
+ };
233
+
234
+ export type ImportedSection = {
235
+ startBlockIndex: number;
236
+ endBlockIndex: number;
237
+ type?: string;
238
+ titlePage: boolean;
239
+ page: {
240
+ widthPt: number;
241
+ heightPt: number;
242
+ orientation?: string;
243
+ marginTopPt: number;
244
+ marginRightPt: number;
245
+ marginBottomPt: number;
246
+ marginLeftPt: number;
247
+ headerPt: number;
248
+ footerPt: number;
249
+ gutterPt: number;
250
+ };
251
+ headers: ImportedSectionReference[];
252
+ footers: ImportedSectionReference[];
253
+ };
254
+
255
+ export type ImportedSectionReference = {
256
+ kind: string;
257
+ relationshipId: string;
258
+ partName: string;
259
+ };
260
+
261
+ export type ImportedHeaderFooter = {
262
+ kind: "header" | "footer";
263
+ partName: string;
264
+ blocks: ImportedBlock[];
265
+ };
266
+
267
+ export type ImportedCommentAnchor = {
268
+ commentId: string;
269
+ kind: "start" | "end" | "reference";
270
+ textOffset: number;
271
+ };
272
+
273
+ export type ImportedComment = {
274
+ id: string;
275
+ parentId?: string;
276
+ resolved?: boolean;
277
+ author?: string;
278
+ initials?: string;
279
+ createdAt?: string;
280
+ blocks: ImportedBlock[];
281
+ };
282
+
283
+ export type ImportedTrackedChange = {
284
+ id: string;
285
+ kind: "insert" | "delete";
286
+ author?: string;
287
+ createdAt?: string;
288
+ blockId: string;
289
+ startInlineIndex: number;
290
+ endInlineIndex: number;
291
+ startTextOffset: number;
292
+ endTextOffset: number;
293
+ };
294
+
295
+ const DEFAULT_LIMITS: DocxImportLimits = {
296
+ maxCompressedBytes: 32 * 1024 * 1024,
297
+ maxEntries: 1_024,
298
+ maxEntryCompressedBytes: 16 * 1024 * 1024,
299
+ maxEntryUncompressedBytes: 24 * 1024 * 1024,
300
+ maxTotalUncompressedBytes: 64 * 1024 * 1024,
301
+ maxCompressionRatio: 100,
302
+ maxXmlBytes: 16 * 1024 * 1024,
303
+ maxTotalXmlBytes: 32 * 1024 * 1024,
304
+ maxRetainedXmlCharacters: 16_000_000,
305
+ maxXmlNodes: 100_000,
306
+ maxXmlDepth: 128,
307
+ maxXmlAttributesPerElement: 256,
308
+ maxRelationshipsPerPart: 10_000,
309
+ maxTotalRelationships: 100_000,
310
+ maxBlocks: 100_000,
311
+ maxTextCharacters: 10_000_000,
312
+ maxSections: 4_096,
313
+ maxProjectedStoryBlocks: 200_000,
314
+ maxProjectedStoryCharacters: 20_000_000,
315
+ maxStyles: 10_000,
316
+ maxStyleInheritanceDepth: 64,
317
+ maxNumberingDefinitions: 20_000,
318
+ maxComments: 10_000,
319
+ maxProjectedCommentWork: 1_000_000,
320
+ };
321
+
322
+ const WORD_RELATIONSHIP_BASE =
323
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
324
+ const PACKAGE_RELATIONSHIP_BASE =
325
+ "http://schemas.openxmlformats.org/package/2006/relationships/metadata/";
326
+ const WORDPROCESSINGML_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
327
+ const CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types";
328
+ const RELATIONSHIPS_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships";
329
+ const COMMENTS_EXTENDED_NAMESPACE = "http://schemas.microsoft.com/office/word/2012/wordml";
330
+
331
+ type ZipEntry = BoundedZipEntry;
332
+
333
+ type XmlNode = XmlElement | XmlText;
334
+ type XmlElement = {
335
+ type: "element";
336
+ name: string;
337
+ localName: string;
338
+ attributes: ReadonlyMap<string, string>;
339
+ children: XmlNode[];
340
+ };
341
+ type XmlText = { type: "text"; value: string };
342
+
343
+ type Relationship = {
344
+ id: string;
345
+ type: string;
346
+ target: string;
347
+ sourcePart: string;
348
+ partName: string;
349
+ };
350
+
351
+ type ImportBudget = {
352
+ limits: DocxImportLimits;
353
+ xmlBytes: number;
354
+ xmlRetainedCharacters: number;
355
+ xmlNodes: number;
356
+ blocks: number;
357
+ textCharacters: number;
358
+ nextBlockId: number;
359
+ };
360
+
361
+ type ParseContext = {
362
+ budget: ImportBudget;
363
+ trackedChanges: ImportedTrackedChange[];
364
+ idPrefix: string;
365
+ };
366
+
367
+ /**
368
+ * Imports the fidelity-bearing, editable WordprocessingML subset into a neutral
369
+ * DTO. The function has no Node imports and is suitable for a browser worker.
370
+ */
371
+ export async function importDocx(
372
+ input: Blob | ArrayBuffer,
373
+ options: Partial<DocxImportLimits> = {},
374
+ ): Promise<ImportedDocument> {
375
+ const limits = resolveLimits(options);
376
+ const bytes = await readInput(input, limits.maxCompressedBytes);
377
+ const entries = parseZipDirectory(bytes, limits);
378
+ const opaqueParts = validatePartNames(entries);
379
+ const entriesByName = new Map(
380
+ entries.filter((entry) => !entry.directory).map((entry) => [entry.name, entry]),
381
+ );
382
+ const partNames = new Set(entriesByName.keys());
383
+ validatePackageParts(partNames);
384
+
385
+ const budget: ImportBudget = {
386
+ limits,
387
+ xmlBytes: 0,
388
+ xmlRetainedCharacters: 0,
389
+ xmlNodes: 0,
390
+ blocks: 0,
391
+ textCharacters: 0,
392
+ nextBlockId: 1,
393
+ };
394
+ const parsedParts = new Set<string>();
395
+ const readXml = async (partName: string): Promise<XmlElement> => {
396
+ const entry = entriesByName.get(partName);
397
+ if (!entry)
398
+ throw new DocxImportError("invalid_package", "Required DOCX part is missing", partName);
399
+ if (parsedParts.has(partName)) {
400
+ throw new DocxImportError(
401
+ "invalid_package",
402
+ "DOCX part was consumed more than once",
403
+ partName,
404
+ );
405
+ }
406
+ parsedParts.add(partName);
407
+ // Inflate and parse one part at a time. The expanded byte buffer and XML
408
+ // source string become collectible as soon as this consumer returns; no
409
+ // archive-wide expanded-parts or parsed-tree cache is retained.
410
+ const value = await inflateBoundedZipEntry(
411
+ bytes,
412
+ entry,
413
+ limits.maxEntryUncompressedBytes,
414
+ docxZipFailure,
415
+ );
416
+ return parseXmlPart(value, partName, budget);
417
+ };
418
+
419
+ // Validate ignored metadata/theme/config one part at a time, without retaining
420
+ // either its expanded bytes or parsed tree. Ignored semantics cannot become a
421
+ // budget or entity-expansion bypass.
422
+ for (const partName of partNames) {
423
+ if (isSyntaxOnlyXmlPart(partName) || (opaqueParts.has(partName) && partName.endsWith(".xml")))
424
+ await readXml(partName);
425
+ }
426
+
427
+ const contentTypes = await readXml("[Content_Types].xml");
428
+ const contentTypeByPart = validateContentTypes(contentTypes, partNames);
429
+ const opaqueContentTypes: DocumentOpaqueContentType[] = [];
430
+ for (const partName of opaqueParts) {
431
+ const contentType = contentTypeByPart.get(partName);
432
+ if (!contentType || !isSafeOpaqueContentType(partName, contentType)) {
433
+ throw new DocxImportError(
434
+ "unsupported_feature",
435
+ `Opaque DOCX part has an unsupported content type: ${contentType ?? "missing"}`,
436
+ partName,
437
+ );
438
+ }
439
+ opaqueContentTypes.push({ partName, contentType });
440
+ }
441
+ if (partNames.has("word/footnotes.xml"))
442
+ validateEmptyNotePart(await readXml("word/footnotes.xml"), "footnote", "word/footnotes.xml");
443
+ if (partNames.has("word/endnotes.xml"))
444
+ validateEmptyNotePart(await readXml("word/endnotes.xml"), "endnote", "word/endnotes.xml");
445
+ const settings = partNames.has("word/settings.xml")
446
+ ? validateSettings(await readXml("word/settings.xml"))
447
+ : { evenAndOddHeaders: false, trackRevisions: false };
448
+ let totalRelationships = 0;
449
+ const readRelationships = async (partName: string): Promise<Relationship[]> => {
450
+ const parsed = parseRelationships(
451
+ await readXml(partName),
452
+ partName,
453
+ limits.maxRelationshipsPerPart,
454
+ );
455
+ if (totalRelationships + parsed.length > limits.maxTotalRelationships) {
456
+ throw limitError("DOCX exceeds maxTotalRelationships", partName);
457
+ }
458
+ totalRelationships += parsed.length;
459
+ return parsed;
460
+ };
461
+ const rootRelationships = await readRelationships("_rels/.rels");
462
+ const officeDocuments = rootRelationships.filter(
463
+ (relationship) => relationship.type === `${WORD_RELATIONSHIP_BASE}officeDocument`,
464
+ );
465
+ const officeDocument = officeDocuments[0];
466
+ if (
467
+ officeDocuments.length !== 1 ||
468
+ !officeDocument ||
469
+ officeDocument.partName !== "word/document.xml"
470
+ ) {
471
+ throw new DocxImportError(
472
+ "invalid_package",
473
+ "DOCX officeDocument must target word/document.xml",
474
+ "_rels/.rels",
475
+ );
476
+ }
477
+
478
+ const documentRelationships = partNames.has("word/_rels/document.xml.rels")
479
+ ? await readRelationships("word/_rels/document.xml.rels")
480
+ : [];
481
+ const auxiliaryRelationships: Relationship[] = [];
482
+ for (const partName of partNames) {
483
+ if (
484
+ !partName.endsWith(".rels") ||
485
+ partName === "_rels/.rels" ||
486
+ partName === "word/_rels/document.xml.rels"
487
+ )
488
+ continue;
489
+ for (const relationship of await readRelationships(partName))
490
+ auxiliaryRelationships.push(relationship);
491
+ }
492
+ const allRelationships = [
493
+ ...rootRelationships,
494
+ ...documentRelationships,
495
+ ...auxiliaryRelationships,
496
+ ];
497
+ const opaqueRelationships = validateRelationships(allRelationships, opaqueParts);
498
+ for (const relationship of allRelationships) {
499
+ if (!partNames.has(relationship.partName)) {
500
+ throw new DocxImportError(
501
+ "invalid_package",
502
+ "Relationship targets a missing package part",
503
+ relationship.partName,
504
+ );
505
+ }
506
+ }
507
+ validateRelationshipCoverage(partNames, allRelationships);
508
+ const relationshipById = new Map(
509
+ documentRelationships.map((relationship) => [relationship.id, relationship]),
510
+ );
511
+
512
+ const styles = partNames.has("word/styles.xml")
513
+ ? parseStyles(await readXml("word/styles.xml"), limits)
514
+ : [];
515
+ const lists = partNames.has("word/numbering.xml")
516
+ ? parseNumbering(await readXml("word/numbering.xml"), limits)
517
+ : [];
518
+ const trackedChanges: ImportedTrackedChange[] = [];
519
+ const mainContext: ParseContext = { budget, trackedChanges, idPrefix: "body" };
520
+ const main = parseMainDocument(await readXml("word/document.xml"), mainContext, relationshipById);
521
+ resolveSectionStoryInheritance(main.sections);
522
+
523
+ const headers: ImportedHeaderFooter[] = [];
524
+ const footers: ImportedHeaderFooter[] = [];
525
+ const parsedStoryParts = new Set<string>();
526
+ for (const relationship of documentRelationships) {
527
+ const kind =
528
+ relationship.type === `${WORD_RELATIONSHIP_BASE}header`
529
+ ? "header"
530
+ : relationship.type === `${WORD_RELATIONSHIP_BASE}footer`
531
+ ? "footer"
532
+ : undefined;
533
+ if (!kind) continue;
534
+ if (parsedStoryParts.has(relationship.partName)) continue;
535
+ parsedStoryParts.add(relationship.partName);
536
+ const root = await readXml(relationship.partName);
537
+ requireLocalName(root, kind === "header" ? "hdr" : "ftr", relationship.partName);
538
+ requireWordNamespace(root, relationship.partName);
539
+ const context: ParseContext = {
540
+ budget,
541
+ trackedChanges,
542
+ idPrefix: `${kind}:${relationship.partName}`,
543
+ };
544
+ const blocks = parseBlockContainer(root, context);
545
+ (kind === "header" ? headers : footers).push({ kind, partName: relationship.partName, blocks });
546
+ }
547
+ validateProjectedStories(
548
+ main.sections,
549
+ headers,
550
+ footers,
551
+ limits.maxProjectedStoryBlocks,
552
+ limits.maxProjectedStoryCharacters,
553
+ );
554
+
555
+ const commentsExtended = partNames.has("word/commentsExtended.xml")
556
+ ? parseCommentsExtended(await readXml("word/commentsExtended.xml"), limits.maxComments)
557
+ : new Map<string, ExtendedComment>();
558
+ const comments = partNames.has("word/comments.xml")
559
+ ? parseComments(
560
+ await readXml("word/comments.xml"),
561
+ { budget, trackedChanges, idPrefix: "comment" },
562
+ commentsExtended,
563
+ limits.maxComments,
564
+ )
565
+ : [];
566
+ if (commentsExtended.size > 0 && comments.length === 0) {
567
+ throw new DocxImportError(
568
+ "invalid_package",
569
+ "commentsExtended.xml exists without comments.xml",
570
+ "word/commentsExtended.xml",
571
+ );
572
+ }
573
+ validateCommentReferences(main.blocks, headers, footers, comments);
574
+ const rootCommentCount = comments.reduce(
575
+ (count, comment) => count + Number(comment.parentId === undefined),
576
+ 0,
577
+ );
578
+ if (
579
+ rootCommentCount > 0 &&
580
+ rootCommentCount * (budget.blocks + comments.length) > limits.maxProjectedCommentWork
581
+ ) {
582
+ throw limitError("DOCX exceeds maxProjectedCommentWork", "word/comments.xml");
583
+ }
584
+
585
+ return {
586
+ format: "docx",
587
+ schemaVersion: 1,
588
+ evenAndOddHeaders: settings.evenAndOddHeaders,
589
+ trackRevisions: settings.trackRevisions,
590
+ blocks: main.blocks,
591
+ sections: main.sections,
592
+ styles,
593
+ lists,
594
+ headers,
595
+ footers,
596
+ comments,
597
+ trackedChanges,
598
+ opaqueContent: {
599
+ parts: [...opaqueParts].sort(compareStrings),
600
+ relationships: opaqueRelationships
601
+ .map(({ sourcePart, type, partName }) => ({ sourcePart, type, targetPart: partName }))
602
+ .sort(compareOpaqueRelationships),
603
+ contentTypes: opaqueContentTypes.sort(compareOpaqueContentTypes),
604
+ },
605
+ };
606
+ }
607
+
608
+ function resolveLimits(options: Partial<DocxImportLimits>): DocxImportLimits {
609
+ if (
610
+ options === null ||
611
+ typeof options !== "object" ||
612
+ Array.isArray(options) ||
613
+ Object.getPrototypeOf(options) !== Object.prototype ||
614
+ Object.getOwnPropertySymbols(options).length > 0
615
+ )
616
+ throw new DocxImportError("invalid_input", "DOCX import limits must be a plain data object");
617
+ for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(options))) {
618
+ if (!("value" in descriptor) || !descriptor.enumerable) {
619
+ throw new DocxImportError(
620
+ "invalid_input",
621
+ "DOCX import limits must contain only enumerable data properties",
622
+ );
623
+ }
624
+ if (!Object.prototype.hasOwnProperty.call(DEFAULT_LIMITS, name)) {
625
+ throw new DocxImportError("invalid_input", `Unknown DOCX import limit: ${name}`);
626
+ }
627
+ }
628
+ const limits = { ...DEFAULT_LIMITS, ...options };
629
+ for (const [name, value] of Object.entries(limits)) {
630
+ if (
631
+ !Number.isFinite(value) ||
632
+ value <= 0 ||
633
+ (name !== "maxCompressionRatio" && !Number.isInteger(value))
634
+ ) {
635
+ throw new DocxImportError("invalid_input", `Invalid DOCX import limit: ${name}`);
636
+ }
637
+ }
638
+ // Limits are caller-tightenable, never caller-expandable. This keeps every
639
+ // public invocation inside the browser-safe envelope audited here.
640
+ for (const [name, cap] of Object.entries(DEFAULT_LIMITS) as Array<
641
+ [keyof DocxImportLimits, number]
642
+ >) {
643
+ if (limits[name] > cap)
644
+ throw new DocxImportError(
645
+ "invalid_input",
646
+ `${name} cannot exceed the hard safety cap of ${cap}`,
647
+ );
648
+ }
649
+ return limits;
650
+ }
651
+
652
+ async function readInput(input: Blob | ArrayBuffer, maxBytes: number): Promise<Uint8Array> {
653
+ if (input instanceof ArrayBuffer) {
654
+ if (input.byteLength > maxBytes) throw limitError("Compressed DOCX exceeds maxCompressedBytes");
655
+ return new Uint8Array(input);
656
+ }
657
+ if (typeof Blob !== "undefined" && input instanceof Blob) {
658
+ if (input.size > maxBytes) throw limitError("Compressed DOCX exceeds maxCompressedBytes");
659
+ return new Uint8Array(await input.arrayBuffer());
660
+ }
661
+ throw new DocxImportError("invalid_input", "DOCX input must be a Blob or ArrayBuffer");
662
+ }
663
+
664
+ function parseZipDirectory(bytes: Uint8Array, limits: DocxImportLimits): ZipEntry[] {
665
+ return parseBoundedZip(
666
+ bytes,
667
+ {
668
+ entries: limits.maxEntries,
669
+ compressedEntryBytes: limits.maxEntryCompressedBytes,
670
+ expandedEntryBytes: limits.maxEntryUncompressedBytes,
671
+ expandedBytes: limits.maxTotalUncompressedBytes,
672
+ compressionRatio: limits.maxCompressionRatio,
673
+ },
674
+ docxZipFailure,
675
+ );
676
+ }
677
+
678
+ function docxZipFailure(
679
+ kind: Parameters<BoundedZipFailure>[0],
680
+ message: string,
681
+ partName?: string,
682
+ ): never {
683
+ const code: DocxImportErrorCode =
684
+ kind === "limit"
685
+ ? "limit_exceeded"
686
+ : kind === "invalid"
687
+ ? "invalid_zip"
688
+ : kind === "platform"
689
+ ? "unsupported_platform"
690
+ : "unsupported_feature";
691
+ throw new DocxImportError(code, message, partName);
692
+ }
693
+
694
+ function validatePartNames(parts: readonly ZipEntry[]): Set<string> {
695
+ const opaqueParts = new Set<string>();
696
+ for (const entry of parts) {
697
+ if (entry.directory) continue;
698
+ const name = entry.name;
699
+ const supported =
700
+ name === "[Content_Types].xml" ||
701
+ name === "_rels/.rels" ||
702
+ /^docProps\/(?:core|app|custom)\.xml$/.test(name) ||
703
+ name === "word/document.xml" ||
704
+ name === "word/styles.xml" ||
705
+ name === "word/numbering.xml" ||
706
+ name === "word/comments.xml" ||
707
+ name === "word/commentsExtended.xml" ||
708
+ name === "word/footnotes.xml" ||
709
+ name === "word/endnotes.xml" ||
710
+ name === "word/settings.xml" ||
711
+ name === "word/webSettings.xml" ||
712
+ name === "word/fontTable.xml" ||
713
+ /^word\/theme\/theme\d+\.xml$/.test(name) ||
714
+ /^word\/(?:header|footer)\d+\.xml$/.test(name) ||
715
+ /^word\/_rels\/(?:document|header\d+|footer\d+|footnotes|endnotes|comments|fontTable)\.xml\.rels$/.test(
716
+ name,
717
+ );
718
+ if (supported) {
719
+ if (isSyntaxOnlyXmlPart(name)) opaqueParts.add(name);
720
+ continue;
721
+ }
722
+ if (isSafeOpaquePartName(name)) {
723
+ opaqueParts.add(name);
724
+ continue;
725
+ }
726
+ const active =
727
+ /(?:^|\/)(?:vbaProject|macros?|activeX|embeddings|oleObject|customUI|webExtensions?|signatures?|drm|encryptedPackage)(?:\/|\.|$)/i.test(
728
+ name,
729
+ );
730
+ const media = /(?:^|\/)media(?:\/|$)|\.(?:png|jpe?g|gif|webp|svg|emf|wmf|tiff?|bmp|bin)$/i.test(
731
+ name,
732
+ );
733
+ throw new DocxImportError(
734
+ "unsupported_feature",
735
+ active
736
+ ? "Active, executable, embedded, encrypted, or signed DOCX content is unsupported"
737
+ : media
738
+ ? "DOCX media is not represented by the editable document model"
739
+ : "DOCX part is neither represented nor in the inert opaque-part allowlist",
740
+ name,
741
+ );
742
+ }
743
+ return opaqueParts;
744
+ }
745
+
746
+ function isSafeOpaquePartName(partName: string): boolean {
747
+ return (
748
+ /^customXml\/item\d+\.xml$/.test(partName) ||
749
+ /^customXml\/itemProps\d+\.xml$/.test(partName) ||
750
+ /^customXml\/_rels\/item\d+\.xml\.rels$/.test(partName) ||
751
+ partName === "word/glossary/document.xml" ||
752
+ partName === "word/glossary/_rels/document.xml.rels"
753
+ );
754
+ }
755
+
756
+ function validatePackageParts(parts: ReadonlySet<string>): void {
757
+ for (const required of ["[Content_Types].xml", "_rels/.rels", "word/document.xml"]) {
758
+ if (!parts.has(required))
759
+ throw new DocxImportError("invalid_package", "Required DOCX part is missing", required);
760
+ }
761
+ }
762
+
763
+ function isSyntaxOnlyXmlPart(partName: string): boolean {
764
+ return (
765
+ /^docProps\/(?:core|app|custom)\.xml$/.test(partName) ||
766
+ partName === "word/webSettings.xml" ||
767
+ partName === "word/fontTable.xml" ||
768
+ /^word\/theme\/theme\d+\.xml$/.test(partName)
769
+ );
770
+ }
771
+
772
+ function parseXmlPart(bytes: Uint8Array, partName: string, budget: ImportBudget): XmlElement {
773
+ if (bytes.byteLength > budget.limits.maxXmlBytes)
774
+ throw limitError("XML part exceeds maxXmlBytes", partName);
775
+ budget.xmlBytes += bytes.byteLength;
776
+ if (budget.xmlBytes > budget.limits.maxTotalXmlBytes)
777
+ throw limitError("DOCX exceeds maxTotalXmlBytes", partName);
778
+ const xml = decodeUtf8(bytes, "XML", partName);
779
+ if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(xml)) {
780
+ throw new DocxImportError(
781
+ "invalid_xml",
782
+ "DOCTYPE and entity declarations are forbidden",
783
+ partName,
784
+ );
785
+ }
786
+ const parsed = parseXml(
787
+ xml,
788
+ partName,
789
+ budget.limits.maxXmlDepth,
790
+ budget.limits.maxXmlNodes - budget.xmlNodes,
791
+ budget.limits.maxRetainedXmlCharacters - budget.xmlRetainedCharacters,
792
+ budget.limits.maxXmlAttributesPerElement,
793
+ partName.endsWith(".rels") ? budget.limits.maxRelationshipsPerPart : undefined,
794
+ );
795
+ validateNamespaceWellFormedness(parsed.root, partName);
796
+ budget.xmlNodes += parsed.nodeCount;
797
+ if (budget.xmlNodes > budget.limits.maxXmlNodes)
798
+ throw limitError("DOCX exceeds maxXmlNodes", partName);
799
+ budget.xmlRetainedCharacters += parsed.retainedCharacters;
800
+ if (budget.xmlRetainedCharacters > budget.limits.maxRetainedXmlCharacters) {
801
+ throw limitError("DOCX exceeds maxRetainedXmlCharacters", partName);
802
+ }
803
+ return parsed.root;
804
+ }
805
+
806
+ function parseXml(
807
+ xml: string,
808
+ partName: string,
809
+ maxDepth: number,
810
+ remainingNodes: number,
811
+ remainingRetainedCharacters: number,
812
+ maxAttributesPerElement: number,
813
+ maxRootElementChildren: number | undefined,
814
+ ): { root: XmlElement; nodeCount: number; retainedCharacters: number } {
815
+ const stack: XmlElement[] = [];
816
+ let root: XmlElement | undefined;
817
+ let cursor = 0;
818
+ let nodeCount = 0;
819
+ let retainedCharacters = 0;
820
+ let sawXmlDeclaration = false;
821
+ let rootElementChildren = 0;
822
+ const addNode = (node: XmlNode): void => {
823
+ nodeCount += 1;
824
+ if (nodeCount > remainingNodes) throw limitError("DOCX exceeds maxXmlNodes", partName);
825
+ retainedCharacters += retainedXmlCharacters(node);
826
+ if (retainedCharacters > remainingRetainedCharacters) {
827
+ throw limitError("DOCX exceeds maxRetainedXmlCharacters", partName);
828
+ }
829
+ const parent = stack.at(-1);
830
+ if (parent) {
831
+ if (parent === root && node.type === "element" && maxRootElementChildren !== undefined) {
832
+ if (rootElementChildren >= maxRootElementChildren) {
833
+ throw limitError("Relationships part exceeds maxRelationshipsPerPart", partName);
834
+ }
835
+ rootElementChildren += 1;
836
+ }
837
+ parent.children.push(node);
838
+ } else if (node.type === "text" && isOnlyXmlWhitespace(node.value)) return;
839
+ else if (node.type === "element" && !root) root = node;
840
+ else
841
+ throw new DocxImportError(
842
+ "invalid_xml",
843
+ "XML has multiple roots or text outside the root",
844
+ partName,
845
+ );
846
+ };
847
+ while (cursor < xml.length) {
848
+ const opening = xml.indexOf("<", cursor);
849
+ if (opening < 0) {
850
+ const tail = xml.slice(cursor);
851
+ if (tail.length > 0) addNode({ type: "text", value: decodeXmlEntities(tail, partName) });
852
+ cursor = xml.length;
853
+ break;
854
+ }
855
+ if (opening > cursor)
856
+ addNode({ type: "text", value: decodeXmlEntities(xml.slice(cursor, opening), partName) });
857
+ if (xml.startsWith("<!--", opening)) {
858
+ const end = xml.indexOf("-->", opening + 4);
859
+ const content = end < 0 ? "" : xml.slice(opening + 4, end);
860
+ if (end < 0 || content.includes("--") || content.endsWith("-"))
861
+ throw new DocxImportError("invalid_xml", "Malformed XML comment", partName);
862
+ validateXmlCharacters(content, partName);
863
+ cursor = end + 3;
864
+ continue;
865
+ }
866
+ if (xml.startsWith("<?", opening)) {
867
+ const end = xml.indexOf("?>", opening + 2);
868
+ if (end < 0)
869
+ throw new DocxImportError("invalid_xml", "Malformed XML processing instruction", partName);
870
+ const instruction = xml.slice(opening + 2, end);
871
+ const validDeclaration =
872
+ /^xml[\x20\t\r\n]+version=(?:"1\.[01]"|'1\.[01]')(?:[\x20\t\r\n]+encoding=(?:"[Uu][Tt][Ff]-8"|'[Uu][Tt][Ff]-8'))?(?:[\x20\t\r\n]+standalone=(?:"(?:yes|no)"|'(?:yes|no)'))?[\x20\t\r\n]*$/.test(
873
+ instruction,
874
+ );
875
+ if (!validDeclaration || opening !== 0 || root || stack.length > 0 || sawXmlDeclaration) {
876
+ throw new DocxImportError(
877
+ "invalid_xml",
878
+ "XML processing instructions are unsupported",
879
+ partName,
880
+ );
881
+ }
882
+ sawXmlDeclaration = true;
883
+ cursor = end + 2;
884
+ continue;
885
+ }
886
+ if (xml.startsWith("<!", opening))
887
+ throw new DocxImportError(
888
+ "invalid_xml",
889
+ "XML declarations and CDATA are unsupported",
890
+ partName,
891
+ );
892
+ const end = findXmlTagEnd(xml, opening + 1, partName);
893
+ const source = xml.slice(opening + 1, end);
894
+ if (source.startsWith("/")) {
895
+ const closing = /^\/([^\x20\t\r\n]+)[\x20\t\r\n]*$/.exec(source);
896
+ const name = closing?.[1];
897
+ if (!name || !XML_QNAME.test(name))
898
+ throw new DocxImportError("invalid_xml", "Malformed XML closing tag", partName);
899
+ const current = stack.pop();
900
+ if (!current || current.name !== name)
901
+ throw new DocxImportError("invalid_xml", "Mismatched XML closing tag", partName);
902
+ } else {
903
+ if (isXmlSpace(source[0]))
904
+ throw new DocxImportError("invalid_xml", "XML start tag has leading whitespace", partName);
905
+ const selfClosing = /\/[\x20\t\r\n]*$/.test(source);
906
+ const element = parseXmlStartTag(
907
+ selfClosing ? source.replace(/\/[\x20\t\r\n]*$/, "") : source,
908
+ partName,
909
+ maxAttributesPerElement,
910
+ );
911
+ addNode(element);
912
+ if (!selfClosing) {
913
+ stack.push(element);
914
+ if (stack.length > maxDepth) throw limitError("XML exceeds maxXmlDepth", partName);
915
+ }
916
+ }
917
+ cursor = end + 1;
918
+ }
919
+ if (stack.length > 0)
920
+ throw new DocxImportError("invalid_xml", "XML document ended with unclosed elements", partName);
921
+ if (!root) throw new DocxImportError("invalid_xml", "XML root element is missing", partName);
922
+ return { root, nodeCount, retainedCharacters };
923
+ }
924
+
925
+ function retainedXmlCharacters(node: XmlNode): number {
926
+ if (node.type === "text") return node.value.length;
927
+ let characters = node.name.length + node.localName.length;
928
+ for (const [name, value] of node.attributes) characters += name.length + value.length;
929
+ return characters;
930
+ }
931
+
932
+ function findXmlTagEnd(xml: string, start: number, partName: string): number {
933
+ let quote: string | undefined;
934
+ for (let index = start; index < xml.length; index += 1) {
935
+ const character = xml[index];
936
+ if (quote) {
937
+ if (character === quote) quote = undefined;
938
+ } else if (character === '"' || character === "'") quote = character;
939
+ else if (character === ">") return index;
940
+ }
941
+ throw new DocxImportError("invalid_xml", "Unterminated XML tag", partName);
942
+ }
943
+
944
+ function parseXmlStartTag(source: string, partName: string, maxAttributes: number): XmlElement {
945
+ let cursor = 0;
946
+ const skipSpace = (): void => {
947
+ while (cursor < source.length && isXmlSpace(source[cursor])) cursor += 1;
948
+ };
949
+ skipSpace();
950
+ const nameStart = cursor;
951
+ while (cursor < source.length && !isXmlSpace(source[cursor])) cursor += 1;
952
+ const name = source.slice(nameStart, cursor);
953
+ if (!XML_QNAME.test(name))
954
+ throw new DocxImportError("invalid_xml", "Invalid XML element name", partName);
955
+ const attributes = new Map<string, string>();
956
+ const localAttributeNames = new Set<string>();
957
+ while (cursor < source.length) {
958
+ if (!isXmlSpace(source[cursor]))
959
+ throw new DocxImportError(
960
+ "invalid_xml",
961
+ "XML attributes must be separated by whitespace",
962
+ partName,
963
+ );
964
+ skipSpace();
965
+ if (cursor >= source.length) break;
966
+ const attributeStart = cursor;
967
+ while (cursor < source.length && !isXmlSpace(source[cursor]) && source[cursor] !== "=")
968
+ cursor += 1;
969
+ const attributeName = source.slice(attributeStart, cursor);
970
+ if (!XML_QNAME.test(attributeName))
971
+ throw new DocxImportError("invalid_xml", "Invalid XML attribute name", partName);
972
+ skipSpace();
973
+ if (source[cursor] !== "=")
974
+ throw new DocxImportError("invalid_xml", "XML attribute lacks a value", partName);
975
+ cursor += 1;
976
+ skipSpace();
977
+ const quote = source[cursor];
978
+ if (quote !== '"' && quote !== "'")
979
+ throw new DocxImportError("invalid_xml", "XML attribute value must be quoted", partName);
980
+ cursor += 1;
981
+ const valueStart = cursor;
982
+ const valueEnd = source.indexOf(quote, valueStart);
983
+ if (valueEnd < 0)
984
+ throw new DocxImportError("invalid_xml", "Unterminated XML attribute value", partName);
985
+ if (attributes.has(attributeName))
986
+ throw new DocxImportError("invalid_xml", "Duplicate XML attribute", partName);
987
+ if (attributes.size >= maxAttributes)
988
+ throw limitError("XML element exceeds maxXmlAttributesPerElement", partName);
989
+ const attributeLocalName = localName(attributeName);
990
+ const namespaceDeclaration = attributeName === "xmlns" || attributeName.startsWith("xmlns:");
991
+ if (!namespaceDeclaration && localAttributeNames.has(attributeLocalName)) {
992
+ throw new DocxImportError(
993
+ "invalid_xml",
994
+ "Ambiguous namespace-local XML attributes",
995
+ partName,
996
+ );
997
+ }
998
+ localAttributeNames.add(attributeLocalName);
999
+ const rawValue = source.slice(valueStart, valueEnd);
1000
+ if (rawValue.includes("<"))
1001
+ throw new DocxImportError(
1002
+ "invalid_xml",
1003
+ "XML attribute contains an unescaped less-than sign",
1004
+ partName,
1005
+ );
1006
+ attributes.set(attributeName, decodeXmlEntities(rawValue, partName));
1007
+ cursor = valueEnd + 1;
1008
+ }
1009
+ return { type: "element", name, localName: localName(name), attributes, children: [] };
1010
+ }
1011
+
1012
+ // Deliberately ASCII: OOXML uses ASCII QNames, and accepting the full XML
1013
+ // Unicode Name production would add a large parser surface for no fidelity.
1014
+ // A QName has at most one colon and non-empty NCName components.
1015
+ const XML_QNAME = /^(?:[A-Za-z_][A-Za-z0-9_.-]*:)?[A-Za-z_][A-Za-z0-9_.-]*$/;
1016
+
1017
+ function isXmlSpace(value: string | undefined): boolean {
1018
+ return value === " " || value === "\t" || value === "\r" || value === "\n";
1019
+ }
1020
+
1021
+ function isOnlyXmlWhitespace(value: string): boolean {
1022
+ for (let index = 0; index < value.length; index += 1) if (!isXmlSpace(value[index])) return false;
1023
+ return true;
1024
+ }
1025
+
1026
+ function decodeXmlEntities(value: string, partName: string): string {
1027
+ validateXmlCharacters(value, partName);
1028
+ if (value.includes("]]>"))
1029
+ throw new DocxImportError("invalid_xml", "XML contains malformed character data", partName);
1030
+ let ampersand = value.indexOf("&");
1031
+ if (ampersand < 0) return value;
1032
+ const output: string[] = [];
1033
+ let cursor = 0;
1034
+ let referenceCount = 0;
1035
+ while (ampersand >= 0) {
1036
+ referenceCount += 1;
1037
+ if (referenceCount > 65_536)
1038
+ throw limitError("XML text node exceeds the entity-reference safety cap", partName);
1039
+ output.push(value.slice(cursor, ampersand));
1040
+ const semicolon = value.indexOf(";", ampersand + 1);
1041
+ const nestedAmpersand = value.indexOf("&", ampersand + 1);
1042
+ if (semicolon < 0 || (nestedAmpersand >= 0 && nestedAmpersand < semicolon)) {
1043
+ throw new DocxImportError(
1044
+ "invalid_xml",
1045
+ "XML contains an unterminated entity reference",
1046
+ partName,
1047
+ );
1048
+ }
1049
+ const entity = value.slice(ampersand + 1, semicolon);
1050
+ if (entity.length === 0 || entity.length > 16) {
1051
+ throw new DocxImportError(
1052
+ "invalid_xml",
1053
+ "XML contains a malformed entity reference",
1054
+ partName,
1055
+ );
1056
+ }
1057
+ output.push(decodeXmlEntity(entity, partName));
1058
+ cursor = semicolon + 1;
1059
+ ampersand = value.indexOf("&", cursor);
1060
+ }
1061
+ output.push(value.slice(cursor));
1062
+ return output.join("");
1063
+ }
1064
+
1065
+ function decodeXmlEntity(entity: string, partName: string): string {
1066
+ if (entity === "amp") return "&";
1067
+ if (entity === "lt") return "<";
1068
+ if (entity === "gt") return ">";
1069
+ if (entity === "quot") return '"';
1070
+ if (entity === "apos") return "'";
1071
+ const decimal = /^#([0-9]+)$/.exec(entity);
1072
+ const hex = /^#x([0-9a-f]+)$/i.exec(entity);
1073
+ const codePoint = decimal ? Number(decimal[1]) : hex ? Number.parseInt(hex[1] ?? "", 16) : NaN;
1074
+ if (!Number.isInteger(codePoint) || !isValidXmlCodePoint(codePoint)) {
1075
+ throw new DocxImportError(
1076
+ "invalid_xml",
1077
+ `Unsupported or invalid XML entity: &${entity};`,
1078
+ partName,
1079
+ );
1080
+ }
1081
+ return String.fromCodePoint(codePoint);
1082
+ }
1083
+
1084
+ function validateXmlCharacters(value: string, partName: string): void {
1085
+ for (const character of value) {
1086
+ const codePoint = character.codePointAt(0) ?? 0;
1087
+ if (!isValidXmlCodePoint(codePoint)) {
1088
+ throw new DocxImportError("invalid_xml", "XML contains a forbidden character", partName);
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ function isValidXmlCodePoint(codePoint: number): boolean {
1094
+ return (
1095
+ codePoint === 0x09 ||
1096
+ codePoint === 0x0a ||
1097
+ codePoint === 0x0d ||
1098
+ (codePoint >= 0x20 && codePoint <= 0xd7ff) ||
1099
+ (codePoint >= 0xe000 && codePoint <= 0xfffd) ||
1100
+ (codePoint >= 0x10000 && codePoint <= 0x10ffff)
1101
+ );
1102
+ }
1103
+
1104
+ function validateContentTypes(root: XmlElement, parts: ReadonlySet<string>): Map<string, string> {
1105
+ requireLocalName(root, "Types", "[Content_Types].xml");
1106
+ requireDefaultNamespaceTree(root, CONTENT_TYPES_NAMESPACE, "[Content_Types].xml");
1107
+ const defaults = new Map<string, string>();
1108
+ const overrides = new Map<string, string>();
1109
+ for (const entry of elementChildren(root)) {
1110
+ if ((entry.name !== "Default" && entry.name !== "Override") || entry.localName !== entry.name) {
1111
+ throw new DocxImportError(
1112
+ "invalid_package",
1113
+ "Unknown content-type declaration",
1114
+ "[Content_Types].xml",
1115
+ );
1116
+ }
1117
+ assertExactAttributes(
1118
+ entry,
1119
+ entry.name === "Default"
1120
+ ? new Set(["Extension", "ContentType"])
1121
+ : new Set(["PartName", "ContentType"]),
1122
+ "[Content_Types].xml",
1123
+ );
1124
+ requireLeafElement(entry, "[Content_Types].xml");
1125
+ const contentType = exactAttribute(entry, "ContentType") ?? "";
1126
+ if (contentType.length === 0)
1127
+ throw new DocxImportError(
1128
+ "invalid_package",
1129
+ "Content-type declaration lacks ContentType",
1130
+ "[Content_Types].xml",
1131
+ );
1132
+ // A Default image MIME declaration is emitted even by some generators
1133
+ // that contain no image part. Actual binary/media parts are rejected by
1134
+ // validatePartNames; declarations alone carry no discarded content.
1135
+ if (
1136
+ /(?:macroEnabled|vbaProject|activeX|oleObject|encryptedPackage|digital-signature|customUI|webExtension)/i.test(
1137
+ contentType,
1138
+ )
1139
+ ) {
1140
+ throw new DocxImportError(
1141
+ "unsupported_feature",
1142
+ `Unsupported DOCX content type: ${contentType}`,
1143
+ "[Content_Types].xml",
1144
+ );
1145
+ }
1146
+ if (entry.localName === "Default") {
1147
+ const extension = requiredExactAttribute(
1148
+ entry,
1149
+ "Extension",
1150
+ "[Content_Types].xml",
1151
+ ).toLowerCase();
1152
+ if (!/^[a-z0-9]+$/.test(extension) || defaults.has(extension)) {
1153
+ throw new DocxImportError(
1154
+ "invalid_package",
1155
+ "Invalid or duplicate default content type",
1156
+ "[Content_Types].xml",
1157
+ );
1158
+ }
1159
+ defaults.set(extension, contentType);
1160
+ } else {
1161
+ const declared = requiredExactAttribute(entry, "PartName", "[Content_Types].xml");
1162
+ if (declared.length > 1_025)
1163
+ throw limitError("Override PartName exceeds its safety cap", "[Content_Types].xml");
1164
+ if (!declared.startsWith("/") || declared.includes("\\") || /%(?:2e|2f|5c)/i.test(declared)) {
1165
+ throw new DocxImportError(
1166
+ "invalid_package",
1167
+ "Unsafe override PartName",
1168
+ "[Content_Types].xml",
1169
+ );
1170
+ }
1171
+ const partName = declared.slice(1);
1172
+ let segmentCount = 1;
1173
+ for (let index = 0; index < partName.length; index += 1) {
1174
+ if (partName.charCodeAt(index) === 47 && ++segmentCount > 256) {
1175
+ throw limitError(
1176
+ "Override PartName exceeds its path-segment safety cap",
1177
+ "[Content_Types].xml",
1178
+ );
1179
+ }
1180
+ }
1181
+ const segments = partName.split("/");
1182
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
1183
+ throw new DocxImportError(
1184
+ "invalid_package",
1185
+ "Unsafe override PartName",
1186
+ "[Content_Types].xml",
1187
+ );
1188
+ }
1189
+ const key = partName.toLowerCase();
1190
+ if (overrides.has(key))
1191
+ throw new DocxImportError(
1192
+ "invalid_package",
1193
+ "Duplicate override content type",
1194
+ "[Content_Types].xml",
1195
+ );
1196
+ if (!parts.has(partName))
1197
+ throw new DocxImportError(
1198
+ "invalid_package",
1199
+ "Content-type override targets a missing part",
1200
+ partName,
1201
+ );
1202
+ overrides.set(key, contentType);
1203
+ }
1204
+ }
1205
+ const contentTypeByPart = new Map<string, string>();
1206
+ for (const partName of parts.keys()) {
1207
+ if (partName === "[Content_Types].xml") continue;
1208
+ const extensionIndex = partName.lastIndexOf(".");
1209
+ const extension = extensionIndex < 0 ? "" : partName.slice(extensionIndex + 1).toLowerCase();
1210
+ const contentType = overrides.get(partName.toLowerCase()) ?? defaults.get(extension);
1211
+ if (!contentType) {
1212
+ throw new DocxImportError(
1213
+ "invalid_package",
1214
+ "Package part has no declared content type",
1215
+ partName,
1216
+ );
1217
+ }
1218
+ contentTypeByPart.set(partName, contentType);
1219
+ }
1220
+ const documentType = overrides.get("word/document.xml");
1221
+ if (
1222
+ documentType !==
1223
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
1224
+ ) {
1225
+ throw new DocxImportError(
1226
+ "invalid_package",
1227
+ "word/document.xml has the wrong content type",
1228
+ "[Content_Types].xml",
1229
+ );
1230
+ }
1231
+ return contentTypeByPart;
1232
+ }
1233
+
1234
+ function isSafeOpaqueContentType(partName: string, contentType: string): boolean {
1235
+ if (
1236
+ /(?:macroEnabled|vbaProject|activeX|oleObject|encryptedPackage|digital-signature|customUI|webExtension)/i.test(
1237
+ contentType,
1238
+ )
1239
+ ) {
1240
+ return false;
1241
+ }
1242
+ if (partName.endsWith(".rels")) {
1243
+ return contentType === "application/vnd.openxmlformats-package.relationships+xml";
1244
+ }
1245
+ return (
1246
+ /^(?:application|text)\/xml$/i.test(contentType) ||
1247
+ /^(?:application|text)\/[A-Za-z0-9!#$&^_.+-]+\+xml$/i.test(contentType)
1248
+ );
1249
+ }
1250
+
1251
+ function parseRelationships(
1252
+ root: XmlElement,
1253
+ relationshipsPart: string,
1254
+ maxRelationships: number,
1255
+ ): Relationship[] {
1256
+ requireLocalName(root, "Relationships", relationshipsPart);
1257
+ requireDefaultNamespaceTree(root, RELATIONSHIPS_NAMESPACE, relationshipsPart);
1258
+ const sourcePart = relationshipSourcePart(relationshipsPart);
1259
+ const relationships: Relationship[] = [];
1260
+ const ids = new Set<string>();
1261
+ for (const child of elementChildren(root)) {
1262
+ if (relationships.length >= maxRelationships)
1263
+ throw limitError("Relationships part exceeds maxRelationshipsPerPart", relationshipsPart);
1264
+ if (child.name !== "Relationship")
1265
+ throw new DocxImportError(
1266
+ "invalid_package",
1267
+ "Unknown relationship element",
1268
+ relationshipsPart,
1269
+ );
1270
+ requireLeafElement(child, relationshipsPart);
1271
+ assertExactAttributes(
1272
+ child,
1273
+ new Set(["Id", "Type", "Target", "TargetMode"]),
1274
+ relationshipsPart,
1275
+ );
1276
+ const id = requiredExactAttribute(child, "Id", relationshipsPart);
1277
+ const type = requiredExactAttribute(child, "Type", relationshipsPart);
1278
+ const target = requiredExactAttribute(child, "Target", relationshipsPart);
1279
+ if (id.length > 512 || type.length > 2_048 || target.length > 4_096) {
1280
+ throw limitError(
1281
+ "Relationship Id, Type, or Target exceeds its safety cap",
1282
+ relationshipsPart,
1283
+ );
1284
+ }
1285
+ if (ids.has(id))
1286
+ throw new DocxImportError("invalid_package", "Duplicate relationship id", relationshipsPart);
1287
+ ids.add(id);
1288
+ if ((exactAttribute(child, "TargetMode") ?? "Internal") !== "Internal") {
1289
+ throw new DocxImportError(
1290
+ "unsupported_feature",
1291
+ "External DOCX relationships are unsupported",
1292
+ relationshipsPart,
1293
+ );
1294
+ }
1295
+ relationships.push({
1296
+ id,
1297
+ type,
1298
+ target,
1299
+ sourcePart,
1300
+ partName: resolvePartTarget(sourcePart, target, relationshipsPart),
1301
+ });
1302
+ }
1303
+ return relationships;
1304
+ }
1305
+
1306
+ function validateRelationships(
1307
+ relationships: readonly Relationship[],
1308
+ opaqueParts: ReadonlySet<string>,
1309
+ ): Relationship[] {
1310
+ const allowed = new Set([
1311
+ `${WORD_RELATIONSHIP_BASE}officeDocument`,
1312
+ `${WORD_RELATIONSHIP_BASE}styles`,
1313
+ `${WORD_RELATIONSHIP_BASE}settings`,
1314
+ `${WORD_RELATIONSHIP_BASE}webSettings`,
1315
+ `${WORD_RELATIONSHIP_BASE}theme`,
1316
+ `${WORD_RELATIONSHIP_BASE}fontTable`,
1317
+ `${WORD_RELATIONSHIP_BASE}numbering`,
1318
+ `${WORD_RELATIONSHIP_BASE}header`,
1319
+ `${WORD_RELATIONSHIP_BASE}footer`,
1320
+ `${WORD_RELATIONSHIP_BASE}comments`,
1321
+ "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
1322
+ `${WORD_RELATIONSHIP_BASE}footnotes`,
1323
+ `${WORD_RELATIONSHIP_BASE}endnotes`,
1324
+ `${PACKAGE_RELATIONSHIP_BASE}core-properties`,
1325
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
1326
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
1327
+ ]);
1328
+ const opaqueRelationships: Relationship[] = [];
1329
+ for (const relationship of relationships) {
1330
+ if (allowed.has(relationship.type)) {
1331
+ validateRelationshipRoute(relationship);
1332
+ if (opaqueParts.has(relationship.partName)) opaqueRelationships.push(relationship);
1333
+ continue;
1334
+ }
1335
+ if (!isSafeOpaqueRelationship(relationship) || !opaqueParts.has(relationship.partName)) {
1336
+ throw new DocxImportError(
1337
+ "unsupported_feature",
1338
+ `Unsupported DOCX relationship: ${relationship.type}`,
1339
+ relationship.partName,
1340
+ );
1341
+ }
1342
+ opaqueRelationships.push(relationship);
1343
+ }
1344
+ const singletonTypes = new Set(
1345
+ [
1346
+ "styles",
1347
+ "settings",
1348
+ "webSettings",
1349
+ "theme",
1350
+ "fontTable",
1351
+ "numbering",
1352
+ "comments",
1353
+ "footnotes",
1354
+ "endnotes",
1355
+ ]
1356
+ .map((suffix) => `${WORD_RELATIONSHIP_BASE}${suffix}`)
1357
+ .concat(["http://schemas.microsoft.com/office/2011/relationships/commentsExtended"]),
1358
+ );
1359
+ const seenSingletons = new Set<string>();
1360
+ for (const relationship of relationships) {
1361
+ if (relationship.sourcePart !== "word/document.xml" || !singletonTypes.has(relationship.type))
1362
+ continue;
1363
+ if (seenSingletons.has(relationship.type)) {
1364
+ throw new DocxImportError(
1365
+ "invalid_package",
1366
+ "Document contains duplicate singleton relationships",
1367
+ relationship.partName,
1368
+ );
1369
+ }
1370
+ seenSingletons.add(relationship.type);
1371
+ }
1372
+ return opaqueRelationships;
1373
+ }
1374
+
1375
+ function isSafeOpaqueRelationship(relationship: Relationship): boolean {
1376
+ const { sourcePart, type, partName } = relationship;
1377
+ if (type === `${WORD_RELATIONSHIP_BASE}customXml`) {
1378
+ return sourcePart === "word/document.xml" && /^customXml\/item\d+\.xml$/.test(partName);
1379
+ }
1380
+ if (type === `${WORD_RELATIONSHIP_BASE}customXmlProps`) {
1381
+ return (
1382
+ /^customXml\/item\d+\.xml$/.test(sourcePart) &&
1383
+ /^customXml\/itemProps\d+\.xml$/.test(partName)
1384
+ );
1385
+ }
1386
+ if (type === `${WORD_RELATIONSHIP_BASE}glossaryDocument`) {
1387
+ return sourcePart === "word/document.xml" && partName === "word/glossary/document.xml";
1388
+ }
1389
+ return false;
1390
+ }
1391
+
1392
+ function validateRelationshipRoute(relationship: Relationship): void {
1393
+ const { sourcePart, type, partName } = relationship;
1394
+ if (sourcePart === "") {
1395
+ const expected = new Map<string, string>([
1396
+ [`${WORD_RELATIONSHIP_BASE}officeDocument`, "word/document.xml"],
1397
+ [`${PACKAGE_RELATIONSHIP_BASE}core-properties`, "docProps/core.xml"],
1398
+ [
1399
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties",
1400
+ "docProps/app.xml",
1401
+ ],
1402
+ [
1403
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties",
1404
+ "docProps/custom.xml",
1405
+ ],
1406
+ ]).get(type);
1407
+ if (!expected || partName !== expected) {
1408
+ throw new DocxImportError(
1409
+ "invalid_package",
1410
+ "Package-root relationship has an invalid type or target",
1411
+ partName,
1412
+ );
1413
+ }
1414
+ return;
1415
+ }
1416
+ if (sourcePart !== "word/document.xml") {
1417
+ // For the currently supported fidelity subset, generator-created story,
1418
+ // comment, note, and font relationship parts are empty. Any auxiliary
1419
+ // relationship necessarily points at content the DTO cannot represent.
1420
+ throw new DocxImportError(
1421
+ "unsupported_feature",
1422
+ "Relationships from auxiliary Word parts are not yet supported",
1423
+ sourcePart,
1424
+ );
1425
+ }
1426
+ const fixedTargets = new Map<string, string>([
1427
+ [`${WORD_RELATIONSHIP_BASE}styles`, "word/styles.xml"],
1428
+ [`${WORD_RELATIONSHIP_BASE}settings`, "word/settings.xml"],
1429
+ [`${WORD_RELATIONSHIP_BASE}webSettings`, "word/webSettings.xml"],
1430
+ [`${WORD_RELATIONSHIP_BASE}fontTable`, "word/fontTable.xml"],
1431
+ [`${WORD_RELATIONSHIP_BASE}numbering`, "word/numbering.xml"],
1432
+ [`${WORD_RELATIONSHIP_BASE}comments`, "word/comments.xml"],
1433
+ [`${WORD_RELATIONSHIP_BASE}footnotes`, "word/footnotes.xml"],
1434
+ [`${WORD_RELATIONSHIP_BASE}endnotes`, "word/endnotes.xml"],
1435
+ [
1436
+ "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
1437
+ "word/commentsExtended.xml",
1438
+ ],
1439
+ ]);
1440
+ const fixed = fixedTargets.get(type);
1441
+ const valid =
1442
+ fixed !== undefined
1443
+ ? partName === fixed
1444
+ : type === `${WORD_RELATIONSHIP_BASE}theme`
1445
+ ? /^word\/theme\/theme\d+\.xml$/.test(partName)
1446
+ : type === `${WORD_RELATIONSHIP_BASE}header`
1447
+ ? /^word\/header\d+\.xml$/.test(partName)
1448
+ : type === `${WORD_RELATIONSHIP_BASE}footer` && /^word\/footer\d+\.xml$/.test(partName);
1449
+ if (!valid)
1450
+ throw new DocxImportError(
1451
+ "invalid_package",
1452
+ "Document relationship has an invalid type or target",
1453
+ partName,
1454
+ );
1455
+ }
1456
+
1457
+ function validateRelationshipCoverage(
1458
+ parts: ReadonlySet<string>,
1459
+ relationships: readonly Relationship[],
1460
+ ): void {
1461
+ const targets = new Set(relationships.map((relationship) => relationship.partName));
1462
+ for (const partName of parts.keys()) {
1463
+ if (partName === "[Content_Types].xml" || partName.endsWith(".rels")) continue;
1464
+ if (!targets.has(partName))
1465
+ throw new DocxImportError(
1466
+ "invalid_package",
1467
+ "Package contains an orphaned content part",
1468
+ partName,
1469
+ );
1470
+ }
1471
+ for (const partName of parts.keys()) {
1472
+ if (!partName.endsWith(".rels") || partName === "_rels/.rels") continue;
1473
+ const sourcePart = relationshipSourcePart(partName);
1474
+ if (!parts.has(sourcePart))
1475
+ throw new DocxImportError(
1476
+ "invalid_package",
1477
+ "Relationships part has no source part",
1478
+ partName,
1479
+ );
1480
+ }
1481
+ }
1482
+
1483
+ function validateEmptyNotePart(
1484
+ root: XmlElement,
1485
+ itemName: "footnote" | "endnote",
1486
+ partName: string,
1487
+ ): void {
1488
+ requireLocalName(root, `${itemName}s`, partName);
1489
+ requireWordNamespace(root, partName);
1490
+ for (const item of elementChildren(root)) {
1491
+ if (item.localName !== itemName)
1492
+ throw new DocxImportError("invalid_package", `Unknown ${itemName} element`, partName);
1493
+ const id = Number(requiredAttribute(item, "id", partName));
1494
+ const type = attribute(item, "type");
1495
+ // Word generators commonly include separator and continuation-separator
1496
+ // records (the latter is often id 0). Their explicit type makes them
1497
+ // pagination metadata; any ordinary note must not be silently dropped.
1498
+ if (!Number.isInteger(id) || (type !== "separator" && type !== "continuationSeparator")) {
1499
+ throw new DocxImportError(
1500
+ "unsupported_feature",
1501
+ `DOCX ${itemName}s are not yet supported`,
1502
+ partName,
1503
+ );
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ function validateSettings(root: XmlElement): {
1509
+ evenAndOddHeaders: boolean;
1510
+ trackRevisions: boolean;
1511
+ } {
1512
+ requireLocalName(root, "settings", "word/settings.xml");
1513
+ requireWordNamespace(root, "word/settings.xml");
1514
+ assertAllowedContentChildren(
1515
+ root,
1516
+ new Set([
1517
+ "displayBackgroundShape",
1518
+ "evenAndOddHeaders",
1519
+ "trackRevisions",
1520
+ "compat",
1521
+ "documentProtection",
1522
+ "writeProtection",
1523
+ ]),
1524
+ "document settings",
1525
+ );
1526
+ for (const protectedFeature of ["documentProtection", "writeProtection"] as const) {
1527
+ if (firstChild(root, protectedFeature)) {
1528
+ throw new DocxImportError(
1529
+ "unsupported_feature",
1530
+ `DOCX ${protectedFeature} is not preserved by import`,
1531
+ "word/settings.xml",
1532
+ );
1533
+ }
1534
+ }
1535
+ const background = singletonChild(root, "displayBackgroundShape", "document settings");
1536
+ const evenAndOdd = singletonChild(root, "evenAndOddHeaders", "document settings");
1537
+ const trackRevisions = singletonChild(root, "trackRevisions", "document settings");
1538
+ const compat = singletonChild(root, "compat", "document settings");
1539
+ assertAllowedAttributes(background, new Set(["val"]), "display-background-shape setting");
1540
+ assertAllowedAttributes(evenAndOdd, new Set(["val"]), "even-and-odd-headers setting");
1541
+ assertAllowedAttributes(trackRevisions, new Set(["val"]), "track-revisions setting");
1542
+ assertAllowedAttributes(compat, new Set(), "compatibility settings");
1543
+ assertAllowedContentChildren(background, new Set(), "display-background-shape setting");
1544
+ assertAllowedContentChildren(evenAndOdd, new Set(), "even-and-odd-headers setting");
1545
+ assertAllowedContentChildren(trackRevisions, new Set(), "track-revisions setting");
1546
+ const tracksRevisions = booleanValue(trackRevisions, false);
1547
+ assertAllowedContentChildren(compat, new Set(["compatSetting"]), "compatibility settings");
1548
+ const seenCompat = new Set<string>();
1549
+ for (const setting of children(compat, "compatSetting")) {
1550
+ assertAllowedAttributes(setting, new Set(["name", "uri", "val"]), "compatibility setting");
1551
+ assertAllowedContentChildren(setting, new Set(), "compatibility setting");
1552
+ const name = attribute(setting, "name");
1553
+ const uri = attribute(setting, "uri");
1554
+ const value = attribute(setting, "val");
1555
+ const key = `${uri ?? ""}\u0000${name ?? ""}`;
1556
+ if (seenCompat.has(key))
1557
+ throw new DocxImportError(
1558
+ "invalid_package",
1559
+ "Duplicate compatibility setting",
1560
+ "word/settings.xml",
1561
+ );
1562
+ seenCompat.add(key);
1563
+ if (
1564
+ name !== "compatibilityMode" ||
1565
+ uri !== "http://schemas.microsoft.com/office/word" ||
1566
+ value !== "15"
1567
+ ) {
1568
+ throw new DocxImportError(
1569
+ "unsupported_feature",
1570
+ "Document compatibility setting is not represented",
1571
+ "word/settings.xml",
1572
+ );
1573
+ }
1574
+ }
1575
+ return { evenAndOddHeaders: booleanValue(evenAndOdd, false), trackRevisions: tracksRevisions };
1576
+ }
1577
+
1578
+ function parseMainDocument(
1579
+ root: XmlElement,
1580
+ context: ParseContext,
1581
+ relationships: ReadonlyMap<string, Relationship>,
1582
+ ): { blocks: ImportedBlock[]; sections: ImportedSection[] } {
1583
+ requireLocalName(root, "document", "word/document.xml");
1584
+ rejectUnsupportedContent(root, "word/document.xml");
1585
+ requireWordNamespace(root, "word/document.xml");
1586
+ const body = onlyElement(root, "body", "word/document.xml");
1587
+ const blocks: ImportedBlock[] = [];
1588
+ const sections: ImportedSection[] = [];
1589
+ let sectionStart = 0;
1590
+ const addSection = (section: ImportedSection): void => {
1591
+ if (sections.length >= context.budget.limits.maxSections) {
1592
+ throw limitError("DOCX exceeds maxSections", "word/document.xml");
1593
+ }
1594
+ sections.push(section);
1595
+ };
1596
+ for (const child of elementChildren(body)) {
1597
+ if (child.localName === "p") {
1598
+ const sectionProperties = firstChild(firstChild(child, "pPr"), "sectPr");
1599
+ if (!sectionProperties || !isPureSectionBoundaryParagraph(child)) {
1600
+ blocks.push(parseParagraph(child, context));
1601
+ }
1602
+ if (sectionProperties) {
1603
+ addSection(parseSection(sectionProperties, sectionStart, blocks.length, relationships));
1604
+ sectionStart = blocks.length;
1605
+ }
1606
+ } else if (child.localName === "tbl") blocks.push(parseTable(child, context));
1607
+ else if (child.localName === "sectPr") {
1608
+ addSection(parseSection(child, sectionStart, blocks.length, relationships));
1609
+ sectionStart = blocks.length;
1610
+ } else if (!NON_CONTENT_MARKERS.has(child.localName)) {
1611
+ throw unsupportedElement(child, "word/document.xml");
1612
+ }
1613
+ }
1614
+ if (sections.length === 0 || sectionStart < blocks.length) {
1615
+ addSection(defaultSection(sectionStart, blocks.length));
1616
+ }
1617
+ return { blocks, sections };
1618
+ }
1619
+
1620
+ function parseBlockContainer(root: XmlElement, context: ParseContext): ImportedBlock[] {
1621
+ rejectUnsupportedContent(root, context.idPrefix);
1622
+ const blocks: ImportedBlock[] = [];
1623
+ for (const child of elementChildren(root)) {
1624
+ if (child.localName === "p") blocks.push(parseParagraph(child, context));
1625
+ else if (child.localName === "tbl") blocks.push(parseTable(child, context));
1626
+ else if (!NON_CONTENT_MARKERS.has(child.localName))
1627
+ throw unsupportedElement(child, context.idPrefix);
1628
+ }
1629
+ return blocks;
1630
+ }
1631
+
1632
+ function parseParagraph(element: XmlElement, context: ParseContext): ImportedParagraph {
1633
+ consumeBlock(context.budget);
1634
+ const id = `${context.idPrefix}:p${context.budget.nextBlockId++}`;
1635
+ const properties = singletonChild(element, "pPr", "paragraph");
1636
+ const styleId = valueOf(firstChild(properties, "pStyle"));
1637
+ const paragraph: ImportedParagraph = {
1638
+ kind: "paragraph",
1639
+ id,
1640
+ ...(styleId !== undefined ? { styleId } : {}),
1641
+ style: parseParagraphProperties(properties),
1642
+ inlines: [],
1643
+ commentAnchors: [],
1644
+ };
1645
+ const firstTrackedChange = context.trackedChanges.length;
1646
+ let textOffset = 0;
1647
+ const addRun = (run: ImportedRun | ImportedPageBreak): void => {
1648
+ paragraph.inlines.push(run);
1649
+ if (run.kind === "run") {
1650
+ textOffset += run.text.length;
1651
+ consumeText(context.budget, run.text.length);
1652
+ }
1653
+ };
1654
+ const parseChildren = (parent: XmlElement, change?: ChangeMetadata): void => {
1655
+ for (const child of elementChildren(parent)) {
1656
+ if (child.localName === "pPr") continue;
1657
+ if (child.localName === "r") {
1658
+ const paragraphOffset = textOffset;
1659
+ const parsed = parseRun(child, change, (commentId, runOffset) => {
1660
+ paragraph.commentAnchors.push({
1661
+ commentId,
1662
+ kind: "reference",
1663
+ textOffset: paragraphOffset + runOffset,
1664
+ });
1665
+ });
1666
+ for (const inline of parsed) addRun(inline);
1667
+ } else if (child.localName === "ins" || child.localName === "del") {
1668
+ if (change)
1669
+ throw new DocxImportError(
1670
+ "invalid_package",
1671
+ "Tracked changes cannot be nested",
1672
+ context.idPrefix,
1673
+ );
1674
+ const changeStart = paragraph.inlines.length;
1675
+ const changeTextStart = textOffset;
1676
+ const author = attribute(child, "author");
1677
+ const createdAt = attribute(child, "date");
1678
+ const metadata: ChangeMetadata = {
1679
+ id: requiredAttribute(child, "id", context.idPrefix),
1680
+ kind: child.localName === "ins" ? "insert" : "delete",
1681
+ ...(author !== undefined ? { author } : {}),
1682
+ ...(createdAt !== undefined ? { createdAt } : {}),
1683
+ };
1684
+ parseChildren(child, metadata);
1685
+ context.trackedChanges.push({
1686
+ id: metadata.id,
1687
+ kind: metadata.kind,
1688
+ ...(metadata.author !== undefined ? { author: metadata.author } : {}),
1689
+ ...(metadata.createdAt !== undefined ? { createdAt: metadata.createdAt } : {}),
1690
+ blockId: id,
1691
+ startInlineIndex: changeStart,
1692
+ endInlineIndex: paragraph.inlines.length,
1693
+ startTextOffset: changeTextStart,
1694
+ endTextOffset: textOffset,
1695
+ });
1696
+ } else if (child.localName === "commentRangeStart" || child.localName === "commentRangeEnd") {
1697
+ paragraph.commentAnchors.push({
1698
+ commentId: requiredAttribute(child, "id", context.idPrefix),
1699
+ kind: child.localName === "commentRangeStart" ? "start" : "end",
1700
+ textOffset,
1701
+ });
1702
+ } else if (child.localName === "commentReference") {
1703
+ paragraph.commentAnchors.push({
1704
+ commentId: requiredAttribute(child, "id", context.idPrefix),
1705
+ kind: "reference",
1706
+ textOffset,
1707
+ });
1708
+ } else if (!NON_CONTENT_MARKERS.has(child.localName))
1709
+ throw unsupportedElement(child, context.idPrefix);
1710
+ }
1711
+ };
1712
+ parseChildren(element);
1713
+ validateScalarBoundaries(paragraph, context.trackedChanges, firstTrackedChange, context.idPrefix);
1714
+ return stripUndefined(paragraph);
1715
+ }
1716
+
1717
+ function isPureSectionBoundaryParagraph(element: XmlElement): boolean {
1718
+ const paragraphChildren = elementChildren(element);
1719
+ if (paragraphChildren.length !== 1 || paragraphChildren[0]?.localName !== "pPr") return false;
1720
+ const propertyChildren = elementChildren(paragraphChildren[0]);
1721
+ return propertyChildren.length === 1 && propertyChildren[0]?.localName === "sectPr";
1722
+ }
1723
+
1724
+ function validateScalarBoundaries(
1725
+ paragraph: ImportedParagraph,
1726
+ trackedChanges: readonly ImportedTrackedChange[],
1727
+ firstTrackedChange: number,
1728
+ partName: string,
1729
+ ): void {
1730
+ const chunks: string[] = [];
1731
+ for (const inline of paragraph.inlines) if (inline.kind === "run") chunks.push(inline.text);
1732
+ const text = chunks.join("");
1733
+ const validate = (offset: number, label: string): void => {
1734
+ if (!Number.isInteger(offset) || offset < 0 || offset > text.length) {
1735
+ throw new DocxImportError("invalid_package", `${label} is outside its paragraph`, partName);
1736
+ }
1737
+ const before = offset > 0 ? text.charCodeAt(offset - 1) : 0;
1738
+ const after = offset < text.length ? text.charCodeAt(offset) : 0;
1739
+ if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) {
1740
+ throw new DocxImportError(
1741
+ "invalid_package",
1742
+ `${label} splits a Unicode scalar value`,
1743
+ partName,
1744
+ );
1745
+ }
1746
+ };
1747
+ for (const anchor of paragraph.commentAnchors) validate(anchor.textOffset, "Comment anchor");
1748
+ for (let index = firstTrackedChange; index < trackedChanges.length; index += 1) {
1749
+ const change = trackedChanges[index];
1750
+ if (!change || change.blockId !== paragraph.id) continue;
1751
+ validate(change.startTextOffset, "Tracked-change start");
1752
+ validate(change.endTextOffset, "Tracked-change end");
1753
+ }
1754
+ }
1755
+
1756
+ type ChangeMetadata = {
1757
+ id: string;
1758
+ kind: "insert" | "delete";
1759
+ author?: string;
1760
+ createdAt?: string;
1761
+ };
1762
+
1763
+ function parseRun(
1764
+ element: XmlElement,
1765
+ change?: ChangeMetadata,
1766
+ onCommentReference?: (commentId: string, textOffset: number) => void,
1767
+ ): ImportedInline[] {
1768
+ const properties = singletonChild(element, "rPr", "run");
1769
+ const styleId = valueOf(firstChild(properties, "rStyle"));
1770
+ const style = parseRunProperties(properties);
1771
+ const styled = styleId !== undefined || Object.keys(style).length > 0;
1772
+ const inlines: ImportedInline[] = [];
1773
+ let text = "";
1774
+ let emittedTextLength = 0;
1775
+ let hasCommentReference = false;
1776
+ const flush = (): void => {
1777
+ if (text.length === 0) return;
1778
+ inlines.push(
1779
+ stripUndefined({
1780
+ kind: "run" as const,
1781
+ text,
1782
+ styleId,
1783
+ style,
1784
+ changeId: change?.id,
1785
+ }),
1786
+ );
1787
+ emittedTextLength += text.length;
1788
+ text = "";
1789
+ };
1790
+ for (const child of elementChildren(element)) {
1791
+ if (child.localName === "rPr") continue;
1792
+ if (child.localName === "t" || child.localName === "delText") text += textContent(child);
1793
+ else if (child.localName === "tab") text += "\t";
1794
+ else if (child.localName === "cr") text += "\n";
1795
+ else if (child.localName === "noBreakHyphen") text += "\u2011";
1796
+ else if (child.localName === "softHyphen") text += "\u00ad";
1797
+ else if (child.localName === "br") {
1798
+ assertAllowedAttributes(child, new Set(["type"]), "run break");
1799
+ assertAllowedContentChildren(child, new Set(), "run break");
1800
+ const type = attribute(child, "type") ?? "textWrapping";
1801
+ if (type === "page") {
1802
+ flush();
1803
+ inlines.push({ kind: "pageBreak" });
1804
+ } else if (type === "textWrapping") text += "\n";
1805
+ else throw new DocxImportError("unsupported_feature", `Unsupported break type: ${type}`);
1806
+ } else if (child.localName === "commentReference") {
1807
+ hasCommentReference = true;
1808
+ const commentId = requiredAttribute(child, "id", "word/document.xml");
1809
+ onCommentReference?.(commentId, emittedTextLength + text.length);
1810
+ } else if (
1811
+ child.localName !== "lastRenderedPageBreak" &&
1812
+ !NON_CONTENT_MARKERS.has(child.localName)
1813
+ ) {
1814
+ throw unsupportedElement(child, "word/document.xml");
1815
+ }
1816
+ }
1817
+ flush();
1818
+ if (emittedTextLength === 0 && styled && (hasCommentReference || inlines.length > 0)) {
1819
+ throw new DocxImportError(
1820
+ "unsupported_feature",
1821
+ "Formatting on a non-text run cannot be represented without changing marker behavior",
1822
+ "word/document.xml",
1823
+ );
1824
+ }
1825
+ // Empty text runs are meaningful insertion-point style carriers in Word and
1826
+ // are represented exactly by DocumentTextRun. Keep one inline rather than
1827
+ // flattening the style or silently changing where subsequent typing lands.
1828
+ if (emittedTextLength === 0 && inlines.length === 0 && !hasCommentReference) {
1829
+ inlines.push(
1830
+ stripUndefined({
1831
+ kind: "run" as const,
1832
+ text: "",
1833
+ styleId,
1834
+ style,
1835
+ changeId: change?.id,
1836
+ }),
1837
+ );
1838
+ }
1839
+ return inlines;
1840
+ }
1841
+
1842
+ function parseTable(element: XmlElement, context: ParseContext): ImportedTable {
1843
+ consumeBlock(context.budget);
1844
+ assertAllowedContentChildren(element, new Set(["tblPr", "tblGrid", "tr"]), "table");
1845
+ const id = `${context.idPrefix}:tbl${context.budget.nextBlockId++}`;
1846
+ const properties = singletonChild(element, "tblPr", "table");
1847
+ assertAllowedPropertyChildren(
1848
+ properties,
1849
+ new Set(["tblStyle", "tblW", "tblInd", "jc", "tblLayout", "tblCellMar", "tblBorders"]),
1850
+ "table properties",
1851
+ );
1852
+ assertUniqueChildNames(properties, "table properties");
1853
+ assertAllowedAttributes(firstChild(properties, "tblStyle"), new Set(["val"]), "table style");
1854
+ assertAllowedAttributes(firstChild(properties, "jc"), new Set(["val"]), "table alignment");
1855
+ const grid = singletonChild(element, "tblGrid", "table");
1856
+ assertAllowedContentChildren(grid, new Set(["gridCol"]), "table grid");
1857
+ assertAllowedAttributes(grid, new Set(), "table grid");
1858
+ for (const column of children(grid, "gridCol")) {
1859
+ assertAllowedAttributes(column, new Set(["w"]), "table grid column");
1860
+ assertAllowedContentChildren(column, new Set(), "table grid column");
1861
+ }
1862
+ const rows: ImportedTableRow[] = [];
1863
+ for (const rowElement of children(element, "tr")) {
1864
+ assertAllowedContentChildren(rowElement, new Set(["trPr", "tc"]), "table row");
1865
+ const rowProperties = singletonChild(rowElement, "trPr", "table row");
1866
+ assertAllowedPropertyChildren(
1867
+ rowProperties,
1868
+ new Set(["tblHeader", "cantSplit", "trHeight"]),
1869
+ "table row properties",
1870
+ );
1871
+ assertUniqueChildNames(rowProperties, "table row properties");
1872
+ assertAllowedAttributes(rowProperties, new Set(), "table row properties");
1873
+ assertAllowedAttributes(
1874
+ firstChild(rowProperties, "tblHeader"),
1875
+ new Set(["val"]),
1876
+ "table header-row setting",
1877
+ );
1878
+ assertAllowedAttributes(
1879
+ firstChild(rowProperties, "cantSplit"),
1880
+ new Set(["val"]),
1881
+ "table row-splitting setting",
1882
+ );
1883
+ assertAllowedAttributes(
1884
+ firstChild(rowProperties, "trHeight"),
1885
+ new Set(["val", "hRule"]),
1886
+ "table row height",
1887
+ );
1888
+ const cells: ImportedTableCell[] = [];
1889
+ for (const cellElement of children(rowElement, "tc")) {
1890
+ assertAllowedContentChildren(cellElement, new Set(["tcPr", "p", "tbl"]), "table cell");
1891
+ const cellProperties = singletonChild(cellElement, "tcPr", "table cell");
1892
+ assertAllowedPropertyChildren(
1893
+ cellProperties,
1894
+ new Set(["tcW", "tcMar", "gridSpan", "vMerge", "vAlign", "shd"]),
1895
+ "table cell properties",
1896
+ );
1897
+ assertUniqueChildNames(cellProperties, "table cell properties");
1898
+ assertAllowedAttributes(cellProperties, new Set(), "table cell properties");
1899
+ for (const name of ["gridSpan", "vMerge", "vAlign"] as const) {
1900
+ assertAllowedAttributes(
1901
+ firstChild(cellProperties, name),
1902
+ new Set(["val"]),
1903
+ `table cell ${name}`,
1904
+ );
1905
+ }
1906
+ const verticalMergeElement = firstChild(cellProperties, "vMerge");
1907
+ const verticalMergeValue = verticalMergeElement
1908
+ ? (valueOf(verticalMergeElement) ?? "continue")
1909
+ : undefined;
1910
+ if (
1911
+ verticalMergeValue !== undefined &&
1912
+ verticalMergeValue !== "restart" &&
1913
+ verticalMergeValue !== "continue"
1914
+ ) {
1915
+ throw new DocxImportError(
1916
+ "invalid_package",
1917
+ `Invalid vertical merge value: ${verticalMergeValue}`,
1918
+ context.idPrefix,
1919
+ );
1920
+ }
1921
+ const columnSpan = integerValue(firstChild(cellProperties, "gridSpan"), 1);
1922
+ if (columnSpan < 1)
1923
+ throw new DocxImportError(
1924
+ "invalid_package",
1925
+ "Table gridSpan must be positive",
1926
+ context.idPrefix,
1927
+ );
1928
+ const blocks: ImportedBlock[] = [];
1929
+ for (const block of elementChildren(cellElement)) {
1930
+ if (block.localName === "tcPr") continue;
1931
+ if (block.localName === "p") blocks.push(parseParagraph(block, context));
1932
+ else if (block.localName === "tbl") blocks.push(parseTable(block, context));
1933
+ else if (!NON_CONTENT_MARKERS.has(block.localName))
1934
+ throw unsupportedElement(block, context.idPrefix);
1935
+ }
1936
+ const shading = firstChild(cellProperties, "shd");
1937
+ validateShading(shading, context.idPrefix);
1938
+ cells.push(
1939
+ stripUndefined({
1940
+ width: parseMeasure(firstChild(cellProperties, "tcW")),
1941
+ margins: parseBoxMeasures(firstChild(cellProperties, "tcMar"), "table cell margins"),
1942
+ columnSpan,
1943
+ verticalMerge:
1944
+ verticalMergeValue === undefined
1945
+ ? undefined
1946
+ : verticalMergeValue === "restart"
1947
+ ? ("restart" as const)
1948
+ : ("continue" as const),
1949
+ verticalAlignment: valueOf(firstChild(cellProperties, "vAlign")),
1950
+ fill: attribute(shading, "fill"),
1951
+ blocks,
1952
+ }),
1953
+ );
1954
+ }
1955
+ const height = firstChild(rowProperties, "trHeight");
1956
+ const heightRule = attribute(height, "hRule");
1957
+ if (
1958
+ heightRule !== undefined &&
1959
+ heightRule !== "auto" &&
1960
+ heightRule !== "atLeast" &&
1961
+ heightRule !== "exact"
1962
+ ) {
1963
+ throw new DocxImportError(
1964
+ "invalid_package",
1965
+ `Invalid table row height rule: ${heightRule}`,
1966
+ context.idPrefix,
1967
+ );
1968
+ }
1969
+ rows.push(
1970
+ stripUndefined({
1971
+ header: booleanValue(firstChild(rowProperties, "tblHeader"), false),
1972
+ cannotSplit: booleanValue(firstChild(rowProperties, "cantSplit"), false),
1973
+ heightPt: numberAttribute(height, "val", twipsToPt),
1974
+ heightRule,
1975
+ cells,
1976
+ }),
1977
+ );
1978
+ }
1979
+ return stripUndefined({
1980
+ kind: "table" as const,
1981
+ id,
1982
+ styleId: valueOf(firstChild(properties, "tblStyle")),
1983
+ width: parseMeasure(firstChild(properties, "tblW")),
1984
+ indent: parseMeasure(firstChild(properties, "tblInd")),
1985
+ alignment: valueOf(firstChild(properties, "jc")),
1986
+ layout: parseTableLayout(firstChild(properties, "tblLayout")),
1987
+ cellMargins: parseBoxMeasures(firstChild(properties, "tblCellMar"), "table cell margins"),
1988
+ borders: parseTableBorders(firstChild(properties, "tblBorders"), context.idPrefix),
1989
+ gridColumnWidthsPt: children(grid, "gridCol").map(
1990
+ (column) => numberAttribute(column, "w", twipsToPt) ?? 0,
1991
+ ),
1992
+ rows,
1993
+ });
1994
+ }
1995
+
1996
+ function parseBoxMeasures(
1997
+ element: XmlElement | undefined,
1998
+ label: string,
1999
+ ): ImportedBoxMeasures | undefined {
2000
+ if (!element) return undefined;
2001
+ assertAllowedAttributes(element, new Set(), label);
2002
+ const allowed = new Set(["top", "right", "bottom", "left", "start", "end"]);
2003
+ assertAllowedPropertyChildren(element, allowed, label);
2004
+ assertUniqueChildNames(element, label);
2005
+ if (
2006
+ (firstChild(element, "left") && firstChild(element, "start")) ||
2007
+ (firstChild(element, "right") && firstChild(element, "end"))
2008
+ ) {
2009
+ throw new DocxImportError(
2010
+ "unsupported_feature",
2011
+ `${label} mixes physical and logical side aliases`,
2012
+ label,
2013
+ );
2014
+ }
2015
+ const result = stripUndefined<ImportedBoxMeasures>({
2016
+ top: parseMeasure(firstChild(element, "top")),
2017
+ right: parseMeasure(firstChild(element, "right")),
2018
+ bottom: parseMeasure(firstChild(element, "bottom")),
2019
+ left: parseMeasure(firstChild(element, "left")),
2020
+ start: parseMeasure(firstChild(element, "start")),
2021
+ end: parseMeasure(firstChild(element, "end")),
2022
+ });
2023
+ return Object.keys(result).length > 0 ? result : undefined;
2024
+ }
2025
+
2026
+ function parseTableBorders(
2027
+ element: XmlElement | undefined,
2028
+ partName: string,
2029
+ ): ImportedTableBorders | undefined {
2030
+ if (!element) return undefined;
2031
+ assertAllowedAttributes(element, new Set(), "table borders");
2032
+ assertAllowedPropertyChildren(
2033
+ element,
2034
+ new Set(["top", "right", "bottom", "left", "insideH", "insideV"]),
2035
+ "table borders",
2036
+ );
2037
+ assertUniqueChildNames(element, "table borders");
2038
+ const parse = (name: string): ImportedBorder | undefined => {
2039
+ const border = firstChild(element, name);
2040
+ if (!border) return undefined;
2041
+ assertAllowedAttributes(
2042
+ border,
2043
+ new Set(["val", "color", "sz", "space", "shadow", "frame"]),
2044
+ "table border",
2045
+ );
2046
+ const style = valueOf(border);
2047
+ if (!style)
2048
+ throw new DocxImportError("invalid_package", "Table border lacks a style", partName);
2049
+ return stripUndefined({
2050
+ style,
2051
+ color: attribute(border, "color"),
2052
+ sizePt: numberAttribute(border, "sz", (value) => value / 8),
2053
+ spacePt: numberAttribute(border, "space"),
2054
+ shadow: optionalBooleanAttribute(border, "shadow"),
2055
+ frame: optionalBooleanAttribute(border, "frame"),
2056
+ });
2057
+ };
2058
+ const result = stripUndefined<ImportedTableBorders>({
2059
+ top: parse("top"),
2060
+ right: parse("right"),
2061
+ bottom: parse("bottom"),
2062
+ left: parse("left"),
2063
+ insideHorizontal: parse("insideH"),
2064
+ insideVertical: parse("insideV"),
2065
+ });
2066
+ return Object.keys(result).length > 0 ? result : undefined;
2067
+ }
2068
+
2069
+ function validateShading(element: XmlElement | undefined, partName: string): void {
2070
+ if (!element) return;
2071
+ assertAllowedAttributes(element, new Set(["val", "color", "fill"]), "table cell shading");
2072
+ const pattern = valueOf(element);
2073
+ const foreground = attribute(element, "color");
2074
+ if (pattern !== undefined && pattern !== "clear" && pattern !== "solid") {
2075
+ throw new DocxImportError(
2076
+ "unsupported_feature",
2077
+ `Table shading pattern ${pattern} is not represented`,
2078
+ partName,
2079
+ );
2080
+ }
2081
+ if (foreground !== undefined && foreground.toLowerCase() !== "auto") {
2082
+ throw new DocxImportError(
2083
+ "unsupported_feature",
2084
+ "Patterned table foreground colors are not represented",
2085
+ partName,
2086
+ );
2087
+ }
2088
+ }
2089
+
2090
+ function parseTableLayout(element: XmlElement | undefined): "fixed" | "autofit" | undefined {
2091
+ if (!element) return undefined;
2092
+ assertAllowedAttributes(element, new Set(["type"]), "table layout");
2093
+ const value = attribute(element, "type");
2094
+ if (value === "fixed") return "fixed";
2095
+ if (value === "autofit") return "autofit";
2096
+ throw new DocxImportError(
2097
+ "invalid_package",
2098
+ `Invalid table layout type: ${String(value)}`,
2099
+ "table layout",
2100
+ );
2101
+ }
2102
+
2103
+ function parseMeasure(element: XmlElement | undefined): ImportedMeasure | undefined {
2104
+ if (!element) return undefined;
2105
+ assertAllowedAttributes(element, new Set(["w", "type"]), "OOXML measure");
2106
+ const width = attribute(element, "w");
2107
+ if (width === undefined)
2108
+ throw new DocxImportError("invalid_package", "OOXML measure lacks a width");
2109
+ const raw = Number(width);
2110
+ if (!Number.isFinite(raw))
2111
+ throw new DocxImportError("invalid_package", "OOXML measure width is invalid");
2112
+ const type = attribute(element, "type") ?? "dxa";
2113
+ if (type === "dxa") return { value: twipsToPt(raw), unit: "pt" };
2114
+ if (type === "pct") return { value: raw / 50, unit: "percent" };
2115
+ if (type === "auto" || type === "nil") return { value: raw, unit: "auto" };
2116
+ throw new DocxImportError("unsupported_feature", `Unsupported OOXML measure type: ${type}`);
2117
+ }
2118
+
2119
+ function parseStyles(root: XmlElement, limits: DocxImportLimits): ImportedStyle[] {
2120
+ requireLocalName(root, "styles", "word/styles.xml");
2121
+ requireWordNamespace(root, "word/styles.xml");
2122
+ rejectUnsupportedContent(root, "word/styles.xml");
2123
+ assertAllowedContentChildren(
2124
+ root,
2125
+ new Set(["docDefaults", "latentStyles", "style"]),
2126
+ "styles root",
2127
+ );
2128
+ const defaults = children(root, "docDefaults");
2129
+ if (defaults.length > 1)
2130
+ throw new DocxImportError("invalid_package", "Styles repeats docDefaults", "word/styles.xml");
2131
+ if (defaults[0]) validateEmptyStyleDefaults(defaults[0]);
2132
+ const styles: ImportedStyle[] = [];
2133
+ const styleIds = new Set<string>();
2134
+ for (const element of children(root, "style")) {
2135
+ if (styles.length >= limits.maxStyles)
2136
+ throw limitError("DOCX exceeds maxStyles", "word/styles.xml");
2137
+ const styleId = requiredAttribute(element, "styleId", "word/styles.xml");
2138
+ if (styleIds.has(styleId))
2139
+ throw new DocxImportError("invalid_package", "Duplicate style id", "word/styles.xml");
2140
+ styleIds.add(styleId);
2141
+ const kind = attribute(element, "type") ?? "paragraph";
2142
+ if (kind !== "paragraph" && kind !== "character") {
2143
+ throw new DocxImportError(
2144
+ "unsupported_feature",
2145
+ `Style type ${kind} is not represented`,
2146
+ "word/styles.xml",
2147
+ );
2148
+ }
2149
+ assertAllowedAttributes(
2150
+ element,
2151
+ new Set(["styleId", "type", "default", "customStyle"]),
2152
+ "style",
2153
+ );
2154
+ assertAllowedPropertyChildren(
2155
+ element,
2156
+ new Set([
2157
+ "name",
2158
+ "aliases",
2159
+ "basedOn",
2160
+ "next",
2161
+ "link",
2162
+ "uiPriority",
2163
+ "semiHidden",
2164
+ "unhideWhenUsed",
2165
+ "qFormat",
2166
+ "locked",
2167
+ "personal",
2168
+ "personalCompose",
2169
+ "personalReply",
2170
+ "rsid",
2171
+ "pPr",
2172
+ "rPr",
2173
+ ]),
2174
+ "style definition",
2175
+ );
2176
+ styles.push(
2177
+ stripUndefined({
2178
+ styleId,
2179
+ kind,
2180
+ name: valueOf(firstChild(element, "name")),
2181
+ basedOn: valueOf(firstChild(element, "basedOn")),
2182
+ next: valueOf(firstChild(element, "next")),
2183
+ isDefault: booleanAttribute(element, "default", false),
2184
+ paragraph: parseParagraphProperties(singletonChild(element, "pPr", "style definition")),
2185
+ run: parseRunProperties(singletonChild(element, "rPr", "style definition")),
2186
+ }),
2187
+ );
2188
+ }
2189
+ const styleById = new Map(styles.map((style) => [style.styleId, style]));
2190
+ for (const style of styles) {
2191
+ const seen = new Set<string>();
2192
+ let current: ImportedStyle | undefined = style;
2193
+ let depth = 0;
2194
+ while (current?.basedOn !== undefined) {
2195
+ if (!seen.add(current.styleId)) {
2196
+ throw new DocxImportError(
2197
+ "invalid_package",
2198
+ "Style inheritance contains a cycle",
2199
+ "word/styles.xml",
2200
+ );
2201
+ }
2202
+ depth += 1;
2203
+ if (depth > limits.maxStyleInheritanceDepth) {
2204
+ throw limitError("DOCX exceeds maxStyleInheritanceDepth", "word/styles.xml");
2205
+ }
2206
+ current = styleById.get(current.basedOn);
2207
+ }
2208
+ }
2209
+ return styles;
2210
+ }
2211
+
2212
+ function validateEmptyStyleDefaults(defaults: XmlElement): void {
2213
+ assertAllowedPropertyChildren(defaults, new Set(["pPrDefault", "rPrDefault"]), "style defaults");
2214
+ for (const [wrapperName, propertyName, parse] of [
2215
+ ["pPrDefault", "pPr", parseParagraphProperties],
2216
+ ["rPrDefault", "rPr", parseRunProperties],
2217
+ ] as const) {
2218
+ const wrappers = children(defaults, wrapperName);
2219
+ if (wrappers.length > 1)
2220
+ throw new DocxImportError(
2221
+ "invalid_package",
2222
+ `Styles repeats ${wrapperName}`,
2223
+ "word/styles.xml",
2224
+ );
2225
+ const wrapper = wrappers[0];
2226
+ assertAllowedPropertyChildren(wrapper, new Set([propertyName]), "style defaults");
2227
+ const properties = singletonChild(wrapper, propertyName, "style defaults");
2228
+ if (properties && Object.keys(parse(properties)).length > 0) {
2229
+ throw new DocxImportError(
2230
+ "unsupported_feature",
2231
+ "Non-empty document style defaults are not yet represented",
2232
+ "word/styles.xml",
2233
+ );
2234
+ }
2235
+ }
2236
+ }
2237
+
2238
+ function parseNumbering(root: XmlElement, limits: DocxImportLimits): ImportedListDefinition[] {
2239
+ requireLocalName(root, "numbering", "word/numbering.xml");
2240
+ requireWordNamespace(root, "word/numbering.xml");
2241
+ rejectUnsupportedContent(root, "word/numbering.xml");
2242
+ assertAllowedContentChildren(
2243
+ root,
2244
+ new Set(["numPicBullet", "abstractNum", "num"]),
2245
+ "numbering root",
2246
+ );
2247
+ if (children(root, "numPicBullet").length > 0) {
2248
+ throw new DocxImportError(
2249
+ "unsupported_feature",
2250
+ "Picture bullets are not yet supported",
2251
+ "word/numbering.xml",
2252
+ );
2253
+ }
2254
+ const abstract = new Map<string, ImportedListLevel[]>();
2255
+ for (const definition of children(root, "abstractNum")) {
2256
+ if (abstract.size >= limits.maxNumberingDefinitions) {
2257
+ throw limitError("DOCX exceeds maxNumberingDefinitions", "word/numbering.xml");
2258
+ }
2259
+ const id = requiredAttribute(definition, "abstractNumId", "word/numbering.xml");
2260
+ assertAllowedAttributes(
2261
+ definition,
2262
+ new Set(["abstractNumId", "w15:restartNumberingAfterBreak"]),
2263
+ "abstract numbering definition",
2264
+ );
2265
+ const restartAfterBreak = prefixedAttribute(definition, "w15", "restartNumberingAfterBreak");
2266
+ if (restartAfterBreak !== undefined && parseBooleanLexical(restartAfterBreak)) {
2267
+ throw new DocxImportError(
2268
+ "unsupported_feature",
2269
+ "Restarting numbering after section breaks is not represented",
2270
+ "word/numbering.xml",
2271
+ );
2272
+ }
2273
+ assertAllowedPropertyChildren(
2274
+ definition,
2275
+ new Set(["nsid", "multiLevelType", "tmpl", "name", "lvl"]),
2276
+ "abstract numbering definition",
2277
+ );
2278
+ if (abstract.has(id))
2279
+ throw new DocxImportError(
2280
+ "invalid_package",
2281
+ "Duplicate abstract numbering id",
2282
+ "word/numbering.xml",
2283
+ );
2284
+ const levels = children(definition, "lvl").map(parseListLevel);
2285
+ validateListLevels(levels, "abstract numbering definition");
2286
+ abstract.set(id, levels);
2287
+ }
2288
+ const numIds = new Set<string>();
2289
+ const nums = children(root, "num");
2290
+ if (nums.length > limits.maxNumberingDefinitions) {
2291
+ throw limitError("DOCX exceeds maxNumberingDefinitions", "word/numbering.xml");
2292
+ }
2293
+ return nums.map((numbering): ImportedListDefinition => {
2294
+ assertAllowedAttributes(numbering, new Set(["numId"]), "numbering definition");
2295
+ assertAllowedPropertyChildren(
2296
+ numbering,
2297
+ new Set(["abstractNumId", "lvlOverride"]),
2298
+ "numbering definition",
2299
+ );
2300
+ const numId = requiredAttribute(numbering, "numId", "word/numbering.xml");
2301
+ if (numIds.has(numId))
2302
+ throw new DocxImportError("invalid_package", "Duplicate numbering id", "word/numbering.xml");
2303
+ numIds.add(numId);
2304
+ const abstractNumId = valueOf(firstChild(numbering, "abstractNumId"));
2305
+ if (!abstractNumId || !abstract.has(abstractNumId))
2306
+ throw new DocxImportError(
2307
+ "invalid_package",
2308
+ "Numbering references a missing abstract definition",
2309
+ "word/numbering.xml",
2310
+ );
2311
+ const overrides = children(numbering, "lvlOverride").map((override) => {
2312
+ assertAllowedAttributes(override, new Set(["ilvl"]), "numbering override");
2313
+ assertAllowedPropertyChildren(
2314
+ override,
2315
+ new Set(["startOverride", "lvl"]),
2316
+ "numbering override",
2317
+ );
2318
+ return stripUndefined<{
2319
+ level: number;
2320
+ start?: number;
2321
+ definition?: ImportedListLevel;
2322
+ }>({
2323
+ level: parseLevelIndex(override),
2324
+ start: numberValue(firstChild(override, "startOverride")),
2325
+ definition: firstChild(override, "lvl")
2326
+ ? parseListLevel(firstChild(override, "lvl") as XmlElement)
2327
+ : undefined,
2328
+ });
2329
+ });
2330
+ validateOverrideLevels(overrides);
2331
+ return {
2332
+ numId,
2333
+ abstractNumId,
2334
+ levels: (abstract.get(abstractNumId) ?? []).map((level) => ({ ...level })),
2335
+ overrides,
2336
+ };
2337
+ });
2338
+ }
2339
+
2340
+ function parseListLevel(level: XmlElement): ImportedListLevel {
2341
+ assertAllowedAttributes(level, new Set(["ilvl", "tplc", "w15:tentative"]), "numbering level");
2342
+ assertAllowedPropertyChildren(
2343
+ level,
2344
+ new Set([
2345
+ "start",
2346
+ "numFmt",
2347
+ "lvlRestart",
2348
+ "pStyle",
2349
+ "isLgl",
2350
+ "suff",
2351
+ "lvlText",
2352
+ "lvlJc",
2353
+ "pPr",
2354
+ "rPr",
2355
+ ]),
2356
+ "numbering level",
2357
+ );
2358
+ assertUniqueChildNames(level, "numbering level");
2359
+ for (const name of [
2360
+ "start",
2361
+ "numFmt",
2362
+ "lvlRestart",
2363
+ "pStyle",
2364
+ "isLgl",
2365
+ "suff",
2366
+ "lvlText",
2367
+ "lvlJc",
2368
+ ] as const) {
2369
+ assertAllowedAttributes(firstChild(level, name), new Set(["val"]), `numbering level ${name}`);
2370
+ }
2371
+ const restartElement = firstChild(level, "lvlRestart");
2372
+ const restart = restartElement ? integerValue(restartElement, 0) : undefined;
2373
+ if (restart !== undefined && (restart < 0 || restart > 9)) {
2374
+ throw new DocxImportError(
2375
+ "invalid_package",
2376
+ "Numbering restart level must be from 0 through 9",
2377
+ "word/numbering.xml",
2378
+ );
2379
+ }
2380
+ return stripUndefined({
2381
+ level: parseLevelIndex(level),
2382
+ start: integerValue(firstChild(level, "start"), 1),
2383
+ restart,
2384
+ legal: optionalBoolean(firstChild(level, "isLgl")),
2385
+ alignment: valueOf(firstChild(level, "lvlJc")),
2386
+ format: valueOf(firstChild(level, "numFmt")),
2387
+ text: valueOf(firstChild(level, "lvlText")),
2388
+ suffix: valueOf(firstChild(level, "suff")),
2389
+ paragraphStyleId: valueOf(firstChild(level, "pStyle")),
2390
+ paragraph: parseParagraphProperties(singletonChild(level, "pPr", "numbering level")),
2391
+ run: parseRunProperties(singletonChild(level, "rPr", "numbering level")),
2392
+ });
2393
+ }
2394
+
2395
+ function parseLevelIndex(element: XmlElement): number {
2396
+ const raw = requiredAttribute(element, "ilvl", "word/numbering.xml");
2397
+ const level = Number(raw);
2398
+ if (!Number.isInteger(level) || level < 0 || level > 8) {
2399
+ throw new DocxImportError(
2400
+ "invalid_package",
2401
+ "Numbering level must be an integer from 0 through 8",
2402
+ "word/numbering.xml",
2403
+ );
2404
+ }
2405
+ return level;
2406
+ }
2407
+
2408
+ function validateListLevels(levels: readonly ImportedListLevel[], label: string): void {
2409
+ if (levels.length > 9)
2410
+ throw new DocxImportError(
2411
+ "invalid_package",
2412
+ `${label} has more than nine levels`,
2413
+ "word/numbering.xml",
2414
+ );
2415
+ const seen = new Set<number>();
2416
+ for (const level of levels) {
2417
+ if (seen.has(level.level))
2418
+ throw new DocxImportError(
2419
+ "invalid_package",
2420
+ `${label} repeats a level`,
2421
+ "word/numbering.xml",
2422
+ );
2423
+ seen.add(level.level);
2424
+ }
2425
+ }
2426
+
2427
+ function validateOverrideLevels(
2428
+ overrides: ReadonlyArray<{ level: number; start?: number; definition?: ImportedListLevel }>,
2429
+ ): void {
2430
+ if (overrides.length > 9)
2431
+ throw new DocxImportError(
2432
+ "invalid_package",
2433
+ "Numbering definition has more than nine overrides",
2434
+ "word/numbering.xml",
2435
+ );
2436
+ const seen = new Set<number>();
2437
+ for (const override of overrides) {
2438
+ if (seen.has(override.level))
2439
+ throw new DocxImportError(
2440
+ "invalid_package",
2441
+ "Numbering definition repeats an override",
2442
+ "word/numbering.xml",
2443
+ );
2444
+ seen.add(override.level);
2445
+ if (override.definition && override.definition.level !== override.level) {
2446
+ throw new DocxImportError(
2447
+ "invalid_package",
2448
+ "Numbering override level disagrees with its definition",
2449
+ "word/numbering.xml",
2450
+ );
2451
+ }
2452
+ }
2453
+ }
2454
+
2455
+ function parseParagraphProperties(element: XmlElement | undefined): ImportedParagraphStyle {
2456
+ assertAllowedPropertyChildren(
2457
+ element,
2458
+ new Set([
2459
+ "pStyle",
2460
+ "jc",
2461
+ "spacing",
2462
+ "ind",
2463
+ "keepNext",
2464
+ "keepLines",
2465
+ "pageBreakBefore",
2466
+ "outlineLvl",
2467
+ "bidi",
2468
+ "numPr",
2469
+ "sectPr",
2470
+ ]),
2471
+ "paragraph properties",
2472
+ );
2473
+ assertUniqueChildNames(element, "paragraph properties");
2474
+ const spacing = firstChild(element, "spacing");
2475
+ const indent = firstChild(element, "ind");
2476
+ const numProperties = firstChild(element, "numPr");
2477
+ assertAllowedPropertyChildren(
2478
+ numProperties,
2479
+ new Set(["numId", "ilvl"]),
2480
+ "paragraph numbering properties",
2481
+ );
2482
+ assertUniqueChildNames(numProperties, "paragraph numbering properties");
2483
+ for (const name of [
2484
+ "pStyle",
2485
+ "jc",
2486
+ "keepNext",
2487
+ "keepLines",
2488
+ "pageBreakBefore",
2489
+ "outlineLvl",
2490
+ "bidi",
2491
+ ] as const) {
2492
+ assertAllowedAttributes(firstChild(element, name), new Set(["val"]), `paragraph ${name}`);
2493
+ }
2494
+ assertAllowedAttributes(
2495
+ spacing,
2496
+ new Set(["before", "after", "line", "lineRule"]),
2497
+ "paragraph spacing",
2498
+ );
2499
+ assertAllowedAttributes(
2500
+ indent,
2501
+ new Set(["left", "right", "firstLine", "hanging"]),
2502
+ "paragraph indentation",
2503
+ );
2504
+ assertAllowedAttributes(numProperties, new Set(), "paragraph numbering properties");
2505
+ assertAllowedAttributes(
2506
+ firstChild(numProperties, "numId"),
2507
+ new Set(["val"]),
2508
+ "paragraph numbering id",
2509
+ );
2510
+ assertAllowedAttributes(
2511
+ firstChild(numProperties, "ilvl"),
2512
+ new Set(["val"]),
2513
+ "paragraph numbering level",
2514
+ );
2515
+ if (attribute(indent, "firstLine") !== undefined && attribute(indent, "hanging") !== undefined) {
2516
+ throw new DocxImportError(
2517
+ "invalid_package",
2518
+ "Paragraph indentation cannot specify both firstLine and hanging",
2519
+ "paragraph indentation",
2520
+ );
2521
+ }
2522
+ const lineRule = attribute(spacing, "lineRule");
2523
+ if (
2524
+ lineRule !== undefined &&
2525
+ lineRule !== "auto" &&
2526
+ lineRule !== "exact" &&
2527
+ lineRule !== "atLeast"
2528
+ ) {
2529
+ throw new DocxImportError(
2530
+ "invalid_package",
2531
+ "Paragraph line rule is invalid",
2532
+ "paragraph spacing",
2533
+ );
2534
+ }
2535
+ const numId = valueOf(firstChild(numProperties, "numId"));
2536
+ return stripUndefined({
2537
+ alignment: valueOf(firstChild(element, "jc")),
2538
+ spaceBeforePt: numberAttribute(spacing, "before", twipsToPt),
2539
+ spaceAfterPt: numberAttribute(spacing, "after", twipsToPt),
2540
+ line: numberAttribute(spacing, "line"),
2541
+ lineRule,
2542
+ indentLeftPt: numberAttribute(indent, "left", twipsToPt),
2543
+ indentRightPt: numberAttribute(indent, "right", twipsToPt),
2544
+ firstLinePt: numberAttribute(indent, "firstLine", twipsToPt),
2545
+ hangingPt: numberAttribute(indent, "hanging", twipsToPt),
2546
+ keepNext: optionalBoolean(firstChild(element, "keepNext")),
2547
+ keepLines: optionalBoolean(firstChild(element, "keepLines")),
2548
+ pageBreakBefore: optionalBoolean(firstChild(element, "pageBreakBefore")),
2549
+ outlineLevel: numberValue(firstChild(element, "outlineLvl")),
2550
+ bidirectional: optionalBoolean(firstChild(element, "bidi")),
2551
+ list:
2552
+ numId === undefined
2553
+ ? undefined
2554
+ : { numId, level: integerValue(firstChild(numProperties, "ilvl"), 0) },
2555
+ });
2556
+ }
2557
+
2558
+ function parseRunProperties(element: XmlElement | undefined): ImportedRunStyle {
2559
+ assertAllowedPropertyChildren(
2560
+ element,
2561
+ new Set([
2562
+ "rStyle",
2563
+ "rFonts",
2564
+ "sz",
2565
+ "szCs",
2566
+ "color",
2567
+ "b",
2568
+ "bCs",
2569
+ "i",
2570
+ "iCs",
2571
+ "u",
2572
+ "strike",
2573
+ "highlight",
2574
+ "vertAlign",
2575
+ "lang",
2576
+ ]),
2577
+ "run properties",
2578
+ );
2579
+ assertUniqueChildNames(element, "run properties");
2580
+ const fonts = firstChild(element, "rFonts");
2581
+ const color = firstChild(element, "color");
2582
+ if (
2583
+ ["asciiTheme", "hAnsiTheme", "eastAsiaTheme", "cstheme"].some(
2584
+ (name) => attribute(fonts, name) !== undefined,
2585
+ )
2586
+ ) {
2587
+ throw new DocxImportError(
2588
+ "unsupported_feature",
2589
+ "Theme font references are not represented by the document model",
2590
+ "run properties",
2591
+ );
2592
+ }
2593
+ if (
2594
+ ["themeColor", "themeTint", "themeShade"].some((name) => attribute(color, name) !== undefined)
2595
+ ) {
2596
+ throw new DocxImportError(
2597
+ "unsupported_feature",
2598
+ "Theme colors are not represented by the document model",
2599
+ "run properties",
2600
+ );
2601
+ }
2602
+ assertAllowedAttributes(fonts, new Set(["ascii", "hAnsi", "eastAsia", "cs"]), "run fonts");
2603
+ assertAllowedAttributes(color, new Set(["val"]), "run color");
2604
+ const underlineElement = firstChild(element, "u");
2605
+ assertAllowedAttributes(underlineElement, new Set(["val"]), "run underline");
2606
+ for (const name of [
2607
+ "rStyle",
2608
+ "sz",
2609
+ "szCs",
2610
+ "b",
2611
+ "bCs",
2612
+ "i",
2613
+ "iCs",
2614
+ "strike",
2615
+ "highlight",
2616
+ "vertAlign",
2617
+ "lang",
2618
+ ] as const) {
2619
+ assertAllowedAttributes(firstChild(element, name), new Set(["val"]), `run ${name}`);
2620
+ }
2621
+ const underline = valueOf(underlineElement);
2622
+ if (underline !== undefined && underline !== "single" && underline !== "none") {
2623
+ throw new DocxImportError(
2624
+ "unsupported_feature",
2625
+ `Underline style ${underline} is not represented by the document model`,
2626
+ "run properties",
2627
+ );
2628
+ }
2629
+ const directFonts = ["ascii", "hAnsi", "eastAsia", "cs"]
2630
+ .map((name) => attribute(fonts, name))
2631
+ .filter((value): value is string => value !== undefined);
2632
+ if (new Set(directFonts).size > 1) {
2633
+ throw new DocxImportError(
2634
+ "unsupported_feature",
2635
+ "Script-specific font families are not represented by the document model",
2636
+ "run properties",
2637
+ );
2638
+ }
2639
+ const colorValue = valueOf(color);
2640
+ if (colorValue?.toLowerCase() === "auto") {
2641
+ throw new DocxImportError(
2642
+ "unsupported_feature",
2643
+ "Automatic run colors are not represented by the document model",
2644
+ "run properties",
2645
+ );
2646
+ }
2647
+ return stripUndefined({
2648
+ fontFamily: directFonts[0],
2649
+ fontSizePt: numberValue(firstChild(element, "sz"), (value) => value / 2),
2650
+ fontSizeComplexScriptPt: numberValue(firstChild(element, "szCs"), (value) => value / 2),
2651
+ color: colorValue,
2652
+ bold: optionalBoolean(firstChild(element, "b")),
2653
+ boldComplexScript: optionalBoolean(firstChild(element, "bCs")),
2654
+ italic: optionalBoolean(firstChild(element, "i")),
2655
+ italicComplexScript: optionalBoolean(firstChild(element, "iCs")),
2656
+ underline,
2657
+ strike: optionalBoolean(firstChild(element, "strike")),
2658
+ highlight: valueOf(firstChild(element, "highlight")),
2659
+ verticalAlign: valueOf(firstChild(element, "vertAlign")),
2660
+ language: attribute(firstChild(element, "lang"), "val"),
2661
+ });
2662
+ }
2663
+
2664
+ function assertAllowedPropertyChildren(
2665
+ element: XmlElement | undefined,
2666
+ allowed: ReadonlySet<string>,
2667
+ label: string,
2668
+ ): void {
2669
+ for (const child of elementChildren(element)) {
2670
+ if (!allowed.has(child.localName)) {
2671
+ throw new DocxImportError(
2672
+ "unsupported_feature",
2673
+ `Unsupported ${label}: ${child.name}`,
2674
+ label,
2675
+ );
2676
+ }
2677
+ }
2678
+ }
2679
+
2680
+ function assertAllowedContentChildren(
2681
+ element: XmlElement | undefined,
2682
+ allowed: ReadonlySet<string>,
2683
+ label: string,
2684
+ ): void {
2685
+ for (const child of elementChildren(element)) {
2686
+ if (!allowed.has(child.localName) && !NON_CONTENT_MARKERS.has(child.localName)) {
2687
+ throw new DocxImportError(
2688
+ "unsupported_feature",
2689
+ `Unsupported ${label} content: ${child.name}`,
2690
+ label,
2691
+ );
2692
+ }
2693
+ }
2694
+ }
2695
+
2696
+ function assertAllowedAttributes(
2697
+ element: XmlElement | undefined,
2698
+ allowed: ReadonlySet<string>,
2699
+ label: string,
2700
+ ): void {
2701
+ if (!element) return;
2702
+ for (const name of element.attributes.keys()) {
2703
+ if (name === "xmlns" || name.startsWith("xmlns:")) continue;
2704
+ const permitted = allowed.has(name) || (name.startsWith("w:") && allowed.has(name.slice(2)));
2705
+ if (!permitted) {
2706
+ throw new DocxImportError(
2707
+ "unsupported_feature",
2708
+ `Unsupported ${label} attribute: ${name}`,
2709
+ label,
2710
+ );
2711
+ }
2712
+ }
2713
+ }
2714
+
2715
+ function parseSection(
2716
+ element: XmlElement,
2717
+ startBlockIndex: number,
2718
+ endBlockIndex: number,
2719
+ relationships: ReadonlyMap<string, Relationship>,
2720
+ ): ImportedSection {
2721
+ assertAllowedAttributes(element, new Set(["rsidR", "rsidRPr", "rsidSect"]), "section properties");
2722
+ assertAllowedPropertyChildren(
2723
+ element,
2724
+ new Set([
2725
+ "headerReference",
2726
+ "footerReference",
2727
+ "type",
2728
+ "pgSz",
2729
+ "pgMar",
2730
+ "pgNumType",
2731
+ "titlePg",
2732
+ "docGrid",
2733
+ ]),
2734
+ "section properties",
2735
+ );
2736
+ for (const singleton of ["type", "pgSz", "pgMar", "pgNumType", "titlePg", "docGrid"] as const) {
2737
+ if (children(element, singleton).length > 1) {
2738
+ throw new DocxImportError(
2739
+ "invalid_package",
2740
+ `Section repeats ${singleton}`,
2741
+ "word/document.xml",
2742
+ );
2743
+ }
2744
+ }
2745
+ const pageSize = firstChild(element, "pgSz");
2746
+ const margins = firstChild(element, "pgMar");
2747
+ assertAllowedAttributes(pageSize, new Set(["w", "h", "orient", "code"]), "section page size");
2748
+ assertAllowedAttributes(
2749
+ margins,
2750
+ new Set(["top", "right", "bottom", "left", "header", "footer", "gutter"]),
2751
+ "section page margins",
2752
+ );
2753
+ assertAllowedContentChildren(pageSize, new Set(), "section page size");
2754
+ assertAllowedContentChildren(margins, new Set(), "section page margins");
2755
+ if (
2756
+ pageSize &&
2757
+ (attribute(pageSize, "w") === undefined || attribute(pageSize, "h") === undefined)
2758
+ ) {
2759
+ throw new DocxImportError(
2760
+ "invalid_package",
2761
+ "Section page size is incomplete",
2762
+ "word/document.xml",
2763
+ );
2764
+ }
2765
+ if (
2766
+ margins &&
2767
+ ["top", "right", "bottom", "left", "header", "footer", "gutter"].some(
2768
+ (name) => attribute(margins, name) === undefined,
2769
+ )
2770
+ ) {
2771
+ throw new DocxImportError(
2772
+ "invalid_package",
2773
+ "Section page margins are incomplete",
2774
+ "word/document.xml",
2775
+ );
2776
+ }
2777
+ const pageNumbering = firstChild(element, "pgNumType");
2778
+ assertAllowedAttributes(pageNumbering, new Set(), "section page numbering");
2779
+ assertAllowedContentChildren(pageNumbering, new Set(), "section page numbering");
2780
+ const documentGrid = firstChild(element, "docGrid");
2781
+ assertAllowedAttributes(
2782
+ documentGrid,
2783
+ new Set(["type", "linePitch", "charSpace"]),
2784
+ "section document grid",
2785
+ );
2786
+ assertAllowedContentChildren(documentGrid, new Set(), "section document grid");
2787
+ const gridType = attribute(documentGrid, "type");
2788
+ if (gridType !== undefined && gridType !== "default") {
2789
+ throw new DocxImportError(
2790
+ "unsupported_feature",
2791
+ `Document grid type ${gridType} is not represented`,
2792
+ "word/document.xml",
2793
+ );
2794
+ }
2795
+ numberAttribute(documentGrid, "linePitch");
2796
+ numberAttribute(documentGrid, "charSpace");
2797
+ const sectionTypeElement = firstChild(element, "type");
2798
+ const titlePageElement = firstChild(element, "titlePg");
2799
+ assertAllowedAttributes(sectionTypeElement, new Set(["val"]), "section break type");
2800
+ assertAllowedAttributes(titlePageElement, new Set(["val"]), "section title-page setting");
2801
+ assertAllowedContentChildren(sectionTypeElement, new Set(), "section break type");
2802
+ assertAllowedContentChildren(titlePageElement, new Set(), "section title-page setting");
2803
+ const orientation = attribute(pageSize, "orient");
2804
+ if (orientation !== undefined && orientation !== "portrait" && orientation !== "landscape") {
2805
+ throw new DocxImportError(
2806
+ "invalid_package",
2807
+ "Section page orientation is invalid",
2808
+ "word/document.xml",
2809
+ );
2810
+ }
2811
+ const sectionType = valueOf(sectionTypeElement);
2812
+ if (
2813
+ sectionType !== undefined &&
2814
+ !new Set(["nextPage", "nextColumn", "continuous", "evenPage", "oddPage"]).has(sectionType)
2815
+ ) {
2816
+ throw new DocxImportError(
2817
+ "invalid_package",
2818
+ "Section break type is invalid",
2819
+ "word/document.xml",
2820
+ );
2821
+ }
2822
+ const references = (kind: "header" | "footer"): ImportedSectionReference[] =>
2823
+ children(element, `${kind}Reference`).map((reference) => {
2824
+ assertAllowedAttributes(reference, new Set(["r:id", "type"]), `${kind} reference`);
2825
+ assertAllowedContentChildren(reference, new Set(), `${kind} reference`);
2826
+ const relationshipId = requiredPrefixedAttribute(reference, "r", "id", "word/document.xml");
2827
+ const relationship = relationships.get(relationshipId);
2828
+ if (!relationship || relationship.type !== `${WORD_RELATIONSHIP_BASE}${kind}`) {
2829
+ throw new DocxImportError(
2830
+ "invalid_package",
2831
+ `Section references a missing ${kind}`,
2832
+ "word/document.xml",
2833
+ );
2834
+ }
2835
+ const referenceKind = attribute(reference, "type") ?? "default";
2836
+ if (referenceKind !== "default" && referenceKind !== "first" && referenceKind !== "even") {
2837
+ throw new DocxImportError(
2838
+ "invalid_package",
2839
+ `Invalid ${kind} reference kind`,
2840
+ "word/document.xml",
2841
+ );
2842
+ }
2843
+ return {
2844
+ kind: referenceKind,
2845
+ relationshipId,
2846
+ partName: relationship.partName,
2847
+ };
2848
+ });
2849
+ const headers = references("header");
2850
+ const footers = references("footer");
2851
+ for (const storyReferences of [headers, footers]) {
2852
+ const kinds = new Set<string>();
2853
+ for (const reference of storyReferences) {
2854
+ if (kinds.has(reference.kind))
2855
+ throw new DocxImportError(
2856
+ "invalid_package",
2857
+ "Section repeats a header/footer reference kind",
2858
+ "word/document.xml",
2859
+ );
2860
+ kinds.add(reference.kind);
2861
+ }
2862
+ }
2863
+ return stripUndefined({
2864
+ startBlockIndex,
2865
+ endBlockIndex,
2866
+ type: sectionType,
2867
+ titlePage: booleanValue(titlePageElement, false),
2868
+ page: {
2869
+ widthPt: numberAttribute(pageSize, "w", twipsToPt) ?? 612,
2870
+ heightPt: numberAttribute(pageSize, "h", twipsToPt) ?? 792,
2871
+ ...(orientation !== undefined ? { orientation } : {}),
2872
+ marginTopPt: numberAttribute(margins, "top", twipsToPt) ?? 72,
2873
+ marginRightPt: numberAttribute(margins, "right", twipsToPt) ?? 72,
2874
+ marginBottomPt: numberAttribute(margins, "bottom", twipsToPt) ?? 72,
2875
+ marginLeftPt: numberAttribute(margins, "left", twipsToPt) ?? 72,
2876
+ headerPt: numberAttribute(margins, "header", twipsToPt) ?? 36,
2877
+ footerPt: numberAttribute(margins, "footer", twipsToPt) ?? 36,
2878
+ gutterPt: numberAttribute(margins, "gutter", twipsToPt) ?? 0,
2879
+ },
2880
+ headers,
2881
+ footers,
2882
+ });
2883
+ }
2884
+
2885
+ function defaultSection(startBlockIndex: number, endBlockIndex: number): ImportedSection {
2886
+ return {
2887
+ startBlockIndex,
2888
+ endBlockIndex,
2889
+ titlePage: false,
2890
+ page: {
2891
+ widthPt: 612,
2892
+ heightPt: 792,
2893
+ marginTopPt: 72,
2894
+ marginRightPt: 72,
2895
+ marginBottomPt: 72,
2896
+ marginLeftPt: 72,
2897
+ headerPt: 36,
2898
+ footerPt: 36,
2899
+ gutterPt: 0,
2900
+ },
2901
+ headers: [],
2902
+ footers: [],
2903
+ };
2904
+ }
2905
+
2906
+ function resolveSectionStoryInheritance(sections: ImportedSection[]): void {
2907
+ for (let index = 1; index < sections.length; index += 1) {
2908
+ const previous = sections[index - 1];
2909
+ const current = sections[index];
2910
+ if (!previous || !current) continue;
2911
+ for (const key of ["headers", "footers"] as const) {
2912
+ const present = new Set(current[key].map((reference) => reference.kind));
2913
+ for (const inherited of previous[key]) {
2914
+ if (!present.has(inherited.kind)) current[key].push({ ...inherited });
2915
+ }
2916
+ }
2917
+ }
2918
+ }
2919
+
2920
+ function validateProjectedStories(
2921
+ sections: readonly ImportedSection[],
2922
+ headers: readonly ImportedHeaderFooter[],
2923
+ footers: readonly ImportedHeaderFooter[],
2924
+ maximumBlocks: number,
2925
+ maximumCharacters: number,
2926
+ ): void {
2927
+ const costByPart = new Map<string, { blocks: number; characters: number }>();
2928
+ for (const story of [...headers, ...footers]) {
2929
+ costByPart.set(story.partName, measureBlockTree(story.blocks));
2930
+ }
2931
+ let projectedBlocks = 0;
2932
+ let projectedCharacters = 0;
2933
+ for (const section of sections) {
2934
+ for (const reference of [...section.headers, ...section.footers]) {
2935
+ const storyCost = costByPart.get(reference.partName);
2936
+ if (storyCost === undefined) {
2937
+ throw new DocxImportError(
2938
+ "invalid_package",
2939
+ "Section references an unparsed header or footer",
2940
+ reference.partName,
2941
+ );
2942
+ }
2943
+ projectedBlocks += storyCost.blocks;
2944
+ projectedCharacters += storyCost.characters;
2945
+ if (projectedBlocks > maximumBlocks)
2946
+ throw limitError("DOCX exceeds maxProjectedStoryBlocks", reference.partName);
2947
+ if (projectedCharacters > maximumCharacters) {
2948
+ throw limitError("DOCX exceeds maxProjectedStoryCharacters", reference.partName);
2949
+ }
2950
+ }
2951
+ }
2952
+ }
2953
+
2954
+ function measureBlockTree(blocks: readonly ImportedBlock[]): {
2955
+ blocks: number;
2956
+ characters: number;
2957
+ } {
2958
+ let count = 0;
2959
+ let characters = 0;
2960
+ const stack = [...blocks];
2961
+ while (stack.length > 0) {
2962
+ const block = stack.pop();
2963
+ if (!block) continue;
2964
+ count += 1;
2965
+ if (block.kind === "paragraph") {
2966
+ for (const inline of block.inlines)
2967
+ if (inline.kind === "run") characters += inline.text.length;
2968
+ } else if (block.kind === "table") {
2969
+ for (const row of block.rows)
2970
+ for (const cell of row.cells) {
2971
+ for (const child of cell.blocks) stack.push(child);
2972
+ }
2973
+ }
2974
+ }
2975
+ return { blocks: count, characters };
2976
+ }
2977
+
2978
+ type ExtendedComment = { parentParagraphId?: string; resolved?: boolean };
2979
+
2980
+ function parseCommentsExtended(root: XmlElement, maximum: number): Map<string, ExtendedComment> {
2981
+ requireLocalName(root, "commentsEx", "word/commentsExtended.xml");
2982
+ requirePrefixedNamespaceTree(
2983
+ root,
2984
+ "w15",
2985
+ COMMENTS_EXTENDED_NAMESPACE,
2986
+ "word/commentsExtended.xml",
2987
+ );
2988
+ const result = new Map<string, ExtendedComment>();
2989
+ for (const comment of elementChildren(root)) {
2990
+ if (comment.localName !== "commentEx")
2991
+ throw unsupportedElement(comment, "word/commentsExtended.xml");
2992
+ if (result.size >= maximum)
2993
+ throw limitError("DOCX exceeds maxComments", "word/commentsExtended.xml");
2994
+ const paragraphId = requiredPrefixedAttribute(
2995
+ comment,
2996
+ "w15",
2997
+ "paraId",
2998
+ "word/commentsExtended.xml",
2999
+ );
3000
+ if (result.has(paragraphId))
3001
+ throw new DocxImportError(
3002
+ "invalid_package",
3003
+ "Duplicate extended comment paragraph id",
3004
+ "word/commentsExtended.xml",
3005
+ );
3006
+ const parentParagraphId = prefixedAttribute(comment, "w15", "paraIdParent");
3007
+ const done = prefixedAttribute(comment, "w15", "done");
3008
+ result.set(paragraphId, {
3009
+ ...(parentParagraphId !== undefined ? { parentParagraphId } : {}),
3010
+ ...(done !== undefined ? { resolved: !FALSE_VALUES.has(done.toLowerCase()) } : {}),
3011
+ });
3012
+ }
3013
+ return result;
3014
+ }
3015
+
3016
+ function parseComments(
3017
+ root: XmlElement,
3018
+ context: ParseContext,
3019
+ extended: ReadonlyMap<string, ExtendedComment>,
3020
+ maximum: number,
3021
+ ): ImportedComment[] {
3022
+ requireLocalName(root, "comments", "word/comments.xml");
3023
+ rejectUnsupportedContent(root, "word/comments.xml");
3024
+ requireWordNamespace(root, "word/comments.xml");
3025
+ const drafts: Array<{ paragraphId?: string; comment: ImportedComment }> = [];
3026
+ for (const comment of elementChildren(root)) {
3027
+ if (comment.localName !== "comment") throw unsupportedElement(comment, "word/comments.xml");
3028
+ if (drafts.length >= maximum) throw limitError("DOCX exceeds maxComments", "word/comments.xml");
3029
+ const lastParagraph = lastDescendant(comment, "p");
3030
+ const paragraphId = prefixedAttribute(lastParagraph, "w14", "paraId");
3031
+ drafts.push({
3032
+ // commentsExtended keys a comment by the paraId of its final paragraph.
3033
+ ...(paragraphId !== undefined ? { paragraphId } : {}),
3034
+ comment: stripUndefined<ImportedComment>({
3035
+ id: requiredAttribute(comment, "id", "word/comments.xml"),
3036
+ author: attribute(comment, "author"),
3037
+ initials: attribute(comment, "initials"),
3038
+ createdAt: attribute(comment, "date"),
3039
+ blocks: parseBlockContainer(comment, context),
3040
+ }),
3041
+ });
3042
+ }
3043
+ const commentIdByParagraphId = new Map<string, string>();
3044
+ const commentIds = new Set<string>();
3045
+ for (const draft of drafts) {
3046
+ if (commentIds.has(draft.comment.id))
3047
+ throw new DocxImportError("invalid_package", "Duplicate comment id", "word/comments.xml");
3048
+ commentIds.add(draft.comment.id);
3049
+ if (!draft.paragraphId) continue;
3050
+ if (commentIdByParagraphId.has(draft.paragraphId)) {
3051
+ throw new DocxImportError(
3052
+ "invalid_package",
3053
+ "Duplicate comment paragraph id",
3054
+ "word/comments.xml",
3055
+ );
3056
+ }
3057
+ commentIdByParagraphId.set(draft.paragraphId, draft.comment.id);
3058
+ }
3059
+ for (const draft of drafts) {
3060
+ if (!draft.paragraphId) continue;
3061
+ const metadata = extended.get(draft.paragraphId);
3062
+ if (!metadata) continue;
3063
+ if (metadata.resolved !== undefined) draft.comment.resolved = metadata.resolved;
3064
+ if (metadata.parentParagraphId !== undefined) {
3065
+ const parentId = commentIdByParagraphId.get(metadata.parentParagraphId);
3066
+ if (!parentId)
3067
+ throw new DocxImportError(
3068
+ "invalid_package",
3069
+ "Comment reply references a missing parent",
3070
+ "word/commentsExtended.xml",
3071
+ );
3072
+ draft.comment.parentId = parentId;
3073
+ }
3074
+ }
3075
+ for (const paragraphId of extended.keys()) {
3076
+ if (!commentIdByParagraphId.has(paragraphId)) {
3077
+ throw new DocxImportError(
3078
+ "invalid_package",
3079
+ "Extended comment references a missing comment paragraph",
3080
+ "word/commentsExtended.xml",
3081
+ );
3082
+ }
3083
+ }
3084
+ return drafts.map((draft) => draft.comment);
3085
+ }
3086
+
3087
+ function validateCommentReferences(
3088
+ blocks: readonly ImportedBlock[],
3089
+ headers: readonly ImportedHeaderFooter[],
3090
+ footers: readonly ImportedHeaderFooter[],
3091
+ comments: readonly ImportedComment[],
3092
+ ): void {
3093
+ const ids = new Set(comments.map((comment) => comment.id));
3094
+ const commentById = new Map(comments.map((comment) => [comment.id, comment]));
3095
+ const roots = new Set(
3096
+ comments.filter((comment) => comment.parentId === undefined).map((comment) => comment.id),
3097
+ );
3098
+ for (const comment of comments) {
3099
+ if (comment.parentId !== undefined && !ids.has(comment.parentId)) {
3100
+ throw new DocxImportError(
3101
+ "invalid_package",
3102
+ "Comment reply references a missing parent",
3103
+ "word/commentsExtended.xml",
3104
+ );
3105
+ }
3106
+ }
3107
+ const visitState = new Map<string, 1 | 2>();
3108
+ for (const comment of comments) {
3109
+ if (visitState.get(comment.id) === 2) continue;
3110
+ const path: string[] = [];
3111
+ let current: ImportedComment | undefined = comment;
3112
+ while (current && visitState.get(current.id) === undefined) {
3113
+ visitState.set(current.id, 1);
3114
+ path.push(current.id);
3115
+ current = current.parentId === undefined ? undefined : commentById.get(current.parentId);
3116
+ }
3117
+ if (current && visitState.get(current.id) === 1) {
3118
+ throw new DocxImportError(
3119
+ "invalid_package",
3120
+ "Comment reply graph contains a cycle",
3121
+ "word/commentsExtended.xml",
3122
+ );
3123
+ }
3124
+ for (const id of path) visitState.set(id, 2);
3125
+ }
3126
+ type Counts = { start: number; end: number; reference: number };
3127
+ const storyCountsByComment = new Map<string, Counts[]>();
3128
+ const countStory = (items: readonly ImportedBlock[]): void => {
3129
+ const counts = new Map<string, Counts>();
3130
+ const walk = (nested: readonly ImportedBlock[]): void => {
3131
+ for (const block of nested) {
3132
+ if (block.kind === "paragraph") {
3133
+ for (const anchor of block.commentAnchors) {
3134
+ if (!ids.has(anchor.commentId))
3135
+ throw new DocxImportError(
3136
+ "invalid_package",
3137
+ "Document references a missing comment",
3138
+ "word/comments.xml",
3139
+ );
3140
+ if (!roots.has(anchor.commentId))
3141
+ throw new DocxImportError(
3142
+ "invalid_package",
3143
+ "Document anchors a comment reply instead of its root",
3144
+ "word/comments.xml",
3145
+ );
3146
+ const count = counts.get(anchor.commentId) ?? { start: 0, end: 0, reference: 0 };
3147
+ count[anchor.kind] += 1;
3148
+ counts.set(anchor.commentId, count);
3149
+ }
3150
+ } else if (block.kind === "table") {
3151
+ for (const row of block.rows) for (const cell of row.cells) walk(cell.blocks);
3152
+ }
3153
+ }
3154
+ };
3155
+ walk(items);
3156
+ for (const [commentId, count] of counts) {
3157
+ const stories = storyCountsByComment.get(commentId);
3158
+ if (stories) stories.push(count);
3159
+ else storyCountsByComment.set(commentId, [count]);
3160
+ }
3161
+ };
3162
+ countStory(blocks);
3163
+ for (const item of [...headers, ...footers]) countStory(item.blocks);
3164
+ for (const comment of comments) {
3165
+ const anchoredStories = storyCountsByComment.get(comment.id) ?? [];
3166
+ if (comment.parentId === undefined) {
3167
+ if (anchoredStories.length !== 1) {
3168
+ throw new DocxImportError(
3169
+ "invalid_package",
3170
+ "Root comment must be anchored in exactly one document story",
3171
+ "word/document.xml",
3172
+ );
3173
+ }
3174
+ const count = anchoredStories[0] as Counts;
3175
+ // OOXML permits point comments with either or both range markers absent;
3176
+ // the commentReference remains the authoritative anchor.
3177
+ if (count.reference !== 1 || count.start > 1 || count.end > 1) {
3178
+ throw new DocxImportError(
3179
+ "invalid_package",
3180
+ "Comment story has duplicate or missing reference anchors",
3181
+ "word/document.xml",
3182
+ );
3183
+ }
3184
+ } else if (anchoredStories.length > 0) {
3185
+ throw new DocxImportError(
3186
+ "invalid_package",
3187
+ "Comment replies must not have document anchors",
3188
+ "word/document.xml",
3189
+ );
3190
+ }
3191
+ }
3192
+ }
3193
+
3194
+ const UNSUPPORTED_CONTENT = new Map<string, string>([
3195
+ ["drawing", "drawings and images"],
3196
+ ["pict", "VML drawings and images"],
3197
+ ["object", "embedded objects"],
3198
+ ["oleObject", "OLE objects"],
3199
+ ["altChunk", "altChunk content"],
3200
+ ["hyperlink", "hyperlinks"],
3201
+ ["fldSimple", "fields"],
3202
+ ["fldChar", "fields"],
3203
+ ["instrText", "fields"],
3204
+ ["footnoteReference", "footnotes"],
3205
+ ["endnoteReference", "endnotes"],
3206
+ ["sdt", "content controls"],
3207
+ ["customXml", "custom XML content"],
3208
+ ["smartTag", "smart tags"],
3209
+ ["oMath", "equations"],
3210
+ ["oMathPara", "equations"],
3211
+ ["sym", "symbol-font characters"],
3212
+ ["ruby", "ruby annotations"],
3213
+ ["subDoc", "subdocuments"],
3214
+ ["txbxContent", "text boxes"],
3215
+ ["AlternateContent", "alternate content"],
3216
+ ["moveFrom", "tracked moves"],
3217
+ ["moveTo", "tracked moves"],
3218
+ ["pPrChange", "paragraph-property revisions"],
3219
+ ["rPrChange", "run-property revisions"],
3220
+ ["tblPrChange", "table-property revisions"],
3221
+ ["tblGridChange", "table-grid revisions"],
3222
+ ["trPrChange", "table-row-property revisions"],
3223
+ ["tcPrChange", "table-cell-property revisions"],
3224
+ ["sectPrChange", "section-property revisions"],
3225
+ ["numberingChange", "numbering-property revisions"],
3226
+ ["cellIns", "table-cell insertion revisions"],
3227
+ ["cellDel", "table-cell deletion revisions"],
3228
+ ["cellMerge", "table-cell merge revisions"],
3229
+ ["background", "document backgrounds"],
3230
+ ]);
3231
+
3232
+ function rejectUnsupportedContent(root: XmlElement, partName: string): void {
3233
+ const stack = [root];
3234
+ while (stack.length > 0) {
3235
+ const element = stack.pop();
3236
+ if (!element) break;
3237
+ const feature = UNSUPPORTED_CONTENT.get(element.localName);
3238
+ if (feature)
3239
+ throw new DocxImportError(
3240
+ "unsupported_feature",
3241
+ `DOCX ${feature} are not yet supported`,
3242
+ partName,
3243
+ );
3244
+ for (const child of elementChildren(element)) stack.push(child);
3245
+ }
3246
+ }
3247
+
3248
+ const NON_CONTENT_MARKERS = new Set([
3249
+ "bookmarkStart",
3250
+ "bookmarkEnd",
3251
+ "proofErr",
3252
+ "permStart",
3253
+ "permEnd",
3254
+ "commentReference",
3255
+ "lastRenderedPageBreak",
3256
+ ]);
3257
+
3258
+ function unsupportedElement(element: XmlElement, partName: string): DocxImportError {
3259
+ return new DocxImportError(
3260
+ "unsupported_feature",
3261
+ `Unsupported content element: ${element.name}`,
3262
+ partName,
3263
+ );
3264
+ }
3265
+
3266
+ function relationshipSourcePart(relationshipPart: string): string {
3267
+ if (relationshipPart === "_rels/.rels") return "";
3268
+ const match = /^(.*\/)?_rels\/([^/]+)\.rels$/.exec(relationshipPart);
3269
+ if (!match)
3270
+ throw new DocxImportError(
3271
+ "invalid_package",
3272
+ "Invalid relationships part name",
3273
+ relationshipPart,
3274
+ );
3275
+ return `${match[1] ?? ""}${match[2] ?? ""}`;
3276
+ }
3277
+
3278
+ function resolvePartTarget(sourcePart: string, target: string, relationshipsPart: string): string {
3279
+ if (
3280
+ /[\u0000-\u001f\u007f]/.test(target) ||
3281
+ target.includes("\\") ||
3282
+ target.startsWith("//") ||
3283
+ /%(?:2e|2f|5c)/i.test(target)
3284
+ ) {
3285
+ throw new DocxImportError("invalid_package", "Unsafe relationship target", relationshipsPart);
3286
+ }
3287
+ const packageAbsolute = target.startsWith("/");
3288
+ const relativeTarget = packageAbsolute ? target.slice(1) : target;
3289
+ const base =
3290
+ !packageAbsolute && sourcePart.includes("/")
3291
+ ? sourcePart.slice(0, sourcePart.lastIndexOf("/") + 1)
3292
+ : "";
3293
+ const combined = `${base}${relativeTarget}`;
3294
+ if (combined.includes("//"))
3295
+ throw new DocxImportError(
3296
+ "invalid_package",
3297
+ "Relationship target contains an empty path segment",
3298
+ relationshipsPart,
3299
+ );
3300
+ let segmentCount = 1;
3301
+ for (let index = 0; index < combined.length; index += 1) {
3302
+ if (combined.charCodeAt(index) === 47) {
3303
+ segmentCount += 1;
3304
+ if (segmentCount > 256)
3305
+ throw limitError(
3306
+ "Relationship target exceeds its path-segment safety cap",
3307
+ relationshipsPart,
3308
+ );
3309
+ }
3310
+ }
3311
+ const output: string[] = [];
3312
+ for (const segment of combined.split("/")) {
3313
+ if (segment === "")
3314
+ throw new DocxImportError(
3315
+ "invalid_package",
3316
+ "Relationship target contains an empty path segment",
3317
+ relationshipsPart,
3318
+ );
3319
+ if (segment === ".") continue;
3320
+ if (segment === "..") {
3321
+ if (output.length === 0)
3322
+ throw new DocxImportError(
3323
+ "invalid_package",
3324
+ "Relationship target escapes the package root",
3325
+ relationshipsPart,
3326
+ );
3327
+ output.pop();
3328
+ } else output.push(segment);
3329
+ }
3330
+ return output.join("/");
3331
+ }
3332
+
3333
+ function consumeBlock(budget: ImportBudget): void {
3334
+ budget.blocks += 1;
3335
+ if (budget.blocks > budget.limits.maxBlocks) throw limitError("DOCX exceeds maxBlocks");
3336
+ }
3337
+
3338
+ function consumeText(budget: ImportBudget, characters: number): void {
3339
+ budget.textCharacters += characters;
3340
+ if (budget.textCharacters > budget.limits.maxTextCharacters)
3341
+ throw limitError("DOCX exceeds maxTextCharacters");
3342
+ }
3343
+
3344
+ function elementChildren(element: XmlElement | undefined): XmlElement[] {
3345
+ return element?.children.filter((child): child is XmlElement => child.type === "element") ?? [];
3346
+ }
3347
+
3348
+ function children(element: XmlElement | undefined, name: string): XmlElement[] {
3349
+ return elementChildren(element).filter((child) => child.localName === name);
3350
+ }
3351
+
3352
+ function firstChild(element: XmlElement | undefined, name: string): XmlElement | undefined {
3353
+ return elementChildren(element).find((child) => child.localName === name);
3354
+ }
3355
+
3356
+ function singletonChild(
3357
+ element: XmlElement | undefined,
3358
+ name: string,
3359
+ label: string,
3360
+ ): XmlElement | undefined {
3361
+ let match: XmlElement | undefined;
3362
+ for (const child of elementChildren(element)) {
3363
+ if (child.localName !== name) continue;
3364
+ if (match) throw new DocxImportError("invalid_package", `${label} repeats ${name}`);
3365
+ match = child;
3366
+ }
3367
+ return match;
3368
+ }
3369
+
3370
+ function assertUniqueChildNames(element: XmlElement | undefined, label: string): void {
3371
+ const seen = new Set<string>();
3372
+ for (const child of elementChildren(element)) {
3373
+ if (seen.has(child.localName)) {
3374
+ throw new DocxImportError("invalid_package", `${label} repeats ${child.localName}`);
3375
+ }
3376
+ seen.add(child.localName);
3377
+ }
3378
+ }
3379
+
3380
+ function lastDescendant(element: XmlElement, name: string): XmlElement | undefined {
3381
+ let match: XmlElement | undefined;
3382
+ const stack = [element];
3383
+ while (stack.length > 0) {
3384
+ const current = stack.pop();
3385
+ if (!current) continue;
3386
+ if (current.localName === name) match = current;
3387
+ const nested = elementChildren(current);
3388
+ for (let index = nested.length - 1; index >= 0; index -= 1) {
3389
+ const child = nested[index];
3390
+ if (child) stack.push(child);
3391
+ }
3392
+ }
3393
+ return match;
3394
+ }
3395
+
3396
+ function onlyElement(element: XmlElement, name: string, partName: string): XmlElement {
3397
+ const matches = children(element, name);
3398
+ if (matches.length !== 1)
3399
+ throw new DocxImportError("invalid_package", `Expected exactly one ${name} element`, partName);
3400
+ return matches[0] as XmlElement;
3401
+ }
3402
+
3403
+ function attribute(element: XmlElement | undefined, name: string): string | undefined {
3404
+ return prefixedAttribute(element, "w", name);
3405
+ }
3406
+
3407
+ function requiredAttribute(element: XmlElement, name: string, partName: string): string {
3408
+ const value = attribute(element, name);
3409
+ if (value === undefined || value === "")
3410
+ throw new DocxImportError("invalid_package", `Missing required ${name} attribute`, partName);
3411
+ return value;
3412
+ }
3413
+
3414
+ function prefixedAttribute(
3415
+ element: XmlElement | undefined,
3416
+ prefix: string,
3417
+ name: string,
3418
+ ): string | undefined {
3419
+ return element?.attributes.get(`${prefix}:${name}`);
3420
+ }
3421
+
3422
+ function requiredPrefixedAttribute(
3423
+ element: XmlElement,
3424
+ prefix: string,
3425
+ name: string,
3426
+ partName: string,
3427
+ ): string {
3428
+ const value = prefixedAttribute(element, prefix, name);
3429
+ if (value === undefined || value === "") {
3430
+ throw new DocxImportError(
3431
+ "invalid_package",
3432
+ `Missing required ${prefix}:${name} attribute`,
3433
+ partName,
3434
+ );
3435
+ }
3436
+ return value;
3437
+ }
3438
+
3439
+ function exactAttribute(element: XmlElement | undefined, name: string): string | undefined {
3440
+ return element?.attributes.get(name);
3441
+ }
3442
+
3443
+ function requiredExactAttribute(element: XmlElement, name: string, partName: string): string {
3444
+ const value = exactAttribute(element, name);
3445
+ if (value === undefined || value === "")
3446
+ throw new DocxImportError("invalid_package", `Missing required ${name} attribute`, partName);
3447
+ return value;
3448
+ }
3449
+
3450
+ function assertExactAttributes(
3451
+ element: XmlElement,
3452
+ allowed: ReadonlySet<string>,
3453
+ partName: string,
3454
+ ): void {
3455
+ for (const name of element.attributes.keys()) {
3456
+ if (name === "xmlns" || name.startsWith("xmlns:")) continue;
3457
+ if (!allowed.has(name))
3458
+ throw new DocxImportError("invalid_package", `Unexpected attribute: ${name}`, partName);
3459
+ }
3460
+ }
3461
+
3462
+ function requireLeafElement(element: XmlElement, partName: string): void {
3463
+ for (const child of element.children) {
3464
+ if (child.type === "element" || !isOnlyXmlWhitespace(child.value)) {
3465
+ throw new DocxImportError(
3466
+ "invalid_package",
3467
+ "Package declaration element must be empty",
3468
+ partName,
3469
+ );
3470
+ }
3471
+ }
3472
+ }
3473
+
3474
+ function textContent(element: XmlElement): string {
3475
+ let output = "";
3476
+ const stack: XmlNode[] = [...element.children].reverse();
3477
+ while (stack.length > 0) {
3478
+ const node = stack.pop();
3479
+ if (!node) break;
3480
+ if (node.type === "text") output += node.value;
3481
+ else {
3482
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
3483
+ const child = node.children[index];
3484
+ if (child) stack.push(child);
3485
+ }
3486
+ }
3487
+ }
3488
+ return output;
3489
+ }
3490
+
3491
+ function requireLocalName(element: XmlElement, expected: string, partName: string): void {
3492
+ if (element.localName !== expected)
3493
+ throw new DocxImportError("invalid_package", `Expected ${expected} root element`, partName);
3494
+ }
3495
+
3496
+ function requireDefaultNamespace(element: XmlElement, expected: string, partName: string): void {
3497
+ if (element.attributes.get("xmlns") !== expected) {
3498
+ throw new DocxImportError(
3499
+ "invalid_package",
3500
+ "Unexpected or missing XML default namespace",
3501
+ partName,
3502
+ );
3503
+ }
3504
+ }
3505
+
3506
+ function requireDefaultNamespaceTree(root: XmlElement, expected: string, partName: string): void {
3507
+ requireDefaultNamespace(root, expected, partName);
3508
+ const stack = [root];
3509
+ while (stack.length > 0) {
3510
+ const element = stack.pop();
3511
+ if (!element) break;
3512
+ if (element.name.includes(":"))
3513
+ throw new DocxImportError(
3514
+ "invalid_package",
3515
+ `Prefixed element is forbidden in this package part: ${element.name}`,
3516
+ partName,
3517
+ );
3518
+ const rebound = element.attributes.get("xmlns");
3519
+ if (rebound !== undefined && rebound !== expected)
3520
+ throw new DocxImportError("invalid_package", "Default XML namespace is rebound", partName);
3521
+ for (const child of elementChildren(element)) stack.push(child);
3522
+ }
3523
+ }
3524
+
3525
+ function requirePrefixedNamespace(
3526
+ element: XmlElement,
3527
+ prefix: string,
3528
+ expected: string,
3529
+ partName: string,
3530
+ ): void {
3531
+ if (element.attributes.get(`xmlns:${prefix}`) !== expected) {
3532
+ throw new DocxImportError(
3533
+ "unsupported_feature",
3534
+ `Unsupported ${prefix} XML namespace`,
3535
+ partName,
3536
+ );
3537
+ }
3538
+ }
3539
+
3540
+ function requireWordNamespace(root: XmlElement, partName: string): void {
3541
+ requirePrefixedNamespaceTree(root, "w", WORDPROCESSINGML_NAMESPACE, partName);
3542
+ }
3543
+
3544
+ function requirePrefixedNamespaceTree(
3545
+ root: XmlElement,
3546
+ prefix: string,
3547
+ expected: string,
3548
+ partName: string,
3549
+ ): void {
3550
+ requirePrefixedNamespace(root, prefix, expected, partName);
3551
+ validateKnownNamespaceBindings(root, partName);
3552
+ const stack = [root];
3553
+ while (stack.length > 0) {
3554
+ const element = stack.pop();
3555
+ if (!element) break;
3556
+ if (!element.name.startsWith(`${prefix}:`)) {
3557
+ throw new DocxImportError(
3558
+ "unsupported_feature",
3559
+ `Unsupported namespaced Word content: ${element.name}`,
3560
+ partName,
3561
+ );
3562
+ }
3563
+ const rebound = element.attributes.get(`xmlns:${prefix}`);
3564
+ if (rebound !== undefined && rebound !== expected) {
3565
+ throw new DocxImportError("invalid_package", `${prefix} XML namespace is rebound`, partName);
3566
+ }
3567
+ for (const child of elementChildren(element)) stack.push(child);
3568
+ }
3569
+ }
3570
+
3571
+ const KNOWN_XML_NAMESPACES = new Map<string, string>([
3572
+ ["xml", "http://www.w3.org/XML/1998/namespace"],
3573
+ ["w", WORDPROCESSINGML_NAMESPACE],
3574
+ ["r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"],
3575
+ ["w14", "http://schemas.microsoft.com/office/word/2010/wordml"],
3576
+ ["w15", COMMENTS_EXTENDED_NAMESPACE],
3577
+ ]);
3578
+
3579
+ function validateNamespaceWellFormedness(root: XmlElement, partName: string): void {
3580
+ const xmlNamespace = KNOWN_XML_NAMESPACES.get("xml") as string;
3581
+ const bindings = new Map<string, string>([["xml", xmlNamespace]]);
3582
+ const visit = (element: XmlElement): void => {
3583
+ const changes: Array<{ prefix: string; previous?: string }> = [];
3584
+ const defaultNamespace = element.attributes.get("xmlns");
3585
+ if (defaultNamespace === xmlNamespace || defaultNamespace === "http://www.w3.org/2000/xmlns/") {
3586
+ throw new DocxImportError(
3587
+ "invalid_xml",
3588
+ "Default namespace uses a reserved XML namespace",
3589
+ partName,
3590
+ );
3591
+ }
3592
+ for (const [name, value] of element.attributes) {
3593
+ if (!name.startsWith("xmlns:")) continue;
3594
+ const prefix = name.slice(6);
3595
+ if (prefix === "xmlns" || value.length === 0 || value === "http://www.w3.org/2000/xmlns/") {
3596
+ throw new DocxImportError("invalid_xml", "Invalid XML namespace declaration", partName);
3597
+ }
3598
+ if ((prefix === "xml") !== (value === xmlNamespace)) {
3599
+ throw new DocxImportError("invalid_xml", "The xml namespace is reserved", partName);
3600
+ }
3601
+ const previous = bindings.get(prefix);
3602
+ changes.push(stripUndefined({ prefix, previous }));
3603
+ bindings.set(prefix, value);
3604
+ }
3605
+ const elementSeparator = element.name.indexOf(":");
3606
+ if (elementSeparator >= 0 && !bindings.has(element.name.slice(0, elementSeparator))) {
3607
+ throw new DocxImportError(
3608
+ "invalid_xml",
3609
+ "Element uses an undeclared namespace prefix",
3610
+ partName,
3611
+ );
3612
+ }
3613
+ for (const name of element.attributes.keys()) {
3614
+ if (name === "xmlns" || name.startsWith("xmlns:")) continue;
3615
+ const separator = name.indexOf(":");
3616
+ if (separator >= 0 && !bindings.has(name.slice(0, separator))) {
3617
+ throw new DocxImportError(
3618
+ "invalid_xml",
3619
+ "Attribute uses an undeclared namespace prefix",
3620
+ partName,
3621
+ );
3622
+ }
3623
+ }
3624
+ for (const child of elementChildren(element)) visit(child);
3625
+ for (let index = changes.length - 1; index >= 0; index -= 1) {
3626
+ const change = changes[index];
3627
+ if (!change) continue;
3628
+ if (change.previous === undefined) bindings.delete(change.prefix);
3629
+ else bindings.set(change.prefix, change.previous);
3630
+ }
3631
+ };
3632
+ visit(root);
3633
+ }
3634
+
3635
+ function validateKnownNamespaceBindings(root: XmlElement, partName: string): void {
3636
+ const bindings = new Map<string, string>([["xml", KNOWN_XML_NAMESPACES.get("xml") as string]]);
3637
+ const visit = (element: XmlElement): void => {
3638
+ const changes: Array<{ prefix: string; previous?: string }> = [];
3639
+ for (const [name, value] of element.attributes) {
3640
+ if (!name.startsWith("xmlns:")) continue;
3641
+ const declaredPrefix = name.slice(6);
3642
+ const previous = bindings.get(declaredPrefix);
3643
+ changes.push(stripUndefined({ prefix: declaredPrefix, previous }));
3644
+ const expected = KNOWN_XML_NAMESPACES.get(declaredPrefix);
3645
+ if (expected !== undefined && value !== expected) {
3646
+ throw new DocxImportError(
3647
+ "invalid_package",
3648
+ `Known namespace ${declaredPrefix} is rebound`,
3649
+ partName,
3650
+ );
3651
+ }
3652
+ bindings.set(declaredPrefix, value);
3653
+ }
3654
+ for (const name of element.attributes.keys()) {
3655
+ if (name === "xmlns" || name.startsWith("xmlns:")) continue;
3656
+ const separator = name.indexOf(":");
3657
+ if (separator < 0) continue;
3658
+ const attributePrefix = name.slice(0, separator);
3659
+ const expected = KNOWN_XML_NAMESPACES.get(attributePrefix);
3660
+ if (!bindings.has(attributePrefix)) {
3661
+ throw new DocxImportError(
3662
+ "invalid_package",
3663
+ `Attribute uses an unbound ${attributePrefix} namespace`,
3664
+ partName,
3665
+ );
3666
+ }
3667
+ if (expected !== undefined && bindings.get(attributePrefix) !== expected) {
3668
+ throw new DocxImportError(
3669
+ "invalid_package",
3670
+ `Attribute uses an unbound ${attributePrefix} namespace`,
3671
+ partName,
3672
+ );
3673
+ }
3674
+ }
3675
+ for (const child of elementChildren(element)) visit(child);
3676
+ for (let index = changes.length - 1; index >= 0; index -= 1) {
3677
+ const change = changes[index];
3678
+ if (!change) continue;
3679
+ if (change.previous === undefined) bindings.delete(change.prefix);
3680
+ else bindings.set(change.prefix, change.previous);
3681
+ }
3682
+ };
3683
+ visit(root);
3684
+ }
3685
+
3686
+ function valueOf(element: XmlElement | undefined): string | undefined {
3687
+ return attribute(element, "val");
3688
+ }
3689
+
3690
+ function numberValue(
3691
+ element: XmlElement | undefined,
3692
+ transform: (value: number) => number = (value) => value,
3693
+ ): number | undefined {
3694
+ const value = valueOf(element);
3695
+ if (value === undefined) return undefined;
3696
+ const number = Number(value);
3697
+ if (!Number.isFinite(number))
3698
+ throw new DocxImportError("invalid_package", "OOXML numeric value is invalid");
3699
+ const transformed = transform(number);
3700
+ if (!Number.isFinite(transformed))
3701
+ throw new DocxImportError("invalid_package", "OOXML numeric value is out of range");
3702
+ return transformed;
3703
+ }
3704
+
3705
+ function integerValue(element: XmlElement | undefined, fallback: number): number {
3706
+ if (valueOf(element) === undefined) return fallback;
3707
+ const value = numberValue(element);
3708
+ if (value === undefined || !Number.isInteger(value)) {
3709
+ throw new DocxImportError("invalid_package", "OOXML integer value is invalid");
3710
+ }
3711
+ return value;
3712
+ }
3713
+
3714
+ function numberAttribute(
3715
+ element: XmlElement | undefined,
3716
+ name: string,
3717
+ transform: (value: number) => number = (value) => value,
3718
+ ): number | undefined {
3719
+ const raw = attribute(element, name);
3720
+ if (raw === undefined) return undefined;
3721
+ const value = Number(raw);
3722
+ if (!Number.isFinite(value))
3723
+ throw new DocxImportError("invalid_package", "OOXML numeric attribute is invalid");
3724
+ const transformed = transform(value);
3725
+ if (!Number.isFinite(transformed))
3726
+ throw new DocxImportError("invalid_package", "OOXML numeric attribute is out of range");
3727
+ return transformed;
3728
+ }
3729
+
3730
+ function booleanAttribute(element: XmlElement, name: string, fallback: boolean): boolean {
3731
+ const value = attribute(element, name);
3732
+ return value === undefined ? fallback : parseBooleanLexical(value);
3733
+ }
3734
+
3735
+ function optionalBooleanAttribute(element: XmlElement, name: string): boolean | undefined {
3736
+ const value = attribute(element, name);
3737
+ return value === undefined ? undefined : parseBooleanLexical(value);
3738
+ }
3739
+
3740
+ function optionalBoolean(element: XmlElement | undefined): boolean | undefined {
3741
+ if (!element) return undefined;
3742
+ return booleanValue(element, true);
3743
+ }
3744
+
3745
+ function booleanValue(element: XmlElement | undefined, fallback: boolean): boolean {
3746
+ if (!element) return fallback;
3747
+ const value = valueOf(element);
3748
+ return value === undefined ? true : parseBooleanLexical(value);
3749
+ }
3750
+
3751
+ const FALSE_VALUES = new Set(["0", "false", "off", "no"]);
3752
+ const TRUE_VALUES = new Set(["1", "true", "on", "yes"]);
3753
+
3754
+ function parseBooleanLexical(value: string): boolean {
3755
+ const normalized = value.toLowerCase();
3756
+ if (FALSE_VALUES.has(normalized)) return false;
3757
+ if (TRUE_VALUES.has(normalized)) return true;
3758
+ throw new DocxImportError("invalid_package", "OOXML boolean value is invalid");
3759
+ }
3760
+
3761
+ function localName(name: string): string {
3762
+ return name.slice(name.lastIndexOf(":") + 1);
3763
+ }
3764
+
3765
+ function twipsToPt(value: number): number {
3766
+ return value / 20;
3767
+ }
3768
+
3769
+ function stripUndefined<T extends object>(value: object): T {
3770
+ const record = value as Record<string, unknown>;
3771
+ for (const key of Object.keys(record)) if (record[key] === undefined) delete record[key];
3772
+ return value as T;
3773
+ }
3774
+
3775
+ function decodeUtf8(bytes: Uint8Array, description: string, partName?: string): string {
3776
+ try {
3777
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3778
+ } catch {
3779
+ throw new DocxImportError(
3780
+ partName ? "invalid_xml" : "invalid_zip",
3781
+ `${description} is not valid UTF-8`,
3782
+ partName,
3783
+ );
3784
+ }
3785
+ }
3786
+
3787
+ function limitError(message: string, partName?: string): DocxImportError {
3788
+ return new DocxImportError("limit_exceeded", message, partName);
3789
+ }
3790
+
3791
+ function compareStrings(left: string, right: string): number {
3792
+ return left < right ? -1 : left > right ? 1 : 0;
3793
+ }
3794
+
3795
+ function compareOpaqueRelationships(
3796
+ left: DocumentOpaqueRelationship,
3797
+ right: DocumentOpaqueRelationship,
3798
+ ): number {
3799
+ return (
3800
+ compareStrings(left.sourcePart, right.sourcePart) ||
3801
+ compareStrings(left.type, right.type) ||
3802
+ compareStrings(left.targetPart, right.targetPart)
3803
+ );
3804
+ }
3805
+
3806
+ function compareOpaqueContentTypes(
3807
+ left: DocumentOpaqueContentType,
3808
+ right: DocumentOpaqueContentType,
3809
+ ): number {
3810
+ return (
3811
+ compareStrings(left.partName, right.partName) ||
3812
+ compareStrings(left.contentType, right.contentType)
3813
+ );
3814
+ }