@ansonlai/docx-redline-js 0.9.0 → 0.10.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.
@@ -1,736 +1,748 @@
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
- }
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, hasUnattributedTrackedChange, resolveExistingRevisionsPolicy, 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
+ let 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
+ if (existingRevisionsPolicy === 'auto') {
175
+ existingRevisionsPolicy = resolveExistingRevisionsPolicy('auto', xmlDoc, author);
176
+ options = { ...options, existingRevisions: existingRevisionsPolicy };
177
+ }
178
+
179
+ const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
180
+ if (parsed.error || parseError) {
181
+ const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
182
+ error('[OxmlEngine] XML parse error:', message);
183
+ return finalize({
184
+ oxml: inputOoxml,
185
+ hasChanges: false,
186
+ status: 'error',
187
+ error: { code: 'PARSE_ERROR', message }
188
+ });
189
+ }
190
+ const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
191
+ ? options._revisionIdAllocator
192
+ : new RevisionIdAllocator();
193
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
194
+
195
+ const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
196
+ ? [xmlDoc.documentElement]
197
+ : getDocumentParagraphs(xmlDoc);
198
+ if (inputParagraphs.length === 1 && modifiedText.length > 0) {
199
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
200
+ if (resurrectionTarget.matches) {
201
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
202
+ return finalize({
203
+ oxml: inputOoxml,
204
+ hasChanges: false,
205
+ status: 'error',
206
+ error: {
207
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
208
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
209
+ ownerAuthor
210
+ }
211
+ });
212
+ }
213
+ }
214
+
215
+ if (containsTrackedChanges(xmlDoc)) {
216
+ if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
217
+ const authors = getTrackedChangeAuthors(xmlDoc);
218
+ const currentAuthor = String(author || '').trim().toLowerCase();
219
+ const isSameAuthor = authors.length > 0
220
+ && !hasUnattributedTrackedChange(xmlDoc)
221
+ && authors.every(a => a.trim().toLowerCase() === currentAuthor);
222
+ if (isSameAuthor) {
223
+ const commentIds = getCommentIdsInOoxml(xmlDoc);
224
+ if (commentIds.length > 0) {
225
+ return finalize({
226
+ oxml: inputOoxml,
227
+ hasChanges: false,
228
+ status: 'error',
229
+ error: {
230
+ code: 'COMMENTED_CONTENT_MERGE',
231
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
232
+ commentIds
233
+ }
234
+ });
235
+ }
236
+ log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
237
+ const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
238
+ preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
239
+ const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
240
+ if (rejected.status === 'error') return finalize(rejected);
241
+ workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
242
+ ? emptyParagraphBaseline(soleParagraph, serializer)
243
+ : rejected.oxml;
244
+ normalizedExistingRevisions = true;
245
+ const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
246
+ parseWarnings.push(...rejectedParsed.warnings);
247
+ xmlDoc = rejectedParsed.doc;
248
+ const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
249
+ if (rejectedParsed.error || rejectedParseError) {
250
+ const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
251
+ error('[OxmlEngine] XML parse error after rejecting same-author revisions:', message);
252
+ return finalize({
253
+ oxml: inputOoxml,
254
+ hasChanges: false,
255
+ status: 'error',
256
+ error: {
257
+ code: 'PARSE_ERROR',
258
+ message
259
+ }
260
+ });
261
+ }
262
+ if (containsTrackedChanges(xmlDoc)) {
263
+ return finalize({
264
+ oxml: inputOoxml,
265
+ hasChanges: false,
266
+ status: 'error',
267
+ error: {
268
+ code: 'UNSAFE_REVISION_NESTING',
269
+ message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
270
+ }
271
+ });
272
+ }
273
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
274
+
275
+ // Re-derive baseline text from the restored baseline OOXML:
276
+ const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
277
+ ? [xmlDoc.documentElement]
278
+ : getDocumentParagraphs(xmlDoc);
279
+ const baselineText = paragraphsInDoc.length > 0
280
+ ? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
281
+ : '';
282
+ originalText = baselineText;
283
+ } else if (existingRevisionsPolicy === 'merge-same-author') {
284
+ log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
285
+ return finalize({
286
+ oxml: inputOoxml,
287
+ hasChanges: false,
288
+ status: 'error',
289
+ error: {
290
+ code: 'EXISTING_REVISIONS',
291
+ 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.`,
292
+ revisionAuthors: authors,
293
+ currentPolicy: existingRevisionsPolicy
294
+ }
295
+ });
296
+ } else {
297
+ const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
298
+ return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
299
+ });
300
+ if (hasMoveRevision) {
301
+ return finalize({
302
+ oxml: inputOoxml,
303
+ hasChanges: false,
304
+ status: 'error',
305
+ error: {
306
+ code: 'UNSAFE_REVISION_NESTING',
307
+ message: 'Cross-author slicing does not support pending move revisions.'
308
+ }
309
+ });
310
+ }
311
+ log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
312
+ }
313
+ } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
314
+ log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
315
+ const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
316
+ if (accepted.status === 'error') return finalize(accepted);
317
+ workingOoxml = accepted.oxml;
318
+ normalizedExistingRevisions = true;
319
+ const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
320
+ parseWarnings.push(...acceptedParsed.warnings);
321
+ xmlDoc = acceptedParsed.doc;
322
+ const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
323
+ if (acceptedParsed.error || acceptedParseError) {
324
+ const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
325
+ error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
326
+ return finalize({
327
+ oxml: inputOoxml,
328
+ hasChanges: false,
329
+ status: 'error',
330
+ error: {
331
+ code: 'PARSE_ERROR',
332
+ message
333
+ }
334
+ });
335
+ }
336
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
337
+ } else {
338
+ log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
339
+ return finalize({
340
+ oxml: inputOoxml,
341
+ hasChanges: false,
342
+ status: 'error',
343
+ error: {
344
+ code: 'EXISTING_REVISIONS',
345
+ message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
346
+ }
347
+ });
348
+ }
349
+ }
350
+
351
+ const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
352
+ if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
353
+ log('[OxmlEngine] Isolating table-cell paragraph before diff');
354
+ const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
355
+ const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
356
+ ...options,
357
+ _isolatedTableCell: true
358
+ });
359
+ if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
360
+ return finalizeUnchanged();
361
+ }
362
+ return isolatedResult;
363
+ }
364
+
365
+ let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
366
+ if (sanitizedText !== modifiedText) {
367
+ operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
368
+ }
369
+ let structuredAnalysis = null;
370
+ if (options.structuredContent !== false && options.literalTextEdit !== true) {
371
+ structuredAnalysis = analyzeStructuredContent(sanitizedText);
372
+ if (!structuredAnalysis.valid) {
373
+ if (options.explicitStructuredContent === true) {
374
+ const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
375
+ return finalize({
376
+ oxml: inputOoxml,
377
+ hasChanges: false,
378
+ status: 'error',
379
+ error: { code: 'STRUCTURED_CONTENT_INVALID', message },
380
+ warnings: structuredAnalysis.issues.map(issue => issue.message)
381
+ });
382
+ }
383
+ structuredAnalysis = null;
384
+ } else if (structuredAnalysis.requiresStructuredContent) {
385
+ sanitizedText = structuredAnalysis.normalizedMarkdown;
386
+ }
387
+ }
388
+ // A caller's structuredContent: false is the documented plain-text contract:
389
+ // inline Markdown markers (`__`, `_`, `**`) stay literal text. Internal calls
390
+ // that pass structuredContent: false still want inline Markdown, so only the
391
+ // operation applier sets this flag.
392
+ const { cleanText: cleanModifiedText, formatHints } = options.plainTextContent === true
393
+ ? { cleanText: sanitizedText, formatHints: [] }
394
+ : preprocessMarkdown(sanitizedText);
395
+
396
+ const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
397
+ const hasLiteralTextChanges = existingRevisionsPolicy === 'slice-cross-author'
398
+ ? cleanModifiedText !== originalText
399
+ : cleanModifiedText.trim() !== originalText.trim();
400
+ // Markdown list markers can equal manually typed ones ("A. ..." lines), so
401
+ // an explicit structured-content request that turns plain paragraphs or
402
+ // soft-break lines into a list is a change even when the text is identical.
403
+ const hasListStructureChange = !hasLiteralTextChanges
404
+ && options.explicitStructuredContent === true
405
+ && isListTargetLoose(cleanModifiedText)
406
+ && !sourceMatchesListShape(paragraphs, cleanModifiedText);
407
+ const hasTextChanges = hasLiteralTextChanges || hasListStructureChange;
408
+ const hasFormatHints = formatHints.length > 0;
409
+ const hasExistingFormatting = existingFormatHints.length > 0;
410
+ // Formatting spans carry only w:t text. Target matching also needs w:br,
411
+ // w:cr and w:tab so host text with soft line breaks (Word's Paragraph.text
412
+ // reports them as "\v") still matches its source paragraph.
413
+ const { textSpans: targetSpans } = buildTextSpansFromParagraphs(paragraphs);
414
+ const visibleText = targetSpans.map(span => textSpanVisibleText(span)).join('');
415
+ const targetFound = originalText.includes('\n') || originalText.includes('\r')
416
+ ? originalText
417
+ .split(/\r?\n/)
418
+ .map(normalizeTargetText)
419
+ .filter(Boolean)
420
+ .every(line => paragraphs.some(paragraph => {
421
+ const paragraphText = targetSpans
422
+ .filter(span => span.paragraph === paragraph)
423
+ .map(textSpanVisibleText)
424
+ .join('');
425
+ return normalizeTargetText(paragraphText).includes(line);
426
+ }))
427
+ : visibleText.includes(originalText.trim())
428
+ || visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
429
+ || normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
430
+ if (
431
+ hasTextChanges
432
+ && typeof originalText === 'string'
433
+ && originalText.trim()
434
+ && !targetFound
435
+ ) {
436
+ log('[OxmlEngine] Target text not found in OOXML');
437
+ return finalize({
438
+ oxml: inputOoxml,
439
+ hasChanges: false,
440
+ status: 'error',
441
+ error: {
442
+ code: 'TARGET_NOT_FOUND',
443
+ message: 'Original text was not found in the supplied OOXML.'
444
+ }
445
+ });
446
+ }
447
+ let paragraphInfos = null;
448
+ const getParagraphInfos = () => {
449
+ if (!paragraphInfos) {
450
+ paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
451
+ }
452
+ return paragraphInfos;
453
+ };
454
+ const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
455
+ const formatResult = applyFormatOnlyChangesSurgical(
456
+ xmlDoc,
457
+ originalText,
458
+ formatHints,
459
+ serializer,
460
+ author,
461
+ generateRedlines,
462
+ precomputedContext
463
+ );
464
+ if (!formatResult.useNativeApi) {
465
+ return formatResult;
466
+ }
467
+
468
+ log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
469
+ return applyReconstructionMode(
470
+ xmlDoc,
471
+ originalText,
472
+ cleanModifiedText,
473
+ serializer,
474
+ author,
475
+ formatHints,
476
+ generateRedlines
477
+ );
478
+ };
479
+
480
+ log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
481
+
482
+ const needsFormatRemoval = options.removeFormatting === true
483
+ && !hasTextChanges
484
+ && !hasFormatHints
485
+ && hasExistingFormatting;
486
+
487
+ if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
488
+ recordRouteSelection(options, 'noChange');
489
+ log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
490
+ return finalizeUnchanged();
491
+ }
492
+
493
+ if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
494
+ log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
495
+ return finalizeUnchanged();
496
+ }
497
+
498
+ if (needsFormatRemoval) {
499
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
500
+ log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
501
+
502
+ const tableCellCtx = initialTableCellContext;
503
+ let targetParagraph = tableCellCtx.targetParagraph || null;
504
+
505
+ if (!targetParagraph) {
506
+ const matchedInfo = findMatchingParagraphInfo(getParagraphInfos(), originalText);
507
+ if (matchedInfo) {
508
+ targetParagraph = matchedInfo.paragraph;
509
+ }
510
+ }
511
+
512
+ let filteredHints = existingFormatHints;
513
+ if (targetParagraph) {
514
+ filteredHints = existingFormatHints.filter(hint => {
515
+ const hintParagraph = getContainingParagraph(hint.run);
516
+ return hintParagraph === targetParagraph;
517
+ });
518
+ }
519
+
520
+ const removalResult = applyFormatRemovalAsSurgicalReplacement(
521
+ xmlDoc,
522
+ textSpans,
523
+ filteredHints,
524
+ serializer,
525
+ author,
526
+ generateRedlines
527
+ );
528
+
529
+ if (tableCellCtx.hasTableWrapper && targetParagraph) {
530
+ return finalize({
531
+ oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
532
+ hasChanges: removalResult.hasChanges
533
+ });
534
+ }
535
+
536
+ return finalize(removalResult);
537
+ }
538
+
539
+ if (!hasTextChanges && hasFormatHints) {
540
+ recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
541
+ log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
542
+
543
+ const tableCellCtx = initialTableCellContext;
544
+ const precomputedFormatContext = {
545
+ textSpans,
546
+ paragraphs,
547
+ paragraphInfos: getParagraphInfos()
548
+ };
549
+ if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
550
+ log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
551
+
552
+ const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
553
+
554
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
555
+ return finalize({
556
+ oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
557
+ hasChanges: formatResult.hasChanges
558
+ });
559
+ }
560
+
561
+ return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
562
+ }
563
+
564
+ const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
565
+ const hasTables = tables.length > 0;
566
+ const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
567
+ // Localized replacements are literal text edits: marker-like lines ("A. ..." separated by
568
+ // soft breaks) must not be re-interpreted as a request to build a numbered list.
569
+ const isTargetList = options.literalTextEdit !== true && isListTargetLoose(cleanModifiedText);
570
+ const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
571
+ const tableCellContext = initialTableCellContext;
572
+
573
+ const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
574
+ log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
575
+
576
+ try {
577
+ if (isMarkdownTable && !hasTables) {
578
+ recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
579
+ log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
580
+ return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
581
+ }
582
+
583
+ if (hasTables && isMarkdownTable) {
584
+ recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
585
+ return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
586
+ }
587
+ if (usesSurgicalTextMode) {
588
+ recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
589
+ const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
590
+ ? tableCellContext.targetParagraph
591
+ : null;
592
+ if (surgicalTarget) {
593
+ log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
594
+ }
595
+
596
+ const result = applySurgicalMode(
597
+ xmlDoc,
598
+ originalText,
599
+ cleanModifiedText,
600
+ serializer,
601
+ author,
602
+ formatHints,
603
+ generateRedlines,
604
+ surgicalTarget,
605
+ {},
606
+ options
607
+ );
608
+
609
+ if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
610
+ return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
611
+ }
612
+
613
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
614
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
615
+ return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
616
+ }
617
+ return finalize(result);
618
+ }
619
+ if (isTargetList || isStructuredContent) {
620
+ const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
621
+ const useDirectListGeneration = sourceParagraphs.length === 1;
622
+ recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
623
+ sourceParagraphCount: sourceParagraphs.length
624
+ });
625
+ log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
626
+ let result;
627
+ if (useDirectListGeneration) {
628
+ const ingested = ingestOoxml(workingOoxml, { xmlDoc });
629
+ const numberingContext = detectNumberingContext(sourceParagraphs[0]);
630
+ result = await executeListGeneration({
631
+ cleanText: cleanModifiedText,
632
+ numberingContext,
633
+ originalRunModel: ingested.runModel,
634
+ originalText: ingested.acceptedText,
635
+ generateRedlines,
636
+ author,
637
+ font: options.font || null,
638
+ revisionIdAllocator,
639
+ numberingService: new NumberingService()
640
+ });
641
+ } else {
642
+ const pipeline = new ReconciliationPipeline({
643
+ author,
644
+ generateRedlines,
645
+ revisionIdAllocator
646
+ });
647
+ result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
648
+ }
649
+
650
+ if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
651
+ return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
652
+ }
653
+
654
+ if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
655
+ const includeNumbering = result.includeNumbering === true;
656
+ log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
657
+ const wrapped = wrapInDocumentFragment(result.ooxml, {
658
+ includeNumbering,
659
+ numberingXml: result.numberingXml
660
+ });
661
+ log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
662
+ return finalize({
663
+ oxml: wrapped,
664
+ hasChanges: true,
665
+ ...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
666
+ });
667
+ }
668
+ return finalizeUnchanged();
669
+ }
670
+
671
+ recordRouteSelection(options, 'reconstruction');
672
+ return finalize(applyReconstructionMode(
673
+ xmlDoc,
674
+ originalText,
675
+ cleanModifiedText,
676
+ serializer,
677
+ author,
678
+ formatHints,
679
+ generateRedlines,
680
+ {},
681
+ options
682
+ ));
683
+ } catch (caught) {
684
+ if (isDiffTokenLimitError(caught)) {
685
+ return finalize({
686
+ oxml: inputOoxml,
687
+ hasChanges: false,
688
+ status: 'error',
689
+ error: { code: caught.code, message: caught.message }
690
+ });
691
+ }
692
+ throw caught;
693
+ }
694
+ }
695
+
696
+ function sourceMatchesListShape(paragraphs, listText) {
697
+ const itemCount = String(listText).split(/\r?\n/).filter(line => line.trim()).length;
698
+ return paragraphs.length === itemCount && paragraphs.every(paragraph => (
699
+ // Current numPr only; a pPrChange snapshot does not make the source a list.
700
+ getCurrentParagraphProperties(paragraph).numPr != null
701
+ ));
702
+ }
703
+
704
+ function normalizeTargetText(text) {
705
+ return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
706
+ }
707
+
708
+ function textSpanVisibleText(span) {
709
+ const node = span?.textElement;
710
+ const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
711
+ if (localName === 'tab') return '\t';
712
+ if (localName === 'br' || localName === 'cr') return '\n';
713
+ if (localName === 'noBreakHyphen') return '\u2011';
714
+ return node?.textContent || '';
715
+ }
716
+
717
+ /**
718
+ * Sanitizes AI response text by removing common prefixes.
719
+ *
720
+ * @param {string} text - AI response text
721
+ * @returns {string}
722
+ */
723
+ export function sanitizeAiResponse(text) {
724
+ return String(text ?? '').replace(
725
+ /^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
726
+ ''
727
+ );
728
+ }
729
+
730
+ /**
731
+ * Parses OOXML into a DOM document.
732
+ *
733
+ * @param {string} ooxmlString - OOXML text
734
+ * @returns {Document}
735
+ */
736
+ export function parseOoxml(ooxmlString) {
737
+ return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
738
+ }
739
+
740
+ /**
741
+ * Serializes a DOM document to OOXML text.
742
+ *
743
+ * @param {Node} doc - XML document/node
744
+ * @returns {string}
745
+ */
746
+ export function serializeOoxml(doc) {
747
+ return serializeXml(doc);
748
+ }