@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.
- package/AGENTS.md +53 -4
- package/ARCHITECTURE.md +57 -9
- package/README.md +47 -3
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +3073 -2594
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +71 -67
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +48 -0
- package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
- package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
- package/engine/format-application.js +13 -14
- package/engine/format-span-application.js +7 -6
- package/engine/formatting-removal.js +15 -12
- package/engine/oxml-engine.js +146 -55
- package/engine/reconstruction-mapper.js +35 -8
- package/engine/reconstruction-mode.js +14 -13
- package/engine/reconstruction-writer.js +97 -78
- package/engine/rpr-helpers.js +34 -32
- package/engine/run-builders.js +150 -39
- package/engine/surgical-diff-application.js +216 -0
- package/engine/surgical-mode.js +84 -519
- package/engine/surgical-run-splitting.js +96 -0
- package/engine/surgical-spans.js +169 -0
- package/engine/table-cell-context.js +15 -13
- package/engine/table-mode.js +39 -35
- package/index.d.ts +148 -0
- package/index.js +15 -13
- package/package.json +8 -1
- package/pipeline/ingestion-export.js +1 -0
- package/pipeline/ingestion-paragraph.js +37 -12
- package/pipeline/ingestion-table.js +11 -8
- package/scripts/build.mjs +35 -0
- package/scripts/check-types.mjs +28 -0
- package/scripts/export-validation-fixtures.mjs +68 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/word-com-smoke.ps1 +48 -0
- package/services/comment-locator.js +10 -9
- package/services/revision-comment-management.js +115 -1
- package/services/standalone-operation-runner.js +119 -69
- 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.
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
101
|
+
const startMarker = createWordElement(xmlDoc, 'w:commentRangeStart');
|
|
101
102
|
startMarker.setAttribute('w:id', String(commentId));
|
|
102
103
|
|
|
103
|
-
const endMarker = xmlDoc
|
|
104
|
+
const endMarker = createWordElement(xmlDoc, 'w:commentRangeEnd');
|
|
104
105
|
endMarker.setAttribute('w:id', String(commentId));
|
|
105
106
|
|
|
106
|
-
const referenceRun = xmlDoc
|
|
107
|
-
const reference = xmlDoc
|
|
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
|
|
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)) {
|