@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
@@ -12,7 +12,7 @@ import {
12
12
  getElementsByTagNSOrTag,
13
13
  getXmlParseError
14
14
  } from '../core/xml-query.js';
15
- import { createParser, createSerializer, parseXml, serializeXml } from '../adapters/xml-adapter.js';
15
+ import { createSerializer, parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
16
16
  import { log, error } from '../adapters/logger.js';
17
17
  import { extractFormattingFromOoxml } from './format-extraction.js';
18
18
  import {
@@ -26,8 +26,13 @@ import { applyReconstructionMode } from './reconstruction-mode.js';
26
26
  import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
27
27
  import { getDefaultAuthor } from '../adapters/config.js';
28
28
  import { containsTrackedChanges, withOoxmlSourceType } from '../core/word-xml.js';
29
- import { NS_W, seedRevisionIdsFromDocument } from '../core/types.js';
29
+ import {
30
+ NS_W,
31
+ RevisionIdAllocator,
32
+ seedRevisionIdsFromDocument
33
+ } from '../core/types.js';
30
34
  import { acceptTrackedChangesInOoxml } from '../services/revision-comment-management.js';
35
+ import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
31
36
 
32
37
  /**
33
38
  * Applies redline track changes to OOXML by modifying the DOM in-place.
@@ -38,73 +43,107 @@ import { acceptTrackedChangesInOoxml } from '../services/revision-comment-manage
38
43
  * @param {Object} [options={}] - Options
39
44
  * @param {string} [options.author='AI'] - Author for track changes
40
45
  * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
41
- * @param {'reject-input'|'accept-all-first'} [options.existingRevisions='reject-input'] - Policy for source OOXML with tracked changes
46
+ * @param {'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='reject-input'] - Policy for source OOXML with tracked changes
47
+ * @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
48
+ * @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
42
49
  * @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
43
50
  */
44
51
  export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
52
+ const inputOoxml = oxml;
53
+ let workingOoxml = oxml;
54
+ originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
55
+ modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
45
56
  const generateRedlines = options.generateRedlines ?? true;
46
57
  const author = options.author || getDefaultAuthor();
47
- const parser = createParser();
48
- const serializer = createSerializer();
58
+ const serializer = createSerializer();
59
+ let parseWarnings = [];
60
+ const operationWarnings = [];
61
+ let normalizedExistingRevisions = false;
62
+ const keepNormalizedNoOp = options.existingRevisions === 'accept-all-first-keep-normalized';
49
63
  const finalize = result => {
50
64
  const withStatus = { ...result };
65
+ if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
66
+ if (keepNormalizedNoOp) {
67
+ withStatus.oxml = workingOoxml;
68
+ withStatus.hasChanges = true;
69
+ withStatus.warnings = [
70
+ ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
71
+ 'Existing revisions were accepted before redlining.'
72
+ ];
73
+ } else {
74
+ withStatus.oxml = inputOoxml;
75
+ }
76
+ }
77
+ const warnings = [...parseWarnings, ...operationWarnings, ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : [])];
78
+ if (warnings.length > 0) {
79
+ withStatus.warnings = [...new Set(warnings)];
80
+ }
51
81
  if (!withStatus.status) {
52
82
  withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
53
83
  }
54
84
  return withOoxmlSourceType(withStatus);
55
85
  };
56
- const noChanges = () => finalize({ oxml, hasChanges: false });
86
+ const finalizeUnchanged = () => {
87
+ if (normalizedExistingRevisions && keepNormalizedNoOp) {
88
+ return finalize({
89
+ oxml: workingOoxml,
90
+ hasChanges: true,
91
+ warnings: ['Existing revisions were accepted before redlining.']
92
+ });
93
+ }
94
+ return finalize({ oxml: inputOoxml, hasChanges: false });
95
+ };
57
96
 
58
- let xmlDoc;
59
- try {
60
- xmlDoc = parser.parseFromString(oxml, 'text/xml');
61
- } catch (e) {
62
- error('[OxmlEngine] Failed to parse OXML:', e);
63
- return finalize({
64
- oxml,
65
- hasChanges: false,
66
- status: 'error',
67
- error: { code: 'PARSE_ERROR', message: 'Could not parse OOXML input.' }
68
- });
69
- }
97
+ const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
98
+ parseWarnings = parsed.warnings;
99
+ let xmlDoc = parsed.doc;
70
100
 
