@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -7,12 +7,15 @@
7
7
  import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
8
8
  import { isListTargetLoose } from '../pipeline/list-markers.js';
9
9
  import { ReconciliationPipeline } from '../pipeline/pipeline.js';
10
- import { wrapInDocumentFragment } from '../pipeline/serialization.js';
11
- import {
12
- getElementsByTagNSOrTag,
13
- getXmlParseError
14
- } from '../core/xml-query.js';
15
- import { createSerializer, parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
10
+ import { ingestOoxml, detectNumberingContext } from '../pipeline/ingestion.js';
11
+ import { executeListGeneration } from '../pipeline/list-generation.js';
12
+ import { analyzeStructuredContent } from '../pipeline/structured-content.js';
13
+ import { wrapInDocumentFragment } from '../pipeline/serialization.js';
14
+ import {
15
+ getElementsByTagNSOrTag,
16
+ getXmlParseError
17
+ } from '../core/xml-query.js';
18
+ import { createSerializer, parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
16
19
  import { log, error } from '../adapters/logger.js';
17
20
  import { extractFormattingFromOoxml } from './format-extraction.js';
18
21
  import {
@@ -21,18 +24,33 @@ import {
21
24
  } from './format-application.js';
22
25
  import { buildParagraphInfos, findMatchingParagraphInfo, getContainingParagraph } from './format-paragraph-targeting.js';
23
26
  import { detectTableCellContext, serializeParagraphOnly } from './table-cell-context.js';
24
- import { applySurgicalMode } from './surgical-mode.js';
25
- import { applyReconstructionMode } from './reconstruction-mode.js';
26
- import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
27
- import { getDefaultAuthor } from '../adapters/config.js';
28
- import { containsTrackedChanges, withOoxmlSourceType } from '../core/word-xml.js';
29
- import {
30
- NS_W,
31
- RevisionIdAllocator,
32
- seedRevisionIdsFromDocument
33
- } from '../core/types.js';
34
- import { acceptTrackedChangesInOoxml } from '../services/revision-comment-management.js';
35
- import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
27
+ import { applySurgicalMode } from './surgical-mode.js';
28
+ import { applyReconstructionMode } from './reconstruction-mode.js';
29
+ import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
30
+ import { getDefaultAuthor } from '../adapters/config.js';
31
+ import { containsTrackedChanges, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
32
+ import {
33
+ NS_W,
34
+ RevisionIdAllocator,
35
+ seedRevisionIdsFromDocument
36
+ } from '../core/types.js';
37
+ import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml } from '../services/revision-comment-management.js';
38
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
39
+ import { getDocumentParagraphs } from './format-extraction.js';
40
+ import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
41
+ import { NumberingService } from '../services/numbering-service.js';
42
+ import { recordRouteSelection } from './route-selection.js';
43
+
44
+ function getCommentIdsInOoxml(node) {
45
+ const ids = new Set();
46
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
47
+ for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
48
+ const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
49
+ if (id !== '') ids.add(id);
50
+ }
51
+ }
52
+ return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
53
+ }
36
54
 
37
55
  /**
38
56
  * Applies redline track changes to OOXML by modifying the DOM in-place.
@@ -40,229 +58,330 @@ import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
40
58
  * @param {string} oxml - Original OOXML string
41
59
  * @param {string} originalText - Original plain text
42
60
  * @param {string} modifiedText - New text (may contain markdown)
43
- * @param {Object} [options={}] - Options
44
- * @param {string} [options.author='AI'] - Author for track changes
45
- * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
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
49
- * @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
50
- */
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 ?? '');
56
- const generateRedlines = options.generateRedlines ?? true;
57
- const author = options.author || getDefaultAuthor();
58
- const serializer = createSerializer();
59
- let parseWarnings = [];
60
- const operationWarnings = [];
61
- let normalizedExistingRevisions = false;
62
- const keepNormalizedNoOp = options.existingRevisions === 'accept-all-first-keep-normalized';
63
- const finalize = result => {
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
- }
81
- if (!withStatus.status) {
82
- withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
83
- }
84
- return withOoxmlSourceType(withStatus);
85
- };
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
- };
61
+ * @param {Object} [options={}] - Options
62
+ * @param {string} [options.author='AI'] - Author for track changes
63
+ * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
64
+ * @param {'merge-same-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
65
+ * @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
66
+ * @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
67
+ * @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
68
+ */
69
+ export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
70
+ const inputOoxml = oxml;
71
+ let workingOoxml = oxml;
72
+ originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
73
+ modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
74
+ const generateRedlines = options.generateRedlines ?? true;
75
+ const author = options.author || getDefaultAuthor();
76
+ const serializer = createSerializer();
77
+ let parseWarnings = [];
78
+ const operationWarnings = [];
79
+ let normalizedExistingRevisions = false;
80
+ const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
81
+ const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
82
+ const finalize = result => {
83
+ const withStatus = { ...result };
84
+ if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
85
+ if (existingRevisionsPolicy === 'merge-same-author') {
86
+ withStatus.oxml = workingOoxml;
87
+ withStatus.hasChanges = true;
88
+ withStatus.warnings = [
89
+ ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
90
+ 'Previous revisions by the same author were reverted to baseline.'
91
+ ];
92
+ } else if (keepNormalizedNoOp) {
93
+ withStatus.oxml = workingOoxml;
94
+ withStatus.hasChanges = true;
95
+ withStatus.warnings = [
96
+ ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
97
+ 'Existing revisions were accepted before redlining.'
98
+ ];
99
+ } else {
100
+ withStatus.oxml = inputOoxml;
101
+ }
102
+ }
103
+ const warnings = [...parseWarnings, ...operationWarnings, ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : [])];
104
+ if (warnings.length > 0) {
105
+ withStatus.warnings = [...new Set(warnings)];
106
+ }
107
+ if (!withStatus.status) {
108
+ withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
109
+ }
110
+ return withOoxmlSourceType(withStatus);
111
+ };
112
+ const finalizeUnchanged = () => {
113
+ if (normalizedExistingRevisions && keepNormalizedNoOp) {
114
+ return finalize({
115
+ oxml: workingOoxml,
116
+ hasChanges: true,
117
+ warnings: ['Existing revisions were accepted before redlining.']
118
+ });
119
+ }
120
+ return finalize({ oxml: inputOoxml, hasChanges: false });
121
+ };
96
122
 
