@ansonlai/docx-redline-js 0.8.0 → 0.8.1
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.
- package/CHANGELOG.md +539 -486
- package/README.md +331 -309
- package/dist/docx-redline-js.esm.js +1083 -163
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +107 -107
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +1083 -163
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +1083 -163
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.js +416 -22
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -575
- package/index.js +349 -348
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/index.d.ts +1 -1
- package/package.json +2 -1
- package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
- package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
- package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
- package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
- package/scripts/generate-word-package-integrity-cases.mjs +141 -0
- package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
- package/scripts/word-com-package-integrity.ps1 +98 -0
- package/services/batch-operation-orchestrator.js +262 -259
- package/services/comment-replies.js +96 -95
- package/services/comment-thread-parts.js +280 -0
- package/services/document-inspection.js +366 -365
- package/services/document-operation-applier.js +230 -202
- package/services/document-operation-contract.js +17 -2
- package/services/error-recovery.js +6 -0
- package/services/headers-footers.js +206 -0
- package/services/operation-batch-compiler.js +1 -0
- package/services/operation-preflight.js +231 -218
- package/services/package-parts.js +80 -0
- package/services/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
|
@@ -1,569 +1,631 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Standalone OOXML/Docx plumbing helpers shared by browser and Node hosts.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
|
-
import { warn as logWarning } from '../adapters/logger.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
.
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (
|
|
538
|
-
throw new Error(
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
);
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if (!
|
|
568
|
-
|
|
569
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Standalone OOXML/Docx plumbing helpers shared by browser and Node hosts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
|
+
import { warn as logWarning } from '../adapters/logger.js';
|
|
7
|
+
import { commentParaId } from './comment-thread-parts.js';
|
|
8
|
+
import { correctedContentTypeFor, getPartSpec } from './package-parts.js';
|
|
9
|
+
|
|
10
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
11
|
+
const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
12
|
+
const NS_RELS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
13
|
+
const NUMBERING = getPartSpec('numbering');
|
|
14
|
+
const COMMENTS = getPartSpec('comments');
|
|
15
|
+
const COMMENTS_EXTENDED = getPartSpec('commentsExtended');
|
|
16
|
+
const NUMBERING_REL_TYPE = NUMBERING.relType;
|
|
17
|
+
const NUMBERING_CONTENT_TYPE = NUMBERING.contentType;
|
|
18
|
+
const COMMENTS_REL_TYPE = COMMENTS.relType;
|
|
19
|
+
const COMMENTS_CONTENT_TYPE = COMMENTS.contentType;
|
|
20
|
+
const COMMENTS_EXTENDED_REL_TYPE = COMMENTS_EXTENDED.relType;
|
|
21
|
+
// Word's own value. The old 'application/vnd.ms-word.commentsExtended+xml' label is a registered legacy value.
|
|
22
|
+
const COMMENTS_EXTENDED_CONTENT_TYPE = COMMENTS_EXTENDED.contentType;
|
|
23
|
+
|
|
24
|
+
const DOCUMENT_PATH = getPartSpec('document').path;
|
|
25
|
+
const NUMBERING_PATH = NUMBERING.path;
|
|
26
|
+
const COMMENTS_PATH = COMMENTS.path;
|
|
27
|
+
const COMMENTS_EXTENDED_PATH = COMMENTS_EXTENDED.path;
|
|
28
|
+
const CONTENT_TYPES_PATH = '[Content_Types].xml';
|
|
29
|
+
const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
|
|
30
|
+
|
|
31
|
+
export function parseXmlStrictStandalone(xmlText, label = 'xml') {
|
|
32
|
+
const parsed = parseOoxmlSafe(xmlText, 'application/xml');
|
|
33
|
+
if (parsed.error || !parsed.doc) {
|
|
34
|
+
const error = new Error(`[XML parse error] ${label}: ${parsed.error?.message || 'Unknown'}`);
|
|
35
|
+
error.code = 'PARSE_ERROR';
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return parsed.doc;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isSectionPropertiesElement(node) {
|
|
42
|
+
return !!node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'sectPr';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getBodyElementFromDocument(xmlDoc) {
|
|
46
|
+
return xmlDoc.getElementsByTagNameNS('*', 'body')[0] || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getDirectSectionProperties(body) {
|
|
50
|
+
for (const child of Array.from(body.childNodes || [])) {
|
|
51
|
+
if (isSectionPropertiesElement(child)) return child;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function insertBodyElementBeforeSectPr(body, element) {
|
|
57
|
+
const sectPr = getDirectSectionProperties(body);
|
|
58
|
+
if (sectPr) {
|
|
59
|
+
body.insertBefore(element, sectPr);
|
|
60
|
+
} else {
|
|
61
|
+
body.appendChild(element);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeBodySectionOrderStandalone(xmlDoc) {
|
|
66
|
+
const body = getBodyElementFromDocument(xmlDoc);
|
|
67
|
+
if (!body) return;
|
|
68
|
+
const sectPr = getDirectSectionProperties(body);
|
|
69
|
+
if (!sectPr) return;
|
|
70
|
+
let cursor = sectPr.nextSibling;
|
|
71
|
+
while (cursor) {
|
|
72
|
+
const next = cursor.nextSibling;
|
|
73
|
+
if (cursor.nodeType === 1) {
|
|
74
|
+
body.insertBefore(cursor, sectPr);
|
|
75
|
+
}
|
|
76
|
+
cursor = next;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Flattens nested table-cell paragraphs (`w:tc > w:p > w:p`) by promoting
|
|
82
|
+
* inner paragraphs to direct `w:tc` children.
|
|
83
|
+
*
|
|
84
|
+
* @param {Document} xmlDoc
|
|
85
|
+
* @param {{ onInfo?: (message: string) => void }} [options]
|
|
86
|
+
* @returns {number} number of nested paragraphs fixed
|
|
87
|
+
*/
|
|
88
|
+
export function sanitizeNestedParagraphsInTables(xmlDoc, options = {}) {
|
|
89
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
90
|
+
const tcs = xmlDoc.getElementsByTagNameNS(NS_W, 'tc');
|
|
91
|
+
let fixed = 0;
|
|
92
|
+
for (const tc of Array.from(tcs)) {
|
|
93
|
+
const outerParagraphs = Array.from(tc.childNodes || []).filter(
|
|
94
|
+
node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
95
|
+
);
|
|
96
|
+
for (const outerParagraph of outerParagraphs) {
|
|
97
|
+
const innerParagraphs = Array.from(outerParagraph.childNodes || []).filter(
|
|
98
|
+
node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
99
|
+
);
|
|
100
|
+
for (const innerParagraph of innerParagraphs) {
|
|
101
|
+
tc.insertBefore(innerParagraph, outerParagraph);
|
|
102
|
+
fixed += 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (fixed > 0) {
|
|
107
|
+
onInfo(`[Sanitize] Fixed ${fixed} nested w:p element(s) in table cells`);
|
|
108
|
+
}
|
|
109
|
+
return fixed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getPackagePartName(partElement) {
|
|
113
|
+
return partElement.getAttribute('pkg:name') || partElement.getAttribute('name') || '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function extractFromPackageXml(packageXml) {
|
|
117
|
+
const serializer = createSerializer();
|
|
118
|
+
const pkgDoc = parseXmlStrictStandalone(packageXml, 'package OOXML');
|
|
119
|
+
const parts = Array.from(pkgDoc.getElementsByTagNameNS('*', 'part'));
|
|
120
|
+
const documentPart = parts.find(part => getPackagePartName(part) === '/word/document.xml');
|
|
121
|
+
if (!documentPart) {
|
|
122
|
+
throw new Error('Package output missing /word/document.xml part');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const xmlData = documentPart.getElementsByTagNameNS('*', 'xmlData')[0];
|
|
126
|
+
if (!xmlData) {
|
|
127
|
+
throw new Error('Package document part missing pkg:xmlData');
|
|
128
|
+
}
|
|
129
|
+
const documentNode = Array.from(xmlData.childNodes || []).find(node => node.nodeType === 1);
|
|
130
|
+
if (!documentNode) {
|
|
131
|
+
throw new Error('Package document part missing XML payload');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const body = documentNode.getElementsByTagNameNS('*', 'body')[0];
|
|
135
|
+
const replacementNodes = body
|
|
136
|
+
? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
|
|
137
|
+
: [documentNode];
|
|
138
|
+
|
|
139
|
+
const numberingPart = parts.find(part => getPackagePartName(part) === '/word/numbering.xml');
|
|
140
|
+
let numberingXml = null;
|
|
141
|
+
if (numberingPart) {
|
|
142
|
+
const numberingXmlData = numberingPart.getElementsByTagNameNS('*', 'xmlData')[0];
|
|
143
|
+
const numberingNode = numberingXmlData
|
|
144
|
+
? Array.from(numberingXmlData.childNodes || []).find(node => node.nodeType === 1)
|
|
145
|
+
: null;
|
|
146
|
+
if (numberingNode) {
|
|
147
|
+
numberingXml = serializer.serializeToString(numberingNode);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
replacementNodes,
|
|
153
|
+
numberingXml,
|
|
154
|
+
sourceType: 'package'
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extracts replacement nodes and optional numbering payload from engine output.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} outputOxml
|
|
162
|
+
* @returns {{ replacementNodes: Element[], numberingXml: string|null, sourceType: 'package'|'document'|'fragment' }}
|
|
163
|
+
*/
|
|
164
|
+
export function extractReplacementNodesFromOoxml(outputOxml) {
|
|
165
|
+
if (typeof outputOxml !== 'string' || !outputOxml.trim()) {
|
|
166
|
+
return {
|
|
167
|
+
replacementNodes: [],
|
|
168
|
+
numberingXml: null,
|
|
169
|
+
sourceType: 'fragment',
|
|
170
|
+
status: 'error',
|
|
171
|
+
error: { code: 'PARSE_ERROR', message: 'Reconciliation engine returned no OOXML payload for this operation' }
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (outputOxml.includes('<pkg:package')) {
|
|
177
|
+
return extractFromPackageXml(outputOxml);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (outputOxml.includes('<w:document')) {
|
|
181
|
+
const doc = parseXmlStrictStandalone(outputOxml, 'document OOXML');
|
|
182
|
+
const body = doc.getElementsByTagNameNS('*', 'body')[0];
|
|
183
|
+
const replacementNodes = body
|
|
184
|
+
? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
|
|
185
|
+
: Array.from(doc.childNodes || []).filter(node => node.nodeType === 1);
|
|
186
|
+
return { replacementNodes, numberingXml: null, sourceType: 'document' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const wrapped = `<root xmlns:w="${NS_W}">${outputOxml}</root>`;
|
|
190
|
+
const fragmentDoc = parseXmlStrictStandalone(wrapped, 'OOXML fragment');
|
|
191
|
+
const replacementNodes = Array.from(fragmentDoc.documentElement.childNodes || []).filter(node => node.nodeType === 1);
|
|
192
|
+
return { replacementNodes, numberingXml: null, sourceType: 'fragment' };
|
|
193
|
+
} catch (caught) {
|
|
194
|
+
return {
|
|
195
|
+
replacementNodes: [],
|
|
196
|
+
numberingXml: null,
|
|
197
|
+
sourceType: 'fragment',
|
|
198
|
+
status: 'error',
|
|
199
|
+
error: { code: 'PARSE_ERROR', message: caught?.message || 'Could not parse OOXML payload.' }
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function upsertContentTypeOverride(ctDoc, partName, contentType) {
|
|
205
|
+
const overrides = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'));
|
|
206
|
+
const existingOverride = overrides.find(
|
|
207
|
+
override => (override.getAttribute('PartName') || '').toLowerCase() === String(partName).toLowerCase()
|
|
208
|
+
);
|
|
209
|
+
if (existingOverride) {
|
|
210
|
+
if ((existingOverride.getAttribute('ContentType') || '') === contentType) return false;
|
|
211
|
+
existingOverride.setAttribute('ContentType', contentType);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const override = ctDoc.createElementNS(NS_CT, 'Override');
|
|
216
|
+
override.setAttribute('PartName', partName);
|
|
217
|
+
override.setAttribute('ContentType', contentType);
|
|
218
|
+
ctDoc.documentElement.appendChild(override);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function upsertDocumentRelationship(relsDoc, relType, target, options = {}) {
|
|
223
|
+
const relsRoot = relsDoc.getElementsByTagNameNS('*', 'Relationships')[0] || relsDoc.documentElement;
|
|
224
|
+
const rels = Array.from(relsRoot.getElementsByTagNameNS('*', 'Relationship'));
|
|
225
|
+
const hasRel = rels.some(rel => (rel.getAttribute('Type') || '') === relType);
|
|
226
|
+
if (hasRel) return false;
|
|
227
|
+
|
|
228
|
+
let maxId = 0;
|
|
229
|
+
for (const rel of rels) {
|
|
230
|
+
const idValue = rel.getAttribute('Id') || '';
|
|
231
|
+
const idNum = Number.parseInt(idValue.replace(/^rId/i, ''), 10);
|
|
232
|
+
if (Number.isFinite(idNum)) {
|
|
233
|
+
maxId = Math.max(maxId, idNum);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const newId = `rId${maxId + 1}`;
|
|
238
|
+
const rel = relsDoc.createElementNS(NS_RELS, 'Relationship');
|
|
239
|
+
rel.setAttribute('Id', newId);
|
|
240
|
+
rel.setAttribute('Type', relType);
|
|
241
|
+
rel.setAttribute('Target', target);
|
|
242
|
+
relsRoot.appendChild(rel);
|
|
243
|
+
if (options?._receiptCollector) {
|
|
244
|
+
options._receiptCollector.recordRelationship(newId);
|
|
245
|
+
} else if (options?._documentOperationSession?.receiptCollector) {
|
|
246
|
+
options._documentOperationSession.receiptCollector.recordRelationship(newId);
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function readZipText(zip, filePath) {
|
|
252
|
+
const entry = zip.file(filePath);
|
|
253
|
+
if (!entry) return null;
|
|
254
|
+
return entry.async('string');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Ensures numbering part + package metadata exist and merges numbering payloads.
|
|
259
|
+
*
|
|
260
|
+
* @param {any} zip
|
|
261
|
+
* @param {string|string[]|null|undefined} numberingXmlList
|
|
262
|
+
* @param {{
|
|
263
|
+
* mergeNumberingXml?: ((existingXml: string, incomingXml: string) => string),
|
|
264
|
+
* onInfo?: (message: string) => void
|
|
265
|
+
* }} [options]
|
|
266
|
+
*/
|
|
267
|
+
export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, options = {}) {
|
|
268
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
269
|
+
const mergeNumberingXml = typeof options?.mergeNumberingXml === 'function'
|
|
270
|
+
? options.mergeNumberingXml
|
|
271
|
+
: null;
|
|
272
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : logWarning;
|
|
273
|
+
const incomingPayloads = (Array.isArray(numberingXmlList) ? numberingXmlList : [numberingXmlList]).filter(Boolean);
|
|
274
|
+
if (incomingPayloads.length === 0) return;
|
|
275
|
+
|
|
276
|
+
const existing = await readZipText(zip, NUMBERING_PATH);
|
|
277
|
+
if (existing && !mergeNumberingXml) {
|
|
278
|
+
onWarn('[Deprecation] Replacing an existing numbering.xml without mergeNumberingXml is deprecated and will throw in the next major version.');
|
|
279
|
+
}
|
|
280
|
+
let mergedNumberingXml = existing || null;
|
|
281
|
+
for (const incomingNumberingXml of incomingPayloads) {
|
|
282
|
+
if (!mergedNumberingXml) {
|
|
283
|
+
mergedNumberingXml = incomingNumberingXml;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
mergedNumberingXml = mergeNumberingXml
|
|
287
|
+
? mergeNumberingXml(mergedNumberingXml, incomingNumberingXml)
|
|
288
|
+
: incomingNumberingXml;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!existing) {
|
|
292
|
+
onInfo('[Demo] Adding numbering.xml');
|
|
293
|
+
} else {
|
|
294
|
+
onInfo('[Demo] Merging numbering.xml payload(s) into existing numbering definitions');
|
|
295
|
+
}
|
|
296
|
+
zip.file(NUMBERING_PATH, mergedNumberingXml);
|
|
297
|
+
|
|
298
|
+
const serializer = createSerializer();
|
|
299
|
+
|
|
300
|
+
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
301
|
+
if (ctText) {
|
|
302
|
+
const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
|
|
303
|
+
if (upsertContentTypeOverride(ctDoc, '/word/numbering.xml', NUMBERING_CONTENT_TYPE)) {
|
|
304
|
+
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
309
|
+
if (relsText) {
|
|
310
|
+
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
311
|
+
if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml', options)) {
|
|
312
|
+
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Ensures comments part + package metadata exist and merges incoming comments.
|
|
319
|
+
*
|
|
320
|
+
* @param {any} zip
|
|
321
|
+
* @param {string|null|undefined} commentsXml
|
|
322
|
+
* @param {{ onInfo?: (message: string) => void }} [options]
|
|
323
|
+
*/
|
|
324
|
+
export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {}) {
|
|
325
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
326
|
+
if (!commentsXml) return;
|
|
327
|
+
|
|
328
|
+
const serializer = createSerializer();
|
|
329
|
+
const existingText = await readZipText(zip, COMMENTS_PATH);
|
|
330
|
+
if (!existingText || options.replaceExisting === true) {
|
|
331
|
+
onInfo('[Demo] Adding comments.xml');
|
|
332
|
+
zip.file(COMMENTS_PATH, commentsXml);
|
|
333
|
+
} else {
|
|
334
|
+
const existingDoc = parseXmlStrictStandalone(existingText, 'word/comments.xml (existing)');
|
|
335
|
+
const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
|
|
336
|
+
const existingRoot = existingDoc.documentElement;
|
|
337
|
+
const existingIds = new Set(
|
|
338
|
+
Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
|
|
339
|
+
.map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
|
|
340
|
+
.filter(Boolean)
|
|
341
|
+
);
|
|
342
|
+
for (const incomingComment of Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'))) {
|
|
343
|
+
const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
|
|
344
|
+
if (id && existingIds.has(id)) {
|
|
345
|
+
throw new Error(`Duplicate comment id: ${id}`);
|
|
346
|
+
}
|
|
347
|
+
existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
|
|
348
|
+
}
|
|
349
|
+
zip.file(COMMENTS_PATH, serializer.serializeToString(existingDoc));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
353
|
+
if (ctText) {
|
|
354
|
+
const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
|
|
355
|
+
if (upsertContentTypeOverride(ctDoc, '/word/comments.xml', COMMENTS_CONTENT_TYPE)) {
|
|
356
|
+
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
361
|
+
if (relsText) {
|
|
362
|
+
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
363
|
+
if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml', options)) {
|
|
364
|
+
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Ensures the modern Word comment-threading part and package metadata exist. */
|
|
370
|
+
export async function ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXml, options = {}) {
|
|
371
|
+
if (!commentsExtendedXml) return;
|
|
372
|
+
parseXmlStrictStandalone(commentsExtendedXml, 'word/commentsExtended.xml');
|
|
373
|
+
zip.file(COMMENTS_EXTENDED_PATH, commentsExtendedXml);
|
|
374
|
+
const serializer = createSerializer();
|
|
375
|
+
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
376
|
+
if (ctText) {
|
|
377
|
+
const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
|
|
378
|
+
if (upsertContentTypeOverride(ctDoc, '/word/commentsExtended.xml', COMMENTS_EXTENDED_CONTENT_TYPE)) {
|
|
379
|
+
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
383
|
+
if (relsText) {
|
|
384
|
+
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
385
|
+
if (upsertDocumentRelationship(relsDoc, COMMENTS_EXTENDED_REL_TYPE, 'commentsExtended.xml', options)) {
|
|
386
|
+
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Rewrites content-type overrides for known parts whose type is wrong (for example the
|
|
393
|
+
* `application/vnd.ms-word.commentsExtended+xml` value written by earlier versions of
|
|
394
|
+
* this package). Does not rewrite [Content_Types].xml when there is nothing to repair.
|
|
395
|
+
*
|
|
396
|
+
* @param {any} zip
|
|
397
|
+
* @returns {Promise<Array<{ partName: string, from: string, to: string }>>}
|
|
398
|
+
*/
|
|
399
|
+
export async function repairKnownContentTypes(zip) {
|
|
400
|
+
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
401
|
+
if (!ctText) return [];
|
|
402
|
+
const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
|
|
403
|
+
const repairs = [];
|
|
404
|
+
for (const override of Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'))) {
|
|
405
|
+
const partName = override.getAttribute('PartName') || '';
|
|
406
|
+
const actual = override.getAttribute('ContentType') || '';
|
|
407
|
+
const expected = correctedContentTypeFor(partName, actual);
|
|
408
|
+
if (!expected) continue;
|
|
409
|
+
override.setAttribute('ContentType', expected);
|
|
410
|
+
repairs.push({ partName, from: actual, to: expected });
|
|
411
|
+
}
|
|
412
|
+
if (repairs.length > 0) zip.file(CONTENT_TYPES_PATH, createSerializer().serializeToString(ctDoc));
|
|
413
|
+
return repairs;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Validates core package integrity for document/comments/numbering artifacts.
|
|
418
|
+
*
|
|
419
|
+
* @param {any} zip
|
|
420
|
+
*/
|
|
421
|
+
export async function validateDocxPackage(zip) {
|
|
422
|
+
const documentXml = await readZipText(zip, DOCUMENT_PATH);
|
|
423
|
+
if (!documentXml) {
|
|
424
|
+
throw new Error('Validation failed: missing word/document.xml');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const documentDoc = parseXmlStrictStandalone(documentXml, DOCUMENT_PATH);
|
|
428
|
+
normalizeBodySectionOrderStandalone(documentDoc);
|
|
429
|
+
const body = getBodyElementFromDocument(documentDoc);
|
|
430
|
+
if (!body) {
|
|
431
|
+
throw new Error('Validation failed: word/document.xml has no w:body');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const directBodyElements = Array.from(body.childNodes || []).filter(node => node.nodeType === 1);
|
|
435
|
+
const sectPrIndexes = directBodyElements
|
|
436
|
+
.map((node, index) => ({ node, index }))
|
|
437
|
+
.filter(entry => isSectionPropertiesElement(entry.node))
|
|
438
|
+
.map(entry => entry.index);
|
|
439
|
+
|
|
440
|
+
if (sectPrIndexes.length > 1) {
|
|
441
|
+
throw new Error('Validation failed: multiple body-level w:sectPr');
|
|
442
|
+
}
|
|
443
|
+
if (sectPrIndexes.length === 1 && sectPrIndexes[0] !== directBodyElements.length - 1) {
|
|
444
|
+
throw new Error('Validation failed: w:sectPr not last');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const tcs = documentDoc.getElementsByTagNameNS(NS_W, 'tc');
|
|
448
|
+
for (const tc of Array.from(tcs)) {
|
|
449
|
+
for (const child of Array.from(tc.childNodes || []).filter(node => node.nodeType === 1)) {
|
|
450
|
+
if (child.namespaceURI === NS_W && child.localName === 'p') {
|
|
451
|
+
const hasNestedParagraph = Array.from(child.childNodes || []).some(
|
|
452
|
+
node => node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
453
|
+
);
|
|
454
|
+
if (hasNestedParagraph) {
|
|
455
|
+
throw new Error('Validation failed: nested w:p');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const hasNumberingUsage = documentDoc.getElementsByTagNameNS(NS_W, 'numPr').length > 0;
|
|
462
|
+
const hasCommentUsage =
|
|
463
|
+
documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeStart').length > 0
|
|
464
|
+
|| documentDoc.getElementsByTagNameNS(NS_W, 'commentRangeEnd').length > 0
|
|
465
|
+
|| documentDoc.getElementsByTagNameNS(NS_W, 'commentReference').length > 0;
|
|
466
|
+
|
|
467
|
+
const numberingXml = await readZipText(zip, NUMBERING_PATH);
|
|
468
|
+
const commentsXml = await readZipText(zip, COMMENTS_PATH);
|
|
469
|
+
const commentsExtendedXml = await readZipText(zip, COMMENTS_EXTENDED_PATH);
|
|
470
|
+
if (commentsExtendedXml && !commentsXml) {
|
|
471
|
+
throw new Error('Validation failed: commentsExtended part exists but comments part is missing');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (numberingXml) {
|
|
475
|
+
parseXmlStrictStandalone(numberingXml, NUMBERING_PATH);
|
|
476
|
+
} else if (hasNumberingUsage) {
|
|
477
|
+
throw new Error('Validation failed: numbering used but part missing');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (commentsXml) {
|
|
481
|
+
const commentsDoc = parseXmlStrictStandalone(commentsXml, COMMENTS_PATH);
|
|
482
|
+
const idsFor = (doc, localName) => Array.from(doc.getElementsByTagNameNS(NS_W, localName))
|
|
483
|
+
.map(node => node.getAttribute('w:id') || node.getAttribute('id'))
|
|
484
|
+
.filter(id => id !== '');
|
|
485
|
+
const starts = new Set(idsFor(documentDoc, 'commentRangeStart'));
|
|
486
|
+
const ends = new Set(idsFor(documentDoc, 'commentRangeEnd'));
|
|
487
|
+
const references = new Set(idsFor(documentDoc, 'commentReference'));
|
|
488
|
+
const definitionIds = idsFor(commentsDoc, 'comment');
|
|
489
|
+
const definitions = new Set(definitionIds);
|
|
490
|
+
const sorted = ids => Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
491
|
+
const difference = (left, right) => sorted(new Set(Array.from(left).filter(id => !right.has(id))));
|
|
492
|
+
|
|
493
|
+
const duplicateDefinitions = sorted(new Set(definitionIds.filter((id, index) => definitionIds.indexOf(id) !== index)));
|
|
494
|
+
if (duplicateDefinitions.length > 0) {
|
|
495
|
+
throw new Error(`Validation failed: duplicate comment definitions for id(s): ${duplicateDefinitions.join(', ')}`);
|
|
496
|
+
}
|
|
497
|
+
const startsWithoutEnds = difference(starts, ends);
|
|
498
|
+
const endsWithoutStarts = difference(ends, starts);
|
|
499
|
+
if (startsWithoutEnds.length > 0 || endsWithoutStarts.length > 0) {
|
|
500
|
+
const parts = [];
|
|
501
|
+
if (startsWithoutEnds.length > 0) parts.push(`start without end: ${startsWithoutEnds.join(', ')}`);
|
|
502
|
+
if (endsWithoutStarts.length > 0) parts.push(`end without start: ${endsWithoutStarts.join(', ')}`);
|
|
503
|
+
throw new Error(`Validation failed: unbalanced comment range marker(s) (${parts.join('; ')})`);
|
|
504
|
+
}
|
|
505
|
+
const rangesWithoutReferences = difference(new Set([...starts, ...ends]), references);
|
|
506
|
+
if (rangesWithoutReferences.length > 0) {
|
|
507
|
+
throw new Error(`Validation failed: comment range has no reference for id(s): ${rangesWithoutReferences.join(', ')}`);
|
|
508
|
+
}
|
|
509
|
+
const usagesWithoutDefinitions = difference(new Set([...starts, ...ends, ...references]), definitions);
|
|
510
|
+
if (usagesWithoutDefinitions.length > 0) {
|
|
511
|
+
throw new Error(`Validation failed: comment usage has no definition for id(s): ${usagesWithoutDefinitions.join(', ')}`);
|
|
512
|
+
}
|
|
513
|
+
const replyCommentIds = new Set();
|
|
514
|
+
if (commentsExtendedXml) {
|
|
515
|
+
const extendedDoc = parseXmlStrictStandalone(commentsExtendedXml, COMMENTS_EXTENDED_PATH);
|
|
516
|
+
const paraIdToCommentId = new Map();
|
|
517
|
+
for (const comment of Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment'))) {
|
|
518
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
519
|
+
const paraId = commentParaId(comment);
|
|
520
|
+
if (paraId) paraIdToCommentId.set(paraId, id);
|
|
521
|
+
}
|
|
522
|
+
const knownParaIds = new Set(paraIdToCommentId.keys());
|
|
523
|
+
const extendedParaIds = new Set();
|
|
524
|
+
for (const entry of Array.from(extendedDoc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
525
|
+
const paraId = entry.getAttribute('w15:paraId') || entry.getAttribute('paraId');
|
|
526
|
+
const parentParaId = entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent');
|
|
527
|
+
if (!paraId || !knownParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: commentsExtended entry has no matching comment paragraph: ${paraId || '(missing)'}`);
|
|
528
|
+
if (extendedParaIds.has(paraId.toUpperCase())) throw new Error(`Validation failed: duplicate commentsExtended entry for paraId: ${paraId}`);
|
|
529
|
+
extendedParaIds.add(paraId.toUpperCase());
|
|
530
|
+
if (parentParaId) {
|
|
531
|
+
if (!knownParaIds.has(parentParaId.toUpperCase())) throw new Error(`Validation failed: commentsExtended parent paragraph was not found: ${parentParaId}`);
|
|
532
|
+
replyCommentIds.add(paraIdToCommentId.get(paraId.toUpperCase()));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const definitionsWithoutReferences = difference(new Set([...definitions].filter(id => !replyCommentIds.has(id))), references);
|
|
537
|
+
if (definitionsWithoutReferences.length > 0) {
|
|
538
|
+
throw new Error(`Validation failed: comment definition has no document reference for id(s): ${definitionsWithoutReferences.join(', ')}`);
|
|
539
|
+
}
|
|
540
|
+
} else if (hasCommentUsage) {
|
|
541
|
+
throw new Error('Validation failed: comments used but part missing');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const ctXml = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
545
|
+
if (!ctXml) {
|
|
546
|
+
throw new Error(`Validation failed: missing ${CONTENT_TYPES_PATH}`);
|
|
547
|
+
}
|
|
548
|
+
const ctDoc = parseXmlStrictStandalone(ctXml, CONTENT_TYPES_PATH);
|
|
549
|
+
|
|
550
|
+
const relsXml = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
551
|
+
if (!relsXml) {
|
|
552
|
+
throw new Error(`Validation failed: missing ${DOCUMENT_RELS_PATH}`);
|
|
553
|
+
}
|
|
554
|
+
const relsDoc = parseXmlStrictStandalone(relsXml, DOCUMENT_RELS_PATH);
|
|
555
|
+
|
|
556
|
+
if (numberingXml) {
|
|
557
|
+
const hasNumberingContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
|
|
558
|
+
(override.getAttribute('PartName') || '').toLowerCase() === '/word/numbering.xml'
|
|
559
|
+
&& (override.getAttribute('ContentType') || '') === NUMBERING_CONTENT_TYPE
|
|
560
|
+
);
|
|
561
|
+
const hasNumberingRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
|
|
562
|
+
(rel.getAttribute('Type') || '') === NUMBERING_REL_TYPE
|
|
563
|
+
);
|
|
564
|
+
if (!hasNumberingContentType) {
|
|
565
|
+
throw new Error('Validation failed: numbering CT override missing');
|
|
566
|
+
}
|
|
567
|
+
if (!hasNumberingRel) {
|
|
568
|
+
throw new Error('Validation failed: numbering rel missing');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (commentsXml) {
|
|
573
|
+
const hasCommentsContentType = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).some(override =>
|
|
574
|
+
(override.getAttribute('PartName') || '').toLowerCase() === '/word/comments.xml'
|
|
575
|
+
&& (override.getAttribute('ContentType') || '') === COMMENTS_CONTENT_TYPE
|
|
576
|
+
);
|
|
577
|
+
const hasCommentsRel = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
|
|
578
|
+
(rel.getAttribute('Type') || '') === COMMENTS_REL_TYPE
|
|
579
|
+
);
|
|
580
|
+
if (!hasCommentsContentType) {
|
|
581
|
+
throw new Error('Validation failed: comments CT override missing');
|
|
582
|
+
}
|
|
583
|
+
if (!hasCommentsRel) {
|
|
584
|
+
throw new Error('Validation failed: comments rel missing');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Header/footer wiring: every w:headerReference / w:footerReference must resolve through the document
|
|
589
|
+
// relationships to a part that exists, is well-formed, and is labeled with Word's content type.
|
|
590
|
+
{
|
|
591
|
+
const relationshipById = new Map();
|
|
592
|
+
for (const rel of Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship'))) {
|
|
593
|
+
relationshipById.set(rel.getAttribute('Id') || '', { type: rel.getAttribute('Type') || '', target: rel.getAttribute('Target') || '' });
|
|
594
|
+
}
|
|
595
|
+
const headerSpec = getPartSpec('header');
|
|
596
|
+
const footerSpec = getPartSpec('footer');
|
|
597
|
+
const overrideTypeByPart = new Map(Array.from(ctDoc.getElementsByTagNameNS('*', 'Override'))
|
|
598
|
+
.map(override => [(override.getAttribute('PartName') || '').toLowerCase(), override.getAttribute('ContentType') || '']));
|
|
599
|
+
for (const reference of [
|
|
600
|
+
...Array.from(documentDoc.getElementsByTagNameNS(NS_W, 'headerReference')),
|
|
601
|
+
...Array.from(documentDoc.getElementsByTagNameNS(NS_W, 'footerReference'))
|
|
602
|
+
]) {
|
|
603
|
+
const relId = reference.getAttributeNS?.('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id') || reference.getAttribute('r:id') || '';
|
|
604
|
+
const spec = reference.localName === 'headerReference' ? headerSpec : footerSpec;
|
|
605
|
+
const relationship = relationshipById.get(relId);
|
|
606
|
+
if (!relationship) throw new Error(`Validation failed: ${reference.localName} ${relId || '(no r:id)'} has no document relationship`);
|
|
607
|
+
if (relationship.type !== spec.relType) throw new Error(`Validation failed: ${reference.localName} ${relId} points at a relationship of the wrong type (${relationship.type})`);
|
|
608
|
+
const partName = relationship.target.startsWith('/') ? relationship.target : `/word/${relationship.target}`;
|
|
609
|
+
const partXml = await readZipText(zip, partName.slice(1));
|
|
610
|
+
if (!partXml) throw new Error(`Validation failed: ${reference.localName} ${relId} targets a missing part: ${partName}`);
|
|
611
|
+
parseXmlStrictStandalone(partXml, partName.slice(1));
|
|
612
|
+
const contentType = overrideTypeByPart.get(partName.toLowerCase());
|
|
613
|
+
if (contentType !== spec.contentType) throw new Error(`Validation failed: ${partName} content type is ${contentType ? `'${contentType}'` : 'missing'}; Word expects '${spec.contentType}'`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (commentsExtendedXml) {
|
|
618
|
+
const extendedOverride = Array.from(ctDoc.getElementsByTagNameNS('*', 'Override')).find(override =>
|
|
619
|
+
(override.getAttribute('PartName') || '').toLowerCase() === '/word/commentsextended.xml'
|
|
620
|
+
);
|
|
621
|
+
if (extendedOverride && extendedOverride.getAttribute('ContentType') !== COMMENTS_EXTENDED_CONTENT_TYPE) {
|
|
622
|
+
throw new Error(`Validation failed: commentsExtended CT override has wrong content type '${extendedOverride.getAttribute('ContentType')}' (Word expects '${COMMENTS_EXTENDED_CONTENT_TYPE}')`);
|
|
623
|
+
}
|
|
624
|
+
const hasContentType = !!extendedOverride;
|
|
625
|
+
const hasRelationship = Array.from(relsDoc.getElementsByTagNameNS('*', 'Relationship')).some(rel =>
|
|
626
|
+
(rel.getAttribute('Type') || '') === COMMENTS_EXTENDED_REL_TYPE
|
|
627
|
+
);
|
|
628
|
+
if (!hasContentType) throw new Error('Validation failed: commentsExtended CT override missing');
|
|
629
|
+
if (!hasRelationship) throw new Error('Validation failed: commentsExtended rel missing');
|
|
630
|
+
}
|
|
631
|
+
}
|