71
- const parseError = getXmlParseError(xmlDoc);
72
- if (parseError) {
73
- error('[OxmlEngine] XML parse error:', parseError.textContent);
101
+ const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
102
+ if (parsed.error || parseError) {
103
+ const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
104
+ error('[OxmlEngine] XML parse error:', message);
74
105
  return finalize({
75
- oxml,
106
+ oxml: inputOoxml,
76
107
  hasChanges: false,
77
108
  status: 'error',
78
- error: { code: 'PARSE_ERROR', message: parseError.textContent || 'Could not parse OOXML input.' }
109
+ error: { code: 'PARSE_ERROR', message }
79
110
  });
80
111
  }
81
- seedRevisionIdsFromDocument(xmlDoc);
112
+ const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
113
+ ? options._revisionIdAllocator
114
+ : new RevisionIdAllocator();
115
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
82
116
 
83
117
  if (containsTrackedChanges(xmlDoc)) {
84
118
  const existingRevisionsPolicy = options.existingRevisions || 'reject-input';
85
- if (existingRevisionsPolicy === 'accept-all-first') {
119
+ if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
86
120
  log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
87
- const accepted = acceptTrackedChangesInOoxml(oxml, { allAuthors: true });
88
- oxml = accepted.oxml;
89
- xmlDoc = parser.parseFromString(oxml, 'text/xml');
90
- const acceptedParseError = getXmlParseError(xmlDoc);
91
- if (acceptedParseError) {
92
- error('[OxmlEngine] XML parse error after accepting existing revisions:', acceptedParseError.textContent);
121
+ const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
122
+ if (accepted.status === 'error') return finalize(accepted);
123
+ workingOoxml = accepted.oxml;
124
+ normalizedExistingRevisions = true;
125
+ const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
126
+ parseWarnings.push(...acceptedParsed.warnings);
127
+ xmlDoc = acceptedParsed.doc;
128
+ const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
129
+ if (acceptedParsed.error || acceptedParseError) {
130
+ const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
131
+ error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
93
132
  return finalize({
94
- oxml,
133
+ oxml: inputOoxml,
95
134
  hasChanges: false,
96
135
  status: 'error',
97
136
  error: {
98
137
  code: 'PARSE_ERROR',
99
- message: 'Could not parse OOXML after accepting existing revisions.'
138
+ message
100
139
  }
101
140
  });
102
141
  }
103
- seedRevisionIdsFromDocument(xmlDoc);
142
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
104
143
  } else {
105
144
  log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
106
145
  return finalize({
107
- oxml,
146
+ oxml: inputOoxml,
108
147
  hasChanges: false,
109
148
  status: 'error',
110
149
  error: {
@@ -119,13 +158,20 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
119
158
  if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
120
159
  log('[OxmlEngine] Isolating table-cell paragraph before diff');
121
160
  const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
122
- return applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
123
- ...options,
124
- _isolatedTableCell: true
125
- });
126
- }
127
-
128
- const sanitizedText = sanitizeAiResponse(modifiedText);
161
+ const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
162
+ ...options,
163
+ _isolatedTableCell: true
164
+ });
165
+ if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
166
+ return finalizeUnchanged();
167
+ }
168
+ return isolatedResult;
169
+ }
170
+
171
+ const sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
172
+ if (sanitizedText !== modifiedText) {
173
+ operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
174
+ }
129
175
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
130
176
 
131
177
  const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
@@ -134,18 +180,30 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
134
180
  const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
135
181
  const hasExistingFormatting = existingFormatHints.length > 0;
136
182
  const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
183
+ const targetFound = originalText.includes('\n') || originalText.includes('\r')
184
+ ? originalText
185
+ .split(/\r?\n/)
186
+ .map(normalizeTargetText)
187
+ .filter(Boolean)
188
+ .every(line => paragraphs.some(paragraph => {
189
+ const paragraphText = textSpans
190
+ .filter(span => span.paragraph === paragraph)
191
+ .map(textSpanVisibleText)
192
+ .join('');
193
+ return normalizeTargetText(paragraphText).includes(line);
194
+ }))
195
+ : visibleText.includes(originalText.trim())
196
+ || visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
197
+ || normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
137
198
  if (
138
199
  hasTextChanges
139
200
  && typeof originalText === 'string'
140
201
  && originalText.trim()
141
- && !originalText.includes('\n')
142
- && !visibleText.includes(originalText.trim())
143
- && !visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
144
- && !normalizeTargetText(visibleText).includes(normalizeTargetText(originalText))
202
+ && !targetFound
145
203
  ) {
146
204
  log('[OxmlEngine] Target text not found in OOXML');
147
205
  return finalize({
148
- oxml,
206
+ oxml: inputOoxml,
149
207
  hasChanges: false,
150
208
  status: 'error',
151
209
  error: {
@@ -189,12 +247,20 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
189
247
 
190
248
  log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
191
249
 
192
- const needsFormatRemoval = !hasTextChanges && !hasFormatHints && hasExistingFormatting;
250
+ const needsFormatRemoval = options.removeFormatting === true
251
+ && !hasTextChanges
252
+ && !hasFormatHints
253
+ && hasExistingFormatting;
193
254
 
194
- if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
195
- log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
196
- return noChanges();
197
- }
255
+ if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
256
+ log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
257
+ return finalizeUnchanged();
258
+ }
259
+
260
+ if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
261
+ log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
262
+ return finalizeUnchanged();
263
+ }
198
264
 
199
265
  if (needsFormatRemoval) {
200
266
  log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
@@ -266,15 +332,16 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
266
332
  const isTargetList = isListTargetLoose(cleanModifiedText);
267
333
  const tableCellContext = initialTableCellContext;
268
334
 
269
- log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
270
-
271
- if (isMarkdownTable && !hasTables) {
335
+ log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
336
+
337
+ try {
338
+ if (isMarkdownTable && !hasTables) {
272
339
  log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
273
- return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines));
340
+ return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
274
341
  }
275
342
 
276
343
  if (hasTables && isMarkdownTable) {
277
- return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines));
344
+ return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
278
345
  }