97
- const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
98
- parseWarnings = parsed.warnings;
99
- let xmlDoc = parsed.doc;
100
-
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);
105
- return finalize({
106
- oxml: inputOoxml,
107
- hasChanges: false,
108
- status: 'error',
109
- error: { code: 'PARSE_ERROR', message }
110
- });
111
- }
112
- const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
113
- ? options._revisionIdAllocator
114
- : new RevisionIdAllocator();
115
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
116
-
117
- if (containsTrackedChanges(xmlDoc)) {
118
- const existingRevisionsPolicy = options.existingRevisions || 'reject-input';
119
- if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
120
- log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
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);
132
- return finalize({
133
- oxml: inputOoxml,
134
- hasChanges: false,
135
- status: 'error',
136
- error: {
137
- code: 'PARSE_ERROR',
138
- message
139
- }
140
- });
141
- }
142
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
143
- } else {
144
- log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
145
- return finalize({
146
- oxml: inputOoxml,
147
- hasChanges: false,
148
- status: 'error',
149
- error: {
150
- code: 'EXISTING_REVISIONS',
151
- message: 'Input OOXML contains existing tracked changes. Pass existingRevisions: "accept-all-first" to normalize before redlining.'
123
+ const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
124
+ parseWarnings = parsed.warnings;
125
+ let xmlDoc = parsed.doc;
126
+
127
+ const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
128
+ if (parsed.error || parseError) {
129
+ const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
130
+ error('[OxmlEngine] XML parse error:', message);
131
+ return finalize({
132
+ oxml: inputOoxml,
133
+ hasChanges: false,
134
+ status: 'error',
135
+ error: { code: 'PARSE_ERROR', message }
136
+ });
137
+ }
138
+ const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
139
+ ? options._revisionIdAllocator
140
+ : new RevisionIdAllocator();
141
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
142
+
143
+ if (containsTrackedChanges(xmlDoc)) {
144
+ if (existingRevisionsPolicy === 'merge-same-author') {
145
+ const authors = getTrackedChangeAuthors(xmlDoc);
146
+ const currentAuthor = String(author || '').trim().toLowerCase();
147
+ const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
148
+ if (isSameAuthor) {
149
+ const commentIds = getCommentIdsInOoxml(xmlDoc);
150
+ if (commentIds.length > 0) {
151
+ return finalize({
152
+ oxml: inputOoxml,
153
+ hasChanges: false,
154
+ status: 'error',
155
+ error: {
156
+ code: 'COMMENTED_CONTENT_MERGE',
157
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
158
+ commentIds
159
+ }
160
+ });
152
161
  }
153
- });
154
- }
155
- }
156
-
157
- const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
162
+ log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
163
+ const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
164
+ if (rejected.status === 'error') return finalize(rejected);
165
+ workingOoxml = rejected.oxml;
166
+ normalizedExistingRevisions = true;
167
+ const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
168
+ parseWarnings.push(...rejectedParsed.warnings);
169
+ xmlDoc = rejectedParsed.doc;
170
+ const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
171
+ if (rejectedParsed.error || rejectedParseError) {
172
+ const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
173
+ error('[OxmlEngine] XML parse error after rejecting same-author revisions:', message);
174
+ return finalize({
175
+ oxml: inputOoxml,
176
+ hasChanges: false,
177
+ status: 'error',
178
+ error: {
179
+ code: 'PARSE_ERROR',
180
+ message
181
+ }
182
+ });
183
+ }
184
+ if (containsTrackedChanges(xmlDoc)) {
185
+ return finalize({
186
+ oxml: inputOoxml,
187
+ hasChanges: false,
188
+ status: 'error',
189
+ error: {
190
+ code: 'UNSAFE_REVISION_NESTING',
191
+ message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
192
+ }
193
+ });
194
+ }
195
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
196
+
197
+ // Re-derive baseline text from the restored baseline OOXML:
198
+ const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
199
+ ? [xmlDoc.documentElement]
200
+ : getDocumentParagraphs(xmlDoc);
201
+ const baselineText = paragraphsInDoc.length > 0
202
+ ? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
203
+ : '';
204
+ originalText = baselineText;
205
+ } else {
206
+ log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
207
+ return finalize({
208
+ oxml: inputOoxml,
209
+ hasChanges: false,
210
+ status: 'error',
211
+ error: {
212
+ code: 'EXISTING_REVISIONS',
213
+ message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
214
+ }
215
+ });
216
+ }
217
+ } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
218
+ log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
219
+ const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
220
+ if (accepted.status === 'error') return finalize(accepted);
221
+ workingOoxml = accepted.oxml;
222
+ normalizedExistingRevisions = true;
223
+ const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
224
+ parseWarnings.push(...acceptedParsed.warnings);
225
+ xmlDoc = acceptedParsed.doc;
226
+ const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
227
+ if (acceptedParsed.error || acceptedParseError) {
228
+ const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
229
+ error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
230
+ return finalize({
231
+ oxml: inputOoxml,
232
+ hasChanges: false,
233
+ status: 'error',
234
+ error: {
235
+ code: 'PARSE_ERROR',
236
+ message
237
+ }
238
+ });
239
+ }
240
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
241
+ } else {
242
+ log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
243
+ return finalize({
244
+ oxml: inputOoxml,
245
+ hasChanges: false,
246
+ status: 'error',
247
+ error: {
248
+ code: 'EXISTING_REVISIONS',
249
+ message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
250
+ }
251
+ });
252
+ }
253
+ }
254
+
255
+ const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
158
256
  if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
