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.
@@ -11,19 +11,43 @@
11
11
  import { ParsedXMLObject } from '../xml/XMLParser.js';
12
12
 
13
13
  /**
14
- * Options for controlling which revision types to accept
14
+ * Options for controlling which revision types to process
15
15
  */
16
16
  export interface RevisionWalkerOptions {
17
- /** Keep content, remove w:ins wrapper (default: true) */
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
- /** Remove w:del and content entirely (default: true) */
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
- /** Remove *Change elements (default: true) */
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
- if (options.acceptDeletions && obj['w:tbl']) {
157
- RevisionWalker.removeFullyDeletedTables(obj);
158
- }
159
- if (options.acceptDeletions && obj['w:tr']) {
160
- RevisionWalker.filterDeletedRows(obj);
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
- * `<w:del/>` row-level deletion marker. Operates on the parsed-object
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 filterDeletedRows(tbl: any): void {
266
+ private static filterMarkedRows(tbl: any, marker: 'w:del' | 'w:ins'): void {
191
267
  const rows = tbl['w:tr'];
192
- const isRowDeleted = (row: any): boolean => {
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
- // w:del at row level is a CT_TrackChange (no children). When absent
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['w:del'];
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 (isRowDeleted(rows[i])) removedIndices.add(i);
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 (isRowDeleted(rows)) {
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 tracked-deleted.
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 filterDeletedRows (invoked on the
225
- * table itself) has no reference back to the container holding the
226
- * `w:tbl` key. Skips tables that still carry potential row containers
227
- * (w:ins / w:moveTo wrappers, w:sdt, w:customXml) — their surviving rows
228
- * are only materialized as direct `w:tr` children later, when the
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 removeFullyDeletedTables(parent: any): void {
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 accepting a deletion — leave them untouched.
318
+ // result of a tracked change — leave them untouched.
236
319
  if (!tbl || typeof tbl !== 'object' || tbl['w:tr'] === undefined) return false;
237
- RevisionWalker.filterDeletedRows(tbl);
320
+ RevisionWalker.filterMarkedRows(tbl, marker);
238
321
  return (
239
322
  tbl['w:tr'] === undefined &&
240
- !tbl['w:ins'] &&
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
- // Check if this is a revision element
299
- if (RevisionWalker.shouldUnwrap(key, options)) {
300
- RevisionWalker.unwrapAllElements(parent, key);
301
- } else if (RevisionWalker.shouldRemove(key, options)) {
302
- RevisionWalker.removeAllElements(parent, key);
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
- * Check if an element should be unwrapped (content kept)
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 shouldUnwrap(key: string, options: Required<RevisionWalkerOptions>): boolean {
311
- if (REVISION_ELEMENTS.UNWRAP.includes(key)) {
312
- if (key === 'w:ins' && !options.acceptInsertions) return false;
313
- if (key === 'w:moveTo' && !options.acceptMoves) return false;
314
- return true;
315
- }
316
- return false;
317
- }
318
-
319
- /**
320
- * Check if an element should be removed (content discarded)
321
- */
322
- private static shouldRemove(key: string, options: Required<RevisionWalkerOptions>): boolean {
323
- if (REVISION_ELEMENTS.REMOVE.includes(key)) {
324
- if (key === 'w:del' && !options.acceptDeletions) return false;
325
- if (key === 'w:moveFrom' && !options.acceptMoves) return false;
326
- return true;
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
- return options.acceptPropertyChanges;
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 true; // Always remove range markers
434
+ return 'remove'; // Always remove range markers
333
435
  }
334
- return false;
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:numberingChange,
9
- * w:cellIns, w:cellDel, w:cellMerge, and any *RangeStart/End markers.
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
- * Accepts all tracked changes in a Word document per Microsoft's OpenXML SDK pattern
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 RevisionAcceptor {
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 method to accept all revisions in the document
59
+ * Main entry point: apply every tracked change in the document in the
60
+ * configured direction (accept or reject).
47
61
  */
48
- public async acceptAllRevisions(): Promise<void> {
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 || !RevisionAcceptor.containsRevisionMarkup(xml)) {
108
+ if (!xml || !RevisionProcessor.containsRevisionMarkup(xml)) {
95
109
  return;
96
110
  }
97
111
 
98
- if (this.useDomBasedProcessing) {
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
- this.remapImageRelationshipsInTree(processed);
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 RevisionAcceptor(zipHandler);
772
- await acceptor.acceptAllRevisions();
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 acceptor = new RevisionAcceptor(zipHandler);
790
- acceptor.cleanupMetadata();
839
+ const processor = new RevisionProcessor(zipHandler);
840
+ processor.cleanupMetadata();
791
841
  }