279
346
  if (hasTables) {
280
347
  const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
@@ -289,10 +356,10 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
289
356
  originalText,
290
357
  cleanModifiedText,
291
358
  serializer,
292
- author,
293
- formatHints,
294
- generateRedlines,
295
- surgicalTarget
359
+ author,
360
+ formatHints,
361
+ generateRedlines,
362
+ surgicalTarget
296
363
  );
297
364
 
298
365
  if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
@@ -303,22 +370,50 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
303
370
  }
304
371
  if (isTargetList) {
305
372
  log('[OxmlEngine] 🎯 Using reconciliation pipeline for list generation');
306
- const pipeline = new ReconciliationPipeline({ author, generateRedlines });
307
- const result = await pipeline.execute(oxml, modifiedText, { xmlDoc });
308
-
309
- if (result.isValid && result.ooxml && result.ooxml !== oxml) {
310
- log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${result.includeNumbering}`);
311
- const wrapped = wrapInDocumentFragment(result.ooxml, {
312
- includeNumbering: result.includeNumbering ?? true,
313
- numberingXml: result.numberingXml
314
- });
373
+ const pipeline = new ReconciliationPipeline({
374
+ author,
375
+ generateRedlines,
376
+ revisionIdAllocator
377
+ });
378
+ const result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
379
+
380
+ if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
381
+ return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
382
+ }
383
+
384
+ if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
385
+ const includeNumbering = result.includeNumbering === true;
386
+ log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
387
+ const wrapped = wrapInDocumentFragment(result.ooxml, {
388
+ includeNumbering,
389
+ numberingXml: result.numberingXml
390
+ });
315
391
  log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
316
392
  return finalize({ oxml: wrapped, hasChanges: true });
317
393
  }
318
- return noChanges();
394
+ return finalizeUnchanged();
319
395
  }
320
396
 
321
- return finalize(applyReconstructionMode(xmlDoc, originalText, cleanModifiedText, serializer, author, formatHints, generateRedlines));
397
+ return finalize(applyReconstructionMode(
398
+ xmlDoc,
399
+ originalText,
400
+ cleanModifiedText,
401
+ serializer,
402
+ author,
403
+ formatHints,
404
+ generateRedlines
405
+ ));
406
+ } catch (caught) {
407
+ if (isDiffTokenLimitError(caught)) {
408
+ return finalize({
409
+ oxml: inputOoxml,
410
+ hasChanges: false,
411
+ status: 'error',
412
+ error: { code: caught.code, message: caught.message }
413
+ });
414
+ }
415
+ throw caught;
416
+ }
322
417
  }
323
418
 
324
419
  function normalizeTargetText(text) {
@@ -340,14 +435,12 @@ function textSpanVisibleText(span) {
340
435
  * @param {string} text - AI response text
341
436
  * @returns {string}
342
437
  */
343
- export function sanitizeAiResponse(text) {
344
- let cleaned = text;
345
- cleaned = cleaned.replace(/^(Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)\s*/i, '');
346
- cleaned = cleaned.replace(/\$\\text\{/g, '').replace(/\}\$/g, '');
347
- cleaned = cleaned.replace(/\$([^0-9\n]+?)\$/g, '$1');
348
- cleaned = cleaned.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
349
- return cleaned;
350
- }
438
+ export function sanitizeAiResponse(text) {
439
+ return String(text ?? '').replace(
440
+ /^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
441
+ ''
442
+ );
443
+ }
351
444
 
352
445
  /**
353
446
  * Parses OOXML into a DOM document.
@@ -355,9 +448,9 @@ export function sanitizeAiResponse(text) {
355
448
  * @param {string} ooxmlString - OOXML text
356
449
  * @returns {Document}
357
450
  */
358
- export function parseOoxml(ooxmlString) {
359
- return parseXml(ooxmlString, 'application/xml');
360
- }
451
+ export function parseOoxml(ooxmlString) {
452
+ return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
453
+ }
361
454
 
362
455
  /**
363
456
  * Serializes a DOM document to OOXML text.