159
257
  log('[OxmlEngine] Isolating table-cell paragraph before diff');
160
258
  const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
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
- }
259
+ const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
260
+ ...options,
261
+ _isolatedTableCell: true
262
+ });
263
+ if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
264
+ return finalizeUnchanged();
265
+ }
266
+ return isolatedResult;
267
+ }
268
+
269
+ let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
270
+ if (sanitizedText !== modifiedText) {
271
+ operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
272
+ }
273
+ let structuredAnalysis = null;
274
+ if (options.structuredContent !== false) {
275
+ structuredAnalysis = analyzeStructuredContent(sanitizedText);
276
+ if (!structuredAnalysis.valid) {
277
+ if (options.explicitStructuredContent === true) {
278
+ const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
279
+ return finalize({
280
+ oxml: inputOoxml,
281
+ hasChanges: false,
282
+ status: 'error',
283
+ error: { code: 'STRUCTURED_CONTENT_INVALID', message },
284
+ warnings: structuredAnalysis.issues.map(issue => issue.message)
285
+ });
286
+ }
287
+ structuredAnalysis = null;
288
+ } else if (structuredAnalysis.requiresStructuredContent) {
289
+ sanitizedText = structuredAnalysis.normalizedMarkdown;
290
+ }
291
+ }
175
292
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
176
293
 
