@ansonlai/docx-redline-js 0.1.4 → 0.1.6

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 (42) hide show
  1. package/AGENTS.md +53 -4
  2. package/ARCHITECTURE.md +57 -9
  3. package/README.md +47 -3
  4. package/core/types.js +35 -8
  5. package/core/word-xml.js +90 -0
  6. package/dist/docx-redline-js.esm.js +3073 -2594
  7. package/dist/docx-redline-js.esm.js.map +4 -4
  8. package/dist/docx-redline-js.esm.min.js +71 -67
  9. package/dist/docx-redline-js.esm.min.js.map +4 -4
  10. package/docs/VALIDATION.md +48 -0
  11. package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
  12. package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
  13. package/engine/format-application.js +13 -14
  14. package/engine/format-span-application.js +7 -6
  15. package/engine/formatting-removal.js +15 -12
  16. package/engine/oxml-engine.js +146 -55
  17. package/engine/reconstruction-mapper.js +35 -8
  18. package/engine/reconstruction-mode.js +14 -13
  19. package/engine/reconstruction-writer.js +97 -78
  20. package/engine/rpr-helpers.js +34 -32
  21. package/engine/run-builders.js +150 -39
  22. package/engine/surgical-diff-application.js +216 -0
  23. package/engine/surgical-mode.js +84 -519
  24. package/engine/surgical-run-splitting.js +96 -0
  25. package/engine/surgical-spans.js +169 -0
  26. package/engine/table-cell-context.js +15 -13
  27. package/engine/table-mode.js +39 -35
  28. package/index.d.ts +148 -0
  29. package/index.js +15 -13
  30. package/package.json +8 -1
  31. package/pipeline/ingestion-export.js +1 -0
  32. package/pipeline/ingestion-paragraph.js +37 -12
  33. package/pipeline/ingestion-table.js +11 -8
  34. package/scripts/build.mjs +35 -0
  35. package/scripts/check-types.mjs +28 -0
  36. package/scripts/export-validation-fixtures.mjs +68 -0
  37. package/scripts/run-tests.mjs +43 -0
  38. package/scripts/word-com-smoke.ps1 +48 -0
  39. package/services/comment-locator.js +10 -9
  40. package/services/revision-comment-management.js +115 -1
  41. package/services/standalone-operation-runner.js +119 -69
  42. package/services/table-reconciliation.js +7 -8
@@ -109,11 +109,12 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
109
109
  });
110
110
  });
111
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
- });
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
+ processedModifiedText = preserveReferencePlaceholders(originalFullText, processedModifiedText, referenceMap);
117
118
 
118
119
  const containerFragments = new Map();
119
120
  uniqueContainers.forEach(container => {
@@ -169,9 +170,35 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
169
170
  getPropertySpanLength,
170
171
  isParagraphStart: index => paragraphStarts.has(index)
171
172
  };
172
- }
173
-
174
- function processChildNode(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode) {
173
+ }
174
+
175
+ function preserveReferencePlaceholders(originalFullText, modifiedText, referenceMap) {
176
+ let result = modifiedText;
177
+
178
+ for (const referenceChar of referenceMap.keys()) {
179
+ if (result.includes(referenceChar)) continue;
180
+
181
+ const originalIndex = originalFullText.indexOf(referenceChar);
182
+ if (originalIndex < 0) continue;
183
+
184
+ const prefix = originalFullText.slice(0, originalIndex);
185
+ const suffix = originalFullText.slice(originalIndex + referenceChar.length);
186
+
187
+ if (prefix && result.startsWith(prefix)) {
188
+ result = `${result.slice(0, prefix.length)}${referenceChar}${result.slice(prefix.length)}`;
189
+ continue;
190
+ }
191
+
192
+ if (suffix && result.endsWith(suffix)) {
193
+ const insertAt = result.length - suffix.length;
194
+ result = `${result.slice(0, insertAt)}${referenceChar}${result.slice(insertAt)}`;
195
+ }
196
+ }
197
+
198
+ return result;
199
+ }
200
+
201
+ function processChildNode(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode) {
175
202
  if (child.nodeName === 'w:r') {
176
203
  return processRunForReconstruction(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode);
177
204
  }
@@ -5,6 +5,7 @@
5
5
  import { computeWordDiffs } from '../pipeline/diff-engine.js';
6
6
  import { buildReconstructionMapping } from './reconstruction-mapper.js';
7
7
  import { applyReconstructionDiffs } from './reconstruction-writer.js';
8
+ import { withOoxmlSourceType } from '../core/word-xml.js';
8
9
 
9
10
  /**
10
11
  * Applies reconstruction mode reconciliation.
@@ -19,20 +20,20 @@ import { applyReconstructionDiffs } from './reconstruction-writer.js';
19
20
  * @returns {{ oxml: string, hasChanges: boolean }}
20
21
  */
21
22
  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
- }
23
+ const mapping = buildReconstructionMapping(xmlDoc, modifiedText);
24
+ if (mapping.paragraphs.length === 0) {
25
+ return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
26
+ }
26
27
 
