@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  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/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /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,346 @@ 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'|'slice-cross-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' || existingRevisionsPolicy === 'slice-cross-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
- }
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' || existingRevisionsPolicy === 'slice-cross-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
+ });
161
+ }
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 if (existingRevisionsPolicy === 'merge-same-author') {
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
+ } else {
217
+ const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
218
+ return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
140
219
  });
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.'
220
+ if (hasMoveRevision) {
221
+ return finalize({
222
+ oxml: inputOoxml,
223
+ hasChanges: false,
224
+ status: 'error',
225
+ error: {
226
+ code: 'UNSAFE_REVISION_NESTING',
227
+ message: 'Cross-author slicing does not support pending move revisions.'
228
+ }
229
+ });
152
230
  }
153
- });
154
- }
155
- }
156
-
157
- const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
231
+ log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
232
+ }
233
+ } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
234
+ log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
235
+ const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
236
+ if (accepted.status === 'error') return finalize(accepted);
237
+ workingOoxml = accepted.oxml;
238
+ normalizedExistingRevisions = true;
239
+ const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
240
+ parseWarnings.push(...acceptedParsed.warnings);
241
+ xmlDoc = acceptedParsed.doc;
242
+ const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
243
+ if (acceptedParsed.error || acceptedParseError) {
244
+ const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
245
+ error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
246
+ return finalize({
247
+ oxml: inputOoxml,
248
+ hasChanges: false,
249
+ status: 'error',
250
+ error: {
251
+ code: 'PARSE_ERROR',
252
+ message
253
+ }
254
+ });
255
+ }
256
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
257
+ } else {
258
+ log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
259
+ return finalize({
260
+ oxml: inputOoxml,
261
+ hasChanges: false,
262
+ status: 'error',
263
+ error: {
264
+ code: 'EXISTING_REVISIONS',
265
+ message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
266
+ }
267
+ });
268
+ }
269
+ }
270
+
271
+ const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
158
272
  if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
159
273
  log('[OxmlEngine] Isolating table-cell paragraph before diff');
160
274
  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
- }
275
+ const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
276
+ ...options,
277
+ _isolatedTableCell: true
278
+ });
279
+ if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
280
+ return finalizeUnchanged();
281
+ }
282
+ return isolatedResult;
283
+ }
284
+
285
+ let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
286
+ if (sanitizedText !== modifiedText) {
287
+ operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
288
+ }
289
+ let structuredAnalysis = null;
290
+ if (options.structuredContent !== false) {
291
+ structuredAnalysis = analyzeStructuredContent(sanitizedText);
292
+ if (!structuredAnalysis.valid) {
293
+ if (options.explicitStructuredContent === true) {
294
+ const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
295
+ return finalize({
296
+ oxml: inputOoxml,
297
+ hasChanges: false,
298
+ status: 'error',
299
+ error: { code: 'STRUCTURED_CONTENT_INVALID', message },
300
+ warnings: structuredAnalysis.issues.map(issue => issue.message)
301
+ });
302
+ }
303
+ structuredAnalysis = null;
304
+ } else if (structuredAnalysis.requiresStructuredContent) {
305
+ sanitizedText = structuredAnalysis.normalizedMarkdown;
306
+ }
307
+ }
175
308
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
176
309
 
177
310
  const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
178
311
  const hasFormatHints = formatHints.length > 0;
