@ansonlai/docx-redline-js 0.8.3 → 0.8.4

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.
@@ -17,7 +17,7 @@ import {
17
17
  } from '../core/xml-query.js';
18
18
  import { createSerializer, parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
19
19
  import { log, error } from '../adapters/logger.js';
20
- import { extractFormattingFromOoxml } from './format-extraction.js';
20
+ import { buildTextSpansFromParagraphs, extractFormattingFromOoxml } from './format-extraction.js';
21
21
  import {
22
22
  applyFormatRemovalAsSurgicalReplacement,
23
23
  applyFormatOnlyChangesSurgical
@@ -28,8 +28,8 @@ import { applySurgicalMode } from './surgical-mode.js';
28
28
  import { applyReconstructionMode } from './reconstruction-mode.js';
29
29
  import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
30
30
  import { getDefaultAuthor } from '../adapters/config.js';
31
- import { containsTrackedChanges, createWordElement, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
32
- import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
31
+ import { containsTrackedChanges, createWordElement, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
32
+ import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
33
33
  import {
34
34
  NS_W,
35
35
  RevisionIdAllocator,
@@ -40,75 +40,75 @@ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
40
40
  import { getDocumentParagraphs } from './format-extraction.js';
41
41
  import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
42
42
  import { NumberingService } from '../services/numbering-service.js';
43
- import { recordRouteSelection } from './route-selection.js';
44
- import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
45
-
46
- function getCommentIdsInOoxml(node) {
47
- const ids = new Set();
48
- for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
49
- for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
50
- const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
51
- if (id !== '') ids.add(id);
52
- }
53
- }
54
- return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
55
- }
56
-
57
- function directWordChild(node, localName) {
58
- return Array.from(node?.childNodes || []).find(child => (
59
- child?.nodeType === 1
60
- && child.namespaceURI === NS_W
61
- && child.localName === localName
62
- )) || null;
63
- }
64
-
65
- function insertedParagraphMarkMetadata(paragraph, author) {
66
- const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
67
- if (!marker) return null;
68
- const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
69
- if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
70
- return {
71
- id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
72
- author: markerAuthor,
73
- date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
74
- };
75
- }
76
-
77
- function emptyParagraphBaseline(paragraph, serializer) {
78
- const clone = paragraph.cloneNode(false);
79
- const pPr = directWordChild(paragraph, 'pPr');
80
- if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
81
- return serializer.serializeToString(clone);
82
- }
83
-
84
- function restoreInsertedParagraphMark(oxml, metadata) {
85
- if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
86
- const parsed = parseOoxmlSafe(oxml, 'text/xml');
87
- if (!parsed.doc || parsed.error) return oxml;
88
- const paragraph = parsed.doc.documentElement?.localName === 'p'
89
- ? parsed.doc.documentElement
90
- : getDocumentParagraphs(parsed.doc)[0];
91
- if (!paragraph) return oxml;
92
-
93
- let pPr = directWordChild(paragraph, 'pPr');
94
- if (!pPr) {
95
- pPr = createWordElement(parsed.doc, 'w:pPr');
96
- paragraph.insertBefore(pPr, paragraph.firstChild);
97
- }
98
- let rPr = directWordChild(pPr, 'rPr');
99
- if (!rPr) {
100
- rPr = createWordElement(parsed.doc, 'w:rPr');
101
- pPr.appendChild(rPr);
102
- }
103
- if (!directWordChild(rPr, 'ins')) {
104
- const marker = createWordElement(parsed.doc, 'w:ins');
105
- if (metadata.id) marker.setAttribute('w:id', metadata.id);
106
- marker.setAttribute('w:author', metadata.author);
107
- if (metadata.date) marker.setAttribute('w:date', metadata.date);
108
- rPr.appendChild(marker);
109
- }
110
- return serializeXml(parsed.doc);
111
- }
43
+ import { recordRouteSelection } from './route-selection.js';
44
+ import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
45
+
46
+ function getCommentIdsInOoxml(node) {
47
+ const ids = new Set();
48
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
49
+ for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
50
+ const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
51
+ if (id !== '') ids.add(id);
52
+ }
53
+ }
54
+ return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
55
+ }
56
+
57
+ function directWordChild(node, localName) {
58
+ return Array.from(node?.childNodes || []).find(child => (
59
+ child?.nodeType === 1
60
+ && child.namespaceURI === NS_W
61
+ && child.localName === localName
62
+ )) || null;
63
+ }
64
+
65
+ function insertedParagraphMarkMetadata(paragraph, author) {
66
+ const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
67
+ if (!marker) return null;
68
+ const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
69
+ if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
70
+ return {
71
+ id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
72
+ author: markerAuthor,
73
+ date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
74
+ };
75
+ }
76
+
77
+ function emptyParagraphBaseline(paragraph, serializer) {
78
+ const clone = paragraph.cloneNode(false);
79
+ const pPr = directWordChild(paragraph, 'pPr');
80
+ if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
81
+ return serializer.serializeToString(clone);
82
+ }
83
+
84
+ function restoreInsertedParagraphMark(oxml, metadata) {
85
+ if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
86
+ const parsed = parseOoxmlSafe(oxml, 'text/xml');
87
+ if (!parsed.doc || parsed.error) return oxml;
88
+ const paragraph = parsed.doc.documentElement?.localName === 'p'
89
+ ? parsed.doc.documentElement
90
+ : getDocumentParagraphs(parsed.doc)[0];
91
+ if (!paragraph) return oxml;
92
+
93
+ let pPr = directWordChild(paragraph, 'pPr');
94
+ if (!pPr) {
95
+ pPr = createWordElement(parsed.doc, 'w:pPr');
96
+ paragraph.insertBefore(pPr, paragraph.firstChild);
97
+ }
98
+ let rPr = directWordChild(pPr, 'rPr');
99
+ if (!rPr) {
100
+ rPr = createWordElement(parsed.doc, 'w:rPr');
101
+ pPr.appendChild(rPr);
102
+ }
103
+ if (!directWordChild(rPr, 'ins')) {
104
+ const marker = createWordElement(parsed.doc, 'w:ins');
105
+ if (metadata.id) marker.setAttribute('w:id', metadata.id);
106
+ marker.setAttribute('w:author', metadata.author);
107
+ if (metadata.date) marker.setAttribute('w:date', metadata.date);
108
+ rPr.appendChild(marker);
109
+ }
110
+ return serializeXml(parsed.doc);
111
+ }
112
112
 
113
113
  /**
114
114
  * Applies redline track changes to OOXML by modifying the DOM in-place.
@@ -119,7 +119,7 @@ function restoreInsertedParagraphMark(oxml, metadata) {
119
119
  * @param {Object} [options={}] - Options
120
120
  * @param {string} [options.author='AI'] - Author for track changes
121
121
  * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
122
- * @param {'merge-same-author'|'slice-cross-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
122
+ * @param {'merge-same-author'|'slice-cross-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
123
123
  * @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
124
124
  * @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
125
125
  * @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
@@ -134,14 +134,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
134
134
  const serializer = createSerializer();
135
135
  let parseWarnings = [];
136
136
  const operationWarnings = [];
137
- let normalizedExistingRevisions = false;
138
- let preservedInsertedParagraphMark = null;
137
+ let normalizedExistingRevisions = false;
138
+ let preservedInsertedParagraphMark = null;
139
139
  const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
140
140
  const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
141
- const finalize = result => {
142
- const withStatus = { ...result };
141
+ const finalize = result => {
142
+ const withStatus = { ...result };
143
143
  if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
144
- if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
144
+ if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
145
145
  withStatus.oxml = workingOoxml;
146
146
  withStatus.hasChanges = true;
147
147
  withStatus.warnings = [
@@ -163,13 +163,13 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
163
163
  if (warnings.length > 0) {
164
164
  withStatus.warnings = [...new Set(warnings)];
165
165
  }
166
- if (!withStatus.status) {
167
- withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
168
- }
169
- if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
170
- withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
171
- }
172
- return withOoxmlSourceType(withStatus);
166
+ if (!withStatus.status) {
167
+ withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
168
+ }
169
+ if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
170
+ withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
171
+ }
172
+ return withOoxmlSourceType(withStatus);
173
173
  };
174
174
  const finalizeUnchanged = () => {
175
175
  if (normalizedExistingRevisions && keepNormalizedNoOp) {
@@ -200,61 +200,61 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
200
200
  const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
201
201
  ? options._revisionIdAllocator
202
202
  : new RevisionIdAllocator();
203
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
204
-
205
- const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
206
- ? [xmlDoc.documentElement]
207
- : getDocumentParagraphs(xmlDoc);
208
- if (inputParagraphs.length === 1 && modifiedText.length > 0) {
209
- const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
210
- if (resurrectionTarget.matches) {
211
- const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
212
- return finalize({
213
- oxml: inputOoxml,
214
- hasChanges: false,
215
- status: 'error',
216
- error: {
217
- code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
218
- message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
219
- ownerAuthor
220
- }
221
- });
222
- }
223
- }
224
-
225
- if (containsTrackedChanges(xmlDoc)) {
226
- if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
203
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
204
+
205
+ const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
206
+ ? [xmlDoc.documentElement]
207
+ : getDocumentParagraphs(xmlDoc);
208
+ if (inputParagraphs.length === 1 && modifiedText.length > 0) {
209
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
210
+ if (resurrectionTarget.matches) {
211
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
212
+ return finalize({
213
+ oxml: inputOoxml,
214
+ hasChanges: false,
215
+ status: 'error',
216
+ error: {
217
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
218
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
219
+ ownerAuthor
220
+ }
221
+ });
222
+ }
223
+ }
224
+
225
+ if (containsTrackedChanges(xmlDoc)) {
226
+ if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
227
227
  const authors = getTrackedChangeAuthors(xmlDoc);
228
- const currentAuthor = String(author || '').trim().toLowerCase();
229
- const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
230
- if (isSameAuthor) {
231
- const commentIds = getCommentIdsInOoxml(xmlDoc);
232
- if (commentIds.length > 0) {
233
- return finalize({
234
- oxml: inputOoxml,
235
- hasChanges: false,
236
- status: 'error',
237
- error: {
238
- code: 'COMMENTED_CONTENT_MERGE',
239
- message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
240
- commentIds
241
- }
242
- });
243
- }
244
- log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
245
- const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
246
- preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
247
- const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
248
- if (rejected.status === 'error') return finalize(rejected);
249
- workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
250
- ? emptyParagraphBaseline(soleParagraph, serializer)
251
- : rejected.oxml;
228
+ const currentAuthor = String(author || '').trim().toLowerCase();
229
+ const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
230
+ if (isSameAuthor) {
231
+ const commentIds = getCommentIdsInOoxml(xmlDoc);
232
+ if (commentIds.length > 0) {
233
+ return finalize({
234
+ oxml: inputOoxml,
235
+ hasChanges: false,
236
+ status: 'error',
237
+ error: {
238
+ code: 'COMMENTED_CONTENT_MERGE',
239
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
240
+ commentIds
241
+ }
242
+ });
243
+ }
244
+ log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
245
+ const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
246
+ preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
247
+ const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
248
+ if (rejected.status === 'error') return finalize(rejected);
249
+ workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
250
+ ? emptyParagraphBaseline(soleParagraph, serializer)
251
+ : rejected.oxml;
252
252
  normalizedExistingRevisions = true;
253
253
  const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
254
254
  parseWarnings.push(...rejectedParsed.warnings);
255
255
  xmlDoc = rejectedParsed.doc;
256
256
  const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
257
- if (rejectedParsed.error || rejectedParseError) {
257
+ if (rejectedParsed.error || rejectedParseError) {
258
258
  const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
259
259
  error('[OxmlEngine] XML parse error after rejecting same-author revisions:', message);
260
260
  return finalize({
@@ -264,21 +264,21 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
264
264
  error: {
265
265
  code: 'PARSE_ERROR',
266
266
  message
267
- }
268
- });
269
- }
270
- if (containsTrackedChanges(xmlDoc)) {
271
- return finalize({
272
- oxml: inputOoxml,
273
- hasChanges: false,
274
- status: 'error',
275
- error: {
276
- code: 'UNSAFE_REVISION_NESTING',
277
- message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
278
- }
279
- });
280
- }
281
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
267
+ }
268
+ });
269
+ }
270
+ if (containsTrackedChanges(xmlDoc)) {
271
+ return finalize({
272
+ oxml: inputOoxml,
273
+ hasChanges: false,
274
+ status: 'error',
275
+ error: {
276
+ code: 'UNSAFE_REVISION_NESTING',
277
+ message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
278
+ }
279
+ });
280
+ }
281
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
282
282
 
283
283
  // Re-derive baseline text from the restored baseline OOXML:
284
284
  const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
@@ -288,37 +288,37 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
288
288
  ? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
289
289
  : '';
290
290
  originalText = baselineText;
291
- } else if (existingRevisionsPolicy === 'merge-same-author') {
292
- log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
291
+ } else if (existingRevisionsPolicy === 'merge-same-author') {
292
+ log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
293
293
  return finalize({
294
294
  oxml: inputOoxml,
295
295
  hasChanges: false,
296
296
  status: 'error',
297
297
  error: {
298
- code: 'EXISTING_REVISIONS',
299
- message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
300
- revisionAuthors: authors,
301
- currentPolicy: existingRevisionsPolicy
298
+ code: 'EXISTING_REVISIONS',
299
+ message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
300
+ revisionAuthors: authors,
301
+ currentPolicy: existingRevisionsPolicy
302
302
  }
303
303
  });
304
- } else {
305
- const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
306
- return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
307
- });
308
- if (hasMoveRevision) {
309
- return finalize({
310
- oxml: inputOoxml,
311
- hasChanges: false,
312
- status: 'error',
313
- error: {
314
- code: 'UNSAFE_REVISION_NESTING',
315
- message: 'Cross-author slicing does not support pending move revisions.'
316
- }
317
- });
318
- }
319
- log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
320
- }
321
- } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
304
+ } else {
305
+ const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
306
+ return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
307
+ });
308
+ if (hasMoveRevision) {
309
+ return finalize({
310
+ oxml: inputOoxml,
311
+ hasChanges: false,
312
+ status: 'error',
313
+ error: {
314
+ code: 'UNSAFE_REVISION_NESTING',
315
+ message: 'Cross-author slicing does not support pending move revisions.'
316
+ }
317
+ });
318
+ }
319
+ log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
320
+ }
321
+ } else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
322
322
  log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
323
323
  const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
324
324
  if (accepted.status === 'error') return finalize(accepted);
@@ -375,7 +375,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
375
375
  operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
376
376
  }
377
377
  let structuredAnalysis = null;
378
- if (options.structuredContent !== false) {
378
+ if (options.structuredContent !== false && options.literalTextEdit !== true) {
379
379
  structuredAnalysis = analyzeStructuredContent(sanitizedText);
380
380
  if (!structuredAnalysis.valid) {
381
381
  if (options.explicitStructuredContent === true) {
@@ -395,21 +395,32 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
395
395
  }
396
396
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
397
397
 
398
- const hasTextChanges = existingRevisionsPolicy === 'slice-cross-author'
399
- ? cleanModifiedText !== originalText
400
- : cleanModifiedText.trim() !== originalText.trim();
401
- const hasFormatHints = formatHints.length > 0;
402
-
403
398
  const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
399
+ const hasLiteralTextChanges = existingRevisionsPolicy === 'slice-cross-author'
400
+ ? cleanModifiedText !== originalText
401
+ : cleanModifiedText.trim() !== originalText.trim();
402
+ // Markdown list markers can equal manually typed ones ("A. ..." lines), so
403
+ // an explicit structured-content request that turns plain paragraphs or
404
+ // soft-break lines into a list is a change even when the text is identical.
405
+ const hasListStructureChange = !hasLiteralTextChanges
406
+ && options.explicitStructuredContent === true
407
+ && isListTargetLoose(cleanModifiedText)
408
+ && !sourceMatchesListShape(paragraphs, cleanModifiedText);
409
+ const hasTextChanges = hasLiteralTextChanges || hasListStructureChange;
410
+ const hasFormatHints = formatHints.length > 0;
404
411
  const hasExistingFormatting = existingFormatHints.length > 0;
405
- const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
412
+ // Formatting spans carry only w:t text. Target matching also needs w:br,
413
+ // w:cr and w:tab so host text with soft line breaks (Word's Paragraph.text
414
+ // reports them as "\v") still matches its source paragraph.
415
+ const { textSpans: targetSpans } = buildTextSpansFromParagraphs(paragraphs);
416
+ const visibleText = targetSpans.map(span => textSpanVisibleText(span)).join('');
406
417
  const targetFound = originalText.includes('\n') || originalText.includes('\r')
407
418
  ? originalText
408
419
  .split(/\r?\n/)
409
420
  .map(normalizeTargetText)
410
421
  .filter(Boolean)
411
422
  .every(line => paragraphs.some(paragraph => {
412
- const paragraphText = textSpans
423
+ const paragraphText = targetSpans
413
424
  .filter(span => span.paragraph === paragraph)
414
425
  .map(textSpanVisibleText)
415
426
  .join('');
@@ -555,12 +566,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
555
566
  const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
556
567
  const hasTables = tables.length > 0;
557
568
  const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
558
- const isTargetList = isListTargetLoose(cleanModifiedText);
569
+ // Localized replacements are literal text edits: marker-like lines ("A. ..." separated by
570
+ // soft breaks) must not be re-interpreted as a request to build a numbered list.
571
+ const isTargetList = options.literalTextEdit !== true && isListTargetLoose(cleanModifiedText);
559
572
  const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
560
573
  const tableCellContext = initialTableCellContext;
561
574
 
562
- const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
563
- log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
575
+ const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
576
+ log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
564
577
 
565
578
  try {
566
579
  if (isMarkdownTable && !hasTables) {
@@ -573,8 +586,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
573
586
  recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
574
587
  return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
575
588
  }
576
- if (usesSurgicalTextMode) {
577
- recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
589
+ if (usesSurgicalTextMode) {
590
+ recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
578
591
  const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
579
592
  ? tableCellContext.targetParagraph
580
593
  : null;
@@ -582,7 +595,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
582
595
  log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
583
596
  }
584
597
 
585
- const result = applySurgicalMode(
598
+ const result = applySurgicalMode(
586
599
  xmlDoc,
587
600
  originalText,
588
601
  cleanModifiedText,
@@ -592,14 +605,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
592
605
  generateRedlines,
593
606
  surgicalTarget,
594
607
  {},
595
- options
596
- );
597
-
598
- if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
599
- return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
600
- }
601
-
602
- if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
608
+ options
609
+ );
610
+
611
+ if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
612
+ return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
613
+ }
614
+
615
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
603
616
  log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
604
617
  return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
605
618
  }
@@ -682,6 +695,13 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
682
695
  }
683
696
  }
684
697
 
698
+ function sourceMatchesListShape(paragraphs, listText) {
699
+ const itemCount = String(listText).split(/\r?\n/).filter(line => line.trim()).length;
700
+ return paragraphs.length === itemCount && paragraphs.every(paragraph => (
701
+ getElementsByTagNSOrTag(paragraph, NS_W, 'numPr').length > 0
702
+ ));
703
+ }
704
+
685
705
  function normalizeTargetText(text) {
686
706
  return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
687
707
  }