@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
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./index.js",
8
8
  "module": "./index.js",
9
+ "types": "./index.d.ts",
9
10
  "exports": {
10
11
  ".": {
12
+ "types": "./index.d.ts",
11
13
  "import": "./index.js",
12
14
  "default": "./index.js"
13
15
  },
@@ -27,7 +29,10 @@
27
29
  "pipeline/",
28
30
  "services/",
29
31
  "orchestration/",
32
+ "scripts/",
33
+ "docs/",
30
34
  "index.js",
35
+ "index.d.ts",
31
36
  "dist/",
32
37
  "ARCHITECTURE.md",
33
38
  "AGENTS.md",
@@ -51,6 +56,8 @@
51
56
  },
52
57
  "scripts": {
53
58
  "build": "node scripts/build.mjs",
59
+ "check:types": "node scripts/check-types.mjs",
60
+ "smoke:word": "powershell -File scripts/word-com-smoke.ps1",
54
61
  "test": "node scripts/run-tests.mjs",
55
62
  "test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
56
63
  "prepublishOnly": "npm run test:isolation && npm run build"
@@ -112,6 +112,7 @@ function collectParagraphSegments(paragraph) {
112
112
  const runs = Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || []);
113
113
  for (const run of runs) {
114
114
  if (hasWordAncestorWithin(run, 'del', paragraph)) continue;
115
+ if (hasWordAncestorWithin(run, 'moveFrom', paragraph)) continue;
115
116
  const text = readRunText(run);
116
117
  if (!text) continue;
117
118
  segments.push({
@@ -217,13 +217,36 @@ function getNodeHandlers(runModel) {
217
217
  return contentResult;
218
218
  });
219
219
 
220
- handlers.set('del', (child, offset) => {
221
- const deletionEntry = processDeletion(child, offset);
222
- if (deletionEntry) {
223
- runModel.push(deletionEntry);
224
- }
225
- return { offset, text: '' };
226
- });
220
+ handlers.set('del', (child, offset) => {
221
+ const deletionEntry = processDeletion(child, offset);
222
+ if (deletionEntry) {
223
+ runModel.push(deletionEntry);
224
+ }
225
+ return { offset, text: '' };
226
+ });
227
+
228
+ handlers.set('moveFrom', (child, offset) => {
229
+ const deletionEntry = processDeletion(child, offset);
230
+ if (deletionEntry) {
231
+ runModel.push(deletionEntry);
232
+ }
233
+ return { offset, text: '' };
234
+ });
235
+
236
+ handlers.set('moveTo', (child, offset) => processNodeRecursive(child, offset, runModel));
237
+
238
+ for (const markerName of ['moveFromRangeStart', 'moveFromRangeEnd', 'moveToRangeStart', 'moveToRangeEnd']) {
239
+ handlers.set(markerName, (child, offset) => {
240
+ runModel.push({
241
+ kind: RunKind.BOOKMARK,
242
+ nodeXml: serializeXml(child),
243
+ startOffset: offset,
244
+ endOffset: offset,
245
+ text: ''
246
+ });
247
+ return { offset, text: '' };
248
+ });
249
+ }
227
250
 
228
251
  handlers.set('bookmarkStart', (child, offset) => {
229
252
  runModel.push({
@@ -320,11 +343,13 @@ function processRun(runElement, startOffset) {
320
343
  };
321
344
  }
322
345
 
323
- function processDeletion(delElement, offset) {
324
- const author = delElement.getAttribute('w:author') || '';
325
-
326
- let text = '';
327
- const delTexts = getElementsByTagNS(delElement, NS_W, 'delText');
346
+ function processDeletion(delElement, offset) {
347
+ const author = delElement.getAttribute('w:author') || '';
348
+
349
+ // Deleted text is retained as a zero-width deletion model entry for revision-aware callers.
350
+ // It is intentionally not added to acceptedText by the w:del handler above.
351
+ let text = '';
352
+ const delTexts = getElementsByTagNS(delElement, NS_W, 'delText');
328
353
  for (const delText of delTexts) {
329
354
  text += delText.textContent || '';
330
355
  }
@@ -20,13 +20,16 @@ import { ingestParagraphElement } from './ingestion-paragraph.js';
20
20
  * @param {Element} tableNode - w:tbl element
21
21
  * @returns {Object}
22
22
  */
23
- export function ingestTableToVirtualGrid(tableNode) {
24
- const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
25
- const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
26
- const colCount = gridCols.length;
27
-
28
- const trElements = getElementsByTagNS(tableNode, NS_W, 'tr');
29
- const rowCount = trElements.length;
23
+ export function ingestTableToVirtualGrid(tableNode) {
24
+ const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
25
+ const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
26
+ const trElements = getElementsByTagNSOrTag(tableNode, NS_W, 'tr');
27
+ const rowCount = trElements.length;
28
+ const inferredColCount = trElements.reduce((max, tr) => {
29
+ const tcElements = getElementsByTagNSOrTag(tr, NS_W, 'tc');
30
+ return Math.max(max, tcElements.length);
31
+ }, 0);
32
+ const colCount = gridCols.length || inferredColCount;
30
33
 
31
34
  const grid = Array.from({ length: rowCount }, () =>
32
35
  Array.from({ length: colCount }, () => null)
@@ -36,7 +39,7 @@ export function ingestTableToVirtualGrid(tableNode) {
36
39
 
37
40
  for (let rowIdx = 0; rowIdx < trElements.length; rowIdx++) {
38
41
  const tr = trElements[rowIdx];
39
- const tcElements = getElementsByTagNS(tr, NS_W, 'tc');
42
+ const tcElements = getElementsByTagNSOrTag(tr, NS_W, 'tc');
40
43
  let gridCol = 0;
41
44
 
42
45
  for (let tcIdx = 0; tcIdx < tcElements.length; tcIdx++) {
@@ -0,0 +1,35 @@
1
+ import { build } from 'esbuild';
2
+ import { readFileSync } from 'fs';
3
+
4
+ const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
5
+
6
+ // ESM bundle with diff-match-patch inlined (for CDN/browser <script type="module">)
7
+ await build({
8
+ entryPoints: ['./index.js'],
9
+ bundle: true,
10
+ format: 'esm',
11
+ outfile: 'dist/docx-redline-js.esm.js',
12
+ platform: 'neutral', // no Node builtins assumed
13
+ target: 'es2020',
14
+ minify: false, // keep readable for debugging
15
+ sourcemap: true,
16
+ banner: {
17
+ js: `// @ansonlai/docx-redline-js v${pkg.version} — https://github.com/AnsonLai/docx-redline-js`
18
+ },
19
+ external: ['@xmldom/xmldom'] // never bundle the Node-only XML parser
20
+ });
21
+
22
+ // Minified version for production CDN use
23
+ await build({
24
+ entryPoints: ['./index.js'],
25
+ bundle: true,
26
+ format: 'esm',
27
+ outfile: 'dist/docx-redline-js.esm.min.js',
28
+ platform: 'neutral',
29
+ target: 'es2020',
30
+ minify: true,
31
+ sourcemap: true,
32
+ external: ['@xmldom/xmldom']
33
+ });
34
+
35
+ console.log('Build complete: dist/docx-redline-js.esm.js, dist/docx-redline-js.esm.min.js');
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'fs';
2
+
3
+ const dts = readFileSync(new URL('../index.d.ts', import.meta.url), 'utf8');
4
+
5
+ const requiredSnippets = [
6
+ 'export interface RedlineOptions',
7
+ 'export interface RedlineResult',
8
+ 'export function applyRedlineToOxml',
9
+ 'export function acceptTrackedChangesInOoxml',
10
+ 'export function rejectTrackedChangesInOoxml',
11
+ 'export function deleteCommentsByAuthorInOoxml'
12
+ ];
13
+
14
+ for (const snippet of requiredSnippets) {
15
+ if (!dts.includes(snippet)) {
16
+ throw new Error(`Missing declaration snippet: ${snippet}`);
17
+ }
18
+ }
19
+
20
+ let balance = 0;
21
+ for (const char of dts) {
22
+ if (char === '{') balance += 1;
23
+ if (char === '}') balance -= 1;
24
+ if (balance < 0) throw new Error('index.d.ts has unbalanced braces');
25
+ }
26
+ if (balance !== 0) throw new Error('index.d.ts has unbalanced braces');
27
+
28
+ console.log('PASS: index.d.ts declaration smoke check');
@@ -0,0 +1,68 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ import { configureXmlProvider } from '../adapters/xml-adapter.js';
5
+ import { applyOperationToDocumentXml } from '../services/standalone-operation-runner.js';
6
+
7
+ const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
8
+ configureXmlProvider({ DOMParser, XMLSerializer });
9
+
10
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
11
+ const outputDir = join(process.cwd(), 'tmp', 'validation-docx');
12
+ mkdirSync(outputDir, { recursive: true });
13
+
14
+ const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
15
+ <w:document xmlns:w="${NS_W}">
16
+ <w:body>
17
+ <w:p><w:r><w:t>${text}</w:t></w:r></w:p>
18
+ <w:sectPr/>
19
+ </w:body>
20
+ </w:document>`;
21
+
22
+ const cases = [
23
+ {
24
+ name: 'simple-redline',
25
+ documentXml: baseDocument('The old sentence.'),
26
+ operation: { type: 'redline', target: 'The old sentence.', modified: 'The new sentence.' }
27
+ },
28
+ {
29
+ name: 'paragraph-insert',
30
+ documentXml: baseDocument('one'),
31
+ operation: { type: 'redline', target: 'one', modified: 'one\ntwo' }
32
+ },
33
+ {
34
+ name: 'format-only',
35
+ documentXml: baseDocument('Make word bold'),
36
+ operation: { type: 'redline', target: 'Make word bold', modified: 'Make **word** bold' }
37
+ }
38
+ ];
39
+
40
+ for (const testCase of cases) {
41
+ const result = await applyOperationToDocumentXml(
42
+ testCase.documentXml,
43
+ testCase.operation,
44
+ 'Validation',
45
+ null,
46
+ { generateRedlines: true }
47
+ );
48
+ writeFileSync(join(outputDir, `${testCase.name}.document.xml`), result.documentXml, 'utf8');
49
+ if (result.numberingXml) {
50
+ writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
51
+ }
52
+ }
53
+
54
+ writeFileSync(join(outputDir, 'README.md'), `# Validation Fixtures
55
+
56
+ This folder contains generated OOXML parts for release-time validation.
57
+
58
+ The script writes document XML rather than complete .docx packages because this
59
+ package intentionally does not add a zip dependency.
60
+
61
+ To manually inspect these fixtures:
62
+
63
+ 1. Copy a generated *.document.xml file into a minimal .docx package as word/document.xml.
64
+ 2. Include any matching *.numbering.xml as word/numbering.xml.
65
+ 3. Open with Word, LibreOffice, or another OOXML consumer.
66
+ `, 'utf8');
67
+
68
+ console.log(`Wrote validation fixtures to ${outputDir}`);
@@ -0,0 +1,43 @@
1
+ import { readdirSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const testDir = join(__dirname, '..', 'tests');
8
+ const testFiles = readdirSync(testDir)
9
+ .filter(f => f.endsWith('.mjs') && f !== 'setup-xml-provider.mjs')
10
+ .sort();
11
+
12
+ let passed = 0;
13
+ let failed = 0;
14
+ const failures = [];
15
+ const failOutputPattern = /(?:❌\s*(?:FAIL|FAILED|FAILURE|TEST FAILED)|\bTEST FAILED\b)/i;
16
+
17
+ for (const file of testFiles) {
18
+ const filePath = join(testDir, file);
19
+ process.stdout.write(` ${file} ... `);
20
+ try {
21
+ const output = execSync(`node "${filePath}"`, { stdio: 'pipe', timeout: 30000 });
22
+ const outputText = output.toString();
23
+ if (failOutputPattern.test(outputText)) {
24
+ throw new Error(`Test printed a failure marker while exiting successfully:\n${outputText}`);
25
+ }
26
+ console.log('PASS');
27
+ passed++;
28
+ } catch (err) {
29
+ console.log('FAIL');
30
+ const stdout = err.stdout?.toString() || '';
31
+ const stderr = err.stderr?.toString() || '';
32
+ failures.push({ file, stderr: [stdout, stderr, err.message].filter(Boolean).join('\n') });
33
+ failed++;
34
+ }
35
+ }
36
+
37
+ console.log(`\n${passed} passed, ${failed} failed out of ${passed + failed} tests`);
38
+ if (failures.length > 0) {
39
+ for (const f of failures) {
40
+ console.error(`\n--- ${f.file} ---\n${f.stderr}`);
41
+ }
42
+ process.exit(1);
43
+ }
@@ -0,0 +1,48 @@
1
+ param(
2
+ [Parameter(Mandatory = $true, Position = 0)]
3
+ [string]$Path
4
+ )
5
+
6
+ $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop
7
+ $word = $null
8
+ $document = $null
9
+
10
+ try {
11
+ $word = New-Object -ComObject Word.Application
12
+ $word.Visible = $false
13
+
14
+ $document = $word.Documents.Open(
15
+ [string]$resolved,
16
+ $false,
17
+ $true,
18
+ $false,
19
+ [Type]::Missing,
20
+ [Type]::Missing,
21
+ $false,
22
+ [Type]::Missing,
23
+ [Type]::Missing,
24
+ [Type]::Missing,
25
+ [Type]::Missing,
26
+ $false,
27
+ $false,
28
+ $false,
29
+ $false,
30
+ $false
31
+ )
32
+
33
+ Write-Output "Opened: $resolved"
34
+ Write-Output "Revisions: $($document.Revisions.Count)"
35
+ Write-Output "PASS: Word opened the document without throwing."
36
+ }
37
+ catch {
38
+ Write-Error "FAIL: Word could not open '$resolved'. $($_.Exception.Message)"
39
+ exit 1
40
+ }
41
+ finally {
42
+ if ($document -ne $null) {
43
+ $document.Close($false) | Out-Null
44
+ }
45
+ if ($word -ne $null) {
46
+ $word.Quit() | Out-Null
47
+ }
48
+ }
@@ -2,8 +2,9 @@
2
2
  * Comment text location and marker injection helpers.
3
3
  */
4
4
 
5
- import { NS_W } from '../core/types.js';
6
- import { getElementsByTag, getFirstElementByTag } from '../core/xml-query.js';
5
+ import { NS_W } from '../core/types.js';
6
+ import { createWordElement } from '../core/word-xml.js';
7
+ import { getElementsByTag, getFirstElementByTag } from '../core/xml-query.js';
7
8
 
8
9
  /**
9
10
  * Builds a paragraph text index in a single pass for repeated lookups.
@@ -67,13 +68,13 @@ export function findTextInParagraphIndex(paragraphIndex, searchText) {
67
68
  };
68
69
  }
69
70
 
70
- function cloneRunWithText(xmlDoc, rPr, newText) {
71
- const newRun = xmlDoc.createElementNS(NS_W, 'w:r');
71
+ function cloneRunWithText(xmlDoc, rPr, newText) {
72
+ const newRun = createWordElement(xmlDoc, 'w:r');
72
73
  if (rPr) {
73
74
  newRun.appendChild(rPr.cloneNode(true));
74
75
  }
75
76
 
76
- const newTextNode = xmlDoc.createElementNS(NS_W, 'w:t');
77
+ const newTextNode = createWordElement(xmlDoc, 'w:t');
77
78
  newTextNode.setAttribute('xml:space', 'preserve');
78
79
  newTextNode.textContent = newText;
79
80
  newRun.appendChild(newTextNode);
@@ -97,14 +98,14 @@ export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commen
97
98
  return false;
98
99
  }
99
100
 
100
- const startMarker = xmlDoc.createElementNS(NS_W, 'w:commentRangeStart');
101
+ const startMarker = createWordElement(xmlDoc, 'w:commentRangeStart');
101
102
  startMarker.setAttribute('w:id', String(commentId));
102
103
 
103
- const endMarker = xmlDoc.createElementNS(NS_W, 'w:commentRangeEnd');
104
+ const endMarker = createWordElement(xmlDoc, 'w:commentRangeEnd');
104
105
  endMarker.setAttribute('w:id', String(commentId));
105
106
 
106
- const referenceRun = xmlDoc.createElementNS(NS_W, 'w:r');
107
- const reference = xmlDoc.createElementNS(NS_W, 'w:commentReference');
107
+ const referenceRun = createWordElement(xmlDoc, 'w:r');
108
+ const reference = createWordElement(xmlDoc, 'w:commentReference');
108
109
  reference.setAttribute('w:id', String(commentId));
109
110
  referenceRun.appendChild(reference);
110
111
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { NS_W } from '../core/types.js';
6
6
  import { createParser, createSerializer } from '../adapters/xml-adapter.js';
7
+ import { createWordElement } from '../core/word-xml.js';
7
8
  import { getXmlParseError } from '../core/xml-query.js';
8
9
 
9
10
  function getAttributeByLocalName(node, localName) {
@@ -97,6 +98,41 @@ function isTableRowRevisionMarker(node) {
97
98
  return isWordElement(parent, 'trPr') && isWordElement(parent?.parentNode, 'tr');
98
99
  }
99
100
 
101
+ function isParagraphMarkRevisionMarker(node) {
102
+ const rPr = node?.parentNode;
103
+ const pPr = rPr?.parentNode;
104
+ const paragraph = pPr?.parentNode;
105
+ return isWordElement(rPr, 'rPr') && isWordElement(pPr, 'pPr') && isWordElement(paragraph, 'p');
106
+ }
107
+
108
+ function getContainingParagraphMarkRevision(node) {
109
+ return isParagraphMarkRevisionMarker(node) ? node.parentNode.parentNode.parentNode : null;
110
+ }
111
+
112
+ function getNextWordParagraph(paragraph) {
113
+ let cursor = paragraph?.nextSibling || null;
114
+ while (cursor) {
115
+ if (isWordElement(cursor, 'p')) return cursor;
116
+ cursor = cursor.nextSibling;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function mergeParagraphIntoNextAndRemove(paragraph) {
122
+ if (!paragraph?.parentNode) return false;
123
+ const nextParagraph = getNextWordParagraph(paragraph);
124
+ if (!nextParagraph) {
125
+ return removeNode(paragraph);
126
+ }
127
+
128
+ const childrenToMove = Array.from(paragraph.childNodes || []).filter(child => !isWordElement(child, 'pPr'));
129
+ const insertionPoint = nextParagraph.firstChild || null;
130
+ for (const child of childrenToMove) {
131
+ nextParagraph.insertBefore(child, insertionPoint);
132
+ }
133
+ return removeNode(paragraph);
134
+ }
135
+
100
136
  /**
101
137
  * Accepts tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
102
138
  * or all authors in the provided OOXML payload.
@@ -122,6 +158,10 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
122
158
 
123
159
  for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
124
160
  if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
161
+ if (isParagraphMarkRevisionMarker(insNode)) {
162
+ if (removeNode(insNode)) acceptedCount += 1;
163
+ continue;
164
+ }
125
165
  if (isTableRowRevisionMarker(insNode)) {
126
166
  if (removeNode(insNode)) acceptedCount += 1;
127
167
  continue;
@@ -131,6 +171,11 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
131
171
 
132
172
  for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
133
173
  if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
174
+ const paragraphMark = getContainingParagraphMarkRevision(delNode);
175
+ if (paragraphMark) {
176
+ if (mergeParagraphIntoNextAndRemove(paragraphMark)) acceptedCount += 1;
177
+ continue;
178
+ }
134
179
  if (isTableRowRevisionMarker(delNode)) {
135
180
  const rowNode = delNode.parentNode?.parentNode;
136
181
  if (removeNode(rowNode)) acceptedCount += 1;
@@ -139,6 +184,18 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
139
184
  if (removeNode(delNode)) acceptedCount += 1;
140
185
  }
141
186
 
187
+ for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
188
+ if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
189
+ if (removeNode(moveFromNode)) acceptedCount += 1;
190
+ }
191
+
192
+ for (const moveToNode of getWordElementsByLocalName(xmlDoc, 'moveTo')) {
193
+ if (!moveToNode.parentNode || !authorMatchesNode(moveToNode, filter)) continue;
194
+ if (unwrapNode(moveToNode)) acceptedCount += 1;
195
+ }
196
+
197
+ acceptedCount += removeMoveRangeMarkers(xmlDoc, filter);
198
+
142
199
  const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
143
200
  for (const localName of changeTags) {
144
201
  for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {
@@ -157,7 +214,7 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
157
214
 
158
215
  function convertDeletionTextNodes(xmlDoc, delNode) {
159
216
  for (const delTextNode of Array.from(delNode.getElementsByTagNameNS(NS_W, 'delText'))) {
160
- const normalText = xmlDoc.createElementNS(NS_W, 'w:t');
217
+ const normalText = createWordElement(xmlDoc, 'w:t');
161
218
  const spaceValue = delTextNode.getAttribute('xml:space');
162
219
  if (spaceValue) {
163
220
  normalText.setAttribute('xml:space', spaceValue);
@@ -212,6 +269,41 @@ function xmlDocImportNode(xmlDoc, node) {
212
269
  return node.cloneNode(true);
213
270
  }
214
271
 
272
+ function collectMoveRangeStartIds(xmlDoc, localName, filter) {
273
+ const ids = new Set();
274
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
275
+ if (!authorMatchesNode(node, filter)) continue;
276
+ const id = getAttributeByLocalName(node, 'id');
277
+ if (id) ids.add(id);
278
+ }
279
+ return ids;
280
+ }
281
+
282
+ function removeMoveRangeMarkers(xmlDoc, filter) {
283
+ let removed = 0;
284
+ const moveFromIds = collectMoveRangeStartIds(xmlDoc, 'moveFromRangeStart', filter);
285
+ const moveToIds = collectMoveRangeStartIds(xmlDoc, 'moveToRangeStart', filter);
286
+ const markerSpecs = [
287
+ ['moveFromRangeStart', moveFromIds, true],
288
+ ['moveFromRangeEnd', moveFromIds, false],
289
+ ['moveToRangeStart', moveToIds, true],
290
+ ['moveToRangeEnd', moveToIds, false]
291
+ ];
292
+
293
+ for (const [localName, ids, isStart] of markerSpecs) {
294
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
295
+ if (!node.parentNode) continue;
296
+ const id = getAttributeByLocalName(node, 'id');
297
+ if (!id) continue;
298
+ if (filter.allAuthors || ids.has(id) || (isStart && authorMatchesNode(node, filter))) {
299
+ if (removeNode(node)) removed += 1;
300
+ }
301
+ }
302
+ }
303
+
304
+ return removed;
305
+ }
306
+
215
307
  /**
216
308
  * Rejects tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
217
309
  * or all authors in the provided OOXML payload.
@@ -237,6 +329,11 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
237
329
 
238
330
  for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
239
331
  if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
332
+ const paragraphMark = getContainingParagraphMarkRevision(insNode);
333
+ if (paragraphMark) {
334
+ if (mergeParagraphIntoNextAndRemove(paragraphMark)) rejectedCount += 1;
335
+ continue;
336
+ }
240
337
  if (isTableRowRevisionMarker(insNode)) {
241
338
  const rowNode = insNode.parentNode?.parentNode;
242
339
  if (removeNode(rowNode)) rejectedCount += 1;
@@ -247,6 +344,10 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
247
344
 
248
345
  for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
249
346
  if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
347
+ if (isParagraphMarkRevisionMarker(delNode)) {
348
+ if (removeNode(delNode)) rejectedCount += 1;
349
+ continue;
350
+ }
250
351
  if (isTableRowRevisionMarker(delNode)) {
251
352
  if (removeNode(delNode)) rejectedCount += 1;
252
353
  continue;
@@ -255,6 +356,19 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
255
356
  if (unwrapNode(delNode)) rejectedCount += 1;
256
357
  }
257
358
 
359
+ for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
360
+ if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
361
+ convertDeletionTextNodes(xmlDoc, moveFromNode);
362
+ if (unwrapNode(moveFromNode)) rejectedCount += 1;
363
+ }
364
+
365
+ for (const moveToNode of getWordElementsByLocalName(xmlDoc, 'moveTo')) {
366
+ if (!moveToNode.parentNode || !authorMatchesNode(moveToNode, filter)) continue;
367
+ if (removeNode(moveToNode)) rejectedCount += 1;
368
+ }
369
+
370
+ rejectedCount += removeMoveRangeMarkers(xmlDoc, filter);
371
+
258
372
  const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
259
373
  for (const localName of changeTags) {
260
374
  for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {