@@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
33
44/**
55 * @typedef {'paired' | 'independent' } TrackChangesReplacements
6- * @typedef {{ type: string, author: string, date: string, internalId: string } } TrackedChangeEntry
7- * @typedef {{ lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements } } WalkContext
6+ * @typedef {{ type: string, author: string, date: string, internalId? : string } } TrackedChangeEntry
7+ * @typedef {{ beforeLastTrackedChange: TrackedChangeEntry | null, lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements } } WalkContext
88 */
99
1010const TRACKED_CHANGE_NAMES = new Set ( [ 'w:ins' , 'w:del' ] ) ;
@@ -44,6 +44,66 @@ function isReplacementPair(previous, current) {
4444 return previous . type !== current . type && previous . author === current . author && previous . date === current . date ;
4545}
4646
47+ /**
48+ * @param {object } element
49+ * @returns {TrackedChangeEntry }
50+ */
51+ function trackedChangeEntryFromElement ( element ) {
52+ return {
53+ type : element . name ,
54+ author : element . attributes ?. [ 'w:author' ] ?? '' ,
55+ date : element . attributes ?. [ 'w:date' ] ?? '' ,
56+ } ;
57+ }
58+
59+ /**
60+ * Returns the next sibling tracked-change element, skipping only non-content
61+ * markers. Content-bearing elements terminate the sibling check because they
62+ * break Word replacement adjacency.
63+ *
64+ * @param {Array } elements
65+ * @param {number } startIndex
66+ * @returns {TrackedChangeEntry | null }
67+ */
68+ function findNextSiblingTrackedChange ( elements , startIndex ) {
69+ if ( ! Array . isArray ( elements ) ) return null ;
70+
71+ for ( let i = startIndex ; i < elements . length ; i += 1 ) {
72+ const element = elements [ i ] ;
73+ if ( TRACKED_CHANGE_NAMES . has ( element ?. name ) ) {
74+ return trackedChangeEntryFromElement ( element ) ;
75+ }
76+ if ( ! PAIRING_TRANSPARENT_NAMES . has ( element ?. name ) ) {
77+ return null ;
78+ }
79+ }
80+
81+ return null ;
82+ }
83+
84+ /**
85+ * Word serializes a replacement selected inside another author's deletion as
86+ * child insertion/deletion sides surrounded by the parent deletion fragments.
87+ * In paired mode the generic adjacent-replacement heuristic would otherwise
88+ * collapse the child sides into one replacement. Keep them independent when
89+ * either side of the candidate pair touches a different-author deletion.
90+ *
91+ * @param {TrackedChangeEntry | null } beforePrevious
92+ * @param {TrackedChangeEntry } previous
93+ * @param {TrackedChangeEntry } current
94+ * @param {TrackedChangeEntry | null } next
95+ * @returns {boolean }
96+ */
97+ function isChildReplacementInsideDeletion ( beforePrevious , previous , current , next ) {
98+ if ( ! isReplacementPair ( previous , current ) ) return false ;
99+
100+ const touchesDifferentAuthorDeletionBefore =
101+ beforePrevious ?. type === 'w:del' && beforePrevious . author !== previous . author ;
102+ const touchesDifferentAuthorDeletionAfter = next ?. type === 'w:del' && next . author !== previous . author ;
103+
104+ return touchesDifferentAuthorDeletionBefore || touchesDifferentAuthorDeletionAfter ;
105+ }
106+
47107/**
48108 * Assigns an internal UUID to a tracked change element. In paired mode,
49109 * adjacent replacement halves (w:del + w:ins with matching author/date)
@@ -53,8 +113,9 @@ function isReplacementPair(previous, current) {
53113 * @param {Map<string, string> } idMap Accumulates Word ID → internal UUID
54114 * @param {WalkContext } context Mutable walk state for replacement pairing
55115 * @param {boolean } insideTrackedChange Whether this element is nested in another tracked change
116+ * @param {TrackedChangeEntry | null } nextTrackedChange
56117 */
57- function assignInternalId ( element , idMap , context , insideTrackedChange ) {
118+ function assignInternalId ( element , idMap , context , insideTrackedChange , nextTrackedChange = null ) {
58119 const wordId = String ( element . attributes ?. [ 'w:id' ] ?? '' ) ;
59120 if ( ! wordId ) return ;
60121
@@ -66,29 +127,40 @@ function assignInternalId(element, idMap, context, insideTrackedChange) {
66127 return ;
67128 }
68129
69- const current = {
70- type : element . name ,
71- author : element . attributes ?. [ 'w:author' ] ?? '' ,
72- date : element . attributes ?. [ 'w:date' ] ?? '' ,
73- } ;
130+ const current = trackedChangeEntryFromElement ( element ) ;
74131
75132 const shouldPair = context . replacements === 'paired' ;
133+ const shouldKeepChildSides =
134+ context . lastTrackedChange &&
135+ isChildReplacementInsideDeletion (
136+ context . beforeLastTrackedChange ,
137+ context . lastTrackedChange ,
138+ current ,
139+ nextTrackedChange ,
140+ ) ;
76141
77- if ( shouldPair && context . lastTrackedChange && isReplacementPair ( context . lastTrackedChange , current ) ) {
142+ if (
143+ shouldPair &&
144+ context . lastTrackedChange &&
145+ ! shouldKeepChildSides &&
146+ isReplacementPair ( context . lastTrackedChange , current )
147+ ) {
78148 // Second half of a replacement — share the first half's UUID, but only
79149 // if this w:id hasn't already been mapped. A reused id that was already
80150 // part of an earlier pair must keep its original mapping.
81151 if ( ! idMap . has ( wordId ) ) {
82152 idMap . set ( wordId , context . lastTrackedChange . internalId ) ;
83153 }
84154 context . lastTrackedChange = null ;
155+ context . beforeLastTrackedChange = null ;
85156 } else {
86157 // Reuse an existing mapping when the same w:id appears more than once
87158 // (Word reuses tracked-change ids across the document). Minting a fresh
88159 // UUID here would overwrite the earlier entry and break any replacement
89160 // pair that was already recorded for this id.
90161 const internalId = idMap . get ( wordId ) ?? uuidv4 ( ) ;
91162 idMap . set ( wordId , internalId ) ;
163+ context . beforeLastTrackedChange = context . lastTrackedChange ;
92164 context . lastTrackedChange = { ...current , internalId } ;
93165 }
94166}
@@ -105,9 +177,11 @@ function assignInternalId(element, idMap, context, insideTrackedChange) {
105177function walkElements ( elements , idMap , context , insideTrackedChange = false ) {
106178 if ( ! Array . isArray ( elements ) ) return ;
107179
108- for ( const element of elements ) {
180+ for ( let index = 0 ; index < elements . length ; index += 1 ) {
181+ const element = elements [ index ] ;
109182 if ( TRACKED_CHANGE_NAMES . has ( element . name ) ) {
110- assignInternalId ( element , idMap , context , insideTrackedChange ) ;
183+ const nextTrackedChange = findNextSiblingTrackedChange ( elements , index + 1 ) ;
184+ assignInternalId ( element , idMap , context , insideTrackedChange , nextTrackedChange ) ;
111185
112186 if ( element . elements ) {
113187 // Descend with an isolated context so content inside a tracked change
@@ -116,7 +190,7 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) {
116190 walkElements (
117191 element . elements ,
118192 idMap ,
119- { lastTrackedChange : null , replacements : context . replacements } ,
193+ { beforeLastTrackedChange : null , lastTrackedChange : null , replacements : context . replacements } ,
120194 /* insideTrackedChange */ true ,
121195 ) ;
122196 }
@@ -125,6 +199,7 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) {
125199 // markers (comment/bookmark/permission ranges) are transparent.
126200 if ( ! PAIRING_TRANSPARENT_NAMES . has ( element . name ) ) {
127201 context . lastTrackedChange = null ;
202+ context . beforeLastTrackedChange = null ;
128203 }
129204
130205 if ( element . elements ) {
@@ -150,7 +225,7 @@ function buildTrackedChangeIdMapForPart(part, options = {}) {
150225
151226 const replacements = options . replacements === 'independent' ? 'independent' : 'paired' ;
152227 const idMap = new Map ( ) ;
153- walkElements ( root . elements , idMap , { lastTrackedChange : null , replacements } ) ;
228+ walkElements ( root . elements , idMap , { beforeLastTrackedChange : null , lastTrackedChange : null , replacements } ) ;
154229 return idMap ;
155230}
156231
0 commit comments