docxmlater 12.0.1 → 12.1.0
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/README.md +8 -6
- package/dist/core/Document.d.ts +1 -1
- package/dist/core/Document.d.ts.map +1 -1
- package/dist/core/Document.js +8 -0
- package/dist/core/Document.js.map +1 -1
- package/dist/esm/core/Document.js +9 -1
- package/dist/esm/core/Document.js.map +1 -1
- package/dist/esm/processors/RevisionWalker.js +172 -38
- package/dist/esm/processors/RevisionWalker.js.map +1 -1
- package/dist/esm/processors/acceptRevisions.js +20 -11
- package/dist/esm/processors/acceptRevisions.js.map +1 -1
- package/dist/processors/RevisionWalker.d.ts +8 -4
- package/dist/processors/RevisionWalker.d.ts.map +1 -1
- package/dist/processors/RevisionWalker.js +172 -38
- package/dist/processors/RevisionWalker.js.map +1 -1
- package/dist/processors/acceptRevisions.d.ts +1 -0
- package/dist/processors/acceptRevisions.d.ts.map +1 -1
- package/dist/processors/acceptRevisions.js +21 -11
- package/dist/processors/acceptRevisions.js.map +1 -1
- package/package.json +1 -1
- package/src/core/Document.ts +25 -2
- package/src/processors/RevisionWalker.ts +322 -54
- package/src/processors/acceptRevisions.ts +67 -17
|
@@ -11,19 +11,43 @@
|
|
|
11
11
|
import { ParsedXMLObject } from '../xml/XMLParser.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Options for controlling which revision types to
|
|
14
|
+
* Options for controlling which revision types to process
|
|
15
15
|
*/
|
|
16
16
|
export interface RevisionWalkerOptions {
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Direction of processing (default: 'accept').
|
|
19
|
+
*
|
|
20
|
+
* - 'accept': keep inserted content, discard deleted content (the post-edit
|
|
21
|
+
* document).
|
|
22
|
+
* - 'reject': discard inserted content, restore deleted content and previous
|
|
23
|
+
* formatting (the original pre-edit document — the exact inverse of accept).
|
|
24
|
+
*/
|
|
25
|
+
mode?: 'accept' | 'reject';
|
|
26
|
+
/**
|
|
27
|
+
* Process insertions. In 'accept' mode w:ins is unwrapped (content kept); in
|
|
28
|
+
* 'reject' mode w:ins is removed (content discarded). Inserted rows/tables are
|
|
29
|
+
* removed under 'reject'. (default: true)
|
|
30
|
+
*/
|
|
18
31
|
acceptInsertions?: boolean;
|
|
19
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Process deletions. In 'accept' mode w:del is removed (content discarded); in
|
|
34
|
+
* 'reject' mode w:del is unwrapped and w:delText restored to w:t. Deleted
|
|
35
|
+
* rows/tables are removed under 'accept'. (default: true)
|
|
36
|
+
*/
|
|
20
37
|
acceptDeletions?: boolean;
|
|
21
38
|
/** Handle w:moveFrom/w:moveTo (default: true) */
|
|
22
39
|
acceptMoves?: boolean;
|
|
23
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Process *Change elements. In 'accept' mode they are removed (current
|
|
42
|
+
* formatting kept); in 'reject' mode the previous formatting stored inside the
|
|
43
|
+
* change is restored. (default: true)
|
|
44
|
+
*/
|
|
24
45
|
acceptPropertyChanges?: boolean;
|
|
25
46
|
}
|
|
26
47
|
|
|
48
|
+
/** Action to take on a revision element at a given level. */
|
|
49
|
+
type RevisionAction = 'unwrap' | 'remove' | 'restore' | 'none';
|
|
50
|
+
|
|
27
51
|
/**
|
|
28
52
|
* Structure for tracking element order
|
|
29
53
|
*/
|
|
@@ -72,6 +96,34 @@ const REVISION_ELEMENTS = {
|
|
|
72
96
|
],
|
|
73
97
|
};
|
|
74
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Children that are tracked independently of a property-change snapshot and
|
|
101
|
+
* must be carried over (not wiped) when rejecting that change restores the
|
|
102
|
+
* previous properties. Keyed by the wrapper element; `position` is where they
|
|
103
|
+
* sit in the wrapper's ECMA-376 child order relative to the restored snapshot:
|
|
104
|
+
*
|
|
105
|
+
* - CT_PPr (§17.3.1.26): the paragraph-mark run properties (`w:rPr`) and the
|
|
106
|
+
* section properties (`w:sectPr`) follow the base paragraph properties, and a
|
|
107
|
+
* `w:pPrChange` snapshot is a CT_PPrBase that contains neither.
|
|
108
|
+
* - CT_SectPr (§17.6.17): the header/footer references PRECEDE the section base
|
|
109
|
+
* contents, and a `w:sectPrChange` snapshot is a CT_SectPrBase that contains
|
|
110
|
+
* neither — so they must be prepended or the section loses its headers/footers.
|
|
111
|
+
*/
|
|
112
|
+
const PRESERVED_ON_RESTORE: Record<string, { keys: string[]; position: 'before' | 'after' }> = {
|
|
113
|
+
'w:pPr': { keys: ['w:rPr', 'w:sectPr'], position: 'after' },
|
|
114
|
+
'w:sectPr': { keys: ['w:headerReference', 'w:footerReference'], position: 'before' },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Deleted run-content elements and the live elements they restore to when a
|
|
119
|
+
* deletion is rejected (§17.3.3.7 w:delText is structurally CT_Text, identical
|
|
120
|
+
* to w:t; w:delInstrText likewise mirrors w:instrText).
|
|
121
|
+
*/
|
|
122
|
+
const DELETED_TEXT_RENAMES: Record<string, string> = {
|
|
123
|
+
'w:delText': 'w:t',
|
|
124
|
+
'w:delInstrText': 'w:instrText',
|
|
125
|
+
};
|
|
126
|
+
|
|
75
127
|
/**
|
|
76
128
|
* DOM-based tree walker for accepting Word document revisions
|
|
77
129
|
*
|
|
@@ -100,6 +152,7 @@ export class RevisionWalker {
|
|
|
100
152
|
*/
|
|
101
153
|
static processTree(obj: ParsedXMLObject, options?: RevisionWalkerOptions): ParsedXMLObject {
|
|
102
154
|
const opts: Required<RevisionWalkerOptions> = {
|
|
155
|
+
mode: options?.mode ?? 'accept',
|
|
103
156
|
acceptInsertions: options?.acceptInsertions ?? true,
|
|
104
157
|
acceptDeletions: options?.acceptDeletions ?? true,
|
|
105
158
|
acceptMoves: options?.acceptMoves ?? true,
|
|
@@ -112,6 +165,14 @@ export class RevisionWalker {
|
|
|
112
165
|
// Walk and transform the tree
|
|
113
166
|
RevisionWalker.walkAndTransform(clone, opts);
|
|
114
167
|
|
|
168
|
+
// Reject restores deleted content by unwrapping w:del; the runs inside a
|
|
169
|
+
// deletion carry their text in w:delText (and field codes in
|
|
170
|
+
// w:delInstrText). Once unwrapped they are live content again, so convert
|
|
171
|
+
// those back to the normal w:t / w:instrText elements. (§17.3.3.7)
|
|
172
|
+
if (opts.mode === 'reject' && opts.acceptDeletions) {
|
|
173
|
+
RevisionWalker.convertDeletedTextToNormal(clone);
|
|
174
|
+
}
|
|
175
|
+
|
|
115
176
|
return clone;
|
|
116
177
|
}
|
|
117
178
|
|
|
@@ -153,11 +214,25 @@ export class RevisionWalker {
|
|
|
153
214
|
// tracked-deleted must be removed entirely (a row-less w:tbl violates
|
|
154
215
|
// §17.4.38's at-least-one-row requirement and Word's Accept All drops
|
|
155
216
|
// the whole table), and only the parent can remove the w:tbl itself.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
217
|
+
// Reject is the mirror image: a row whose w:trPr carries a self-closing
|
|
218
|
+
// w:ins marker was added by the edit and must be removed to revert to the
|
|
219
|
+
// original (gated on acceptInsertions, since this undoes an insertion). A
|
|
220
|
+
// table whose rows are ALL insert-marked is removed entirely, mirroring the
|
|
221
|
+
// accept-side at-least-one-row handling.
|
|
222
|
+
if (options.mode === 'reject') {
|
|
223
|
+
if (options.acceptInsertions && obj['w:tbl']) {
|
|
224
|
+
RevisionWalker.removeFullyMarkedTables(obj, 'w:ins');
|
|
225
|
+
}
|
|
226
|
+
if (options.acceptInsertions && obj['w:tr']) {
|
|
227
|
+
RevisionWalker.filterMarkedRows(obj, 'w:ins');
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
if (options.acceptDeletions && obj['w:tbl']) {
|
|
231
|
+
RevisionWalker.removeFullyMarkedTables(obj, 'w:del');
|
|
232
|
+
}
|
|
233
|
+
if (options.acceptDeletions && obj['w:tr']) {
|
|
234
|
+
RevisionWalker.filterMarkedRows(obj, 'w:del');
|
|
235
|
+
}
|
|
161
236
|
}
|
|
162
237
|
|
|
163
238
|
// Get keys to process (excluding metadata keys)
|
|
@@ -183,26 +258,27 @@ export class RevisionWalker {
|
|
|
183
258
|
}
|
|
184
259
|
|
|
185
260
|
/**
|
|
186
|
-
* Remove `<w:tr>` entries whose `<w:trPr>` contains a self-closing
|
|
187
|
-
*
|
|
261
|
+
* Remove `<w:tr>` entries whose `<w:trPr>` contains a self-closing row-level
|
|
262
|
+
* tracking marker. `marker` is `w:del` when accepting (drop deleted rows) or
|
|
263
|
+
* `w:ins` when rejecting (drop inserted rows). Operates on the parsed-object
|
|
188
264
|
* representation (arrays when multiple, single object otherwise).
|
|
189
265
|
*/
|
|
190
|
-
private static
|
|
266
|
+
private static filterMarkedRows(tbl: any, marker: 'w:del' | 'w:ins'): void {
|
|
191
267
|
const rows = tbl['w:tr'];
|
|
192
|
-
const
|
|
268
|
+
const isRowMarked = (row: any): boolean => {
|
|
193
269
|
if (!row || typeof row !== 'object') return false;
|
|
194
270
|
const trPr = row['w:trPr'];
|
|
195
271
|
if (!trPr || typeof trPr !== 'object') return false;
|
|
196
|
-
//
|
|
272
|
+
// The row-level marker is a CT_TrackChange (no children). When absent
|
|
197
273
|
// the parser leaves no key; when present it's a self-closing tag
|
|
198
274
|
// parsed as an object with only @_w:id/@_w:author/@_w:date keys.
|
|
199
|
-
return !!trPr[
|
|
275
|
+
return !!trPr[marker];
|
|
200
276
|
};
|
|
201
277
|
|
|
202
278
|
if (Array.isArray(rows)) {
|
|
203
279
|
const removedIndices = new Set<number>();
|
|
204
280
|
for (let i = 0; i < rows.length; i++) {
|
|
205
|
-
if (
|
|
281
|
+
if (isRowMarked(rows[i])) removedIndices.add(i);
|
|
206
282
|
}
|
|
207
283
|
if (removedIndices.size === 0) return;
|
|
208
284
|
const kept = rows.filter((_, i) => !removedIndices.has(i));
|
|
@@ -212,33 +288,39 @@ export class RevisionWalker {
|
|
|
212
288
|
tbl['w:tr'] = kept;
|
|
213
289
|
}
|
|
214
290
|
RevisionWalker.dropOrderedChildEntries(tbl, 'w:tr', removedIndices);
|
|
215
|
-
} else if (
|
|
291
|
+
} else if (isRowMarked(rows)) {
|
|
216
292
|
delete tbl['w:tr'];
|
|
217
293
|
RevisionWalker.dropOrderedChildEntries(tbl, 'w:tr', new Set([0]));
|
|
218
294
|
}
|
|
219
295
|
}
|
|
220
296
|
|
|
221
297
|
/**
|
|
222
|
-
* Remove `<w:tbl>` children whose rows are ALL
|
|
298
|
+
* Remove `<w:tbl>` children whose rows are ALL marked with the given
|
|
299
|
+
* row-level tracking marker (`w:del` when accepting, `w:ins` when rejecting).
|
|
223
300
|
*
|
|
224
|
-
* Runs at the table's parent because
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* (w:ins
|
|
228
|
-
*
|
|
301
|
+
* Runs at the table's parent because filterMarkedRows (invoked on the table
|
|
302
|
+
* itself) has no reference back to the container holding the `w:tbl` key.
|
|
303
|
+
* Skips tables that still carry potential row containers — the wrappers that
|
|
304
|
+
* SURVIVE this direction (w:ins/w:moveTo when accepting deletions,
|
|
305
|
+
* w:del/w:moveFrom when rejecting insertions), plus w:sdt / w:customXml —
|
|
306
|
+
* since their rows only materialize as direct `w:tr` children later, when the
|
|
229
307
|
* wrappers are unwrapped during recursion.
|
|
230
308
|
*/
|
|
231
|
-
private static
|
|
309
|
+
private static removeFullyMarkedTables(parent: any, marker: 'w:del' | 'w:ins'): void {
|
|
232
310
|
const tables = parent['w:tbl'];
|
|
311
|
+
// Wrappers whose rows survive in this direction must not be treated as
|
|
312
|
+
// "rowless": accept keeps w:ins/w:moveTo content, reject keeps w:del/
|
|
313
|
+
// w:moveFrom content.
|
|
314
|
+
const survivingWrappers: string[] =
|
|
315
|
+
marker === 'w:del' ? ['w:ins', 'w:moveTo'] : ['w:del', 'w:moveFrom'];
|
|
233
316
|
const becomesRowless = (tbl: any): boolean => {
|
|
234
317
|
// Tables with no rows at all are pre-existing structures, not the
|
|
235
|
-
// result of
|
|
318
|
+
// result of a tracked change — leave them untouched.
|
|
236
319
|
if (!tbl || typeof tbl !== 'object' || tbl['w:tr'] === undefined) return false;
|
|
237
|
-
RevisionWalker.
|
|
320
|
+
RevisionWalker.filterMarkedRows(tbl, marker);
|
|
238
321
|
return (
|
|
239
322
|
tbl['w:tr'] === undefined &&
|
|
240
|
-
!tbl[
|
|
241
|
-
!tbl['w:moveTo'] &&
|
|
323
|
+
!survivingWrappers.some((w) => tbl[w]) &&
|
|
242
324
|
!tbl['w:sdt'] &&
|
|
243
325
|
!tbl['w:customXml']
|
|
244
326
|
);
|
|
@@ -295,43 +377,63 @@ export class RevisionWalker {
|
|
|
295
377
|
);
|
|
296
378
|
|
|
297
379
|
for (const key of keysToProcess) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
380
|
+
switch (RevisionWalker.getActionForKey(key, options)) {
|
|
381
|
+
case 'unwrap':
|
|
382
|
+
RevisionWalker.unwrapAllElements(parent, key);
|
|
383
|
+
break;
|
|
384
|
+
case 'remove':
|
|
385
|
+
RevisionWalker.removeAllElements(parent, key);
|
|
386
|
+
break;
|
|
387
|
+
case 'restore':
|
|
388
|
+
RevisionWalker.restorePropertyChange(parent, key);
|
|
389
|
+
break;
|
|
390
|
+
case 'none':
|
|
391
|
+
break;
|
|
303
392
|
}
|
|
304
393
|
}
|
|
305
394
|
}
|
|
306
395
|
|
|
307
396
|
/**
|
|
308
|
-
*
|
|
397
|
+
* Resolve what to do with a revision element of the given key, honouring the
|
|
398
|
+
* processing direction. Accept and reject are exact inverses for content
|
|
399
|
+
* revisions; property changes are removed on accept and restored on reject.
|
|
309
400
|
*/
|
|
310
|
-
private static
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
401
|
+
private static getActionForKey(
|
|
402
|
+
key: string,
|
|
403
|
+
options: Required<RevisionWalkerOptions>
|
|
404
|
+
): RevisionAction {
|
|
405
|
+
const reject = options.mode === 'reject';
|
|
406
|
+
|
|
407
|
+
// Content revisions are driven off REVISION_ELEMENTS so membership stays a
|
|
408
|
+
// single source of truth (shared with isRevisionElement). On accept, UNWRAP
|
|
409
|
+
// keeps content and REMOVE discards it; reject is the exact inverse.
|
|
410
|
+
const isUnwrap = REVISION_ELEMENTS.UNWRAP.includes(key);
|
|
411
|
+
const isRemove = REVISION_ELEMENTS.REMOVE.includes(key);
|
|
412
|
+
if (isUnwrap || isRemove) {
|
|
413
|
+
// Per-key gating: w:ins ↔ insertions, w:del ↔ deletions, w:moveTo/
|
|
414
|
+
// w:moveFrom ↔ moves.
|
|
415
|
+
const gate =
|
|
416
|
+
key === 'w:ins'
|
|
417
|
+
? options.acceptInsertions
|
|
418
|
+
: key === 'w:del'
|
|
419
|
+
? options.acceptDeletions
|
|
420
|
+
: options.acceptMoves;
|
|
421
|
+
if (!gate) return 'none';
|
|
422
|
+
const keepContent = reject ? isRemove : isUnwrap;
|
|
423
|
+
return keepContent ? 'unwrap' : 'remove';
|
|
327
424
|
}
|
|
328
425
|
if (REVISION_ELEMENTS.PROPERTY_CHANGES.includes(key)) {
|
|
329
|
-
|
|
426
|
+
if (!options.acceptPropertyChanges) return 'none';
|
|
427
|
+
// w:numberingChange (CT_TrackChangeNumbering) is a legacy attribute-only
|
|
428
|
+
// marker with no embedded previous-properties element, so there is
|
|
429
|
+
// nothing to restore — drop it in both directions.
|
|
430
|
+
if (reject && key !== 'w:numberingChange') return 'restore';
|
|
431
|
+
return 'remove';
|
|
330
432
|
}
|
|
331
433
|
if (REVISION_ELEMENTS.RANGE_MARKERS.includes(key)) {
|
|
332
|
-
return
|
|
434
|
+
return 'remove'; // Always remove range markers
|
|
333
435
|
}
|
|
334
|
-
return
|
|
436
|
+
return 'none';
|
|
335
437
|
}
|
|
336
438
|
|
|
337
439
|
/**
|
|
@@ -490,6 +592,172 @@ export class RevisionWalker {
|
|
|
490
592
|
delete parent[key];
|
|
491
593
|
}
|
|
492
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Restore the previous formatting recorded by a property-change element
|
|
597
|
+
* (reject direction). A change such as `w:rPrChange` lives inside the
|
|
598
|
+
* properties wrapper it tracks (`w:rPr`) and embeds the complete previous
|
|
599
|
+
* wrapper as a child:
|
|
600
|
+
*
|
|
601
|
+
* <w:rPr><w:b/><w:rPrChange><w:rPr><w:i/></w:rPr></w:rPrChange></w:rPr>
|
|
602
|
+
*
|
|
603
|
+
* Rejecting it replaces the current direct formatting (`w:b`) with the
|
|
604
|
+
* embedded previous formatting (`w:i`) and drops the change marker. Children
|
|
605
|
+
* that are tracked independently of the snapshot — e.g. a paragraph's mark
|
|
606
|
+
* run properties / section properties, or a section's header/footer
|
|
607
|
+
* references (see {@link PRESERVED_ON_RESTORE}) — are carried over from the
|
|
608
|
+
* current wrapper in their correct schema position rather than discarded.
|
|
609
|
+
*
|
|
610
|
+
* @param parent - the properties wrapper containing the change element
|
|
611
|
+
* @param changeKey - the change element key, e.g. 'w:rPrChange'
|
|
612
|
+
*/
|
|
613
|
+
private static restorePropertyChange(parent: any, changeKey: string): void {
|
|
614
|
+
const changeRaw = parent[changeKey];
|
|
615
|
+
if (!changeRaw) return;
|
|
616
|
+
const changeEl = Array.isArray(changeRaw) ? changeRaw[0] : changeRaw;
|
|
617
|
+
|
|
618
|
+
// 'w:rPrChange' -> 'w:rPr', 'w:tblGridChange' -> 'w:tblGrid', etc.
|
|
619
|
+
const previousKey = changeKey.replace(/Change$/, '');
|
|
620
|
+
const previousRaw =
|
|
621
|
+
changeEl && typeof changeEl === 'object' ? changeEl[previousKey] : undefined;
|
|
622
|
+
|
|
623
|
+
// Malformed change with no embedded previous-properties snapshot: there is
|
|
624
|
+
// nothing to restore, so drop just the marker and leave the current
|
|
625
|
+
// formatting intact rather than emptying the wrapper.
|
|
626
|
+
if (previousRaw === undefined) {
|
|
627
|
+
RevisionWalker.removeAllElements(parent, changeKey);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const previousEl = Array.isArray(previousRaw) ? previousRaw[0] : previousRaw;
|
|
631
|
+
|
|
632
|
+
const preserve = PRESERVED_ON_RESTORE[previousKey];
|
|
633
|
+
const preserveKeys = preserve?.keys ?? [];
|
|
634
|
+
|
|
635
|
+
// Snapshot children become the restored formatting (minus any preserved
|
|
636
|
+
// keys it might redundantly carry).
|
|
637
|
+
const snapshotChildren =
|
|
638
|
+
previousEl && typeof previousEl === 'object'
|
|
639
|
+
? RevisionWalker.getOrderedChildList(previousEl).filter(
|
|
640
|
+
(c) => !preserveKeys.includes(c.type)
|
|
641
|
+
)
|
|
642
|
+
: [];
|
|
643
|
+
|
|
644
|
+
// Preserved children are read from the CURRENT wrapper in their existing
|
|
645
|
+
// document order (captured before the wrapper is cleared below).
|
|
646
|
+
const preservedChildren = preserveKeys.length
|
|
647
|
+
? RevisionWalker.getOrderedChildList(parent).filter(
|
|
648
|
+
(c) => preserveKeys.includes(c.type) && c.type !== changeKey
|
|
649
|
+
)
|
|
650
|
+
: [];
|
|
651
|
+
|
|
652
|
+
const newChildren =
|
|
653
|
+
preserve?.position === 'before'
|
|
654
|
+
? [...preservedChildren, ...snapshotChildren]
|
|
655
|
+
: [...snapshotChildren, ...preservedChildren];
|
|
656
|
+
|
|
657
|
+
RevisionWalker.rebuildChildren(parent, newChildren);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Replace all non-metadata children of `parent` with the supplied ordered
|
|
662
|
+
* `{type, element}` list, regrouping per-type arrays and rebuilding
|
|
663
|
+
* `_orderedChildren` so the serializer emits them in the given order.
|
|
664
|
+
* Attributes (`@_*`) and `#text` are left untouched.
|
|
665
|
+
*/
|
|
666
|
+
private static rebuildChildren(parent: any, ordered: { type: string; element: any }[]): void {
|
|
667
|
+
for (const k of Object.keys(parent)) {
|
|
668
|
+
if (!k.startsWith('@_') && k !== '#text' && k !== '_orderedChildren') {
|
|
669
|
+
delete parent[k];
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const grouped = new Map<string, any[]>();
|
|
674
|
+
for (const { type, element } of ordered) {
|
|
675
|
+
if (!grouped.has(type)) grouped.set(type, []);
|
|
676
|
+
grouped.get(type)!.push(element);
|
|
677
|
+
}
|
|
678
|
+
for (const [type, elements] of grouped) {
|
|
679
|
+
parent[type] = elements.length === 1 ? elements[0] : elements;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const orderedChildren: OrderedChildInfo[] = [];
|
|
683
|
+
const counters = new Map<string, number>();
|
|
684
|
+
for (const { type } of ordered) {
|
|
685
|
+
const idx = counters.get(type) || 0;
|
|
686
|
+
counters.set(type, idx + 1);
|
|
687
|
+
orderedChildren.push({ type, index: idx });
|
|
688
|
+
}
|
|
689
|
+
if (orderedChildren.length > 0) {
|
|
690
|
+
parent._orderedChildren = orderedChildren;
|
|
691
|
+
} else {
|
|
692
|
+
delete parent._orderedChildren;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Flatten an element's children into an ordered `{type, element}` list,
|
|
698
|
+
* honouring `_orderedChildren` when present and falling back to key order.
|
|
699
|
+
*/
|
|
700
|
+
private static getOrderedChildList(el: any): { type: string; element: any }[] {
|
|
701
|
+
const result: { type: string; element: any }[] = [];
|
|
702
|
+
const ordered = el._orderedChildren as OrderedChildInfo[] | undefined;
|
|
703
|
+
|
|
704
|
+
if (Array.isArray(ordered) && ordered.length > 0) {
|
|
705
|
+
const counters = new Map<string, number>();
|
|
706
|
+
for (const entry of ordered) {
|
|
707
|
+
const { type } = entry;
|
|
708
|
+
const idx = counters.get(type) || 0;
|
|
709
|
+
counters.set(type, idx + 1);
|
|
710
|
+
const value = el[type];
|
|
711
|
+
if (value === undefined) continue;
|
|
712
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
713
|
+
if (idx < arr.length) result.push({ type, element: arr[idx] });
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
for (const key of Object.keys(el)) {
|
|
719
|
+
if (key.startsWith('@_') || key === '#text' || key === '_orderedChildren') continue;
|
|
720
|
+
const value = el[key];
|
|
721
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
722
|
+
for (const element of arr) result.push({ type: key, element });
|
|
723
|
+
}
|
|
724
|
+
return result;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Convert restored deleted text back to live text throughout the tree
|
|
729
|
+
* (reject direction). After w:del unwrapping, the runs that were deleted
|
|
730
|
+
* still hold their text in w:delText / w:delInstrText; rename those to the
|
|
731
|
+
* normal w:t / w:instrText so the content is real again, fixing any
|
|
732
|
+
* `_orderedChildren` type references as we go.
|
|
733
|
+
*/
|
|
734
|
+
private static convertDeletedTextToNormal(obj: any): void {
|
|
735
|
+
if (obj === null || typeof obj !== 'object') return;
|
|
736
|
+
|
|
737
|
+
if (Array.isArray(obj)) {
|
|
738
|
+
for (const item of obj) RevisionWalker.convertDeletedTextToNormal(item);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// When this node carries any deleted-text element, rebuild its children
|
|
743
|
+
// from the document-ordered list with the deleted-text keys renamed. Going
|
|
744
|
+
// through the ordered list keeps text in its original position even if a
|
|
745
|
+
// run holds both a w:delText and a live w:t (CT_R permits interleaving).
|
|
746
|
+
const hasDeletedText = Object.keys(obj).some((k) => k in DELETED_TEXT_RENAMES);
|
|
747
|
+
if (hasDeletedText) {
|
|
748
|
+
const ordered = RevisionWalker.getOrderedChildList(obj).map((child) => ({
|
|
749
|
+
type: DELETED_TEXT_RENAMES[child.type] ?? child.type,
|
|
750
|
+
element: child.element,
|
|
751
|
+
}));
|
|
752
|
+
RevisionWalker.rebuildChildren(obj, ordered);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
for (const key of Object.keys(obj)) {
|
|
756
|
+
if (key.startsWith('@_') || key === '#text' || key === '_orderedChildren') continue;
|
|
757
|
+
RevisionWalker.convertDeletedTextToNormal(obj[key]);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
493
761
|
/**
|
|
494
762
|
* Merge a child value into the parent, handling arrays properly
|
|
495
763
|
*/
|
|
@@ -5,16 +5,19 @@ import { RevisionWalker } from './RevisionWalker.js';
|
|
|
5
5
|
/**
|
|
6
6
|
* Markers covered: w:ins, w:del, w:moveFrom, w:moveTo,
|
|
7
7
|
* w:rPrChange, w:pPrChange, w:tblPrChange, w:tblPrExChange,
|
|
8
|
-
* w:trPrChange, w:tcPrChange, w:sectPrChange, w:
|
|
9
|
-
* w:cellIns, w:cellDel, w:cellMerge, and any
|
|
8
|
+
* w:trPrChange, w:tcPrChange, w:sectPrChange, w:tblGridChange,
|
|
9
|
+
* w:numberingChange, w:cellIns, w:cellDel, w:cellMerge, and any
|
|
10
|
+
* *RangeStart/End markers.
|
|
10
11
|
*/
|
|
11
12
|
const REVISION_MARKER_PATTERN =
|
|
12
|
-
/<w:(?:ins|del|moveFrom|moveTo|rPrChange|pPrChange|tblPrChange|tblPrExChange|trPrChange|tcPrChange|sectPrChange|numberingChange|cellIns|cellDel|cellMerge|moveFromRangeStart|moveFromRangeEnd|moveToRangeStart|moveToRangeEnd|customXmlInsRangeStart|customXmlInsRangeEnd|customXmlDelRangeStart|customXmlDelRangeEnd|customXmlMoveFromRangeStart|customXmlMoveFromRangeEnd|customXmlMoveToRangeStart|customXmlMoveToRangeEnd)\b/;
|
|
13
|
+
/<w:(?:ins|del|moveFrom|moveTo|rPrChange|pPrChange|tblPrChange|tblPrExChange|trPrChange|tcPrChange|sectPrChange|tblGridChange|numberingChange|cellIns|cellDel|cellMerge|moveFromRangeStart|moveFromRangeEnd|moveToRangeStart|moveToRangeEnd|customXmlInsRangeStart|customXmlInsRangeEnd|customXmlDelRangeStart|customXmlDelRangeEnd|customXmlMoveFromRangeStart|customXmlMoveFromRangeEnd|customXmlMoveToRangeStart|customXmlMoveToRangeEnd)\b/;
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
+
* Applies tracked changes in a Word document per Microsoft's OpenXML SDK
|
|
17
|
+
* pattern, in either direction (see `mode`).
|
|
16
18
|
*
|
|
17
|
-
* This implementation uses DOM-based tree walking for reliability
|
|
19
|
+
* This implementation uses DOM-based tree walking for reliability. In 'accept'
|
|
20
|
+
* mode (the post-edit document):
|
|
18
21
|
* 1. Insertions (<w:ins>): Keep content, remove wrapper tags
|
|
19
22
|
* 2. Deletions (<w:del>): Remove entirely (content and tags)
|
|
20
23
|
* 3. Move From (<w:moveFrom>): Remove entirely (source of move)
|
|
@@ -22,17 +25,27 @@ const REVISION_MARKER_PATTERN =
|
|
|
22
25
|
* 5. Property changes: Remove all *Change elements
|
|
23
26
|
* 6. Range markers: Remove all boundary markers
|
|
24
27
|
*
|
|
28
|
+
* In 'reject' mode every content decision is inverted and property changes
|
|
29
|
+
* restore the previous formatting (see RevisionWalker).
|
|
30
|
+
*
|
|
25
31
|
* Also cleans up metadata in people.xml, settings.xml, and core.xml
|
|
26
32
|
*
|
|
27
33
|
* @see https://learn.microsoft.com/en-us/office/open-xml/how-to-accept-all-revisions
|
|
28
34
|
*/
|
|
29
|
-
class
|
|
35
|
+
class RevisionProcessor {
|
|
30
36
|
private zipHandler: ZipHandler;
|
|
31
37
|
/** Feature flag for DOM-based processing (default: true) */
|
|
32
38
|
private useDomBasedProcessing = true;
|
|
39
|
+
/**
|
|
40
|
+
* Processing direction. 'accept' yields the post-edit document; 'reject'
|
|
41
|
+
* reverts to the original pre-edit document (the exact inverse). Reject is
|
|
42
|
+
* DOM-only — the regex fallback cannot restore previous formatting.
|
|
43
|
+
*/
|
|
44
|
+
private mode: 'accept' | 'reject';
|
|
33
45
|
|
|
34
|
-
constructor(zipHandler: ZipHandler) {
|
|
46
|
+
constructor(zipHandler: ZipHandler, mode: 'accept' | 'reject' = 'accept') {
|
|
35
47
|
this.zipHandler = zipHandler;
|
|
48
|
+
this.mode = mode;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
/**
|
|
@@ -43,9 +56,10 @@ class RevisionAcceptor {
|
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
/**
|
|
46
|
-
* Main
|
|
59
|
+
* Main entry point: apply every tracked change in the document in the
|
|
60
|
+
* configured direction (accept or reject).
|
|
47
61
|
*/
|
|
48
|
-
public async
|
|
62
|
+
public async process(): Promise<void> {
|
|
49
63
|
// Process document.xml
|
|
50
64
|
await this.processDocumentPart('word/document.xml');
|
|
51
65
|
|
|
@@ -91,11 +105,13 @@ class RevisionAcceptor {
|
|
|
91
105
|
// byte-for-byte passthrough preservation in downstream consumers
|
|
92
106
|
// (e.g., comments.xml round-trip with no tracked changes inside).
|
|
93
107
|
const xml = this.zipHandler.getFileAsString(partPath);
|
|
94
|
-
if (!xml || !
|
|
108
|
+
if (!xml || !RevisionProcessor.containsRevisionMarkup(xml)) {
|
|
95
109
|
return;
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
|
|
112
|
+
// Reject must restore previous formatting from *Change snapshots, which the
|
|
113
|
+
// regex fallback cannot do — always take the DOM path when rejecting.
|
|
114
|
+
if (this.useDomBasedProcessing || this.mode === 'reject') {
|
|
99
115
|
return this.processDocumentPartDOM(partPath);
|
|
100
116
|
}
|
|
101
117
|
return this.processDocumentPartRegex(partPath);
|
|
@@ -126,14 +142,22 @@ class RevisionAcceptor {
|
|
|
126
142
|
|
|
127
143
|
// Step 2: Process revisions using DOM walker
|
|
128
144
|
const processed = RevisionWalker.processTree(parsed, {
|
|
145
|
+
mode: this.mode,
|
|
129
146
|
acceptInsertions: true,
|
|
130
147
|
acceptDeletions: true,
|
|
131
148
|
acceptMoves: true,
|
|
132
149
|
acceptPropertyChanges: true,
|
|
133
150
|
});
|
|
134
151
|
|
|
135
|
-
// Step 3: Handle image relationship ID remapping
|
|
136
|
-
|
|
152
|
+
// Step 3: Handle image relationship ID remapping.
|
|
153
|
+
// Only relevant when accepting: unwrapping an inserted image can surface a
|
|
154
|
+
// relationship ID that Word reused across tracked-change contexts, so a
|
|
155
|
+
// fresh unique ID is assigned. Rejecting discards insertions and restores
|
|
156
|
+
// the original content, whose relationship IDs are already unique, so
|
|
157
|
+
// remapping would needlessly rewrite valid references.
|
|
158
|
+
if (this.mode === 'accept') {
|
|
159
|
+
this.remapImageRelationshipsInTree(processed);
|
|
160
|
+
}
|
|
137
161
|
|
|
138
162
|
// Step 4: Convert back to XML
|
|
139
163
|
const outputXml =
|
|
@@ -768,8 +792,34 @@ class RevisionAcceptor {
|
|
|
768
792
|
* Convenience function to accept all revisions in a document
|
|
769
793
|
*/
|
|
770
794
|
export async function acceptAllRevisions(zipHandler: ZipHandler): Promise<void> {
|
|
771
|
-
const acceptor = new
|
|
772
|
-
await acceptor.
|
|
795
|
+
const acceptor = new RevisionProcessor(zipHandler);
|
|
796
|
+
await acceptor.process();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Convenience function to reject all revisions in a document, reverting it to
|
|
801
|
+
* its original pre-edit state (the exact inverse of {@link acceptAllRevisions}):
|
|
802
|
+
*
|
|
803
|
+
* 1. Insertions (`w:ins`): removed — inserted content is discarded
|
|
804
|
+
* 2. Deletions (`w:del`): unwrapped and `w:delText` restored to `w:t` — deleted
|
|
805
|
+
* content reappears
|
|
806
|
+
* 3. MoveFrom (`w:moveFrom`): unwrapped — the original source content is restored
|
|
807
|
+
* 4. MoveTo (`w:moveTo`): removed — the moved-to destination is discarded
|
|
808
|
+
* 5. Property changes (`w:rPrChange`, `w:pPrChange`, ...): previous formatting
|
|
809
|
+
* stored inside the change is restored, then the marker is removed
|
|
810
|
+
* 6. Inserted rows/tables are removed; deleted rows/tables are kept
|
|
811
|
+
* 7. Range markers are removed and revision metadata is cleaned up
|
|
812
|
+
*
|
|
813
|
+
* Limitations (shared with {@link acceptAllRevisions}): cell-level markers
|
|
814
|
+
* (`w:cellIns`/`w:cellDel`/`w:cellMerge`) are left in place, and the legacy
|
|
815
|
+
* `w:numberingChange` is dropped rather than reverted (it embeds no recoverable
|
|
816
|
+
* previous value).
|
|
817
|
+
*
|
|
818
|
+
* @param zipHandler - The ZipHandler containing the DOCX package
|
|
819
|
+
*/
|
|
820
|
+
export async function rejectAllRevisions(zipHandler: ZipHandler): Promise<void> {
|
|
821
|
+
const rejector = new RevisionProcessor(zipHandler, 'reject');
|
|
822
|
+
await rejector.process();
|
|
773
823
|
}
|
|
774
824
|
|
|
775
825
|
/**
|
|
@@ -786,6 +836,6 @@ export async function acceptAllRevisions(zipHandler: ZipHandler): Promise<void>
|
|
|
786
836
|
* @param zipHandler - The ZipHandler containing the DOCX package
|
|
787
837
|
*/
|
|
788
838
|
export function cleanupRevisionMetadata(zipHandler: ZipHandler): void {
|
|
789
|
-
const
|
|
790
|
-
|
|
839
|
+
const processor = new RevisionProcessor(zipHandler);
|
|
840
|
+
processor.cleanupMetadata();
|
|
791
841
|
}
|