177
294
  const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
178
295
  const hasFormatHints = formatHints.length > 0;
179
296
 
180
- const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
181
- const hasExistingFormatting = existingFormatHints.length > 0;
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));
198
- if (
199
- hasTextChanges
200
- && typeof originalText === 'string'
201
- && originalText.trim()
202
- && !targetFound
203
- ) {
204
- log('[OxmlEngine] Target text not found in OOXML');
205
- return finalize({
206
- oxml: inputOoxml,
207
- hasChanges: false,
208
- status: 'error',
209
- error: {
210
- code: 'TARGET_NOT_FOUND',
211
- message: 'Original text was not found in the supplied OOXML.'
212
- }
213
- });
214
- }
215
- let paragraphInfos = null;
216
- const getParagraphInfos = () => {
217
- if (!paragraphInfos) {
218
- paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
219
- }
220
- return paragraphInfos;
221
- };
222
- const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
223
- const formatResult = applyFormatOnlyChangesSurgical(
224
- xmlDoc,
225
- originalText,
226
- formatHints,
227
- serializer,
228
- author,
229
- generateRedlines,
230
- precomputedContext
231
- );
232
- if (!formatResult.useNativeApi) {
233
- return formatResult;
234
- }
235
-
236
- log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
237
- return applyReconstructionMode(
238
- xmlDoc,
239
- originalText,
240
- cleanModifiedText,
241
- serializer,
242
- author,
243
- formatHints,
244
- generateRedlines
245
- );
246
- };
297
+ const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
298
+ const hasExistingFormatting = existingFormatHints.length > 0;
299
+ const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
300
+ const targetFound = originalText.includes('\n') || originalText.includes('\r')
301
+ ? originalText
302
+ .split(/\r?\n/)
303
+ .map(normalizeTargetText)
304
+ .filter(Boolean)
305
+ .every(line => paragraphs.some(paragraph => {
306
+ const paragraphText = textSpans
307
+ .filter(span => span.paragraph === paragraph)
308
+ .map(textSpanVisibleText)
309
+ .join('');
310
+ return normalizeTargetText(paragraphText).includes(line);
311
+ }))
312
+ : visibleText.includes(originalText.trim())
313
+ || visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
314
+ || normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
315
+ if (
316
+ hasTextChanges
317
+ && typeof originalText === 'string'
318
+ && originalText.trim()
319
+ && !targetFound
320
+ ) {
321
+ log('[OxmlEngine] Target text not found in OOXML');
322
+ return finalize({
323
+ oxml: inputOoxml,
324
+ hasChanges: false,
325
+ status: 'error',
326
+ error: {
327
+ code: 'TARGET_NOT_FOUND',
328
+ message: 'Original text was not found in the supplied OOXML.'
329
+ }
330
+ });
331
+ }
332
+ let paragraphInfos = null;
333
+ const getParagraphInfos = () => {
334
+ if (!paragraphInfos) {
335
+ paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
336
+ }
337
+ return paragraphInfos;
338
+ };
339
+ const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
340
+ const formatResult = applyFormatOnlyChangesSurgical(
341
+ xmlDoc,
342
+ originalText,
343
+ formatHints,
344
+ serializer,
345
+ author,
346
+ generateRedlines,
347
+ precomputedContext
348
+ );
349
+ if (!formatResult.useNativeApi) {
350
+ return formatResult;
351
+ }
352
+
353
+ log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
354
+ return applyReconstructionMode(
355
+ xmlDoc,
356
+ originalText,
357
+ cleanModifiedText,
358
+ serializer,
359
+ author,
360
+ formatHints,
361
+ generateRedlines
362
+ );
363
+ };
247
364
 
