@ansonlai/docx-redline-js 0.8.3 → 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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  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 +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  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 +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -1,46 +1,48 @@
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 { 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';
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
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';
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';
43
44
  import { recordRouteSelection } from './route-selection.js';
45
+ import { restoreParagraphMarkInserted } from './paragraph-mark-revisions.js';
44
46
  import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
45
47
 
46
48
  function getCommentIdsInOoxml(node) {
@@ -90,79 +92,63 @@ function restoreInsertedParagraphMark(oxml, metadata) {
90
92
  : getDocumentParagraphs(parsed.doc)[0];
91
93
  if (!paragraph) return oxml;
92
94
 
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
- }
95
+ restoreParagraphMarkInserted(paragraph, metadata);
110
96
  return serializeXml(parsed.doc);
111
97
  }
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
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
122
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
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 = [];
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 = [];
137
123
  let normalizedExistingRevisions = false;
138
124
  let preservedInsertedParagraphMark = null;
139
- const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
140
- const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
125
+ const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
126
+ const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
141
127
  const finalize = result => {
142
128
  const withStatus = { ...result };
143
- if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
129
+ if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
144
130
  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
- }
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
+ }
166
152
  if (!withStatus.status) {
167
153
  withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
168
154
  }
@@ -170,36 +156,36 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
170
156
  withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
171
157
  }
172
158
  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();
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();
203
189
  seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
204
190
 
205
191
  const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
@@ -224,7 +210,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
224
210
 
225
211
  if (containsTrackedChanges(xmlDoc)) {
226
212
  if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
227
- const authors = getTrackedChangeAuthors(xmlDoc);
213
+ const authors = getTrackedChangeAuthors(xmlDoc);
228
214
  const currentAuthor = String(author || '').trim().toLowerCase();
229
215
  const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
230
216
  if (isSameAuthor) {
@@ -249,21 +235,21 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
249
235
  workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
250
236
  ? emptyParagraphBaseline(soleParagraph, serializer)
251
237
  : 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;
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;
257
243
  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
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
267
253
  }
268
254
  });
269
255
  }
@@ -279,28 +265,28 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
279
265
  });
280
266
  }
