@ansonlai/docx-redline-js 0.1.4 → 0.2.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 (47) hide show
  1. package/AGENTS.md +53 -4
  2. package/ARCHITECTURE.md +75 -11
  3. package/README.md +62 -3
  4. package/core/redline-validation.js +156 -0
  5. package/core/types.js +35 -8
  6. package/core/word-xml.js +90 -0
  7. package/dist/docx-redline-js.esm.js +3195 -2592
  8. package/dist/docx-redline-js.esm.js.map +4 -4
  9. package/dist/docx-redline-js.esm.min.js +71 -67
  10. package/dist/docx-redline-js.esm.min.js.map +4 -4
  11. package/docs/VALIDATION.md +104 -0
  12. package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
  13. package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
  14. package/docs/plans/2026-05-31-architectural changes.md +591 -0
  15. package/engine/format-application.js +13 -14
  16. package/engine/format-span-application.js +7 -6
  17. package/engine/formatting-removal.js +15 -12
  18. package/engine/oxml-engine.js +146 -55
  19. package/engine/reconstruction-mapper.js +35 -8
  20. package/engine/reconstruction-mode.js +14 -13
  21. package/engine/reconstruction-writer.js +97 -78
  22. package/engine/rpr-helpers.js +34 -32
  23. package/engine/run-builders.js +150 -39
  24. package/engine/surgical-diff-application.js +216 -0
  25. package/engine/surgical-mode.js +84 -519
  26. package/engine/surgical-run-splitting.js +96 -0
  27. package/engine/surgical-spans.js +169 -0
  28. package/engine/table-cell-context.js +15 -13
  29. package/engine/table-mode.js +39 -35
  30. package/index.d.ts +172 -0
  31. package/index.js +50 -47
  32. package/package.json +10 -2
  33. package/pipeline/ingestion-export.js +1 -0
  34. package/pipeline/ingestion-paragraph.js +37 -12
  35. package/pipeline/ingestion-table.js +11 -8
  36. package/scripts/build.mjs +40 -0
  37. package/scripts/check-types.mjs +29 -0
  38. package/scripts/export-validation-fixtures.mjs +125 -0
  39. package/scripts/lib/minimal-zip.mjs +155 -0
  40. package/scripts/run-tests.mjs +43 -0
  41. package/scripts/validate-fixtures-xsd.sh +37 -0
  42. package/scripts/word-com-differential.ps1 +133 -0
  43. package/scripts/word-com-smoke.ps1 +48 -0
  44. package/services/comment-locator.js +10 -9
  45. package/services/revision-comment-management.js +115 -1
  46. package/services/standalone-operation-runner.js +119 -69
  47. package/services/table-reconciliation.js +7 -8
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Minimal zip writer for assembling validation .docx fixtures.
3
+ *
4
+ * Script-only helper (not part of the published API surface) so the package
5
+ * keeps its no-zip-dependency guarantee while release tooling can still emit
6
+ * real .docx files for Word/LibreOffice validation. Uses deflate via
7
+ * node:zlib and a fixed timestamp for deterministic output.
8
+ */
9
+
10
+ import { deflateRawSync } from 'zlib';
11
+
12
+ const CRC_TABLE = (() => {
13
+ const table = new Uint32Array(256);
14
+ for (let n = 0; n < 256; n++) {
15
+ let c = n;
16
+ for (let k = 0; k < 8; k++) {
17
+ c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
18
+ }
19
+ table[n] = c >>> 0;
20
+ }
21
+ return table;
22
+ })();
23
+
24
+ function crc32(buffer) {
25
+ let crc = 0xFFFFFFFF;
26
+ for (let i = 0; i < buffer.length; i++) {
27
+ crc = CRC_TABLE[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8);
28
+ }
29
+ return (crc ^ 0xFFFFFFFF) >>> 0;
30
+ }
31
+
32
+ // Fixed DOS date/time (2026-01-01 00:00:00) keeps fixture bytes deterministic.
33
+ const DOS_TIME = 0;
34
+ const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
35
+
36
+ /**
37
+ * Builds a zip archive.
38
+ *
39
+ * @param {Array<{ name: string, data: Buffer|string }>} entries - Entry names
40
+ * must use forward slashes (OPC requirement for .docx parts).
41
+ * @returns {Buffer}
42
+ */
43
+ export function buildZip(entries) {
44
+ const localParts = [];
45
+ const centralParts = [];
46
+ let offset = 0;
47
+
48
+ for (const entry of entries) {
49
+ const nameBytes = Buffer.from(entry.name, 'utf8');
50
+ const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
51
+ const crc = crc32(data);
52
+
53
+ const deflated = deflateRawSync(data, { level: 9 });
54
+ const useDeflate = deflated.length < data.length;
55
+ const method = useDeflate ? 8 : 0;
56
+ const payload = useDeflate ? deflated : data;
57
+
58
+ const localHeader = Buffer.alloc(30);
59
+ localHeader.writeUInt32LE(0x04034B50, 0);
60
+ localHeader.writeUInt16LE(20, 4); // version needed
61
+ localHeader.writeUInt16LE(0, 6); // flags
62
+ localHeader.writeUInt16LE(method, 8);
63
+ localHeader.writeUInt16LE(DOS_TIME, 10);
64
+ localHeader.writeUInt16LE(DOS_DATE, 12);
65
+ localHeader.writeUInt32LE(crc, 14);
66
+ localHeader.writeUInt32LE(payload.length, 18);
67
+ localHeader.writeUInt32LE(data.length, 22);
68
+ localHeader.writeUInt16LE(nameBytes.length, 26);
69
+ localHeader.writeUInt16LE(0, 28); // extra length
70
+
71
+ localParts.push(localHeader, nameBytes, payload);
72
+
73
+ const centralHeader = Buffer.alloc(46);
74
+ centralHeader.writeUInt32LE(0x02014B50, 0);
75
+ centralHeader.writeUInt16LE(20, 4); // version made by
76
+ centralHeader.writeUInt16LE(20, 6); // version needed
77
+ centralHeader.writeUInt16LE(0, 8); // flags
78
+ centralHeader.writeUInt16LE(method, 10);
79
+ centralHeader.writeUInt16LE(DOS_TIME, 12);
80
+ centralHeader.writeUInt16LE(DOS_DATE, 14);
81
+ centralHeader.writeUInt32LE(crc, 16);
82
+ centralHeader.writeUInt32LE(payload.length, 20);
83
+ centralHeader.writeUInt32LE(data.length, 24);
84
+ centralHeader.writeUInt16LE(nameBytes.length, 28);
85
+ centralHeader.writeUInt16LE(0, 30); // extra length
86
+ centralHeader.writeUInt16LE(0, 32); // comment length
87
+ centralHeader.writeUInt16LE(0, 34); // disk number
88
+ centralHeader.writeUInt16LE(0, 36); // internal attrs
89
+ centralHeader.writeUInt32LE(0, 38); // external attrs
90
+ centralHeader.writeUInt32LE(offset, 42);
91
+
92
+ centralParts.push(centralHeader, nameBytes);
93
+ offset += localHeader.length + nameBytes.length + payload.length;
94
+ }
95
+
96
+ const centralDirectory = Buffer.concat(centralParts);
97
+
98
+ const endRecord = Buffer.alloc(22);
99
+ endRecord.writeUInt32LE(0x06054B50, 0);
100
+ endRecord.writeUInt16LE(0, 4); // disk number
101
+ endRecord.writeUInt16LE(0, 6); // central dir start disk
102
+ endRecord.writeUInt16LE(entries.length, 8);
103
+ endRecord.writeUInt16LE(entries.length, 10);
104
+ endRecord.writeUInt32LE(centralDirectory.length, 12);
105
+ endRecord.writeUInt32LE(offset, 16);
106
+ endRecord.writeUInt16LE(0, 20); // comment length
107
+
108
+ return Buffer.concat([...localParts, centralDirectory, endRecord]);
109
+ }
110
+
111
+ const CONTENT_TYPES_BASE = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
112
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
113
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
114
+ <Default Extension="xml" ContentType="application/xml"/>
115
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
116
+ %OVERRIDES%</Types>`;
117
+
118
+ const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
119
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
120
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
121
+ </Relationships>`;
122
+
123
+ /**
124
+ * Assembles a minimal .docx package around a word/document.xml payload.
125
+ *
126
+ * @param {string} documentXml - Complete word/document.xml content
127
+ * @param {{ numberingXml?: string|null }} [parts] - Optional extra parts
128
+ * @returns {Buffer} - .docx bytes
129
+ */
130
+ export function buildMinimalDocx(documentXml, parts = {}) {
131
+ const overrides = [];
132
+ const documentRels = [];
133
+ const entries = [];
134
+
135
+ if (parts.numberingXml) {
136
+ overrides.push(' <Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>\n');
137
+ documentRels.push(' <Relationship Id="rIdNum1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>');
138
+ }
139
+
140
+ entries.push({ name: '[Content_Types].xml', data: CONTENT_TYPES_BASE.replace('%OVERRIDES%', overrides.join('')) });
141
+ entries.push({ name: '_rels/.rels', data: ROOT_RELS });
142
+ entries.push({
143
+ name: 'word/_rels/document.xml.rels',
144
+ data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
145
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
146
+ ${documentRels.join('\n')}
147
+ </Relationships>`
148
+ });
149
+ entries.push({ name: 'word/document.xml', data: documentXml });
150
+ if (parts.numberingXml) {
151
+ entries.push({ name: 'word/numbering.xml', data: parts.numberingXml });
152
+ }
153
+
154
+ return buildZip(entries);
155
+ }
@@ -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,37 @@
1
+ #!/usr/bin/env bash
2
+ # Validates generated validation fixtures against the ECMA-376 transitional
3
+ # wordprocessingml XSD using xmllint. Used by the nightly validation workflow
4
+ # and runnable locally on any machine with curl, unzip, and xmllint.
5
+ #
6
+ # Usage: scripts/validate-fixtures-xsd.sh [fixtures-dir]
7
+ set -euo pipefail
8
+
9
+ FIXTURES_DIR="${1:-tmp/validation-docx}"
10
+ CACHE_DIR="${OOXML_SCHEMA_DIR:-.cache/ooxml-schemas}"
11
+ ECMA_ZIP_URL="https://ecma-international.org/wp-content/uploads/ECMA-376-4_5th_edition_december_2016.zip"
12
+ XML_XSD_URL="https://www.w3.org/2001/xml.xsd"
13
+
14
+ command -v xmllint >/dev/null || { echo "xmllint not found (install libxml2-utils)"; exit 1; }
15
+
16
+ shopt -s nullglob
17
+ fixtures=("$FIXTURES_DIR"/*.document.xml)
18
+ if [ ${#fixtures[@]} -eq 0 ]; then
19
+ echo "No *.document.xml fixtures in $FIXTURES_DIR — run: node scripts/export-validation-fixtures.mjs"
20
+ exit 1
21
+ fi
22
+
23
+ if [ ! -f "$CACHE_DIR/wml.xsd" ]; then
24
+ echo "Downloading ECMA-376 Part 4 transitional schemas..."
25
+ mkdir -p "$CACHE_DIR"
26
+ curl -sSL --retry 3 -o "$CACHE_DIR/ecma376-4.zip" "$ECMA_ZIP_URL"
27
+ unzip -o -q "$CACHE_DIR/ecma376-4.zip" "OfficeOpenXML-XMLSchema-Transitional.zip" -d "$CACHE_DIR"
28
+ unzip -o -q "$CACHE_DIR/OfficeOpenXML-XMLSchema-Transitional.zip" -d "$CACHE_DIR"
29
+ curl -sSL --retry 3 -o "$CACHE_DIR/xml.xsd" "$XML_XSD_URL"
30
+ # ECMA's published XSDs import the xml namespace without a schemaLocation;
31
+ # point them at the local copy so xmllint can compile offline.
32
+ sed -i.bak 's|<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>|<xsd:import namespace="http://www.w3.org/XML/1998/namespace" schemaLocation="xml.xsd"/>|' "$CACHE_DIR"/*.xsd
33
+ rm -f "$CACHE_DIR"/*.xsd.bak "$CACHE_DIR/ecma376-4.zip"
34
+ fi
35
+
36
+ xmllint --noout --schema "$CACHE_DIR/wml.xsd" "${fixtures[@]}"
37
+ echo "XSD validation passed for ${#fixtures[@]} fixture(s)."
@@ -0,0 +1,133 @@
1
+ param(
2
+ [string]$FixturesDir = "tmp/validation-docx"
3
+ )
4
+
5
+ # Differential validation against desktop Microsoft Word (the authoritative
6
+ # OOXML consumer). For each generated fixture, Word itself accepts all
7
+ # revisions and then rejects all revisions, and the resulting document text
8
+ # is compared to the expected outcomes recorded at generation time. This
9
+ # makes Word an independent oracle for the redline engine instead of
10
+ # verifying the library against its own accept/reject transforms.
11
+ #
12
+ # Usage:
13
+ # node scripts/export-validation-fixtures.mjs
14
+ # npm run smoke:word:diff
15
+
16
+ $ErrorActionPreference = 'Stop'
17
+
18
+ function Get-NormalizedText([string]$text) {
19
+ if ($null -eq $text) { return '' }
20
+ $text = $text -replace [string][char]7, ' ' # table cell markers
21
+ return ($text -replace '\s+', ' ').Trim()
22
+ }
23
+
24
+ $resolvedDir = Resolve-Path -LiteralPath $FixturesDir -ErrorAction SilentlyContinue
25
+ if (-not $resolvedDir) {
26
+ Write-Error "Fixtures directory '$FixturesDir' not found. Run: node scripts/export-validation-fixtures.mjs"
27
+ exit 1
28
+ }
29
+
30
+ $expectations = Get-ChildItem -LiteralPath $resolvedDir -Filter '*.expected.json' | Sort-Object Name
31
+ if ($expectations.Count -eq 0) {
32
+ Write-Error "No *.expected.json fixtures in '$resolvedDir'. Run: node scripts/export-validation-fixtures.mjs"
33
+ exit 1
34
+ }
35
+
36
+ $word = $null
37
+ $failures = 0
38
+ $results = @()
39
+
40
+ function Open-FixtureDocument($word, [string]$path) {
41
+ # Single-argument Open: Windows PowerShell 5.1 COM binding rejects the
42
+ # long optional-parameter signature. Defaults leave the document
43
+ # writable, which accept/reject requires; nothing is ever saved.
44
+ return $word.Documents.Open($path)
45
+ }
46
+
47
+ try {
48
+ $word = New-Object -ComObject Word.Application
49
+ $word.Visible = $false
50
+ $word.DisplayAlerts = 0 # wdAlertsNone
51
+
52
+ foreach ($expectationFile in $expectations) {
53
+ $name = $expectationFile.BaseName -replace '\.expected$', ''
54
+ $docxPath = Join-Path $resolvedDir "$name.docx"
55
+ if (-not (Test-Path -LiteralPath $docxPath)) {
56
+ Write-Warning "SKIP ${name}: no matching .docx"
57
+ continue
58
+ }
59
+
60
+ # -Encoding UTF8 is required: Windows PowerShell 5.1 otherwise reads
61
+ # BOM-less UTF-8 sidecars as ANSI and garbles non-ASCII expectations.
62
+ $expected = Get-Content -LiteralPath $expectationFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
63
+ $expectedAccepted = Get-NormalizedText $expected.expectedAcceptedText
64
+ $expectedRejected = Get-NormalizedText $expected.expectedRejectedText
65
+ $caseFailed = $false
66
+ $document = $null
67
+
68
+ # Phase 1: open cleanly, revisions present, accept-all matches intent.
69
+ try {
70
+ $document = Open-FixtureDocument $word ([string]$docxPath)
71
+ $revisionCount = $document.Revisions.Count
72
+ if ($revisionCount -lt 1) {
73
+ Write-Output "FAIL ${name}: Word sees no tracked revisions"
74
+ $caseFailed = $true
75
+ }
76
+ else {
77
+ $document.AcceptAllRevisions()
78
+ $acceptedText = Get-NormalizedText $document.Content.Text
79
+ if ($acceptedText -ne $expectedAccepted) {
80
+ Write-Output "FAIL ${name}: accept-all mismatch"
81
+ Write-Output " expected: $expectedAccepted"
82
+ Write-Output " actual: $acceptedText"
83
+ $caseFailed = $true
84
+ }
85
+ }
86
+ }
87
+ catch {
88
+ Write-Output "FAIL ${name}: Word could not open/accept: $($_.Exception.Message)"
89
+ $caseFailed = $true
90
+ }
91
+ finally {
92
+ if ($null -ne $document) { $document.Close(0) | Out-Null; $document = $null }
93
+ }
94
+
95
+ # Phase 2: fresh open, reject-all restores the original text.
96
+ if (-not $caseFailed) {
97
+ try {
98
+ $document = Open-FixtureDocument $word ([string]$docxPath)
99
+ $document.RejectAllRevisions()
100
+ $rejectedText = Get-NormalizedText $document.Content.Text
101
+ if ($rejectedText -ne $expectedRejected) {
102
+ Write-Output "FAIL ${name}: reject-all mismatch"
103
+ Write-Output " expected: $expectedRejected"
104
+ Write-Output " actual: $rejectedText"
105
+ $caseFailed = $true
106
+ }
107
+ }
108
+ catch {
109
+ Write-Output "FAIL ${name}: Word could not open/reject: $($_.Exception.Message)"
110
+ $caseFailed = $true
111
+ }
112
+ finally {
113
+ if ($null -ne $document) { $document.Close(0) | Out-Null; $document = $null }
114
+ }
115
+ }
116
+
117
+ if ($caseFailed) {
118
+ $failures++
119
+ $results += "FAIL $name"
120
+ }
121
+ else {
122
+ Write-Output "PASS ${name} (revisions: $revisionCount)"
123
+ $results += "PASS $name"
124
+ }
125
+ }
126
+ }
127
+ finally {
128
+ if ($null -ne $word) { $word.Quit() | Out-Null }
129
+ }
130
+
131
+ Write-Output ""
132
+ Write-Output "Word differential: $($results.Count - $failures)/$($results.Count) fixtures passed."
133
+ if ($failures -gt 0) { exit 1 }
@@ -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)) {