248
365
  log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
249
366
 
250
- const needsFormatRemoval = options.removeFormatting === true
251
- && !hasTextChanges
252
- && !hasFormatHints
253
- && hasExistingFormatting;
367
+ const needsFormatRemoval = options.removeFormatting === true
368
+ && !hasTextChanges
369
+ && !hasFormatHints
370
+ && hasExistingFormatting;
254
371
 
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
- }
372
+ if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
373
+ recordRouteSelection(options, 'noChange');
374
+ log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
375
+ return finalizeUnchanged();
376
+ }
377
+
378
+ if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
379
+ log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
380
+ return finalizeUnchanged();
381
+ }
264
382
 
265
383
  if (needsFormatRemoval) {
384
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
266
385
  log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
267
386
 
268
387
  const tableCellCtx = initialTableCellContext;
@@ -293,16 +412,17 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
293
412
  );
294
413
 
295
414
  if (tableCellCtx.hasTableWrapper && targetParagraph) {
296
- return finalize({
297
- oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
298
- hasChanges: removalResult.hasChanges
299
- });
300
- }
301
-
302
- return finalize(removalResult);
415
+ return finalize({
416
+ oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
417
+ hasChanges: removalResult.hasChanges
418
+ });
419
+ }
420
+
421
+ return finalize(removalResult);
303
422
  }
304
423
 
305
424
  if (!hasTextChanges && hasFormatHints) {
425
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
306
426
  log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
307
427
 
308
428
  const tableCellCtx = initialTableCellContext;
@@ -311,39 +431,43 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
311
431
  paragraphs,
312
432
  paragraphInfos: getParagraphInfos()
313
433
  };
314
- if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
315
- log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
316
-
317
- const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
318
-
319
- log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
320
- return finalize({
321
- oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
322
- hasChanges: formatResult.hasChanges
323
- });
324
- }
325
-
326
- return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
327
- }
434
+ if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
435
+ log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
436
+
437
+ const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
328
438
 
329
- const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
439
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
440
+ return finalize({
441
+ oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
442
+ hasChanges: formatResult.hasChanges
443
+ });
444
+ }
445
+
446
+ return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
447
+ }
448
+
449
+ const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
330
450
  const hasTables = tables.length > 0;
331
451
  const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
332
452
  const isTargetList = isListTargetLoose(cleanModifiedText);
453
+ const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
333
454
  const tableCellContext = initialTableCellContext;
334
455
 
335
- log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
336
-
337
- try {
338
- if (isMarkdownTable && !hasTables) {
456
+ log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
457
+
458
+ try {
459
+ if (isMarkdownTable && !hasTables) {
460
+ recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
339
461
  log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
340
- return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
341
- }
342
-
343
- if (hasTables && isMarkdownTable) {
344
- return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
345
- }
462
+ return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
463
+ }
464
+
465
+ if (hasTables && isMarkdownTable) {
466
+ recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
467
+ return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
468
+ }
346
469
  if (hasTables) {
470
+ recordRouteSelection(options, 'surgical', { tableScoped: true });
347
471
  const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
348
472
  ? tableCellContext.targetParagraph
349
473
  : null;
@@ -356,91 +480,122 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
356
480
  originalText,
357
481
  cleanModifiedText,
358
482
  serializer,
359
- author,
360
- formatHints,
361
- generateRedlines,
362
- surgicalTarget
483
+ author,
484
+ formatHints,
485
+ generateRedlines,
486
+ surgicalTarget,
487
+ {},
488
+ options
363
489
  );
364
490
 
365
- if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
366
- log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
367
- return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
368
- }
369
- return finalize(result);
491
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
492
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
493
+ return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
494
+ }
495
+ return finalize(result);
370
496
  }
