@ansonlai/docx-redline-js 0.2.1 → 0.4.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 +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1124 -545
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +84 -99
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +3 -3
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +22 -4
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- package/services/table-reconciliation.js +23 -11
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$FixturesDir = "tmp/word-visual-review/fixtures",
|
|
3
|
+
[string]$OutputDir = "tmp/word-visual-review/rendered",
|
|
4
|
+
[string[]]$Case = @()
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
# Produces repeatable visual evidence from desktop Microsoft Word. This lane
|
|
8
|
+
# intentionally records "rendered", never "pass": a person or AI reviewer must
|
|
9
|
+
# still inspect the PDFs before certifying visual correctness.
|
|
10
|
+
$ErrorActionPreference = 'Stop'
|
|
11
|
+
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
12
|
+
$fixturesPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $FixturesDir))
|
|
13
|
+
$outputPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputDir))
|
|
14
|
+
$manifestPath = Join-Path $outputPath 'manifest.json'
|
|
15
|
+
|
|
16
|
+
New-Item -ItemType Directory -Force -Path $fixturesPath, $outputPath | Out-Null
|
|
17
|
+
|
|
18
|
+
Push-Location $repoRoot
|
|
19
|
+
try {
|
|
20
|
+
& node (Join-Path $PSScriptRoot 'export-validation-fixtures.mjs') --output-dir $fixturesPath
|
|
21
|
+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
22
|
+
|
|
23
|
+
$prepareArgs = @((Join-Path $PSScriptRoot 'prepare-word-visual-review.mjs'), "--output=$manifestPath")
|
|
24
|
+
foreach ($caseName in $Case) { $prepareArgs += "--case=$caseName" }
|
|
25
|
+
& node @prepareArgs
|
|
26
|
+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
Pop-Location
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
33
|
+
$word = $null
|
|
34
|
+
$rpcDisconnected = -2147417848 # 0x80010108 RPC_E_DISCONNECTED
|
|
35
|
+
|
|
36
|
+
function Test-RpcDisconnected($exception) {
|
|
37
|
+
return $exception -is [Runtime.InteropServices.COMException] -and $exception.HResult -eq $rpcDisconnected
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Start-WordApplication {
|
|
41
|
+
$application = New-Object -ComObject Word.Application
|
|
42
|
+
$application.Visible = $false
|
|
43
|
+
$application.DisplayAlerts = 0
|
|
44
|
+
return $application
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Stop-WordApplicationSafely($application) {
|
|
48
|
+
if ($null -eq $application) { return }
|
|
49
|
+
try { $application.Quit() | Out-Null }
|
|
50
|
+
catch {
|
|
51
|
+
if (-not (Test-RpcDisconnected $_.Exception)) { throw }
|
|
52
|
+
Write-Warning 'Word disconnected while quitting; the COM process was already gone.'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Close-WordDocumentSafely($document) {
|
|
57
|
+
if ($null -eq $document) { return }
|
|
58
|
+
try { $document.Close(0) | Out-Null }
|
|
59
|
+
catch {
|
|
60
|
+
if (-not (Test-RpcDisconnected $_.Exception)) { throw }
|
|
61
|
+
Write-Warning 'Word disconnected after export while closing the document.'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Export-View($word, [string]$docxPath, [string]$pdfPath, [string]$viewName) {
|
|
66
|
+
$document = $null
|
|
67
|
+
try {
|
|
68
|
+
$document = $word.Documents.OpenNoRepairDialog($docxPath)
|
|
69
|
+
if ($viewName -eq 'acceptAll') {
|
|
70
|
+
$document.AcceptAllRevisions()
|
|
71
|
+
$exportItem = 0 # wdExportDocumentContent
|
|
72
|
+
}
|
|
73
|
+
elseif ($viewName -eq 'rejectAll') {
|
|
74
|
+
$document.RejectAllRevisions()
|
|
75
|
+
$exportItem = 0
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
$document.ShowRevisions = $true
|
|
79
|
+
$document.PrintRevisions = $true
|
|
80
|
+
$exportItem = 7 # wdExportDocumentWithMarkup
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
$pages = $document.ComputeStatistics(2) # wdStatisticPages
|
|
84
|
+
$document.ExportAsFixedFormat($pdfPath, 17, $false, 0, 0, 1, 1, $exportItem, $true, $true, 0, $true, $true, $false)
|
|
85
|
+
$pdf = Get-Item -LiteralPath $pdfPath -ErrorAction Stop
|
|
86
|
+
if ($pdf.Length -lt 1000) { throw "Word produced an unexpectedly small PDF ($($pdf.Length) bytes)" }
|
|
87
|
+
if ($pages -lt 1) { throw "Word reported no rendered pages" }
|
|
88
|
+
return @{ pages = [int]$pages; bytes = [long]$pdf.Length }
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
Close-WordDocumentSafely $document
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
$word = Start-WordApplication
|
|
97
|
+
$manifest.word.version = [string]$word.Version
|
|
98
|
+
try { $manifest.word.build = [string]$word.Build } catch { $manifest.word.build = $null }
|
|
99
|
+
|
|
100
|
+
foreach ($entry in $manifest.cases) {
|
|
101
|
+
$docxPath = Join-Path $fixturesPath "$($entry.name).docx"
|
|
102
|
+
if (-not (Test-Path -LiteralPath $docxPath)) { throw "Missing fixture: $docxPath" }
|
|
103
|
+
foreach ($viewProperty in $entry.views.PSObject.Properties) {
|
|
104
|
+
$pdfPath = Join-Path $outputPath ([string]$viewProperty.Value.pdf)
|
|
105
|
+
$render = $null
|
|
106
|
+
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
$render = Export-View $word $docxPath $pdfPath ([string]$viewProperty.Name)
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
if (-not (Test-RpcDisconnected $_.Exception) -or $attempt -eq 2) { throw }
|
|
113
|
+
Write-Warning "Word disconnected while rendering $($entry.name) $($viewProperty.Name); restarting and retrying once."
|
|
114
|
+
Stop-WordApplicationSafely $word
|
|
115
|
+
$word = Start-WordApplication
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
$viewProperty.Value.status = 'rendered'
|
|
119
|
+
$viewProperty.Value.pages = $render.pages
|
|
120
|
+
$viewProperty.Value.bytes = $render.bytes
|
|
121
|
+
}
|
|
122
|
+
$entry.renderStatus = 'rendered'
|
|
123
|
+
Write-Output "RENDERED $($entry.name) (markup, accept-all, reject-all)"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
Stop-WordApplicationSafely $word
|
|
128
|
+
$manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Write-Output "Visual evidence: $($manifest.cases.Count) cases, $($manifest.cases.Count * 3) PDFs."
|
|
132
|
+
Write-Output "Manifest: $manifestPath"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { NS_W, getNextRevisionId, getRevisionTimestamp, resetRevisionIdCounter } from '../core/types.js';
|
|
8
|
-
import {
|
|
8
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
9
9
|
import { log, error as logError } from '../adapters/logger.js';
|
|
10
10
|
import { getElementsByTag, getFirstElementByTag, getXmlParseError } from '../core/xml-query.js';
|
|
11
11
|
import { buildCommentElement, buildCommentsPartXml, buildCommentMarkers } from './comment-builders.js';
|
|
@@ -35,17 +35,19 @@ export { buildCommentElement, buildCommentsPartXml, buildCommentMarkers };
|
|
|
35
35
|
* @property {string[]} warnings - Any issues encountered
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
function parseDocumentOxml(oxml,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
function parseDocumentOxml(oxml, parseFailureWarning) {
|
|
39
|
+
const parsed = parseOoxmlSafe(oxml, 'text/xml');
|
|
40
|
+
const parseError = parsed.doc ? getXmlParseError(parsed.doc) : null;
|
|
41
|
+
if (parsed.error || parseError) {
|
|
42
|
+
const message = parsed.error?.message || parseError?.textContent || 'parse error';
|
|
43
|
+
return {
|
|
44
|
+
xmlDoc: null,
|
|
45
|
+
warning: parseFailureWarning(message),
|
|
46
|
+
warnings: parsed.warnings,
|
|
47
|
+
error: { code: 'PARSE_ERROR', message }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { xmlDoc: parsed.doc, warning: null, warnings: parsed.warnings, error: null };
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -60,32 +62,35 @@ function parseDocumentOxml(oxml, parser, parseFailureWarning) {
|
|
|
60
62
|
export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
61
63
|
const author = options?.author || getDefaultAuthor();
|
|
62
64
|
const date = getRevisionTimestamp();
|
|
63
|
-
const warnings = [];
|
|
65
|
+
const warnings = [];
|
|
64
66
|
const placedComments = [];
|
|
65
67
|
|
|
66
68
|
if (!comments || comments.length === 0) {
|
|
67
|
-
return {
|
|
68
|
-
oxml,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const serializer = createSerializer();
|
|
76
|
-
const parseResult = parseDocumentOxml(
|
|
77
|
-
oxml,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
);
|
|
69
|
+
return {
|
|
70
|
+
oxml,
|
|
71
|
+
hasChanges: false,
|
|
72
|
+
commentsApplied: 0,
|
|
73
|
+
warnings: ['No comments to inject']
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const serializer = createSerializer();
|
|
78
|
+
const parseResult = parseDocumentOxml(
|
|
79
|
+
oxml,
|
|
80
|
+
warning => `Failed to parse OXML: ${warning}`
|
|
81
|
+
);
|
|
82
|
+
warnings.push(...(parseResult.warnings || []));
|
|
81
83
|
|
|
82
84
|
if (!parseResult.xmlDoc) {
|
|
83
85
|
logError('[CommentEngine] Parse failure:', parseResult.warning);
|
|
84
|
-
return {
|
|
85
|
-
oxml,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
return {
|
|
87
|
+
oxml,
|
|
88
|
+
hasChanges: false,
|
|
89
|
+
commentsApplied: 0,
|
|
90
|
+
status: 'error',
|
|
91
|
+
error: parseResult.error,
|
|
92
|
+
warnings: [...warnings, parseResult.warning]
|
|
93
|
+
};
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
const xmlDoc = parseResult.xmlDoc;
|
|
@@ -155,16 +160,18 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
if (placedComments.length === 0) {
|
|
158
|
-
return {
|
|
159
|
-
oxml,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
return {
|
|
164
|
+
oxml,
|
|
165
|
+
hasChanges: false,
|
|
166
|
+
commentsApplied: 0,
|
|
167
|
+
warnings
|
|
168
|
+
};
|
|
163
169
|
}
|
|
164
170
|
|
|
165
|
-
return {
|
|
166
|
-
oxml: serializer.serializeToString(xmlDoc),
|
|
167
|
-
|
|
171
|
+
return {
|
|
172
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
173
|
+
hasChanges: true,
|
|
174
|
+
commentsXml: buildCommentsPartXml(placedComments),
|
|
168
175
|
commentsApplied: placedComments.length,
|
|
169
176
|
warnings
|
|
170
177
|
};
|
|
@@ -185,12 +192,10 @@ export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, comm
|
|
|
185
192
|
const date = getRevisionTimestamp();
|
|
186
193
|
const commentId = getNextRevisionId();
|
|
187
194
|
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
parser,
|
|
193
|
-
warning => `Failed to parse paragraph OOXML: ${warning}`
|
|
195
|
+
const serializer = createSerializer();
|
|
196
|
+
const parseResult = parseDocumentOxml(
|
|
197
|
+
paragraphOoxml,
|
|
198
|
+
warning => `Failed to parse paragraph OOXML: ${warning}`
|
|
194
199
|
);
|
|
195
200
|
|
|
196
201
|
if (!parseResult.xmlDoc) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Comment package builders and pkg:part wiring.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
6
|
import { error as logError } from '../adapters/logger.js';
|
|
7
7
|
import { buildDocumentCommentsPackage, buildParagraphCommentsPackage } from './package-builder.js';
|
|
8
8
|
import { getElementsByTagNS, getXmlParseError } from '../core/xml-query.js';
|
|
@@ -29,14 +29,14 @@ export function wrapParagraphWithComments(paragraphXml, commentsXml) {
|
|
|
29
29
|
* @returns {string}
|
|
30
30
|
*/
|
|
31
31
|
export function injectCommentsIntoPackage(packageOxml, commentsXml) {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const pkgDoc =
|
|
35
|
-
|
|
36
|
-
const parseError = getXmlParseError(pkgDoc);
|
|
37
|
-
if (parseError) {
|
|
38
|
-
logError('[CommentEngine] Failed to parse package:', parseError
|
|
39
|
-
return packageOxml;
|
|
32
|
+
const serializer = createSerializer();
|
|
33
|
+
const packageParsed = parseOoxmlSafe(packageOxml, 'text/xml');
|
|
34
|
+
const pkgDoc = packageParsed.doc;
|
|
35
|
+
|
|
36
|
+
const parseError = pkgDoc ? getXmlParseError(pkgDoc) : null;
|
|
37
|
+
if (packageParsed.error || parseError) {
|
|
38
|
+
logError('[CommentEngine] Failed to parse package:', packageParsed.error?.message || parseError?.textContent);
|
|
39
|
+
return packageOxml;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const pkgPackage = pkgDoc.documentElement;
|
|
@@ -46,7 +46,8 @@ export function injectCommentsIntoPackage(packageOxml, commentsXml) {
|
|
|
46
46
|
commentsPart.setAttribute('pkg:contentType', 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml');
|
|
47
47
|
|
|
48
48
|
const commentsXmlData = pkgDoc.createElementNS(PKG_NS, 'pkg:xmlData');
|
|
49
|
-
const commentsDoc =
|
|
49
|
+
const commentsDoc = parseOoxmlSafe(commentsXml, 'text/xml').doc;
|
|
50
|
+
if (!commentsDoc) return packageOxml;
|
|
50
51
|
commentsXmlData.appendChild(pkgDoc.importNode(commentsDoc.documentElement, true));
|
|
51
52
|
commentsPart.appendChild(commentsXmlData);
|
|
52
53
|
pkgPackage.appendChild(commentsPart);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Manages list numbering to ensure continuation and consistent formatting.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { NumberFormat, NumberSuffix } from '../core/types.js';
|
|
8
8
|
|
|
9
9
|
export class NumberingService {
|
|
10
10
|
constructor() {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { NS_W } from '../core/types.js';
|
|
6
|
-
import {
|
|
6
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
7
7
|
import { createWordElement } from '../core/word-xml.js';
|
|
8
8
|
import { getXmlParseError } from '../core/xml-query.js';
|
|
9
9
|
|
|
@@ -62,17 +62,19 @@ function authorMatchesNode(node, filter) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function parseXmlWithWarnings(oxml, parseFailurePrefix) {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const parsed = parseOoxmlSafe(oxml, 'application/xml');
|
|
66
|
+
const parseError = parsed.doc ? getXmlParseError(parsed.doc) : null;
|
|
67
|
+
if (parsed.error || parseError) {
|
|
68
|
+
const message = parsed.error?.message || parseError?.textContent || 'parse error';
|
|
69
69
|
return {
|
|
70
70
|
xmlDoc: null,
|
|
71
71
|
serializer: null,
|
|
72
|
-
warning: `${parseFailurePrefix}: ${
|
|
72
|
+
warning: `${parseFailurePrefix}: ${message}`,
|
|
73
|
+
warnings: parsed.warnings,
|
|
74
|
+
error: { code: 'PARSE_ERROR', message }
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
|
-
return { xmlDoc, serializer: createSerializer(), warning: null };
|
|
77
|
+
return { xmlDoc: parsed.doc, serializer: createSerializer(), warning: null, warnings: parsed.warnings, error: null };
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
function removeNode(node) {
|
|
@@ -150,10 +152,18 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
150
152
|
|
|
151
153
|
const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
|
|
152
154
|
if (!parseResult.xmlDoc) {
|
|
153
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
oxml,
|
|
157
|
+
hasChanges: false,
|
|
158
|
+
acceptedCount: 0,
|
|
159
|
+
status: 'error',
|
|
160
|
+
error: parseResult.error,
|
|
161
|
+
warnings: [...(parseResult.warnings || []), parseResult.warning]
|
|
162
|
+
};
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
const { xmlDoc, serializer } = parseResult;
|
|
166
|
+
warnings.push(...(parseResult.warnings || []));
|
|
157
167
|
let acceptedCount = 0;
|
|
158
168
|
|
|
159
169
|
for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
|
|
@@ -321,10 +331,18 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
321
331
|
|
|
322
332
|
const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
|
|
323
333
|
if (!parseResult.xmlDoc) {
|
|
324
|
-
return {
|
|
334
|
+
return {
|
|
335
|
+
oxml,
|
|
336
|
+
hasChanges: false,
|
|
337
|
+
rejectedCount: 0,
|
|
338
|
+
status: 'error',
|
|
339
|
+
error: parseResult.error,
|
|
340
|
+
warnings: [...(parseResult.warnings || []), parseResult.warning]
|
|
341
|
+
};
|
|
325
342
|
}
|
|
326
343
|
|
|
327
344
|
const { xmlDoc, serializer } = parseResult;
|
|
345
|
+
warnings.push(...(parseResult.warnings || []));
|
|
328
346
|
let rejectedCount = 0;
|
|
329
347
|
|
|
330
348
|
for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
|
|
@@ -472,11 +490,14 @@ export function deleteCommentsByAuthorInOoxml(oxml, options = {}) {
|
|
|
472
490
|
hasChanges: false,
|
|
473
491
|
commentsRemoved: 0,
|
|
474
492
|
referencesRemoved: 0,
|
|
475
|
-
|
|
493
|
+
status: 'error',
|
|
494
|
+
error: parseResult.error,
|
|
495
|
+
warnings: [...(parseResult.warnings || []), parseResult.warning]
|
|
476
496
|
};
|
|
477
497
|
}
|
|
478
498
|
|
|
479
499
|
const { xmlDoc, serializer } = parseResult;
|
|
500
|
+
warnings.push(...(parseResult.warnings || []));
|
|
480
501
|
const { targetIds, commentNodes } = collectCommentTargetIds(xmlDoc, filter);
|
|
481
502
|
|
|
482
503
|
if (filter.allAuthors) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Standalone OOXML/Docx plumbing helpers shared by browser and Node hosts.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
6
|
|
|
7
7
|
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
8
8
|
const NS_CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
@@ -19,13 +19,13 @@ const CONTENT_TYPES_PATH = '[Content_Types].xml';
|
|
|
19
19
|
const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
|
|
20
20
|
|
|
21
21
|
export function parseXmlStrictStandalone(xmlText, label = 'xml') {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
throw
|
|
22
|
+
const parsed = parseOoxmlSafe(xmlText, 'application/xml');
|
|
23
|
+
if (parsed.error || !parsed.doc) {
|
|
24
|
+
const error = new Error(`[XML parse error] ${label}: ${parsed.error?.message || 'Unknown'}`);
|
|
25
|
+
error.code = 'PARSE_ERROR';
|
|
26
|
+
throw error;
|
|
27
27
|
}
|
|
28
|
-
return
|
|
28
|
+
return parsed.doc;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function isSectionPropertiesElement(node) {
|
|
@@ -104,9 +104,8 @@ export function getPackagePartName(partElement) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
function extractFromPackageXml(packageXml) {
|
|
107
|
-
const parser = createParser();
|
|
108
107
|
const serializer = createSerializer();
|
|
109
|
-
const pkgDoc =
|
|
108
|
+
const pkgDoc = parseXmlStrictStandalone(packageXml, 'package OOXML');
|
|
110
109
|
const parts = Array.from(pkgDoc.getElementsByTagNameNS('*', 'part'));
|
|
111
110
|
const documentPart = parts.find(part => getPackagePartName(part) === '/word/document.xml');
|
|
112
111
|
if (!documentPart) {
|
|
@@ -154,28 +153,42 @@ function extractFromPackageXml(packageXml) {
|
|
|
154
153
|
*/
|
|
155
154
|
export function extractReplacementNodesFromOoxml(outputOxml) {
|
|
156
155
|
if (typeof outputOxml !== 'string' || !outputOxml.trim()) {
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
return {
|
|
157
|
+
replacementNodes: [],
|
|
158
|
+
numberingXml: null,
|
|
159
|
+
sourceType: 'fragment',
|
|
160
|
+
status: 'error',
|
|
161
|
+
error: { code: 'PARSE_ERROR', message: 'Reconciliation engine returned no OOXML payload for this operation' }
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
if (outputOxml.includes('<pkg:package')) {
|
|
167
|
+
return extractFromPackageXml(outputOxml);
|
|
168
|
+
}
|
|
159
169
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
if (outputOxml.includes('<w:document')) {
|
|
171
|
+
const doc = parseXmlStrictStandalone(outputOxml, 'document OOXML');
|
|
172
|
+
const body = doc.getElementsByTagNameNS('*', 'body')[0];
|
|
173
|
+
const replacementNodes = body
|
|
174
|
+
? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
|
|
175
|
+
: Array.from(doc.childNodes || []).filter(node => node.nodeType === 1);
|
|
176
|
+
return { replacementNodes, numberingXml: null, sourceType: 'document' };
|
|
177
|
+
}
|
|
163
178
|
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
:
|
|
171
|
-
|
|
179
|
+
const wrapped = `<root xmlns:w="${NS_W}">${outputOxml}</root>`;
|
|
180
|
+
const fragmentDoc = parseXmlStrictStandalone(wrapped, 'OOXML fragment');
|
|
181
|
+
const replacementNodes = Array.from(fragmentDoc.documentElement.childNodes || []).filter(node => node.nodeType === 1);
|
|
182
|
+
return { replacementNodes, numberingXml: null, sourceType: 'fragment' };
|
|
183
|
+
} catch (caught) {
|
|
184
|
+
return {
|
|
185
|
+
replacementNodes: [],
|
|
186
|
+
numberingXml: null,
|
|
187
|
+
sourceType: 'fragment',
|
|
188
|
+
status: 'error',
|
|
189
|
+
error: { code: 'PARSE_ERROR', message: caught?.message || 'Could not parse OOXML payload.' }
|
|
190
|
+
};
|
|
172
191
|
}
|
|
173
|
-
|
|
174
|
-
const wrapped = `<root xmlns:w="${NS_W}">${outputOxml}</root>`;
|
|
175
|
-
const parser = createParser();
|
|
176
|
-
const fragmentDoc = parser.parseFromString(wrapped, 'application/xml');
|
|
177
|
-
const replacementNodes = Array.from(fragmentDoc.documentElement.childNodes || []).filter(node => node.nodeType === 1);
|
|
178
|
-
return { replacementNodes, numberingXml: null, sourceType: 'fragment' };
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
function upsertContentTypeOverride(ctDoc, partName, contentType) {
|
|
@@ -258,12 +271,11 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
|
|
|
258
271
|
}
|
|
259
272
|
zip.file(NUMBERING_PATH, mergedNumberingXml);
|
|
260
273
|
|
|
261
|
-
const parser = createParser();
|
|
262
274
|
const serializer = createSerializer();
|
|
263
275
|
|
|
264
276
|
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
265
277
|
if (ctText) {
|
|
266
|
-
const ctDoc =
|
|
278
|
+
const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
|
|
267
279
|
if (upsertContentTypeOverride(ctDoc, '/word/numbering.xml', NUMBERING_CONTENT_TYPE)) {
|
|
268
280
|
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
269
281
|
}
|
|
@@ -271,7 +283,7 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
|
|
|
271
283
|
|
|
272
284
|
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
273
285
|
if (relsText) {
|
|
274
|
-
const relsDoc =
|
|
286
|
+
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
275
287
|
if (upsertDocumentRelationship(relsDoc, NUMBERING_REL_TYPE, 'numbering.xml')) {
|
|
276
288
|
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
277
289
|
}
|
|
@@ -289,7 +301,6 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
|
|
|
289
301
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
290
302
|
if (!commentsXml) return;
|
|
291
303
|
|
|
292
|
-
const parser = createParser();
|
|
293
304
|
const serializer = createSerializer();
|
|
294
305
|
const existingText = await readZipText(zip, COMMENTS_PATH);
|
|
295
306
|
if (!existingText) {
|
|
@@ -316,7 +327,7 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
|
|
|
316
327
|
|
|
317
328
|
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
318
329
|
if (ctText) {
|
|
319
|
-
const ctDoc =
|
|
330
|
+
const ctDoc = parseXmlStrictStandalone(ctText, CONTENT_TYPES_PATH);
|
|
320
331
|
if (upsertContentTypeOverride(ctDoc, '/word/comments.xml', COMMENTS_CONTENT_TYPE)) {
|
|
321
332
|
zip.file(CONTENT_TYPES_PATH, serializer.serializeToString(ctDoc));
|
|
322
333
|
}
|
|
@@ -324,7 +335,7 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
|
|
|
324
335
|
|
|
325
336
|
const relsText = await readZipText(zip, DOCUMENT_RELS_PATH);
|
|
326
337
|
if (relsText) {
|
|
327
|
-
const relsDoc =
|
|
338
|
+
const relsDoc = parseXmlStrictStandalone(relsText, DOCUMENT_RELS_PATH);
|
|
328
339
|
if (upsertDocumentRelationship(relsDoc, COMMENTS_REL_TYPE, 'comments.xml')) {
|
|
329
340
|
zip.file(DOCUMENT_RELS_PATH, serializer.serializeToString(relsDoc));
|
|
330
341
|
}
|