179
312
 
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
- };
313
+ const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
314
+ const hasExistingFormatting = existingFormatHints.length > 0;
315
+ const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
316
+ const targetFound = originalText.includes('\n') || originalText.includes('\r')
317
+ ? originalText
318
+ .split(/\r?\n/)
319
+ .map(normalizeTargetText)
320
+ .filter(Boolean)
321
+ .every(line => paragraphs.some(paragraph => {
322
+ const paragraphText = textSpans
323
+ .filter(span => span.paragraph === paragraph)
324
+ .map(textSpanVisibleText)
325
+ .join('');
326
+ return normalizeTargetText(paragraphText).includes(line);
327
+ }))
328
+ : visibleText.includes(originalText.trim())
329
+ || visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
330
+ || normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
331
+ if (
332
+ hasTextChanges
333
+ && typeof originalText === 'string'
334
+ && originalText.trim()
335
+ && !targetFound
336
+ ) {
337
+ log('[OxmlEngine] Target text not found in OOXML');
338
+ return finalize({
339
+ oxml: inputOoxml,
340
+ hasChanges: false,
341
+ status: 'error',
342
+ error: {
343
+ code: 'TARGET_NOT_FOUND',
344
+ message: 'Original text was not found in the supplied OOXML.'
345
+ }
346
+ });
347
+ }
348
+ let paragraphInfos = null;
349
+ const getParagraphInfos = () => {
350
+ if (!paragraphInfos) {
351
+ paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
352
+ }
353
+ return paragraphInfos;
354
+ };
355
+ const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
356
+ const formatResult = applyFormatOnlyChangesSurgical(
357
+ xmlDoc,
358
+ originalText,
359
+ formatHints,
360
+ serializer,
361
+ author,
362
+ generateRedlines,
363
+ precomputedContext
364
+ );
365
+ if (!formatResult.useNativeApi) {
366
+ return formatResult;
367
+ }
368
+
369
+ log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
370
+ return applyReconstructionMode(
371
+ xmlDoc,
372
+ originalText,
373
+ cleanModifiedText,
374
+ serializer,
375
+ author,
376
+ formatHints,
377
+ generateRedlines
378
+ );
379
+ };
247
380
 
248
381
  log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
249
382
 
250
- const needsFormatRemoval = options.removeFormatting === true
251
- && !hasTextChanges
252
- && !hasFormatHints
253
- && hasExistingFormatting;
383
+ const needsFormatRemoval = options.removeFormatting === true
384
+ && !hasTextChanges
385
+ && !hasFormatHints
386
+ && hasExistingFormatting;
254
387
 
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
- }
388
+ if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
389
+ recordRouteSelection(options, 'noChange');
390
+ log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
391
+ return finalizeUnchanged();
392
+ }
393
+
394
+ if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
395
+ log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
396
+ return finalizeUnchanged();
397
+ }
264
398
 
265
399
  if (needsFormatRemoval) {
400
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
266
401
  log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
267
402
 
268
403
  const tableCellCtx = initialTableCellContext;
@@ -293,16 +428,17 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
293
428
  );
294
429
 
295
430
  if (tableCellCtx.hasTableWrapper && targetParagraph) {
296
- return finalize({
297
- oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
298
- hasChanges: removalResult.hasChanges
299
- });
300
- }
301
-
302
- return finalize(removalResult);
431
+ return finalize({
432
+ oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
433
+ hasChanges: removalResult.hasChanges
434
+ });
435
+ }
436
+
437
+ return finalize(removalResult);
303
438
  }
304
439
 
305
440
  if (!hasTextChanges && hasFormatHints) {
441
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
306
442
  log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
307
443
 
308
444
  const tableCellCtx = initialTableCellContext;
@@ -311,39 +447,44 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
311
447
  paragraphs,
312
448
  paragraphInfos: getParagraphInfos()
313
449
  };
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
- }
450
+ if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
451
+ log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
452
+
453
+ const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
454
+
455
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
456
+ return finalize({
457
+ oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
458
+ hasChanges: formatResult.hasChanges
459
+ });
460
+ }
328
461
 
329
- const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
462
+ return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
463
+ }
464
+
465
+ const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
330
466
  const hasTables = tables.length > 0;
331
467
  const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
332
468
  const isTargetList = isListTargetLoose(cleanModifiedText);
469
+ const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
333
470
  const tableCellContext = initialTableCellContext;
334
471
 