371
- if (isTargetList) {
372
- log('[OxmlEngine] 🎯 Using reconciliation pipeline for list generation');
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
- });
497
+ if (isTargetList || isStructuredContent) {
498
+ const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
499
+ const useDirectListGeneration = sourceParagraphs.length === 1;
500
+ recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
501
+ sourceParagraphCount: sourceParagraphs.length
502
+ });
503
+ log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
504
+ let result;
505
+ if (useDirectListGeneration) {
506
+ const ingested = ingestOoxml(workingOoxml, { xmlDoc });
507
+ const numberingContext = detectNumberingContext(sourceParagraphs[0]);
508
+ result = await executeListGeneration({
509
+ cleanText: cleanModifiedText,
510
+ numberingContext,
511
+ originalRunModel: ingested.runModel,
512
+ originalText: ingested.acceptedText,
513
+ generateRedlines,
514
+ author,
515
+ font: options.font || null,
516
+ revisionIdAllocator,
517
+ numberingService: new NumberingService()
518
+ });
519
+ } else {
520
+ const pipeline = new ReconciliationPipeline({
521
+ author,
522
+ generateRedlines,
523
+ revisionIdAllocator
524
+ });
525
+ result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
526
+ }
527
+
528
+ if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
529
+ return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
530
+ }
531
+
532
+ if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
533
+ const includeNumbering = result.includeNumbering === true;
534
+ log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
535
+ const wrapped = wrapInDocumentFragment(result.ooxml, {
536
+ includeNumbering,
537
+ numberingXml: result.numberingXml
538
+ });
391
539
  log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
392
- return finalize({ oxml: wrapped, hasChanges: true });
393
- }
394
- return finalizeUnchanged();
395
- }
396
-
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
- }
417
- }
418
-
419
- function normalizeTargetText(text) {
420
- return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
421
- }
422
-
423
- function textSpanVisibleText(span) {
424
- const node = span?.textElement;
425
- const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
426
- if (localName === 'tab') return '\t';
427
- if (localName === 'br' || localName === 'cr') return '\n';
428
- if (localName === 'noBreakHyphen') return '\u2011';
429
- return node?.textContent || '';
430
- }
431
-
432
- /**
540
+ return finalize({
541
+ oxml: wrapped,
542
+ hasChanges: true,
543
+ ...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
544
+ });
545
+ }
546
+ return finalizeUnchanged();
547
+ }
548
+
549
+ recordRouteSelection(options, 'reconstruction');
550
+ return finalize(applyReconstructionMode(
551
+ xmlDoc,
552
+ originalText,
553
+ cleanModifiedText,
554
+ serializer,
555
+ author,
556
+ formatHints,
557
+ generateRedlines,
558
+ {},
559
+ options
560
+ ));
561
+ } catch (caught) {
562
+ if (isDiffTokenLimitError(caught)) {
563
+ return finalize({
564
+ oxml: inputOoxml,
565
+ hasChanges: false,
566
+ status: 'error',
567
+ error: { code: caught.code, message: caught.message }
568
+ });
569
+ }
570
+ throw caught;
571
+ }
572
+ }
573
+
574
+ function normalizeTargetText(text) {
575
+ return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
576
+ }
577
+
578
+ function textSpanVisibleText(span) {
579
+ const node = span?.textElement;
580
+ const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
581
+ if (localName === 'tab') return '\t';
582
+ if (localName === 'br' || localName === 'cr') return '\n';
583
+ if (localName === 'noBreakHyphen') return '\u2011';
584
+ return node?.textContent || '';
585
+ }
586
+
587
+ /**
433
588
  * Sanitizes AI response text by removing common prefixes.
434
589
  *
435
590
  * @param {string} text - AI response text
436
591
  * @returns {string}
437
592
  */
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
- }
593
+ export function sanitizeAiResponse(text) {
594
+ return String(text ?? '').replace(
595
+ /^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
596
+ ''
597
+ );
598
+ }
444
599
 
445
600
  /**
446
601
  * Parses OOXML into a DOM document.
@@ -448,9 +603,9 @@ export function sanitizeAiResponse(text) {
448
603
  * @param {string} ooxmlString - OOXML text
449
604
  * @returns {Document}
450
605
  */
451
- export function parseOoxml(ooxmlString) {
452
- return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
453
- }
606
+ export function parseOoxml(ooxmlString) {
607
+ return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
608
+ }
454
609
 
455
610
  /**
456
611
  * Serializes a DOM document to OOXML text.