@ansonlai/docx-redline-js 0.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.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. package/standalone.js +5 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Reconstruction mapper.
3
+ *
4
+ * Builds paragraph/property/sentinel mappings and indexed lookups used by reconstruction writing.
5
+ */
6
+
7
+ import { appendParagraphBoundary } from '../core/paragraph-offset-policy.js';
8
+ import { getDocumentParagraphs } from './format-extraction.js';
9
+ import { getElementsByTag, getFirstElementByTag } from '../core/xml-query.js';
10
+
11
+ function createRangeCursorLookup(ranges) {
12
+ let cursor = 0;
13
+ return {
14
+ at(index) {
15
+ while (cursor < ranges.length && ranges[cursor].end <= index) {
16
+ cursor++;
17
+ }
18
+ const match = ranges[cursor];
19
+ if (!match) return null;
20
+ if (match.start <= index && index < match.end) return match;
21
+ return null;
22
+ }
23
+ };
24
+ }
25
+
26
+ function indexSentinelsByStart(sentinelMap) {
27
+ const sentinelMapByStart = new Map();
28
+ sentinelMap.forEach(sentinel => {
29
+ if (!sentinelMapByStart.has(sentinel.start)) {
30
+ sentinelMapByStart.set(sentinel.start, []);
31
+ }
32
+ sentinelMapByStart.get(sentinel.start).push(sentinel);
33
+ });
34
+ return sentinelMapByStart;
35
+ }
36
+
37
+ /**
38
+ * Builds reconstruction mapping and cursor-based lookup helpers.
39
+ *
40
+ * @param {Document} xmlDoc - XML document
41
+ * @param {string} modifiedText - Modified text
42
+ * @returns {{
43
+ * paragraphs: Element[],
44
+ * body: Element|Document,
45
+ * paragraphMap: Array<{start:number,end:number,pPr:Element|null,container:Node}>,
46
+ * paragraphStarts: Set<number>,
47
+ * propertyMap: Array<{start:number,end:number,rPr:Element|null,wrapper?:Element}>,
48
+ * sentinelMap: Array<Object>,
49
+ * sentinelMapByStart: Map<number, Object[]>,
50
+ * referenceMap: Map<string, Node>,
51
+ * tokenToCharMap: Map<string, string>,
52
+ * containerFragments: Map<Node, DocumentFragment>,
53
+ * replacementContainers: Map<Node, Node>,
54
+ * originalFullText: string,
55
+ * processedModifiedText: string,
56
+ * getParagraphInfo: (index:number) => {start:number,end:number,pPr:Element|null,container:Node},
57
+ * getRunProperties: (index:number) => {rPr:Element|null,wrapper?:Element},
58
+ * getPropertySpanLength: (index:number,maxLength:number) => number,
59
+ * isParagraphStart: (index:number) => boolean
60
+ * }}
61
+ */
62
+ export function buildReconstructionMapping(xmlDoc, modifiedText) {
63
+ const rootElement = xmlDoc.documentElement;
64
+ const isBodyRoot = rootElement.nodeName === 'w:body' || rootElement.nodeName.endsWith(':package');
65
+ const paragraphs = getDocumentParagraphs(xmlDoc);
66
+
67
+ let body = getFirstElementByTag(xmlDoc, 'w:body');
68
+ if (!body && isBodyRoot) body = rootElement;
69
+
70
+ let originalFullText = '';
71
+ const propertyMap = [];
72
+ const paragraphMap = [];
73
+ const sentinelMap = [];
74
+ const referenceMap = new Map();
75
+ const tokenToCharMap = new Map();
76
+ let nextCharCode = 0xe000;
77
+ const uniqueContainers = new Set();
78
+
79
+ paragraphs.forEach((paragraph, paragraphIndex) => {
80
+ const paragraphStart = originalFullText.length;
81
+
82
+ Array.from(paragraph.childNodes).forEach(child => {
83
+ originalFullText = processChildNode(
84
+ child,
85
+ originalFullText,
86
+ propertyMap,
87
+ sentinelMap,
88
+ referenceMap,
89
+ tokenToCharMap,
90
+ nextCharCode
91
+ );
92
+ if (referenceMap.size > tokenToCharMap.size) {
93
+ nextCharCode++;
94
+ }
95
+ });
96
+
97
+ originalFullText = appendParagraphBoundary(originalFullText, paragraphIndex, paragraphs.length);
98
+
99
+ const paragraphEnd = originalFullText.length;
100
+ const pPr = getFirstElementByTag(paragraph, 'w:pPr');
101
+ const container = paragraph.parentNode;
102
+ if (container) uniqueContainers.add(container);
103
+
104
+ paragraphMap.push({
105
+ start: paragraphStart,
106
+ end: paragraphEnd,
107
+ pPr,
108
+ container: container || body
109
+ });
110
+ });
111
+
112
+ let processedModifiedText = modifiedText;
113
+ tokenToCharMap.forEach((char, tokenString) => {
114
+ const escapedToken = tokenString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
115
+ processedModifiedText = processedModifiedText.replace(new RegExp(escapedToken, 'g'), char);
116
+ });
117
+
118
+ const containerFragments = new Map();
119
+ uniqueContainers.forEach(container => {
120
+ containerFragments.set(container, xmlDoc.createDocumentFragment());
121
+ });
122
+ if (body && !containerFragments.has(body)) {
123
+ containerFragments.set(body, xmlDoc.createDocumentFragment());
124
+ }
125
+ if (!containerFragments.has(xmlDoc)) {
126
+ containerFragments.set(xmlDoc, xmlDoc.createDocumentFragment());
127
+ }
128
+
129
+ const replacementContainers = new Map();
130
+ const paragraphStarts = new Set(paragraphMap.map(paragraph => paragraph.start));
131
+ const paragraphLookup = createRangeCursorLookup(paragraphMap);
132
+ const propertyLookup = createRangeCursorLookup(propertyMap);
133
+ const sentinelMapByStart = indexSentinelsByStart(sentinelMap);
134
+
135
+ const getParagraphInfo = (index) => {
136
+ const match = paragraphLookup.at(index);
137
+ if (match) return match;
138
+ if (paragraphMap.length > 0) return paragraphMap[paragraphMap.length - 1];
139
+ return { start: 0, end: 0, pPr: null, container: body || xmlDoc };
140
+ };
141
+
142
+ const getRunProperties = (index) => {
143
+ const match = propertyLookup.at(index);
144
+ return match ? { rPr: match.rPr, wrapper: match.wrapper } : { rPr: null };
145
+ };
146
+
147
+ const getPropertySpanLength = (index, maxLength) => {
148
+ const match = propertyLookup.at(index);
149
+ if (!match) return 1;
150
+ return Math.min(match.end - index, maxLength);
151
+ };
152
+
153
+ return {
154
+ paragraphs,
155
+ body: body || xmlDoc,
156
+ paragraphMap,
157
+ paragraphStarts,
158
+ propertyMap,
159
+ sentinelMap,
160
+ sentinelMapByStart,
161
+ referenceMap,
162
+ tokenToCharMap,
163
+ containerFragments,
164
+ replacementContainers,
165
+ originalFullText,
166
+ processedModifiedText,
167
+ getParagraphInfo,
168
+ getRunProperties,
169
+ getPropertySpanLength,
170
+ isParagraphStart: index => paragraphStarts.has(index)
171
+ };
172
+ }
173
+
174
+ function processChildNode(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode) {
175
+ if (child.nodeName === 'w:r') {
176
+ return processRunForReconstruction(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode);
177
+ }
178
+ if (child.nodeName === 'w:hyperlink') {
179
+ return processHyperlinkForReconstruction(child, originalFullText, propertyMap);
180
+ }
181
+ if (['w:sdt', 'w:oMath', 'm:oMath', 'w:bookmarkStart', 'w:bookmarkEnd'].includes(child.nodeName)) {
182
+ sentinelMap.push({ start: originalFullText.length, node: child });
183
+ return originalFullText + '\uFFFC';
184
+ }
185
+ if (['w:commentRangeStart', 'w:commentRangeEnd'].includes(child.nodeName)) {
186
+ sentinelMap.push({ start: originalFullText.length, node: child, isCommentMarker: true });
187
+ return originalFullText;
188
+ }
189
+ return originalFullText;
190
+ }
191
+
192
+ function processRunForReconstruction(runElement, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode) {
193
+ let fullText = originalFullText;
194
+ const rPr = getFirstElementByTag(runElement, 'w:rPr');
195
+
196
+ Array.from(runElement.childNodes).forEach(runChild => {
197
+ if (runChild.nodeName === 'w:t') {
198
+ const textContent = runChild.textContent || '';
199
+ if (textContent.length > 0) {
200
+ propertyMap.push({
201
+ start: fullText.length,
202
+ end: fullText.length + textContent.length,
203
+ rPr
204
+ });
205
+ fullText += textContent;
206
+ }
207
+ } else if (runChild.nodeName === 'w:br' || runChild.nodeName === 'w:cr') {
208
+ fullText += '\n';
209
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
210
+ } else if (runChild.nodeName === 'w:tab') {
211
+ fullText += '\t';
212
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
213
+ } else if (runChild.nodeName === 'w:noBreakHyphen') {
214
+ fullText += '\u2011';
215
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
216
+ } else if (['w:drawing', 'w:pict', 'w:object', 'w:fldChar', 'w:instrText', 'w:sym'].includes(runChild.nodeName)) {
217
+ const textBoxContent = getFirstElementByTag(runChild, 'w:txbxContent');
218
+ const hasTextBox = runChild.nodeName === 'w:pict' && !!textBoxContent;
219
+
220
+ sentinelMap.push({
221
+ start: fullText.length,
222
+ node: runChild,
223
+ isTextBox: hasTextBox,
224
+ originalContainer: hasTextBox ? textBoxContent : undefined
225
+ });
226
+ fullText += '\uFFFC';
227
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
228
+ } else if (runChild.nodeName === 'w:footnoteReference' || runChild.nodeName === 'w:endnoteReference') {
229
+ const id = runChild.getAttribute('w:id');
230
+ if (id) {
231
+ const type = runChild.nodeName === 'w:footnoteReference' ? 'FN' : 'EN';
232
+ const tokenString = `{{__${type}_${id}__}}`;
233
+ const char = String.fromCharCode(nextCharCode);
234
+ referenceMap.set(char, runChild);
235
+ tokenToCharMap.set(tokenString, char);
236
+ fullText += char;
237
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
238
+ }
239
+ } else if (runChild.nodeName === 'w:commentReference') {
240
+ sentinelMap.push({ start: fullText.length, node: runChild, isCommentMarker: true });
241
+ }
242
+ });
243
+
244
+ return fullText;
245
+ }
246
+
247
+ function processHyperlinkForReconstruction(hyperlinkElement, originalFullText, propertyMap) {
248
+ let fullText = originalFullText;
249
+
250
+ Array.from(hyperlinkElement.childNodes).forEach(hyperlinkChild => {
251
+ if (hyperlinkChild.nodeName !== 'w:r') return;
252
+
253
+ const rPr = getFirstElementByTag(hyperlinkChild, 'w:rPr');
254
+ const texts = getElementsByTag(hyperlinkChild, 'w:t');
255
+ texts.forEach(textNode => {
256
+ const textContent = textNode.textContent || '';
257
+ if (textContent.length === 0) return;
258
+
259
+ propertyMap.push({
260
+ start: fullText.length,
261
+ end: fullText.length + textContent.length,
262
+ rPr,
263
+ wrapper: hyperlinkElement
264
+ });
265
+ fullText += textContent;
266
+ });
267
+ });
268
+
269
+ return fullText;
270
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Reconstruction reconciliation mode orchestration.
3
+ */
4
+
5
+ import { computeWordDiffs } from '../pipeline/diff-engine.js';
6
+ import { buildReconstructionMapping } from './reconstruction-mapper.js';
7
+ import { applyReconstructionDiffs } from './reconstruction-writer.js';
8
+
9
+ /**
10
+ * Applies reconstruction mode reconciliation.
11
+ *
12
+ * @param {Document} xmlDoc - XML document
13
+ * @param {string} originalText - Original text (kept for signature compatibility)
14
+ * @param {string} modifiedText - Modified text
15
+ * @param {XMLSerializer} serializer - Serializer instance
16
+ * @param {string} author - Author name
17
+ * @param {Array} formatHints - Format hints
18
+ * @param {boolean} [generateRedlines=true] - Track change toggle
19
+ * @returns {{ oxml: string, hasChanges: boolean }}
20
+ */
21
+ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true) {
22
+ const mapping = buildReconstructionMapping(xmlDoc, modifiedText);
23
+ if (mapping.paragraphs.length === 0) {
24
+ return { oxml: serializer.serializeToString(xmlDoc), hasChanges: false };
25
+ }
26
+
27
+ const diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText);
28
+
29
+ return applyReconstructionDiffs(
30
+ xmlDoc,
31
+ diffs,
32
+ mapping,
33
+ serializer,
34
+ author,
35
+ formatHints,
36
+ generateRedlines
37
+ );
38
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Reconstruction writer.
3
+ *
4
+ * Applies diff segments to mapped reconstruction context and writes updated DOM content.
5
+ */
6
+
7
+ import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
8
+ import { createTrackChange, createFormattedRuns } from './run-builders.js';
9
+ import { getFirstElementByTag } from '../core/xml-query.js';
10
+
11
+ /**
12
+ * Applies diffs to reconstruction context and writes updated XML.
13
+ *
14
+ * @param {Document} xmlDoc - XML document
15
+ * @param {Array<[number, string]>} diffs - Diff tuples from diff-match-patch
16
+ * @param {ReturnType<import('./reconstruction-mapper.js').buildReconstructionMapping>} context - Reconstruction mapping
17
+ * @param {XMLSerializer} serializer - Serializer instance
18
+ * @param {string} author - Author name
19
+ * @param {Array} formatHints - Format hints
20
+ * @param {boolean} [generateRedlines=true] - Track change toggle
21
+ * @returns {{ oxml: string, hasChanges: boolean }}
22
+ */
23
+ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, author, formatHints, generateRedlines = true) {
24
+ const {
25
+ paragraphs,
26
+ paragraphMap,
27
+ containerFragments,
28
+ sentinelMapByStart,
29
+ referenceMap,
30
+ replacementContainers,
31
+ getParagraphInfo,
32
+ getRunProperties,
33
+ getPropertySpanLength,
34
+ isParagraphStart
35
+ } = context;
36
+
37
+ const createNewParagraph = (pPr) => {
38
+ const newParagraph = xmlDoc.createElement('w:p');
39
+ if (pPr) newParagraph.appendChild(pPr.cloneNode(true));
40
+ return newParagraph;
41
+ };
42
+
43
+ const startInfo = getParagraphInfo(0);
44
+ let currentParagraph = createNewParagraph(startInfo.pPr);
45
+ const initialFragment = containerFragments.get(startInfo.container);
46
+ if (initialFragment) {
47
+ initialFragment.appendChild(currentParagraph);
48
+ }
49
+
50
+ let currentOriginalIndex = 0;
51
+ let currentInsertOffset = 0;
52
+
53
+ for (const [op, text] of diffs) {
54
+ if (op === 0 || op === -1) {
55
+ const type = op === 0 ? 'equal' : 'delete';
56
+ let offset = 0;
57
+
58
+ while (offset < text.length) {
59
+ const chunkStart = currentOriginalIndex + offset;
60
+ const properties = getRunProperties(chunkStart);
61
+ const chunkLength = getPropertySpanLength(chunkStart, text.length - offset);
62
+ const chunk = text.substring(offset, offset + chunkLength);
63
+
64
+ appendTextToCurrent(
65
+ xmlDoc,
66
+ chunk,
67
+ type,
68
+ properties.rPr,
69
+ properties.wrapper,
70
+ chunkStart,
71
+ currentParagraph,
72
+ containerFragments,
73
+ sentinelMapByStart,
74
+ referenceMap,
75
+ replacementContainers,
76
+ getParagraphInfo,
77
+ createNewParagraph,
78
+ author,
79
+ formatHints,
80
+ currentInsertOffset,
81
+ generateRedlines
82
+ );
83
+
84
+ if (op === 0) {
85
+ currentInsertOffset += chunkLength;
86
+ }
87
+ offset += chunkLength;
88
+ }
89
+
90
+ currentOriginalIndex += text.length;
91
+ continue;
92
+ }
93
+
94
+ if (op === 1) {
95
+ const properties = currentOriginalIndex > 0 && !isParagraphStart(currentOriginalIndex)
96
+ ? getRunProperties(currentOriginalIndex - 1)
97
+ : getRunProperties(currentOriginalIndex);
98
+
99
+ appendTextToCurrent(
100
+ xmlDoc,
101
+ text,
102
+ 'insert',
103
+ properties.rPr,
104
+ properties.wrapper,
105
+ currentOriginalIndex,
106
+ currentParagraph,
107
+ containerFragments,
108
+ sentinelMapByStart,
109
+ referenceMap,
110
+ replacementContainers,
111
+ getParagraphInfo,
112
+ createNewParagraph,
113
+ author,
114
+ formatHints,
115
+ currentInsertOffset,
116
+ generateRedlines
117
+ );
118
+ currentInsertOffset += text.length;
119
+ }
120
+ }
121
+
122
+ paragraphs.forEach(paragraph => {
123
+ if (paragraph.parentNode) {
124
+ paragraph.parentNode.removeChild(paragraph);
125
+ }
126
+ });
127
+
128
+ containerFragments.forEach((fragment, container) => {
129
+ const replacement = replacementContainers.get(container);
130
+ const target = replacement || container;
131
+
132
+ if (target.nodeType === 9) {
133
+ const firstChild = fragment.firstChild;
134
+ if (firstChild) {
135
+ target.appendChild(firstChild);
136
+ while (fragment.firstChild) {
137
+ target.documentElement.appendChild(fragment.firstChild);
138
+ }
139
+ }
140
+ return;
141
+ }
142
+
143
+ target.appendChild(fragment);
144
+ });
145
+
146
+ return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
147
+ }
148
+
149
+ function appendTextToCurrent(
150
+ xmlDoc,
151
+ text,
152
+ type,
153
+ rPr,
154
+ wrapper,
155
+ baseIndex,
156
+ currentParagraphRef,
157
+ containerFragments,
158
+ sentinelMapByStart,
159
+ referenceMap,
160
+ replacementContainers,
161
+ getParagraphInfo,
162
+ createNewParagraph,
163
+ author,
164
+ formatHints = [],
165
+ insertOffset = 0,
166
+ generateRedlines = true
167
+ ) {
168
+ let localBaseIndex = baseIndex;
169
+ let localInsertOffset = insertOffset;
170
+ let localParagraph = currentParagraphRef;
171
+
172
+ const parts = text.split(/([\n\uFFFC]|[\uE000-\uF8FF])/);
173
+
174
+ parts.forEach(part => {
175
+ const sentinelsAtOffset = sentinelMapByStart.get(localBaseIndex) || [];
176
+ const commentMarkers = sentinelsAtOffset.filter(sentinel => sentinel.isCommentMarker);
177
+
178
+ commentMarkers.forEach(marker => {
179
+ if (marker.node.nodeName === 'w:commentReference') {
180
+ const run = xmlDoc.createElement('w:r');
181
+ run.appendChild(marker.node.cloneNode(true));
182
+ localParagraph.appendChild(run);
183
+ } else {
184
+ localParagraph.appendChild(marker.node.cloneNode(true));
185
+ }
186
+ });
187
+
188
+ if (part === '\n') {
189
+ if (type !== 'delete') {
190
+ const info = getParagraphInfo(localBaseIndex);
191
+ const nextParagraph = createNewParagraph(info.pPr);
192
+ const fragment = containerFragments.get(info.container);
193
+ if (fragment) {
194
+ fragment.appendChild(nextParagraph);
195
+ localParagraph = nextParagraph;
196
+ }
197
+ }
198
+ localBaseIndex++;
199
+ if (type !== 'delete') localInsertOffset++;
200
+ return;
201
+ }
202
+
203
+ if (part === '\uFFFC') {
204
+ const sentinel = sentinelsAtOffset.find(entry => !entry.isCommentMarker) || sentinelsAtOffset[0];
205
+ if (sentinel) {
206
+ const clone = sentinel.node.cloneNode(true);
207
+ if (sentinel.isTextBox && sentinel.originalContainer) {
208
+ const newContainer = getFirstElementByTag(clone, 'w:txbxContent');
209
+ if (newContainer) {
210
+ while (newContainer.firstChild) newContainer.removeChild(newContainer.firstChild);
211
+ replacementContainers.set(sentinel.originalContainer, newContainer);
212
+ }
213
+ }
214
+ localParagraph.appendChild(clone);
215
+ }
216
+ localBaseIndex++;
217
+ if (type !== 'delete') localInsertOffset++;
218
+ return;
219
+ }
220
+
221
+ if (referenceMap.has(part)) {
222
+ if (type !== 'delete') {
223
+ const refNode = referenceMap.get(part);
224
+ if (refNode) {
225
+ const clone = refNode.cloneNode(true);
226
+ const run = xmlDoc.createElement('w:r');
227
+ if (rPr) run.appendChild(rPr.cloneNode(true));
228
+ run.appendChild(clone);
229
+ localParagraph.appendChild(run);
230
+ }
231
+ }
232
+ localBaseIndex++;
233
+ if (type !== 'delete') localInsertOffset++;
234
+ return;
235
+ }
236
+
237
+ if (part.length === 0) return;
238
+
239
+ let parent = localParagraph;
240
+ if (wrapper) {
241
+ const wrapperClone = wrapper.cloneNode(false);
242
+ parent = wrapperClone;
243
+ localParagraph.appendChild(wrapperClone);
244
+ }
245
+
246
+ if (type === 'delete') {
247
+ const run = xmlDoc.createElement('w:r');
248
+ if (rPr) run.appendChild(rPr.cloneNode(true));
249
+ const delText = xmlDoc.createElement('w:delText');
250
+ delText.setAttribute('xml:space', 'preserve');
251
+ delText.textContent = part;
252
+ run.appendChild(delText);
253
+
254
+ if (generateRedlines) {
255
+ const del = createTrackChange(xmlDoc, 'del', run, author);
256
+ parent.appendChild(del);
257
+ }
258
+ } else {
259
+ const applicableHints = getApplicableFormatHints(formatHints, localInsertOffset, localInsertOffset + part.length);
260
+ const runs = createFormattedRuns(xmlDoc, part, rPr, applicableHints, localInsertOffset, author, generateRedlines);
261
+
262
+ if (type === 'insert' && generateRedlines) {
263
+ const ins = createTrackChange(xmlDoc, 'ins', null, author);
264
+ runs.forEach(run => ins.appendChild(run));
265
+ parent.appendChild(ins);
266
+ } else {
267
+ runs.forEach(run => parent.appendChild(run));
268
+ }
269
+ }
270
+
271
+ if (type !== 'delete') {
272
+ localInsertOffset += part.length;
273
+ }
274
+ localBaseIndex += part.length;
275
+ });
276
+ }