335
- log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
336
-
337
- try {
338
- if (isMarkdownTable && !hasTables) {
472
+ const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
473
+ log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
474
+
475
+ try {
476
+ if (isMarkdownTable && !hasTables) {
477
+ recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
339
478
  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
- }
346
- if (hasTables) {
479
+ return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
480
+ }
481
+
482
+ if (hasTables && isMarkdownTable) {
483
+ recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
484
+ return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
485
+ }
486
+ if (usesSurgicalTextMode) {
487
+ recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
347
488
  const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
348
489
  ? tableCellContext.targetParagraph
349
490
  : null;
@@ -356,91 +497,122 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
356
497
  originalText,
357
498
  cleanModifiedText,
358
499
  serializer,
359
- author,
360
- formatHints,
361
- generateRedlines,
362
- surgicalTarget
500
+ author,
501
+ formatHints,
502
+ generateRedlines,
503
+ surgicalTarget,
504
+ {},
505
+ options
363
506
  );
364
507
 
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);
508
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
509
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
510
+ return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
511
+ }
512
+ return finalize(result);
370
513
  }
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
- });
514
+ if (isTargetList || isStructuredContent) {
515
+ const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
516
+ const useDirectListGeneration = sourceParagraphs.length === 1;
517
+ recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
518
+ sourceParagraphCount: sourceParagraphs.length
519
+ });
520
+ log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
521
+ let result;
522
+ if (useDirectListGeneration) {
523
+ const ingested = ingestOoxml(workingOoxml, { xmlDoc });
524
+ const numberingContext = detectNumberingContext(sourceParagraphs[0]);
525
+ result = await executeListGeneration({
526
+ cleanText: cleanModifiedText,
527
+ numberingContext,
528
+ originalRunModel: ingested.runModel,
529
+ originalText: ingested.acceptedText,
530
+ generateRedlines,
531
+ author,
532
+ font: options.font || null,
533
+ revisionIdAllocator,
534
+ numberingService: new NumberingService()
535
+ });
536
+ } else {
537
+ const pipeline = new ReconciliationPipeline({
538
+ author,
539
+ generateRedlines,
540
+ revisionIdAllocator
541
+ });
542
+ result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
543
+ }
544
+
545
+ if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
546
+ return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
547
+ }
548
+
549
+ if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
550
+ const includeNumbering = result.includeNumbering === true;
551
+ log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
552
+ const wrapped = wrapInDocumentFragment(result.ooxml, {
553
+ includeNumbering,
554
+ numberingXml: result.numberingXml
555
+ });
391
556
  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
- /**
557
+ return finalize({
558
+ oxml: wrapped,
559
+ hasChanges: true,
560
+ ...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
561
+ });
562
+ }
563
+ return finalizeUnchanged();
564
+ }
565
+
566
+ recordRouteSelection(options, 'reconstruction');
567
+ return finalize(applyReconstructionMode(
568
+ xmlDoc,
569
+ originalText,
570
+ cleanModifiedText,
571
+ serializer,
572
+ author,
573
+ formatHints,
574
+ generateRedlines,
575
+ {},
576
+ options
577
+ ));
578
+ } catch (caught) {
579
+ if (isDiffTokenLimitError(caught)) {
580
+ return finalize({
581
+ oxml: inputOoxml,
582
+ hasChanges: false,
583
+ status: 'error',
584
+ error: { code: caught.code, message: caught.message }
585
+ });
586
+ }
587
+ throw caught;
588
+ }
589
+ }
590
+
591
+ function normalizeTargetText(text) {
592
+ return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
593
+ }
594
+
595
+ function textSpanVisibleText(span) {
596
+ const node = span?.textElement;
597
+ const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
598
+ if (localName === 'tab') return '\t';
599
+ if (localName === 'br' || localName === 'cr') return '\n';
600
+ if (localName === 'noBreakHyphen') return '\u2011';
601
+ return node?.textContent || '';
602
+ }
603
+
604
+ /**
433
605
  * Sanitizes AI response text by removing common prefixes.
434
606
  *
435
607
  * @param {string} text - AI response text
436
608
  * @returns {string}
437
609
  */
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
- }
610
+ export function sanitizeAiResponse(text) {
611
+ return String(text ?? '').replace(
612
+ /^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
613
+ ''
614
+ );
615
+ }
444
616
 
445
617
  /**
446
618
  * Parses OOXML into a DOM document.
@@ -448,9 +620,9 @@ export function sanitizeAiResponse(text) {
448
620
  * @param {string} ooxmlString - OOXML text
449
621
  * @returns {Document}
450
622
  */
451
- export function parseOoxml(ooxmlString) {
452
- return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
453
- }
623
+ export function parseOoxml(ooxmlString) {
624
+ return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
625
+ }
454
626
 
455
627
  /**
456
628
  * Serializes a DOM document to OOXML text.