27
28
  const diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText);
28
29
 
29
- return applyReconstructionDiffs(
30
+ return withOoxmlSourceType(applyReconstructionDiffs(
30
31
  xmlDoc,
31
- diffs,
32
- mapping,
33
- serializer,
34
- author,
35
- formatHints,
36
- generateRedlines
37
- );
38
- }
32
+ diffs,
33
+ mapping,
34
+ serializer,
35
+ author,
36
+ formatHints,
37
+ generateRedlines
38
+ ));
39
+ }
@@ -4,9 +4,15 @@
4
4
  * Applies diff segments to mapped reconstruction context and writes updated DOM content.
5
5
  */
6
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';
7
+ import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
8
+ import {
9
+ createTrackChange,
10
+ createFormattedRuns,
11
+ markParagraphMarkDeleted,
12
+ markParagraphMarkInserted
13
+ } from './run-builders.js';
14
+ import { getFirstElementByTag } from '../core/xml-query.js';
15
+ import { createWordElement } from '../core/word-xml.js';
10
16
 
11
17
  /**
12
18
  * Applies diffs to reconstruction context and writes updated XML.
@@ -32,23 +38,24 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
32
38
  getRunProperties,
33
39
  getPropertySpanLength,
34
40
  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
- };
41
+ } = context;
42
+
43
+ const createNewParagraph = (pPr) => {
44
+ const newParagraph = createWordElement(xmlDoc, 'w:p');
45
+ if (pPr) newParagraph.appendChild(pPr.cloneNode(true));
46
+ return newParagraph;
47
+ };
42
48
 
43
49
  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;
50
+ let currentParagraph = createNewParagraph(startInfo.pPr);
51
+ const initialFragment = containerFragments.get(startInfo.container);
52
+ if (initialFragment) {
53
+ initialFragment.appendChild(currentParagraph);
54
+ }
55
+
56
+ let currentOriginalIndex = 0;
57
+ let currentInsertOffset = 0;
58
+ const emittedCommentMarkers = new WeakSet();
52
59
 
53
60
  for (const [op, text] of diffs) {
54
61
  if (op === 0 || op === -1) {
@@ -61,9 +68,9 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
61
68
  const chunkLength = getPropertySpanLength(chunkStart, text.length - offset);
62
69
  const chunk = text.substring(offset, offset + chunkLength);
63
70
 
64
- appendTextToCurrent(
65
- xmlDoc,
66
- chunk,
71
+ const appendResult = appendTextToCurrent(
72
+ xmlDoc,
73
+ chunk,
67
74
  type,
68
75
  properties.rPr,
69
76
  properties.wrapper,
@@ -76,13 +83,15 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
76
83
  getParagraphInfo,
77
84
  createNewParagraph,
78
85
  author,
79
- formatHints,
80
- currentInsertOffset,
81
- generateRedlines
82
- );
83
-
84
- if (op === 0) {
85
- currentInsertOffset += chunkLength;
86
+ formatHints,
87
+ currentInsertOffset,
88
+ generateRedlines,
89
+ emittedCommentMarkers
90
+ );
91
+ currentParagraph = appendResult.currentParagraph;
92
+
93
+ if (op === 0) {
94
+ currentInsertOffset += chunkLength;
86
95
  }
87
96
  offset += chunkLength;
88
97
  }
@@ -96,9 +105,9 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
96
105
  ? getRunProperties(currentOriginalIndex - 1)
97
106
  : getRunProperties(currentOriginalIndex);
98
107
 
99
- appendTextToCurrent(
100
- xmlDoc,
101
- text,
108
+ const appendResult = appendTextToCurrent(
109
+ xmlDoc,
110
+ text,
102
111
  'insert',
103
112
  properties.rPr,
104
113
  properties.wrapper,
@@ -111,13 +120,15 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
111
120
  getParagraphInfo,
112
121
  createNewParagraph,
113
122
  author,
114
- formatHints,
115
- currentInsertOffset,
116
- generateRedlines
117
- );
118
- currentInsertOffset += text.length;
119
- }
120
- }
123
+ formatHints,
124
+ currentInsertOffset,
125
+ generateRedlines,
126
+ emittedCommentMarkers
127
+ );
128
+ currentParagraph = appendResult.currentParagraph;
129
+ currentInsertOffset += text.length;
130
+ }
131
+ }
121
132
 
122
133
  paragraphs.forEach(paragraph => {
123
134
  if (paragraph.parentNode) {
@@ -163,8 +174,9 @@ function appendTextToCurrent(
163
174
  author,
164
175
  formatHints = [],
165
176
  insertOffset = 0,
166
- generateRedlines = true
167
- ) {
177
+ generateRedlines = true,
178
+ emittedCommentMarkers = new WeakSet()
179
+ ) {
168
180
  let localBaseIndex = baseIndex;
169
181
  let localInsertOffset = insertOffset;
170
182
  let localParagraph = currentParagraphRef;
@@ -173,30 +185,35 @@ function appendTextToCurrent(
173
185
 
174
186
  parts.forEach(part => {
175
187
  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 {
188
+ const commentMarkers = sentinelsAtOffset.filter(sentinel => sentinel.isCommentMarker && !emittedCommentMarkers.has(sentinel.node));
189
+
190
+ commentMarkers.forEach(marker => {
191
+ emittedCommentMarkers.add(marker.node);
192
+ if (marker.node.nodeName === 'w:commentReference') {
193
+ const run = createWordElement(xmlDoc, 'w:r');
194
+ run.appendChild(marker.node.cloneNode(true));
195
+ localParagraph.appendChild(run);
196
+ } else {
184
197
  localParagraph.appendChild(marker.node.cloneNode(true));
185
198
  }
186
199
  });
187
200
 
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++;
201
+ if (part === '\n') {
202
+ const info = getParagraphInfo(localBaseIndex + 1);
203
+ const nextParagraph = createNewParagraph(info.pPr);
204
+ if (generateRedlines && type === 'insert') {
205
+ markParagraphMarkInserted(xmlDoc, nextParagraph, author);
206
+ } else if (generateRedlines && type === 'delete') {
207
+ markParagraphMarkDeleted(xmlDoc, nextParagraph, author);
208
+ }
209
+
210
+ const fragment = containerFragments.get(info.container);
211
+ if (fragment) {
212
+ fragment.appendChild(nextParagraph);
213
+ localParagraph = nextParagraph;
214
+ }
215
+ localBaseIndex++;
216
+ if (type !== 'delete') localInsertOffset++;
200
217
  return;
201
218
  }
202
219
 
@@ -220,13 +237,13 @@ function appendTextToCurrent(
220
237
 
221
238
  if (referenceMap.has(part)) {
222
239
  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);
240
+ const refNode = referenceMap.get(part);
241
+ if (refNode) {
242
+ const clone = refNode.cloneNode(true);
243
+ const run = createWordElement(xmlDoc, 'w:r');
244
+ if (rPr) run.appendChild(rPr.cloneNode(true));
245
+ run.appendChild(clone);
246
+ localParagraph.appendChild(run);
230
247
  }
231
248
  }
232
249
  localBaseIndex++;
@@ -242,14 +259,14 @@ function appendTextToCurrent(
242
259
  parent = wrapperClone;
243
260
  localParagraph.appendChild(wrapperClone);
244
261
  }
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);
262
+
263
+ if (type === 'delete') {
264
+ const run = createWordElement(xmlDoc, 'w:r');
265
+ if (rPr) run.appendChild(rPr.cloneNode(true));
266
+ const delText = createWordElement(xmlDoc, 'w:delText');
267
+ delText.setAttribute('xml:space', 'preserve');
268
+ delText.textContent = part;
269
+ run.appendChild(delText);
253
270
 
254
271
  if (generateRedlines) {
255
272
  const del = createTrackChange(xmlDoc, 'del', run, author);
@@ -271,6 +288,8 @@ function appendTextToCurrent(
271
288
  if (type !== 'delete') {
272
289
  localInsertOffset += part.length;
273
290
  }
274
- localBaseIndex += part.length;
275
- });
276
- }
291
+ localBaseIndex += part.length;
292
+ });
293
+
294
+ return { currentParagraph: localParagraph };
295
+ }
@@ -2,10 +2,12 @@
2
2
  * Run property (w:rPr) helper utilities.
3
3
  *
4
4
  * This module owns low-level formatting element operations, including
5
- * schema-order insertion, format extraction, and format add/remove transforms.
6
- */
7
-
8
- /**
5
+ * schema-order insertion, format extraction, and format add/remove transforms.
6
+ */
7
+
8
+ import { createWordElement } from '../core/word-xml.js';
9
+
10
+ /**
9
11
  * Canonical OOXML run-property schema ordering.
10
12
  * Shared by all rPr synchronizers.
11
13
  */
