@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.
Files changed (86) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1124 -545
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +84 -99
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +3 -3
  36. package/engine/surgical-mode.js +3 -2
  37. package/engine/table-mode.js +27 -16
  38. package/index.d.ts +95 -3
  39. package/index.js +14 -13
  40. package/orchestration/list-structural-fallback.js +16 -39
  41. package/package.json +22 -4
  42. package/pipeline/diff-engine.js +174 -55
  43. package/pipeline/ingestion-export.js +39 -24
  44. package/pipeline/ingestion-paragraph.js +7 -5
  45. package/pipeline/list-generation.js +27 -18
  46. package/pipeline/patching.js +2 -3
  47. package/pipeline/pipeline.js +65 -36
  48. package/pipeline/serialization.js +13 -5
  49. package/scripts/build-test-dashboard.mjs +43 -0
  50. package/scripts/check-types.mjs +16 -24
  51. package/scripts/export-validation-fixtures.mjs +191 -45
  52. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  53. package/scripts/generate-test-dashboard.mjs +199 -0
  54. package/scripts/inspect-visual-evidence.mjs +271 -0
  55. package/scripts/lib/minimal-zip.mjs +199 -18
  56. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  57. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  58. package/scripts/lib/zip-reader.mjs +64 -0
  59. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  60. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  61. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  62. package/scripts/prepare-word-review.mjs +77 -0
  63. package/scripts/prepare-word-visual-review.mjs +90 -0
  64. package/scripts/render-agenda-multilevel.mjs +70 -0
  65. package/scripts/render-case22.mjs +73 -0
  66. package/scripts/render-case40.ps1 +35 -0
  67. package/scripts/render-multilevel-bullet-images.py +58 -0
  68. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  69. package/scripts/render-multilevel-cases.mjs +80 -0
  70. package/scripts/report-coverage-gaps.mjs +103 -0
  71. package/scripts/report-word-coverage.mjs +71 -0
  72. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  73. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  74. package/scripts/word-com-corpus-suite.ps1 +43 -0
  75. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  76. package/scripts/word-com-differential.ps1 +158 -16
  77. package/scripts/word-com-suite.ps1 +19 -0
  78. package/scripts/word-com-visual-suite.ps1 +132 -0
  79. package/services/comment-engine.js +51 -46
  80. package/services/comment-locator.js +0 -1
  81. package/services/comment-package.js +11 -10
  82. package/services/numbering-service.js +1 -1
  83. package/services/revision-comment-management.js +31 -10
  84. package/services/standalone-docx-plumbing.js +45 -34
  85. package/services/standalone-operation-runner.js +315 -75
  86. 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 { createParser, createSerializer } from '../adapters/xml-adapter.js';
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, parser, parseFailureWarning) {
39
- try {
40
- const xmlDoc = parser.parseFromString(oxml, 'text/xml');
41
- const parseError = getXmlParseError(xmlDoc);
42
- if (parseError) {
43
- return { xmlDoc: null, warning: parseFailureWarning(parseError.textContent || 'parse error') };
44
- }
45
- return { xmlDoc, warning: null };
46
- } catch (error) {
47
- return { xmlDoc: null, warning: parseFailureWarning(error.message) };
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
- commentsApplied: 0,
70
- warnings: ['No comments to inject']
71
- };
72
- }
73
-
74
- const parser = createParser();
75
- const serializer = createSerializer();
76
- const parseResult = parseDocumentOxml(
77
- oxml,
78
- parser,
79
- warning => `Failed to parse OXML: ${warning}`
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
- commentsApplied: 0,
87
- warnings: [parseResult.warning]
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
- commentsApplied: 0,
161
- warnings
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
- commentsXml: buildCommentsPartXml(placedComments),
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 parser = createParser();
189
- const serializer = createSerializer();
190
- const parseResult = parseDocumentOxml(
191
- paragraphOoxml,
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,6 @@
2
2
  * Comment text location and marker injection helpers.
3
3
  */
4
4
 
5
- import { NS_W } from '../core/types.js';
6
5
  import { createWordElement } from '../core/word-xml.js';
7
6
  import { getElementsByTag, getFirstElementByTag } from '../core/xml-query.js';
8
7
 
@@ -2,7 +2,7 @@
2
2
  * Comment package builders and pkg:part wiring.
3
3
  */
4
4
 
5
- import { createParser, createSerializer } from '../adapters/xml-adapter.js';
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 parser = createParser();
33
- const serializer = createSerializer();
34
- const pkgDoc = parser.parseFromString(packageOxml, 'text/xml');
35
-
36
- const parseError = getXmlParseError(pkgDoc);
37
- if (parseError) {
38
- logError('[CommentEngine] Failed to parse package:', parseError.textContent);
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 = parser.parseFromString(commentsXml, 'text/xml');
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 { NS_W, NumberFormat, NumberSuffix } from '../core/types.js';
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 { createParser, createSerializer } from '../adapters/xml-adapter.js';
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 parser = createParser();
66
- const xmlDoc = parser.parseFromString(oxml, 'application/xml');
67
- const parseError = getXmlParseError(xmlDoc);
68
- if (parseError) {
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}: ${parseError.textContent || 'parse error'}`
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 { oxml, hasChanges: false, acceptedCount: 0, warnings: [parseResult.warning] };
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 { oxml, hasChanges: false, rejectedCount: 0, warnings: [parseResult.warning] };
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
- warnings: [parseResult.warning]
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 { createParser, createSerializer } from '../adapters/xml-adapter.js';
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 parser = createParser();
23
- const xmlDoc = parser.parseFromString(xmlText, 'application/xml');
24
- const parseError = xmlDoc.getElementsByTagName('parsererror')[0];
25
- if (parseError) {
26
- throw new Error(`[XML parse error] ${label}: ${parseError.textContent || 'Unknown'}`);
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 xmlDoc;
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 = parser.parseFromString(packageXml, 'application/xml');
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
- throw new Error('Reconciliation engine returned no OOXML payload for this operation');
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
- if (outputOxml.includes('<pkg:package')) {
161
- return extractFromPackageXml(outputOxml);
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
- if (outputOxml.includes('<w:document')) {
165
- const parser = createParser();
166
- const doc = parser.parseFromString(outputOxml, 'application/xml');
167
- const body = doc.getElementsByTagNameNS('*', 'body')[0];
168
- const replacementNodes = body
169
- ? Array.from(body.childNodes || []).filter(node => node.nodeType === 1 && !isSectionPropertiesElement(node))
170
- : Array.from(doc.childNodes || []).filter(node => node.nodeType === 1);
171
- return { replacementNodes, numberingXml: null, sourceType: 'document' };
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 = parser.parseFromString(ctText, 'application/xml');
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 = parser.parseFromString(relsText, 'application/xml');
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 = parser.parseFromString(ctText, 'application/xml');
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 = parser.parseFromString(relsText, 'application/xml');
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
  }