@ansonlai/docx-redline-js 0.8.4 → 0.9.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 (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. package/services/table-reconciliation.js +59 -7
@@ -1,749 +1,736 @@
1
- /**
2
- * OOXML Engine V5.1 - Hybrid Mode
3
- *
4
- * Router/orchestrator for formatting/text reconciliation modes.
5
- */
6
-
7
- import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
8
- import { isListTargetLoose } from '../pipeline/list-markers.js';
9
- import { ReconciliationPipeline } from '../pipeline/pipeline.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';
19
- import { log, error } from '../adapters/logger.js';
20
- import { buildTextSpansFromParagraphs, extractFormattingFromOoxml } from './format-extraction.js';
21
- import {
22
- applyFormatRemovalAsSurgicalReplacement,
23
- applyFormatOnlyChangesSurgical
24
- } from './format-application.js';
25
- import { buildParagraphInfos, findMatchingParagraphInfo, getContainingParagraph } from './format-paragraph-targeting.js';
26
- import { detectTableCellContext, serializeParagraphOnly } from './table-cell-context.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, createWordElement, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
32
- import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
33
- import {
34
- NS_W,
35
- RevisionIdAllocator,
36
- seedRevisionIdsFromDocument
37
- } from '../core/types.js';
38
- import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml } from '../services/revision-comment-management.js';
39
- import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
40
- import { getDocumentParagraphs } from './format-extraction.js';
41
- import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
42
- import { NumberingService } from '../services/numbering-service.js';
43
- import { recordRouteSelection } from './route-selection.js';
44
- import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
45
-
46
- function getCommentIdsInOoxml(node) {
47
- const ids = new Set();
48
- for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
49
- for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
50
- const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
51
- if (id !== '') ids.add(id);
52
- }
53
- }
54
- return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
55
- }
56
-
57
- function directWordChild(node, localName) {
58
- return Array.from(node?.childNodes || []).find(child => (
59
- child?.nodeType === 1
60
- && child.namespaceURI === NS_W
61
- && child.localName === localName
62
- )) || null;
63
- }
64
-
65
- function insertedParagraphMarkMetadata(paragraph, author) {
66
- const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
67
- if (!marker) return null;
68
- const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
69
- if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
70
- return {
71
- id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
72
- author: markerAuthor,
73
- date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
74
- };
75
- }
76
-
77
- function emptyParagraphBaseline(paragraph, serializer) {
78
- const clone = paragraph.cloneNode(false);
79
- const pPr = directWordChild(paragraph, 'pPr');
80
- if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
81
- return serializer.serializeToString(clone);
82
- }
83
-
84
- function restoreInsertedParagraphMark(oxml, metadata) {
85
- if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
86
- const parsed = parseOoxmlSafe(oxml, 'text/xml');
87
- if (!parsed.doc || parsed.error) return oxml;
88
- const paragraph = parsed.doc.documentElement?.localName === 'p'
89
- ? parsed.doc.documentElement
90
- : getDocumentParagraphs(parsed.doc)[0];
91
- if (!paragraph) return oxml;
92
-
93
- let pPr = directWordChild(paragraph, 'pPr');
94
- if (!pPr) {
95
- pPr = createWordElement(parsed.doc, 'w:pPr');
96
- paragraph.insertBefore(pPr, paragraph.firstChild);
97
- }
98
- let rPr = directWordChild(pPr, 'rPr');
99
- if (!rPr) {
100
- rPr = createWordElement(parsed.doc, 'w:rPr');
101
- pPr.appendChild(rPr);
102
- }
103
- if (!directWordChild(rPr, 'ins')) {
104
- const marker = createWordElement(parsed.doc, 'w:ins');
105
- if (metadata.id) marker.setAttribute('w:id', metadata.id);
106
- marker.setAttribute('w:author', metadata.author);
107
- if (metadata.date) marker.setAttribute('w:date', metadata.date);
108
- rPr.appendChild(marker);
109
- }
110
- return serializeXml(parsed.doc);
111
- }
112
-
113
- /**
114
- * Applies redline track changes to OOXML by modifying the DOM in-place.
115
- *
116
- * @param {string} oxml - Original OOXML string
117
- * @param {string} originalText - Original plain text
118
- * @param {string} modifiedText - New text (may contain markdown)
119
- * @param {Object} [options={}] - Options
120
- * @param {string} [options.author='AI'] - Author for track changes
121
- * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
122
- * @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
123
- * @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
124
- * @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
125
- * @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
126
- */
127
- export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
128
- const inputOoxml = oxml;
129
- let workingOoxml = oxml;
130
- originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
131
- modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
132
- const generateRedlines = options.generateRedlines ?? true;
133
- const author = options.author || getDefaultAuthor();
134
- const serializer = createSerializer();
135
- let parseWarnings = [];
136
- const operationWarnings = [];
137
- let normalizedExistingRevisions = false;
138
- let preservedInsertedParagraphMark = null;
139
- const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
140
- const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
141
- const finalize = result => {
142
- const withStatus = { ...result };
143
- if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
144
- if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
145
- withStatus.oxml = workingOoxml;
146
- withStatus.hasChanges = true;
147
- withStatus.warnings = [
148
- ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
149
- 'Previous revisions by the same author were reverted to baseline.'
150
- ];
151
- } else if (keepNormalizedNoOp) {
152
- withStatus.oxml = workingOoxml;
153
- withStatus.hasChanges = true;
154
- withStatus.warnings = [
155
- ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
156
- 'Existing revisions were accepted before redlining.'
157
- ];
158
- } else {
159
- withStatus.oxml = inputOoxml;
160
- }
161
- }
162
- const warnings = [...parseWarnings, ...operationWarnings, ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : [])];
163
- if (warnings.length > 0) {
164
- withStatus.warnings = [...new Set(warnings)];
165
- }
166
- if (!withStatus.status) {
167
- withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
168
- }
169
- if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
170
- withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
171
- }
172
- return withOoxmlSourceType(withStatus);
173
- };
174
- const finalizeUnchanged = () => {
175
- if (normalizedExistingRevisions && keepNormalizedNoOp) {
176
- return finalize({
177
- oxml: workingOoxml,
178
- hasChanges: true,
179
- warnings: ['Existing revisions were accepted before redlining.']
180
- });
181
- }
182
- return finalize({ oxml: inputOoxml, hasChanges: false });
183
- };
184
-
185
- const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
186
- parseWarnings = parsed.warnings;
187
- let xmlDoc = parsed.doc;
188
-
189
- const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
190
- if (parsed.error || parseError) {
191
- const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
192
- error('[OxmlEngine] XML parse error:', message);
193
- return finalize({
194
- oxml: inputOoxml,
195
- hasChanges: false,
196
- status: 'error',
197
- error: { code: 'PARSE_ERROR', message }
198
- });
199
- }
200
- const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
201
- ? options._revisionIdAllocator
202
- : new RevisionIdAllocator();
203
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
204
-
205
- const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
206
- ? [xmlDoc.documentElement]
207
- : getDocumentParagraphs(xmlDoc);
208
- if (inputParagraphs.length === 1 && modifiedText.length > 0) {
209
- const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
210
- if (resurrectionTarget.matches) {
211
- const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
212
- return finalize({
213
- oxml: inputOoxml,
214
- hasChanges: false,
215
- status: 'error',
216
- error: {
217
- code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
218
- message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
219
- ownerAuthor
220
- }
221
- });
222
- }
223
- }
224
-
225
- if (containsTrackedChanges(xmlDoc)) {
226
- if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
227
- const authors = getTrackedChangeAuthors(xmlDoc);
228
- const currentAuthor = String(author || '').trim().toLowerCase();
229
- const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
230
- if (isSameAuthor) {
231
- const commentIds = getCommentIdsInOoxml(xmlDoc);
232
- if (commentIds.length > 0) {
233
- return finalize({
234
- oxml: inputOoxml,
235
- hasChanges: false,
236
- status: 'error',
237
- error: {
238
- code: 'COMMENTED_CONTENT_MERGE',
239
- message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
240
- commentIds
241
- }
242
- });
243
- }
244
- log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
245
- const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
246
- preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
247
- const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
248
- if (rejected.status === 'error') return finalize(rejected);
249
- workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
250
- ? emptyParagraphBaseline(soleParagraph, serializer)
251
- : rejected.oxml;
252
- normalizedExistingRevisions = true;
253
- const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
254
- parseWarnings.push(...rejectedParsed.warnings);
255
- xmlDoc = rejectedParsed.doc;
256
- const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
257
- if (rejectedParsed.error || rejectedParseError) {
258
- const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
259
- error('[OxmlEngine] XML parse error after rejecting same-author revisions:', message);
260
- return finalize({
261
- oxml: inputOoxml,
262
- hasChanges: false,
263
- status: 'error',
264
- error: {
265
- code: 'PARSE_ERROR',
266
- message
267
- }
268
- });
269
- }
270
- if (containsTrackedChanges(xmlDoc)) {
271
- return finalize({
272
- oxml: inputOoxml,
273
- hasChanges: false,
274
- status: 'error',
275
- error: {
276
- code: 'UNSAFE_REVISION_NESTING',
277
- message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
278
- }
279
- });
280
- }
281
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
282
-
283
- // Re-derive baseline text from the restored baseline OOXML:
284
- const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
285
- ? [xmlDoc.documentElement]
286
- : getDocumentParagraphs(xmlDoc);
287
- const baselineText = paragraphsInDoc.length > 0
288
- ? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
289
- : '';
290
- originalText = baselineText;
291
- } else if (existingRevisionsPolicy === 'merge-same-author') {
292
- log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
293
- return finalize({
294
- oxml: inputOoxml,
295
- hasChanges: false,
296
- status: 'error',
297
- error: {
298
- code: 'EXISTING_REVISIONS',
299
- message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
300
- revisionAuthors: authors,
301
- currentPolicy: existingRevisionsPolicy
302
- }
303
- });
304
- } else {
305
- const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
306
- return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
307
- });
308
- if (hasMoveRevision) {
309
- return finalize({
310
- oxml: inputOoxml,
311
- hasChanges: false,
312
- status: 'error',
313
- error: {
314
- code: 'UNSAFE_REVISION_NESTING',
315
- message: 'Cross-author slicing does not support pending move revisions.'
316
- }
317
- });
318
- }
319
- log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
320
- }
321
- } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
322
- log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
323
- const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
324
- if (accepted.status === 'error') return finalize(accepted);
325
- workingOoxml = accepted.oxml;
326
- normalizedExistingRevisions = true;
327
- const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
328
- parseWarnings.push(...acceptedParsed.warnings);
329
- xmlDoc = acceptedParsed.doc;
330
- const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
331
- if (acceptedParsed.error || acceptedParseError) {
332
- const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
333
- error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
334
- return finalize({
335
- oxml: inputOoxml,
336
- hasChanges: false,
337
- status: 'error',
338
- error: {
339
- code: 'PARSE_ERROR',
340
- message
341
- }
342
- });
343
- }
344
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
345
- } else {
346
- log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
347
- return finalize({
348
- oxml: inputOoxml,
349
- hasChanges: false,
350
- status: 'error',
351
- error: {
352
- code: 'EXISTING_REVISIONS',
353
- message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
354
- }
355
- });
356
- }
357
- }
358
-
359
- const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
360
- if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
361
- log('[OxmlEngine] Isolating table-cell paragraph before diff');
362
- const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
363
- const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
364
- ...options,
365
- _isolatedTableCell: true
366
- });
367
- if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
368
- return finalizeUnchanged();
369
- }
370
- return isolatedResult;
371
- }
372
-
373
- let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
374
- if (sanitizedText !== modifiedText) {
375
- operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
376
- }
377
- let structuredAnalysis = null;
378
- if (options.structuredContent !== false && options.literalTextEdit !== true) {
379
- structuredAnalysis = analyzeStructuredContent(sanitizedText);
380
- if (!structuredAnalysis.valid) {
381
- if (options.explicitStructuredContent === true) {
382
- const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
383
- return finalize({
384
- oxml: inputOoxml,
385
- hasChanges: false,
386
- status: 'error',
387
- error: { code: 'STRUCTURED_CONTENT_INVALID', message },
388
- warnings: structuredAnalysis.issues.map(issue => issue.message)
389
- });
390
- }
391
- structuredAnalysis = null;
392
- } else if (structuredAnalysis.requiresStructuredContent) {
393
- sanitizedText = structuredAnalysis.normalizedMarkdown;
394
- }
395
- }
396
- const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
397
-
398
- const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
399
- const hasLiteralTextChanges = existingRevisionsPolicy === 'slice-cross-author'
400
- ? cleanModifiedText !== originalText
401
- : cleanModifiedText.trim() !== originalText.trim();
402
- // Markdown list markers can equal manually typed ones ("A. ..." lines), so
403
- // an explicit structured-content request that turns plain paragraphs or
404
- // soft-break lines into a list is a change even when the text is identical.
405
- const hasListStructureChange = !hasLiteralTextChanges
406
- && options.explicitStructuredContent === true
407
- && isListTargetLoose(cleanModifiedText)
408
- && !sourceMatchesListShape(paragraphs, cleanModifiedText);
409
- const hasTextChanges = hasLiteralTextChanges || hasListStructureChange;
410
- const hasFormatHints = formatHints.length > 0;
411
- const hasExistingFormatting = existingFormatHints.length > 0;
412
- // Formatting spans carry only w:t text. Target matching also needs w:br,
413
- // w:cr and w:tab so host text with soft line breaks (Word's Paragraph.text
414
- // reports them as "\v") still matches its source paragraph.
415
- const { textSpans: targetSpans } = buildTextSpansFromParagraphs(paragraphs);
416
- const visibleText = targetSpans.map(span => textSpanVisibleText(span)).join('');
417
- const targetFound = originalText.includes('\n') || originalText.includes('\r')
418
- ? originalText
419
- .split(/\r?\n/)
420
- .map(normalizeTargetText)
421
- .filter(Boolean)
422
- .every(line => paragraphs.some(paragraph => {
423
- const paragraphText = targetSpans
424
- .filter(span => span.paragraph === paragraph)
425
- .map(textSpanVisibleText)
426
- .join('');
427
- return normalizeTargetText(paragraphText).includes(line);
428
- }))
429
- : visibleText.includes(originalText.trim())
430
- || visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
431
- || normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
432
- if (
433
- hasTextChanges
434
- && typeof originalText === 'string'
435
- && originalText.trim()
436
- && !targetFound
437
- ) {
438
- log('[OxmlEngine] Target text not found in OOXML');
439
- return finalize({
440
- oxml: inputOoxml,
441
- hasChanges: false,
442
- status: 'error',
443
- error: {
444
- code: 'TARGET_NOT_FOUND',
445
- message: 'Original text was not found in the supplied OOXML.'
446
- }
447
- });
448
- }
449
- let paragraphInfos = null;
450
- const getParagraphInfos = () => {
451
- if (!paragraphInfos) {
452
- paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
453
- }
454
- return paragraphInfos;
455
- };
456
- const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
457
- const formatResult = applyFormatOnlyChangesSurgical(
458
- xmlDoc,
459
- originalText,
460
- formatHints,
461
- serializer,
462
- author,
463
- generateRedlines,
464
- precomputedContext
465
- );
466
- if (!formatResult.useNativeApi) {
467
- return formatResult;
468
- }
469
-
470
- log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
471
- return applyReconstructionMode(
472
- xmlDoc,
473
- originalText,
474
- cleanModifiedText,
475
- serializer,
476
- author,
477
- formatHints,
478
- generateRedlines
479
- );
480
- };
481
-
482
- log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
483
-
484
- const needsFormatRemoval = options.removeFormatting === true
485
- && !hasTextChanges
486
- && !hasFormatHints
487
- && hasExistingFormatting;
488
-
489
- if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
490
- recordRouteSelection(options, 'noChange');
491
- log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
492
- return finalizeUnchanged();
493
- }
494
-
495
- if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
496
- log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
497
- return finalizeUnchanged();
498
- }
499
-
500
- if (needsFormatRemoval) {
501
- recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
502
- log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
503
-
504
- const tableCellCtx = initialTableCellContext;
505
- let targetParagraph = tableCellCtx.targetParagraph || null;
506
-
507
- if (!targetParagraph) {
508
- const matchedInfo = findMatchingParagraphInfo(getParagraphInfos(), originalText);
509
- if (matchedInfo) {
510
- targetParagraph = matchedInfo.paragraph;
511
- }
512
- }
513
-
514
- let filteredHints = existingFormatHints;
515
- if (targetParagraph) {
516
- filteredHints = existingFormatHints.filter(hint => {
517
- const hintParagraph = getContainingParagraph(hint.run);
518
- return hintParagraph === targetParagraph;
519
- });
520
- }
521
-
522
- const removalResult = applyFormatRemovalAsSurgicalReplacement(
523
- xmlDoc,
524
- textSpans,
525
- filteredHints,
526
- serializer,
527
- author,
528
- generateRedlines
529
- );
530
-
531
- if (tableCellCtx.hasTableWrapper && targetParagraph) {
532
- return finalize({
533
- oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
534
- hasChanges: removalResult.hasChanges
535
- });
536
- }
537
-
538
- return finalize(removalResult);
539
- }
540
-
541
- if (!hasTextChanges && hasFormatHints) {
542
- recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
543
- log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
544
-
545
- const tableCellCtx = initialTableCellContext;
546
- const precomputedFormatContext = {
547
- textSpans,
548
- paragraphs,
549
- paragraphInfos: getParagraphInfos()
550
- };
551
- if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
552
- log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
553
-
554
- const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
555
-
556
- log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
557
- return finalize({
558
- oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
559
- hasChanges: formatResult.hasChanges
560
- });
561
- }
562
-
563
- return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
564
- }
565
-
566
- const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
567
- const hasTables = tables.length > 0;
568
- const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
569
- // Localized replacements are literal text edits: marker-like lines ("A. ..." separated by
570
- // soft breaks) must not be re-interpreted as a request to build a numbered list.
571
- const isTargetList = options.literalTextEdit !== true && isListTargetLoose(cleanModifiedText);
572
- const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
573
- const tableCellContext = initialTableCellContext;
574
-
575
- const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
576
- log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
577
-
578
- try {
579
- if (isMarkdownTable && !hasTables) {
580
- recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
581
- log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
582
- return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
583
- }
584
-
585
- if (hasTables && isMarkdownTable) {
586
- recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
587
- return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
588
- }
589
- if (usesSurgicalTextMode) {
590
- recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
591
- const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
592
- ? tableCellContext.targetParagraph
593
- : null;
594
- if (surgicalTarget) {
595
- log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
596
- }
597
-
598
- const result = applySurgicalMode(
599
- xmlDoc,
600
- originalText,
601
- cleanModifiedText,
602
- serializer,
603
- author,
604
- formatHints,
605
- generateRedlines,
606
- surgicalTarget,
607
- {},
608
- options
609
- );
610
-
611
- if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
612
- return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
613
- }
614
-
615
- if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
616
- log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
617
- return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
618
- }
619
- return finalize(result);
620
- }
621
- if (isTargetList || isStructuredContent) {
622
- const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
623
- const useDirectListGeneration = sourceParagraphs.length === 1;
624
- recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
625
- sourceParagraphCount: sourceParagraphs.length
626
- });
627
- log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
628
- let result;
629
- if (useDirectListGeneration) {
630
- const ingested = ingestOoxml(workingOoxml, { xmlDoc });
631
- const numberingContext = detectNumberingContext(sourceParagraphs[0]);
632
- result = await executeListGeneration({
633
- cleanText: cleanModifiedText,
634
- numberingContext,
635
- originalRunModel: ingested.runModel,
636
- originalText: ingested.acceptedText,
637
- generateRedlines,
638
- author,
639
- font: options.font || null,
640
- revisionIdAllocator,
641
- numberingService: new NumberingService()
642
- });
643
- } else {
644
- const pipeline = new ReconciliationPipeline({
645
- author,
646
- generateRedlines,
647
- revisionIdAllocator
648
- });
649
- result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
650
- }
651
-
652
- if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
653
- return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
654
- }
655
-
656
- if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
657
- const includeNumbering = result.includeNumbering === true;
658
- log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
659
- const wrapped = wrapInDocumentFragment(result.ooxml, {
660
- includeNumbering,
661
- numberingXml: result.numberingXml
662
- });
663
- log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
664
- return finalize({
665
- oxml: wrapped,
666
- hasChanges: true,
667
- ...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
668
- });
669
- }
670
- return finalizeUnchanged();
671
- }
672
-
673
- recordRouteSelection(options, 'reconstruction');
674
- return finalize(applyReconstructionMode(
675
- xmlDoc,
676
- originalText,
677
- cleanModifiedText,
678
- serializer,
679
- author,
680
- formatHints,
681
- generateRedlines,
682
- {},
683
- options
684
- ));
685
- } catch (caught) {
686
- if (isDiffTokenLimitError(caught)) {
687
- return finalize({
688
- oxml: inputOoxml,
689
- hasChanges: false,
690
- status: 'error',
691
- error: { code: caught.code, message: caught.message }
692
- });
693
- }
694
- throw caught;
695
- }
696
- }
697
-
698
- function sourceMatchesListShape(paragraphs, listText) {
699
- const itemCount = String(listText).split(/\r?\n/).filter(line => line.trim()).length;
700
- return paragraphs.length === itemCount && paragraphs.every(paragraph => (
701
- getElementsByTagNSOrTag(paragraph, NS_W, 'numPr').length > 0
702
- ));
703
- }
704
-
705
- function normalizeTargetText(text) {
706
- return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
707
- }
708
-
709
- function textSpanVisibleText(span) {
710
- const node = span?.textElement;
711
- const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
712
- if (localName === 'tab') return '\t';
713
- if (localName === 'br' || localName === 'cr') return '\n';
714
- if (localName === 'noBreakHyphen') return '\u2011';
715
- return node?.textContent || '';
716
- }
717
-
718
- /**
719
- * Sanitizes AI response text by removing common prefixes.
720
- *
721
- * @param {string} text - AI response text
722
- * @returns {string}
723
- */
724
- export function sanitizeAiResponse(text) {
725
- return String(text ?? '').replace(
726
- /^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
727
- ''
728
- );
729
- }
730
-
731
- /**
732
- * Parses OOXML into a DOM document.
733
- *
734
- * @param {string} ooxmlString - OOXML text
735
- * @returns {Document}
736
- */
737
- export function parseOoxml(ooxmlString) {
738
- return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
739
- }
740
-
741
- /**
742
- * Serializes a DOM document to OOXML text.
743
- *
744
- * @param {Node} doc - XML document/node
745
- * @returns {string}
746
- */
747
- export function serializeOoxml(doc) {
748
- return serializeXml(doc);
749
- }
1
+ /**
2
+ * OOXML Engine V5.1 - Hybrid Mode
3
+ *
4
+ * Router/orchestrator for formatting/text reconciliation modes.
5
+ */
6
+
7
+ import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
8
+ import { isListTargetLoose } from '../pipeline/list-markers.js';
9
+ import { ReconciliationPipeline } from '../pipeline/pipeline.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';
19
+ import { log, error } from '../adapters/logger.js';
20
+ import { buildTextSpansFromParagraphs, extractFormattingFromOoxml } from './format-extraction.js';
21
+ import {
22
+ applyFormatRemovalAsSurgicalReplacement,
23
+ applyFormatOnlyChangesSurgical
24
+ } from './format-application.js';
25
+ import { buildParagraphInfos, findMatchingParagraphInfo, getContainingParagraph } from './format-paragraph-targeting.js';
26
+ import { detectTableCellContext, serializeParagraphOnly } from './table-cell-context.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 { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
33
+ import {
34
+ NS_W,
35
+ RevisionIdAllocator,
36
+ seedRevisionIdsFromDocument
37
+ } from '../core/types.js';
38
+ import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml } from '../services/revision-comment-management.js';
39
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
40
+ import { getCurrentParagraphProperties } from '../core/current-properties.js';
41
+ import { getDocumentParagraphs } from './format-extraction.js';
42
+ import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
43
+ import { NumberingService } from '../services/numbering-service.js';
44
+ import { recordRouteSelection } from './route-selection.js';
45
+ import { restoreParagraphMarkInserted } from './paragraph-mark-revisions.js';
46
+ import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
47
+
48
+ function getCommentIdsInOoxml(node) {
49
+ const ids = new Set();
50
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
51
+ for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
52
+ const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
53
+ if (id !== '') ids.add(id);
54
+ }
55
+ }
56
+ return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
57
+ }
58
+
59
+ function directWordChild(node, localName) {
60
+ return Array.from(node?.childNodes || []).find(child => (
61
+ child?.nodeType === 1
62
+ && child.namespaceURI === NS_W
63
+ && child.localName === localName
64
+ )) || null;
65
+ }
66
+
67
+ function insertedParagraphMarkMetadata(paragraph, author) {
68
+ const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
69
+ if (!marker) return null;
70
+ const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
71
+ if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
72
+ return {
73
+ id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
74
+ author: markerAuthor,
75
+ date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
76
+ };
77
+ }
78
+
79
+ function emptyParagraphBaseline(paragraph, serializer) {
80
+ const clone = paragraph.cloneNode(false);
81
+ const pPr = directWordChild(paragraph, 'pPr');
82
+ if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
83
+ return serializer.serializeToString(clone);
84
+ }
85
+
86
+ function restoreInsertedParagraphMark(oxml, metadata) {
87
+ if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
88
+ const parsed = parseOoxmlSafe(oxml, 'text/xml');
89
+ if (!parsed.doc || parsed.error) return oxml;
90
+ const paragraph = parsed.doc.documentElement?.localName === 'p'
91
+ ? parsed.doc.documentElement
92
+ : getDocumentParagraphs(parsed.doc)[0];
93
+ if (!paragraph) return oxml;
94
+
95
+ restoreParagraphMarkInserted(paragraph, metadata);
96
+ return serializeXml(parsed.doc);
97
+ }
98
+
99
+ /**
100
+ * Applies redline track changes to OOXML by modifying the DOM in-place.
101
+ *
102
+ * @param {string} oxml - Original OOXML string
103
+ * @param {string} originalText - Original plain text
104
+ * @param {string} modifiedText - New text (may contain markdown)
105
+ * @param {Object} [options={}] - Options
106
+ * @param {string} [options.author='AI'] - Author for track changes
107
+ * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
108
+ * @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
109
+ * @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
110
+ * @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
111
+ * @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
112
+ */
113
+ export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
114
+ const inputOoxml = oxml;
115
+ let workingOoxml = oxml;
116
+ originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
117
+ modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
118
+ const generateRedlines = options.generateRedlines ?? true;
119
+ const author = options.author || getDefaultAuthor();
120
+ const serializer = createSerializer();
121
+ let parseWarnings = [];
122
+ const operationWarnings = [];
123
+ let normalizedExistingRevisions = false;
124
+ let preservedInsertedParagraphMark = null;
125
+ const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
126
+ const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
127
+ const finalize = result => {
128
+ const withStatus = { ...result };
129
+ if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
130
+ if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
131
+ withStatus.oxml = workingOoxml;
132
+ withStatus.hasChanges = true;
133
+ withStatus.warnings = [
134
+ ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
135
+ 'Previous revisions by the same author were reverted to baseline.'
136
+ ];
137
+ } else if (keepNormalizedNoOp) {
138
+ withStatus.oxml = workingOoxml;
139
+ withStatus.hasChanges = true;
140
+ withStatus.warnings = [
141
+ ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
142
+ 'Existing revisions were accepted before redlining.'
143
+ ];
144
+ } else {
145
+ withStatus.oxml = inputOoxml;
146
+ }
147
+ }
148
+ const warnings = [...parseWarnings, ...operationWarnings, ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : [])];
149
+ if (warnings.length > 0) {
150
+ withStatus.warnings = [...new Set(warnings)];
151
+ }
152
+ if (!withStatus.status) {
153
+ withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
154
+ }
155
+ if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
156
+ withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
157
+ }
158
+ return withOoxmlSourceType(withStatus);
159
+ };
160
+ const finalizeUnchanged = () => {
161
+ if (normalizedExistingRevisions && keepNormalizedNoOp) {
162
+ return finalize({
163
+ oxml: workingOoxml,
164
+ hasChanges: true,
165
+ warnings: ['Existing revisions were accepted before redlining.']
166
+ });
167
+ }
168
+ return finalize({ oxml: inputOoxml, hasChanges: false });
169
+ };
170
+
171
+ const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
172
+ parseWarnings = parsed.warnings;
173
+ let xmlDoc = parsed.doc;
174
+
175
+ const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
176
+ if (parsed.error || parseError) {
177
+ const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
178
+ error('[OxmlEngine] XML parse error:', message);
179
+ return finalize({
180
+ oxml: inputOoxml,
181
+ hasChanges: false,
182
+ status: 'error',
183
+ error: { code: 'PARSE_ERROR', message }
184
+ });
185
+ }
186
+ const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
187
+ ? options._revisionIdAllocator
188
+ : new RevisionIdAllocator();
189
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
190
+
191
+ const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
192
+ ? [xmlDoc.documentElement]
193
+ : getDocumentParagraphs(xmlDoc);
194
+ if (inputParagraphs.length === 1 && modifiedText.length > 0) {
195
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
196
+ if (resurrectionTarget.matches) {
197
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
198
+ return finalize({
199
+ oxml: inputOoxml,
200
+ hasChanges: false,
201
+ status: 'error',
202
+ error: {
203
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
204
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
205
+ ownerAuthor
206
+ }
207
+ });
208
+ }
209
+ }
210
+
211
+ if (containsTrackedChanges(xmlDoc)) {
212
+ if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
213
+ const authors = getTrackedChangeAuthors(xmlDoc);
214
+ const currentAuthor = String(author || '').trim().toLowerCase();
215
+ const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
216
+ if (isSameAuthor) {
217
+ const commentIds = getCommentIdsInOoxml(xmlDoc);
218
+ if (commentIds.length > 0) {
219
+ return finalize({
220
+ oxml: inputOoxml,
221
+ hasChanges: false,
222
+ status: 'error',
223
+ error: {
224
+ code: 'COMMENTED_CONTENT_MERGE',
225
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
226
+ commentIds
227
+ }
228
+ });
229
+ }
230
+ log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
231
+ const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
232
+ preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
233
+ const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
234
+ if (rejected.status === 'error') return finalize(rejected);
235
+ workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
236
+ ? emptyParagraphBaseline(soleParagraph, serializer)
237
+ : rejected.oxml;
238
+ normalizedExistingRevisions = true;
239
+ const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
240
+ parseWarnings.push(...rejectedParsed.warnings);
241
+ xmlDoc = rejectedParsed.doc;
242
+ const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
243
+ if (rejectedParsed.error || rejectedParseError) {
244
+ const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
245
+ error('[OxmlEngine] XML parse error after rejecting same-author 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
+ if (containsTrackedChanges(xmlDoc)) {
257
+ return finalize({
258
+ oxml: inputOoxml,
259
+ hasChanges: false,
260
+ status: 'error',
261
+ error: {
262
+ code: 'UNSAFE_REVISION_NESTING',
263
+ message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
264
+ }
265
+ });
266
+ }
267
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
268
+
269
+ // Re-derive baseline text from the restored baseline OOXML:
270
+ const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
271
+ ? [xmlDoc.documentElement]
272
+ : getDocumentParagraphs(xmlDoc);
273
+ const baselineText = paragraphsInDoc.length > 0
274
+ ? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
275
+ : '';
276
+ originalText = baselineText;
277
+ } else if (existingRevisionsPolicy === 'merge-same-author') {
278
+ log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
279
+ return finalize({
280
+ oxml: inputOoxml,
281
+ hasChanges: false,
282
+ status: 'error',
283
+ error: {
284
+ code: 'EXISTING_REVISIONS',
285
+ message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
286
+ revisionAuthors: authors,
287
+ currentPolicy: existingRevisionsPolicy
288
+ }
289
+ });
290
+ } else {
291
+ const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
292
+ return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
293
+ });
294
+ if (hasMoveRevision) {
295
+ return finalize({
296
+ oxml: inputOoxml,
297
+ hasChanges: false,
298
+ status: 'error',
299
+ error: {
300
+ code: 'UNSAFE_REVISION_NESTING',
301
+ message: 'Cross-author slicing does not support pending move revisions.'
302
+ }
303
+ });
304
+ }
305
+ log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
306
+ }
307
+ } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
308
+ log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
309
+ const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
310
+ if (accepted.status === 'error') return finalize(accepted);
311
+ workingOoxml = accepted.oxml;
312
+ normalizedExistingRevisions = true;
313
+ const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
314
+ parseWarnings.push(...acceptedParsed.warnings);
315
+ xmlDoc = acceptedParsed.doc;
316
+ const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
317
+ if (acceptedParsed.error || acceptedParseError) {
318
+ const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
319
+ error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
320
+ return finalize({
321
+ oxml: inputOoxml,
322
+ hasChanges: false,
323
+ status: 'error',
324
+ error: {
325
+ code: 'PARSE_ERROR',
326
+ message
327
+ }
328
+ });
329
+ }
330
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
331
+ } else {
332
+ log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
333
+ return finalize({
334
+ oxml: inputOoxml,
335
+ hasChanges: false,
336
+ status: 'error',
337
+ error: {
338
+ code: 'EXISTING_REVISIONS',
339
+ message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
340
+ }
341
+ });
342
+ }
343
+ }
344
+
345
+ const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
346
+ if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
347
+ log('[OxmlEngine] Isolating table-cell paragraph before diff');
348
+ const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
349
+ const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
350
+ ...options,
351
+ _isolatedTableCell: true
352
+ });
353
+ if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
354
+ return finalizeUnchanged();
355
+ }
356
+ return isolatedResult;
357
+ }
358
+
359
+ let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
360
+ if (sanitizedText !== modifiedText) {
361
+ operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
362
+ }
363
+ let structuredAnalysis = null;
364
+ if (options.structuredContent !== false && options.literalTextEdit !== true) {
365
+ structuredAnalysis = analyzeStructuredContent(sanitizedText);
366
+ if (!structuredAnalysis.valid) {
367
+ if (options.explicitStructuredContent === true) {
368
+ const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
369
+ return finalize({
370
+ oxml: inputOoxml,
371
+ hasChanges: false,
372
+ status: 'error',
373
+ error: { code: 'STRUCTURED_CONTENT_INVALID', message },
374
+ warnings: structuredAnalysis.issues.map(issue => issue.message)
375
+ });
376
+ }
377
+ structuredAnalysis = null;
378
+ } else if (structuredAnalysis.requiresStructuredContent) {
379
+ sanitizedText = structuredAnalysis.normalizedMarkdown;
380
+ }
381
+ }
382
+ const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
383
+
384
+ const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
385
+ const hasLiteralTextChanges = existingRevisionsPolicy === 'slice-cross-author'
386
+ ? cleanModifiedText !== originalText
387
+ : cleanModifiedText.trim() !== originalText.trim();
388
+ // Markdown list markers can equal manually typed ones ("A. ..." lines), so
389
+ // an explicit structured-content request that turns plain paragraphs or
390
+ // soft-break lines into a list is a change even when the text is identical.
391
+ const hasListStructureChange = !hasLiteralTextChanges
392
+ && options.explicitStructuredContent === true
393
+ && isListTargetLoose(cleanModifiedText)
394
+ && !sourceMatchesListShape(paragraphs, cleanModifiedText);
395
+ const hasTextChanges = hasLiteralTextChanges || hasListStructureChange;
396
+ const hasFormatHints = formatHints.length > 0;
397
+ const hasExistingFormatting = existingFormatHints.length > 0;
398
+ // Formatting spans carry only w:t text. Target matching also needs w:br,
399
+ // w:cr and w:tab so host text with soft line breaks (Word's Paragraph.text
400
+ // reports them as "\v") still matches its source paragraph.
401
+ const { textSpans: targetSpans } = buildTextSpansFromParagraphs(paragraphs);
402
+ const visibleText = targetSpans.map(span => textSpanVisibleText(span)).join('');
403
+ const targetFound = originalText.includes('\n') || originalText.includes('\r')
404
+ ? originalText
405
+ .split(/\r?\n/)
406
+ .map(normalizeTargetText)
407
+ .filter(Boolean)
408
+ .every(line => paragraphs.some(paragraph => {
409
+ const paragraphText = targetSpans
410
+ .filter(span => span.paragraph === paragraph)
411
+ .map(textSpanVisibleText)
412
+ .join('');
413
+ return normalizeTargetText(paragraphText).includes(line);
414
+ }))
415
+ : visibleText.includes(originalText.trim())
416
+ || visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
417
+ || normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
418
+ if (
419
+ hasTextChanges
420
+ && typeof originalText === 'string'
421
+ && originalText.trim()
422
+ && !targetFound
423
+ ) {
424
+ log('[OxmlEngine] Target text not found in OOXML');
425
+ return finalize({
426
+ oxml: inputOoxml,
427
+ hasChanges: false,
428
+ status: 'error',
429
+ error: {
430
+ code: 'TARGET_NOT_FOUND',
431
+ message: 'Original text was not found in the supplied OOXML.'
432
+ }
433
+ });
434
+ }
435
+ let paragraphInfos = null;
436
+ const getParagraphInfos = () => {
437
+ if (!paragraphInfos) {
438
+ paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
439
+ }
440
+ return paragraphInfos;
441
+ };
442
+ const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
443
+ const formatResult = applyFormatOnlyChangesSurgical(
444
+ xmlDoc,
445
+ originalText,
446
+ formatHints,
447
+ serializer,
448
+ author,
449
+ generateRedlines,
450
+ precomputedContext
451
+ );
452
+ if (!formatResult.useNativeApi) {
453
+ return formatResult;
454
+ }
455
+
456
+ log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
457
+ return applyReconstructionMode(
458
+ xmlDoc,
459
+ originalText,
460
+ cleanModifiedText,
461
+ serializer,
462
+ author,
463
+ formatHints,
464
+ generateRedlines
465
+ );
466
+ };
467
+
468
+ log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
469
+
470
+ const needsFormatRemoval = options.removeFormatting === true
471
+ && !hasTextChanges
472
+ && !hasFormatHints
473
+ && hasExistingFormatting;
474
+
475
+ if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
476
+ recordRouteSelection(options, 'noChange');
477
+ log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
478
+ return finalizeUnchanged();
479
+ }
480
+
481
+ if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
482
+ log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
483
+ return finalizeUnchanged();
484
+ }
485
+
486
+ if (needsFormatRemoval) {
487
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
488
+ log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
489
+
490
+ const tableCellCtx = initialTableCellContext;
491
+ let targetParagraph = tableCellCtx.targetParagraph || null;
492
+
493
+ if (!targetParagraph) {
494
+ const matchedInfo = findMatchingParagraphInfo(getParagraphInfos(), originalText);
495
+ if (matchedInfo) {
496
+ targetParagraph = matchedInfo.paragraph;
497
+ }
498
+ }
499
+
500
+ let filteredHints = existingFormatHints;
501
+ if (targetParagraph) {
502
+ filteredHints = existingFormatHints.filter(hint => {
503
+ const hintParagraph = getContainingParagraph(hint.run);
504
+ return hintParagraph === targetParagraph;
505
+ });
506
+ }
507
+
508
+ const removalResult = applyFormatRemovalAsSurgicalReplacement(
509
+ xmlDoc,
510
+ textSpans,
511
+ filteredHints,
512
+ serializer,
513
+ author,
514
+ generateRedlines
515
+ );
516
+
517
+ if (tableCellCtx.hasTableWrapper && targetParagraph) {
518
+ return finalize({
519
+ oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
520
+ hasChanges: removalResult.hasChanges
521
+ });
522
+ }
523
+
524
+ return finalize(removalResult);
525
+ }
526
+
527
+ if (!hasTextChanges && hasFormatHints) {
528
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
529
+ log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
530
+
531
+ const tableCellCtx = initialTableCellContext;
532
+ const precomputedFormatContext = {
533
+ textSpans,
534
+ paragraphs,
535
+ paragraphInfos: getParagraphInfos()
536
+ };
537
+ if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
538
+ log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
539
+
540
+ const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
541
+
542
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
543
+ return finalize({
544
+ oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
545
+ hasChanges: formatResult.hasChanges
546
+ });
547
+ }
548
+
549
+ return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
550
+ }
551
+
552
+ const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
553
+ const hasTables = tables.length > 0;
554
+ const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
555
+ // Localized replacements are literal text edits: marker-like lines ("A. ..." separated by
556
+ // soft breaks) must not be re-interpreted as a request to build a numbered list.
557
+ const isTargetList = options.literalTextEdit !== true && isListTargetLoose(cleanModifiedText);
558
+ const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
559
+ const tableCellContext = initialTableCellContext;
560
+
561
+ const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
562
+ log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
563
+
564
+ try {
565
+ if (isMarkdownTable && !hasTables) {
566
+ recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
567
+ log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
568
+ return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
569
+ }
570
+
571
+ if (hasTables && isMarkdownTable) {
572
+ recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
573
+ return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
574
+ }
575
+ if (usesSurgicalTextMode) {
576
+ recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
577
+ const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
578
+ ? tableCellContext.targetParagraph
579
+ : null;
580
+ if (surgicalTarget) {
581
+ log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
582
+ }
583
+
584
+ const result = applySurgicalMode(
585
+ xmlDoc,
586
+ originalText,
587
+ cleanModifiedText,
588
+ serializer,
589
+ author,
590
+ formatHints,
591
+ generateRedlines,
592
+ surgicalTarget,
593
+ {},
594
+ options
595
+ );
596
+
597
+ if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
598
+ return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
599
+ }
600
+
601
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
602
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
603
+ return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
604
+ }
605
+ return finalize(result);
606
+ }
607
+ if (isTargetList || isStructuredContent) {
608
+ const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
609
+ const useDirectListGeneration = sourceParagraphs.length === 1;
610
+ recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
611
+ sourceParagraphCount: sourceParagraphs.length
612
+ });
613
+ log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
614
+ let result;
615
+ if (useDirectListGeneration) {
616
+ const ingested = ingestOoxml(workingOoxml, { xmlDoc });
617
+ const numberingContext = detectNumberingContext(sourceParagraphs[0]);
618
+ result = await executeListGeneration({
619
+ cleanText: cleanModifiedText,
620
+ numberingContext,
621
+ originalRunModel: ingested.runModel,
622
+ originalText: ingested.acceptedText,
623
+ generateRedlines,
624
+ author,
625
+ font: options.font || null,
626
+ revisionIdAllocator,
627
+ numberingService: new NumberingService()
628
+ });
629
+ } else {
630
+ const pipeline = new ReconciliationPipeline({
631
+ author,
632
+ generateRedlines,
633
+ revisionIdAllocator
634
+ });
635
+ result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
636
+ }
637
+
638
+ if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
639
+ return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
640
+ }
641
+
642
+ if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
643
+ const includeNumbering = result.includeNumbering === true;
644
+ log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
645
+ const wrapped = wrapInDocumentFragment(result.ooxml, {
646
+ includeNumbering,
647
+ numberingXml: result.numberingXml
648
+ });
649
+ log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
650
+ return finalize({
651
+ oxml: wrapped,
652
+ hasChanges: true,
653
+ ...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
654
+ });
655
+ }
656
+ return finalizeUnchanged();
657
+ }
658
+
659
+ recordRouteSelection(options, 'reconstruction');
660
+ return finalize(applyReconstructionMode(
661
+ xmlDoc,
662
+ originalText,
663
+ cleanModifiedText,
664
+ serializer,
665
+ author,
666
+ formatHints,
667
+ generateRedlines,
668
+ {},
669
+ options
670
+ ));
671
+ } catch (caught) {
672
+ if (isDiffTokenLimitError(caught)) {
673
+ return finalize({
674
+ oxml: inputOoxml,
675
+ hasChanges: false,
676
+ status: 'error',
677
+ error: { code: caught.code, message: caught.message }
678
+ });
679
+ }
680
+ throw caught;
681
+ }
682
+ }
683
+
684
+ function sourceMatchesListShape(paragraphs, listText) {
685
+ const itemCount = String(listText).split(/\r?\n/).filter(line => line.trim()).length;
686
+ return paragraphs.length === itemCount && paragraphs.every(paragraph => (
687
+ // Current numPr only; a pPrChange snapshot does not make the source a list.
688
+ getCurrentParagraphProperties(paragraph).numPr != null
689
+ ));
690
+ }
691
+
692
+ function normalizeTargetText(text) {
693
+ return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
694
+ }
695
+
696
+ function textSpanVisibleText(span) {
697
+ const node = span?.textElement;
698
+ const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
699
+ if (localName === 'tab') return '\t';
700
+ if (localName === 'br' || localName === 'cr') return '\n';
701
+ if (localName === 'noBreakHyphen') return '\u2011';
702
+ return node?.textContent || '';
703
+ }
704
+
705
+ /**
706
+ * Sanitizes AI response text by removing common prefixes.
707
+ *
708
+ * @param {string} text - AI response text
709
+ * @returns {string}
710
+ */
711
+ export function sanitizeAiResponse(text) {
712
+ return String(text ?? '').replace(
713
+ /^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
714
+ ''
715
+ );
716
+ }
717
+
718
+ /**
719
+ * Parses OOXML into a DOM document.
720
+ *
721
+ * @param {string} ooxmlString - OOXML text
722
+ * @returns {Document}
723
+ */
724
+ export function parseOoxml(ooxmlString) {
725
+ return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
726
+ }
727
+
728
+ /**
729
+ * Serializes a DOM document to OOXML text.
730
+ *
731
+ * @param {Node} doc - XML document/node
732
+ * @returns {string}
733
+ */
734
+ export function serializeOoxml(doc) {
735
+ return serializeXml(doc);
736
+ }