281
267
  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;
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;
291
277
  } else if (existingRevisionsPolicy === 'merge-same-author') {
292
278
  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: {
279
+ return finalize({
280
+ oxml: inputOoxml,
281
+ hasChanges: false,
282
+ status: 'error',
283
+ error: {
298
284
  code: 'EXISTING_REVISIONS',
299
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.`,
300
286
  revisionAuthors: authors,
301
287
  currentPolicy: existingRevisionsPolicy
302
- }
303
- });
288
+ }
289
+ });
304
290
  } else {
305
291
  const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
306
292
  return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
@@ -319,279 +305,292 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
319
305
  log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
320
306
  }
321
307
  } 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) {
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 hasTextChanges = existingRevisionsPolicy === 'slice-cross-author'
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'
399
386
  ? cleanModifiedText !== originalText
400
387
  : cleanModifiedText.trim() !== originalText.trim();
401
- const hasFormatHints = formatHints.length > 0;
402
-
403
- const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
404
- const hasExistingFormatting = existingFormatHints.length > 0;
405
- const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
406
- const targetFound = originalText.includes('\n') || originalText.includes('\r')
407
- ? originalText
408
- .split(/\r?\n/)
409
- .map(normalizeTargetText)
410
- .filter(Boolean)
411
- .every(line => paragraphs.some(paragraph => {
412
- const paragraphText = textSpans
413
- .filter(span => span.paragraph === paragraph)
414
- .map(textSpanVisibleText)
415
- .join('');
416
- return normalizeTargetText(paragraphText).includes(line);
417
- }))
418
- : visibleText.includes(originalText.trim())
419
- || visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
420
- || normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
421
- if (
422
- hasTextChanges
423
- && typeof originalText === 'string'
424
- && originalText.trim()
425
- && !targetFound
426
- ) {
427
- log('[OxmlEngine] Target text not found in OOXML');
428
- return finalize({
429
- oxml: inputOoxml,
430
- hasChanges: false,
431
- status: 'error',
432
- error: {
433
- code: 'TARGET_NOT_FOUND',
434
- message: 'Original text was not found in the supplied OOXML.'
435
- }
436
- });
437
- }
438
- let paragraphInfos = null;
439
- const getParagraphInfos = () => {
440
- if (!paragraphInfos) {
441
- paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
442
- }
443
- return paragraphInfos;
444
- };
445
- const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
446
- const formatResult = applyFormatOnlyChangesSurgical(
447
- xmlDoc,
448
- originalText,
449
- formatHints,
450
- serializer,
451
- author,
452
- generateRedlines,
453
- precomputedContext
454
- );
455
- if (!formatResult.useNativeApi) {
456
- return formatResult;
457
- }
458
-
459
- log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
460
- return applyReconstructionMode(
461
- xmlDoc,
462
- originalText,
463
- cleanModifiedText,
464
- serializer,
465
- author,
466
- formatHints,
467
- generateRedlines
468
- );
469
- };
470
-
471
- log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
472
-
473
- const needsFormatRemoval = options.removeFormatting === true
474
- && !hasTextChanges
475
- && !hasFormatHints
476
- && hasExistingFormatting;
477
-
478
- if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
479
- recordRouteSelection(options, 'noChange');
480
- log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
481
- return finalizeUnchanged();
482
- }
483
-
484
- if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
485
- log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
486
- return finalizeUnchanged();
487
- }
488
-
489
- if (needsFormatRemoval) {
490
- recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
491
- log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
492
-
493
- const tableCellCtx = initialTableCellContext;
494
- let targetParagraph = tableCellCtx.targetParagraph || null;
495
-
496
- if (!targetParagraph) {
497
- const matchedInfo = findMatchingParagraphInfo(getParagraphInfos(), originalText);
498
- if (matchedInfo) {
499
- targetParagraph = matchedInfo.paragraph;
500
- }
501
- }
502
-
503
- let filteredHints = existingFormatHints;
504
- if (targetParagraph) {
505
- filteredHints = existingFormatHints.filter(hint => {
506
- const hintParagraph = getContainingParagraph(hint.run);
507
- return hintParagraph === targetParagraph;
508
- });
509
- }
510
-
511
- const removalResult = applyFormatRemovalAsSurgicalReplacement(
512
- xmlDoc,
513
- textSpans,
514
- filteredHints,
515
- serializer,
516
- author,
517
- generateRedlines
518
- );
519
-
520
- if (tableCellCtx.hasTableWrapper && targetParagraph) {
521
- return finalize({
522
- oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
523
- hasChanges: removalResult.hasChanges
524
- });
525
- }
526
-
527
- return finalize(removalResult);
528
- }
529
-
530
- if (!hasTextChanges && hasFormatHints) {
531
- recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
532
- log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
533
-
534
- const tableCellCtx = initialTableCellContext;
535
- const precomputedFormatContext = {
536
- textSpans,
537
- paragraphs,
538
- paragraphInfos: getParagraphInfos()
539
- };
540
- if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
541
- log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
542
-
543
- const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
544
-
545
- log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
546
- return finalize({
547
- oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
548
- hasChanges: formatResult.hasChanges
549
- });
550
- }
551
-
552
- return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
553
- }
554
-
555
- const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
556
- const hasTables = tables.length > 0;
557
- const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
558
- const isTargetList = isListTargetLoose(cleanModifiedText);
559
- const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
560
- const tableCellContext = initialTableCellContext;
561
-
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
+
562
561
  const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
563
562
  log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
564
-
565
- try {
566
- if (isMarkdownTable && !hasTables) {
567
- recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
568
- log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
569
- return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
570
- }
571
-
572
- if (hasTables && isMarkdownTable) {
573
- recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
574
- return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
575
- }
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
+ }
576
575
  if (usesSurgicalTextMode) {
577
576
  recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
578
- const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
579
- ? tableCellContext.targetParagraph
580
- : null;
581
- if (surgicalTarget) {
582
- log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
583
- }
584
-
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
+
585
584
  const result = applySurgicalMode(
586
- xmlDoc,
587
- originalText,
588
- cleanModifiedText,
589
- serializer,
590
- author,
591
- formatHints,
592
- generateRedlines,
593
- surgicalTarget,
594
- {},
585
+ xmlDoc,
586
+ originalText,
587
+ cleanModifiedText,
588
+ serializer,
589
+ author,
590
+ formatHints,
591
+ generateRedlines,
592
+ surgicalTarget,
593
+ {},
595
594
  options
596
595
  );
597
596
 
@@ -600,130 +599,138 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
600
599
  }
601
600
 
602
601
  if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
603
- log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
604
- return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
605
- }
606
- return finalize(result);
607
- }
608
- if (isTargetList || isStructuredContent) {
609
- const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
610
- const useDirectListGeneration = sourceParagraphs.length === 1;
611
- recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
612
- sourceParagraphCount: sourceParagraphs.length
613
- });
614
- log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
615
- let result;
616
- if (useDirectListGeneration) {
617
- const ingested = ingestOoxml(workingOoxml, { xmlDoc });
618
- const numberingContext = detectNumberingContext(sourceParagraphs[0]);
619
- result = await executeListGeneration({
620
- cleanText: cleanModifiedText,
621
- numberingContext,
622
- originalRunModel: ingested.runModel,
623
- originalText: ingested.acceptedText,
624
- generateRedlines,
625
- author,
626
- font: options.font || null,
627
- revisionIdAllocator,
628
- numberingService: new NumberingService()
629
- });
630
- } else {
631
- const pipeline = new ReconciliationPipeline({
632
- author,
633
- generateRedlines,
634
- revisionIdAllocator
635
- });
636
- result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
637
- }
638
-
639
- if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
640
- return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
641
- }
642
-
643
- if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
644
- const includeNumbering = result.includeNumbering === true;
645
- log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
646
- const wrapped = wrapInDocumentFragment(result.ooxml, {
647
- includeNumbering,
648
- numberingXml: result.numberingXml
649
- });
650
- log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
651
- return finalize({
652
- oxml: wrapped,
653
- hasChanges: true,
654
- ...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
655
- });
656
- }
657
- return finalizeUnchanged();
658
- }
659
-
660
- recordRouteSelection(options, 'reconstruction');
661
- return finalize(applyReconstructionMode(
662
- xmlDoc,
663
- originalText,
664
- cleanModifiedText,
665
- serializer,
666
- author,
667
- formatHints,
668
- generateRedlines,
669
- {},
670
- options
671
- ));
672
- } catch (caught) {
673
- if (isDiffTokenLimitError(caught)) {
674
- return finalize({
675
- oxml: inputOoxml,
676
- hasChanges: false,
677
- status: 'error',
678
- error: { code: caught.code, message: caught.message }
679
- });
680
- }
681
- throw caught;
682
- }
683
- }
684
-
685
- function normalizeTargetText(text) {
686
- return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
687
- }
688
-
689
- function textSpanVisibleText(span) {
690
- const node = span?.textElement;
691
- const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
692
- if (localName === 'tab') return '\t';
693
- if (localName === 'br' || localName === 'cr') return '\n';
694
- if (localName === 'noBreakHyphen') return '\u2011';
695
- return node?.textContent || '';
696
- }
697
-
698
- /**
699
- * Sanitizes AI response text by removing common prefixes.
700
- *
701
- * @param {string} text - AI response text
702
- * @returns {string}
703
- */
704
- export function sanitizeAiResponse(text) {
705
- return String(text ?? '').replace(
706
- /^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
707
- ''
708
- );
709
- }
710
-
711
- /**
712
- * Parses OOXML into a DOM document.
713
- *
714
- * @param {string} ooxmlString - OOXML text
715
- * @returns {Document}
716
- */
717
- export function parseOoxml(ooxmlString) {
718
- return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
719
- }
720
-
721
- /**
722
- * Serializes a DOM document to OOXML text.
723
- *
724
- * @param {Node} doc - XML document/node
725
- * @returns {string}
726
- */
727
- export function serializeOoxml(doc) {
728
- return serializeXml(doc);
729
- }
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
+ }