@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.
- package/AGENTS.md +53 -4
- package/ARCHITECTURE.md +75 -11
- package/README.md +62 -3
- package/core/redline-validation.js +156 -0
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +3195 -2592
- 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 +104 -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/docs/plans/2026-05-31-architectural changes.md +591 -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 +172 -0
- package/index.js +50 -47
- package/package.json +10 -2
- 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 +40 -0
- package/scripts/check-types.mjs +29 -0
- package/scripts/export-validation-fixtures.mjs +125 -0
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -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
|
@@ -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 {
|
|
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)) {
|