@@ -82,33 +84,33 @@ function _applyOverrides(xmlDoc, rPr, formatFlags, mode) {
82
84
  }
83
85
  }
84
86
 
85
- if (applyBold) {
86
- const b = xmlDoc.createElement('w:b');
87
- b.setAttribute('w:val', mode === 'add' ? '1' : '0');
88
- insertRPrChildInOrder(rPr, b);
89
-
90
- const bCs = xmlDoc.createElement('w:bCs');
91
- bCs.setAttribute('w:val', mode === 'add' ? '1' : '0');
92
- insertRPrChildInOrder(rPr, bCs);
93
- }
94
- if (applyItalic) {
95
- const i = xmlDoc.createElement('w:i');
96
- i.setAttribute('w:val', mode === 'add' ? '1' : '0');
97
- insertRPrChildInOrder(rPr, i);
98
-
99
- const iCs = xmlDoc.createElement('w:iCs');
100
- iCs.setAttribute('w:val', mode === 'add' ? '1' : '0');
101
- insertRPrChildInOrder(rPr, iCs);
102
- }
103
- if (applyUnderline) {
104
- const u = xmlDoc.createElement('w:u');
105
- u.setAttribute('w:val', mode === 'add' ? 'single' : 'none');
106
- insertRPrChildInOrder(rPr, u);
107
- }
108
- if (applyStrike) {
109
- const strike = xmlDoc.createElement('w:strike');
110
- strike.setAttribute('w:val', mode === 'add' ? '1' : '0');
111
- insertRPrChildInOrder(rPr, strike);
87
+ if (applyBold) {
88
+ const b = createWordElement(xmlDoc, 'w:b');
89
+ b.setAttribute('w:val', mode === 'add' ? '1' : '0');
90
+ insertRPrChildInOrder(rPr, b);
91
+
92
+ const bCs = createWordElement(xmlDoc, 'w:bCs');
93
+ bCs.setAttribute('w:val', mode === 'add' ? '1' : '0');
94
+ insertRPrChildInOrder(rPr, bCs);
95
+ }
96
+ if (applyItalic) {
97
+ const i = createWordElement(xmlDoc, 'w:i');
98
+ i.setAttribute('w:val', mode === 'add' ? '1' : '0');
99
+ insertRPrChildInOrder(rPr, i);
100
+
101
+ const iCs = createWordElement(xmlDoc, 'w:iCs');
102
+ iCs.setAttribute('w:val', mode === 'add' ? '1' : '0');
103
+ insertRPrChildInOrder(rPr, iCs);
104
+ }
105
+ if (applyUnderline) {
106
+ const u = createWordElement(xmlDoc, 'w:u');
107
+ u.setAttribute('w:val', mode === 'add' ? 'single' : 'none');
108
+ insertRPrChildInOrder(rPr, u);
109
+ }
110
+ if (applyStrike) {
111
+ const strike = createWordElement(xmlDoc, 'w:strike');
112
+ strike.setAttribute('w:val', mode === 'add' ? '1' : '0');
113
+ insertRPrChildInOrder(rPr, strike);
112
114
  }
113
115
  }
114
116
 
@@ -123,7 +125,7 @@ function _applyOverrides(xmlDoc, rPr, formatFlags, mode) {
123
125
  */
124
126
  export function buildOverrideRPrXml(xmlDoc, originalRun, formatToRemove, serializer) {
125
127
  const baseRPr = originalRun.getElementsByTagName('w:rPr')[0] || null;
126
- const rPr = baseRPr ? baseRPr.cloneNode(true) : xmlDoc.createElement('w:rPr');
128
+ const rPr = baseRPr ? baseRPr.cloneNode(true) : createWordElement(xmlDoc, 'w:rPr');
127
129
  _applyOverrides(xmlDoc, rPr, formatToRemove, 'remove');
128
130
 
129
131
  let rPrXml = serializer.serializeToString(rPr);