@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,258 @@
1
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
2
+ import {
3
+ RevisionIdAllocator,
4
+ seedRevisionIdsFromDocument,
5
+ setRevisionIdAllocatorForDocument
6
+ } from '../core/types.js';
7
+ import {
8
+ buildParagraphMetadataIndex,
9
+ buildTargetReferenceSnapshot
10
+ } from '../core/paragraph-targeting.js';
11
+ import { ReceiptCollector } from './receipt-collector.js';
12
+
13
+ /**
14
+ * Owns the document state used by one standalone operation or batch.
15
+ *
16
+ * The session keeps accuracy-sensitive mutation decisions unchanged while a
17
+ * batch shares one live DOM. Savepoints protect against partial failed/no-op
18
+ * mutations, and the original input string remains the rollback authority.
19
+ */
20
+ export class DocumentOperationSession {
21
+ constructor(documentXml, options = {}) {
22
+ this.originalDocumentXml = typeof documentXml === 'string' ? documentXml : '';
23
+ this.currentDocumentXml = this.originalDocumentXml;
24
+ this.serializer = createSerializer();
25
+ this.parseResult = parseOoxmlSafe(this.originalDocumentXml, 'application/xml');
26
+ this.document = this.parseResult.doc || null;
27
+ this.revisionIdAllocator = null;
28
+ this.initialTargetReferenceSnapshot = null;
29
+ this.paragraphIndex = null;
30
+ this.invalidated = false;
31
+ this.hasChanges = false;
32
+ this.documentHasChanges = false;
33
+ this.deferSerialization = options?._deferDocumentSerialization === true;
34
+ this.instrumentation = options?._sessionInstrumentation || null;
35
+ this.runtimeContext = null;
36
+ this.commentsXml = null;
37
+ this.commentsExtendedXml = null;
38
+ this.commentsXmlMode = 'merge';
39
+ this.commentsExtendedXmlMode = 'merge';
40
+ this.numberingXmlParts = [];
41
+ this.results = [];
42
+ this.executionOrder = [];
43
+ this.authorsUsed = new Set();
44
+ this.captureTable = new Map();
45
+ this.nextCaptureParaId = 1;
46
+ this.receiptCollector = new ReceiptCollector();
47
+
48
+ if (this.document) {
49
+ this.instrumentation?.onDocumentParse?.(this.originalDocumentXml);
50
+ this.revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
51
+ ? options._revisionIdAllocator
52
+ : new RevisionIdAllocator();
53
+ this.revisionIdAllocator._receiptCollector = this.receiptCollector;
54
+ seedRevisionIdsFromDocument(this.document, this.revisionIdAllocator);
55
+ setRevisionIdAllocatorForDocument(this.document, this.revisionIdAllocator);
56
+ const paragraphIndex = this.getParagraphMetadataIndex();
57
+ this.initialTargetReferenceSnapshot = buildTargetReferenceSnapshot(this.document, paragraphIndex);
58
+ }
59
+ }
60
+
61
+ get valid() {
62
+ return !!this.document && !this.parseResult.error;
63
+ }
64
+
65
+ setDocumentXml(documentXml) {
66
+ this.currentDocumentXml = documentXml;
67
+ this.invalidateParagraphIndex();
68
+ }
69
+
70
+ invalidateParagraphIndex() {
71
+ this.invalidated = true;
72
+ this.paragraphIndex = null;
73
+ }
74
+
75
+ serialize() {
76
+ if (!this.document) return this.currentDocumentXml;
77
+ this.instrumentation?.onDocumentSerialize?.(this.document);
78
+ return this.serializer.serializeToString(this.document);
79
+ }
80
+
81
+ serializeCurrent() {
82
+ if (!this.hasChanges) return this.originalDocumentXml;
83
+ if (!this.documentHasChanges) return this.currentDocumentXml;
84
+ this.currentDocumentXml = this.serialize();
85
+ return this.currentDocumentXml;
86
+ }
87
+
88
+ markMutationCommitted(documentChanged = true) {
89
+ this.hasChanges = true;
90
+ this.documentHasChanges = this.documentHasChanges || documentChanged;
91
+ if (documentChanged) this.invalidateParagraphIndex();
92
+ }
93
+
94
+ generateParagraphId() {
95
+ return (0x40000000 + (this.nextCaptureParaId++)).toString(16).toUpperCase();
96
+ }
97
+
98
+ createSavepoint() {
99
+ if (!this.document) return null;
100
+ return {
101
+ document: this.document.cloneNode(true),
102
+ allocatorNextId: this.revisionIdAllocator?.nextId,
103
+ allocatorOccupiedIds: this.revisionIdAllocator?.occupiedIds instanceof Set
104
+ ? new Set(this.revisionIdAllocator.occupiedIds)
105
+ : null,
106
+ hasChanges: this.hasChanges,
107
+ documentHasChanges: this.documentHasChanges,
108
+ currentDocumentXml: this.currentDocumentXml,
109
+ commentsXml: this.commentsXml,
110
+ commentsExtendedXml: this.commentsExtendedXml,
111
+ commentsXmlMode: this.commentsXmlMode,
112
+ commentsExtendedXmlMode: this.commentsExtendedXmlMode,
113
+ captureTable: cloneCaptureTable(this.captureTable),
114
+ nextCaptureParaId: this.nextCaptureParaId,
115
+ receiptCollector: this.receiptCollector ? this.receiptCollector.createSavepoint() : null
116
+ };
117
+ }
118
+
119
+ restoreSavepoint(savepoint) {
120
+ if (!savepoint?.document) return;
121
+ this.document = savepoint.document;
122
+ this.hasChanges = savepoint.hasChanges;
123
+ this.documentHasChanges = savepoint.documentHasChanges === true;
124
+ this.currentDocumentXml = savepoint.currentDocumentXml;
125
+ this.commentsXml = savepoint.commentsXml;
126
+ this.commentsExtendedXml = savepoint.commentsExtendedXml;
127
+ this.commentsXmlMode = savepoint.commentsXmlMode || 'merge';
128
+ this.commentsExtendedXmlMode = savepoint.commentsExtendedXmlMode || 'merge';
129
+ this.captureTable = savepoint.captureTable ? cloneCaptureTable(savepoint.captureTable) : new Map();
130
+ if (typeof savepoint.nextCaptureParaId === 'number') {
131
+ this.nextCaptureParaId = savepoint.nextCaptureParaId;
132
+ }
133
+ if (this.revisionIdAllocator) {
134
+ this.revisionIdAllocator.nextId = savepoint.allocatorNextId;
135
+ if (savepoint.allocatorOccupiedIds instanceof Set) {
136
+ this.revisionIdAllocator.occupiedIds = new Set(savepoint.allocatorOccupiedIds);
137
+ }
138
+ this.revisionIdAllocator._receiptCollector = this.receiptCollector;
139
+ setRevisionIdAllocatorForDocument(this.document, this.revisionIdAllocator);
140
+ }
141
+ if (savepoint.receiptCollector && this.receiptCollector) {
142
+ this.receiptCollector.restoreSavepoint(savepoint.receiptCollector);
143
+ }
144
+ this.invalidateParagraphIndex();
145
+ }
146
+
147
+ getParagraphIndex() {
148
+ return this.getParagraphMetadataIndex().entries;
149
+ }
150
+
151
+ getParagraphMetadataIndex() {
152
+ if (this.paragraphIndex) return this.paragraphIndex;
153
+ this.paragraphIndex = buildParagraphMetadataIndex(this.document);
154
+ this.invalidated = false;
155
+ return this.paragraphIndex;
156
+ }
157
+
158
+ rollback() {
159
+ this.currentDocumentXml = this.originalDocumentXml;
160
+ this.hasChanges = false;
161
+ this.documentHasChanges = false;
162
+ this.captureTable.clear();
163
+ this.receiptCollector?.clear();
164
+ return this.originalDocumentXml;
165
+ }
166
+ }
167
+
168
+ function cloneCaptureTable(table) {
169
+ const cloned = new Map();
170
+ if (table instanceof Map) {
171
+ for (const [k, v] of table.entries()) {
172
+ cloned.set(k, JSON.parse(JSON.stringify(v)));
173
+ }
174
+ }
175
+ return cloned;
176
+ }
177
+
178
+ export function prepareRevisionAllocator(xmlDoc, options = {}) {
179
+ const allocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
180
+ ? options._revisionIdAllocator
181
+ : new RevisionIdAllocator();
182
+ if (options?._documentOperationSession?.receiptCollector) {
183
+ allocator._receiptCollector = options._documentOperationSession.receiptCollector;
184
+ }
185
+ seedRevisionIdsFromDocument(xmlDoc, allocator);
186
+ setRevisionIdAllocatorForDocument(xmlDoc, allocator);
187
+ return allocator;
188
+ }
189
+
190
+ export function cloneBatchRuntimeContext(runtimeContext) {
191
+ if (!runtimeContext || typeof runtimeContext !== 'object') return {};
192
+
193
+ const context = { ...runtimeContext };
194
+ if (runtimeContext.listFallbackSharedNumIdByKey instanceof Map) {
195
+ context.listFallbackSharedNumIdByKey = new Map(runtimeContext.listFallbackSharedNumIdByKey);
196
+ }
197
+ if (runtimeContext.tableStructuralRedlineKeys instanceof Set) {
198
+ context.tableStructuralRedlineKeys = new Set(runtimeContext.tableStructuralRedlineKeys);
199
+ }
200
+ if (runtimeContext.numberingIdState && typeof runtimeContext.numberingIdState === 'object') {
201
+ context.numberingIdState = {
202
+ ...runtimeContext.numberingIdState,
203
+ usedNumIds: runtimeContext.numberingIdState.usedNumIds instanceof Set
204
+ ? new Set(runtimeContext.numberingIdState.usedNumIds)
205
+ : runtimeContext.numberingIdState.usedNumIds,
206
+ usedAbstractNumIds: runtimeContext.numberingIdState.usedAbstractNumIds instanceof Set
207
+ ? new Set(runtimeContext.numberingIdState.usedAbstractNumIds)
208
+ : runtimeContext.numberingIdState.usedAbstractNumIds
209
+ };
210
+ }
211
+ if (runtimeContext.listFallbackSequenceState && typeof runtimeContext.listFallbackSequenceState === 'object') {
212
+ context.listFallbackSequenceState = {
213
+ ...runtimeContext.listFallbackSequenceState,
214
+ explicitByNumberingKey: runtimeContext.listFallbackSequenceState.explicitByNumberingKey instanceof Map
215
+ ? new Map(runtimeContext.listFallbackSequenceState.explicitByNumberingKey)
216
+ : runtimeContext.listFallbackSequenceState.explicitByNumberingKey
217
+ };
218
+ }
219
+ return context;
220
+ }
221
+
222
+ export function commitBatchRuntimeContext(runtimeContext, context) {
223
+ if (!runtimeContext || typeof runtimeContext !== 'object') return;
224
+
225
+ if (runtimeContext.listFallbackSharedNumIdByKey instanceof Map && context.listFallbackSharedNumIdByKey instanceof Map) {
226
+ runtimeContext.listFallbackSharedNumIdByKey.clear();
227
+ for (const entry of context.listFallbackSharedNumIdByKey) runtimeContext.listFallbackSharedNumIdByKey.set(...entry);
228
+ context.listFallbackSharedNumIdByKey = runtimeContext.listFallbackSharedNumIdByKey;
229
+ }
230
+ if (runtimeContext.tableStructuralRedlineKeys instanceof Set && context.tableStructuralRedlineKeys instanceof Set) {
231
+ runtimeContext.tableStructuralRedlineKeys.clear();
232
+ for (const value of context.tableStructuralRedlineKeys) runtimeContext.tableStructuralRedlineKeys.add(value);
233
+ context.tableStructuralRedlineKeys = runtimeContext.tableStructuralRedlineKeys;
234
+ }
235
+ if (runtimeContext.numberingIdState && context.numberingIdState) {
236
+ for (const key of ['usedNumIds', 'usedAbstractNumIds']) {
237
+ if (runtimeContext.numberingIdState[key] instanceof Set && context.numberingIdState[key] instanceof Set) {
238
+ runtimeContext.numberingIdState[key].clear();
239
+ for (const value of context.numberingIdState[key]) runtimeContext.numberingIdState[key].add(value);
240
+ context.numberingIdState[key] = runtimeContext.numberingIdState[key];
241
+ }
242
+ }
243
+ Object.assign(runtimeContext.numberingIdState, context.numberingIdState);
244
+ context.numberingIdState = runtimeContext.numberingIdState;
245
+ }
246
+ if (runtimeContext.listFallbackSequenceState && context.listFallbackSequenceState) {
247
+ const originalMap = runtimeContext.listFallbackSequenceState.explicitByNumberingKey;
248
+ const updatedMap = context.listFallbackSequenceState.explicitByNumberingKey;
249
+ if (originalMap instanceof Map && updatedMap instanceof Map) {
250
+ originalMap.clear();
251
+ for (const entry of updatedMap) originalMap.set(...entry);
252
+ context.listFallbackSequenceState.explicitByNumberingKey = originalMap;
253
+ }
254
+ Object.assign(runtimeContext.listFallbackSequenceState, context.listFallbackSequenceState);
255
+ context.listFallbackSequenceState = runtimeContext.listFallbackSequenceState;
256
+ }
257
+ Object.assign(runtimeContext, context);
258
+ }
@@ -5,6 +5,11 @@
5
5
  */
6
6
 
7
7
  import { NumberFormat, NumberSuffix } from '../core/types.js';
8
+
9
+ function isUsableNumId(numId) {
10
+ const normalized = String(numId ?? '');
11
+ return /^\d+$/.test(normalized) && Number.parseInt(normalized, 10) > 0;
12
+ }
8
13
 
9
14
  export class NumberingService {
10
15
  constructor() {
@@ -19,8 +24,10 @@ export class NumberingService {
19
24
  * @param {string} signature - Format signature (e.g., 'bullet' or 'decimal')
20
25
  * @param {string} numId - Existing numId from Word
21
26
  */
22
- registerExistingNumId(signature, numId) {
23
- this.contextMap.set(signature, numId);
27
+ registerExistingNumId(signature, numId) {
28
+ if (isUsableNumId(numId)) {
29
+ this.contextMap.set(signature, String(numId));
30
+ }
24
31
  }
25
32
 
26
33
  /**
@@ -34,15 +41,17 @@ export class NumberingService {
34
41
  const requestedType = formatConfig.type || NumberFormat.BULLET;
35
42
 
36
43
  // Priority 1: Use existing context if it matches the requested type
37
- if (existingContext && existingContext.numId) {
44
+ if (existingContext && isUsableNumId(existingContext.numId)) {
38
45
  if (existingContext.type === requestedType || existingContext.type === 'unknown') {
39
46
  return existingContext.numId;
40
47
  }
41
48
  }
42
49
 
43
50
  // Priority 2: Use cached numId for this format
44
- if (this.contextMap.has(requestedType)) {
45
- return this.contextMap.get(requestedType);
51
+ if (this.contextMap.has(requestedType)) {
52
+ const cachedNumId = this.contextMap.get(requestedType);
53
+ if (isUsableNumId(cachedNumId)) return cachedNumId;
54
+ this.contextMap.delete(requestedType);
46
55
  }
47
56
 
48
57
  // Priority 3: Special Handling for Outline (recursive 1.1.1)
@@ -0,0 +1,173 @@
1
+ import {
2
+ getParagraphText,
3
+ normalizeWhitespaceForTargeting
4
+ } from '../core/paragraph-targeting.js';
5
+ import {
6
+ getParagraphListInfo,
7
+ stripRedundantLeadingListMarkers
8
+ } from '../core/list-targeting.js';
9
+
10
+ const LIST_LINE_REGEX = /^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/;
11
+ const INLINE_LIST_MARKER_REGEX = /(?:^|\s)(?:\d+(?:\.\d+)*\.?|[A-Za-z]\.|[ivxlcIVXLC]+\.)\s+/g;
12
+
13
+ function parseOutlineLevelFromMarker(marker) {
14
+ const normalized = String(marker || '').trim();
15
+ if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
16
+ const parts = normalized.replace(/\.$/, '').split('.');
17
+ return Math.max(0, parts.length - 1);
18
+ }
19
+
20
+ function parseModifiedListLines(modifiedText) {
21
+ const lines = String(modifiedText || '')
22
+ .split(/\r?\n/g)
23
+ .map(line => line.trimEnd())
24
+ .filter(line => line.trim().length > 0);
25
+ if (lines.length < 2) return null;
26
+
27
+ const parsed = [];
28
+ for (const line of lines) {
29
+ const markerMatch = line.match(LIST_LINE_REGEX);
30
+ if (!markerMatch) return null;
31
+ const marker = markerMatch[2];
32
+ const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
33
+ parsed.push({
34
+ marker,
35
+ markerType,
36
+ level: Math.floor((markerMatch[1] || '').length / 2),
37
+ outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
38
+ text: stripRedundantLeadingListMarkers(markerMatch[3])
39
+ });
40
+ }
41
+ return parsed.length >= 2 ? parsed : null;
42
+ }
43
+
44
+ export function buildExplicitRangeInsertionEntries(explicitRangeParagraphs, modifiedText) {
45
+ if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return null;
46
+ const parsedLines = parseModifiedListLines(modifiedText);
47
+ if (!parsedLines) return null;
48
+
49
+ const originalTexts = explicitRangeParagraphs.map(paragraph =>
50
+ normalizeWhitespaceForTargeting(getParagraphText(paragraph))
51
+ );
52
+ const modifiedTexts = parsedLines.map(item => normalizeWhitespaceForTargeting(item.text));
53
+ if (originalTexts.some(text => !text) || modifiedTexts.some(text => !text)) return null;
54
+
55
+ const listInfos = explicitRangeParagraphs.map(paragraph => getParagraphListInfo(paragraph));
56
+ if (listInfos.some(info => !info || !info.numId)) return null;
57
+ const baselineNumId = String(listInfos[0].numId);
58
+ if (listInfos.some(info => String(info.numId) !== baselineNumId)) return null;
59
+
60
+ const matchedPairs = [];
61
+ let originalIndex = 0;
62
+ for (let modifiedIndex = 0; modifiedIndex < modifiedTexts.length && originalIndex < originalTexts.length; modifiedIndex += 1) {
63
+ if (modifiedTexts[modifiedIndex] === originalTexts[originalIndex]) {
64
+ matchedPairs.push({ originalIndex, modifiedIndex });
65
+ originalIndex += 1;
66
+ }
67
+ }
68
+ if (originalIndex !== originalTexts.length) return null;
69
+
70
+ const matchedModifiedIndexes = new Set(matchedPairs.map(pair => pair.modifiedIndex));
71
+ const insertedIndexes = [];
72
+ for (let idx = 0; idx < parsedLines.length; idx += 1) {
73
+ if (!matchedModifiedIndexes.has(idx)) insertedIndexes.push(idx);
74
+ }
75
+ if (insertedIndexes.length === 0) return null;
76
+
77
+ const baseIndentLevel = parsedLines[0]?.level || 0;
78
+ return insertedIndexes.map(modifiedIndex => {
79
+ const nextMatch = matchedPairs.find(pair => pair.modifiedIndex > modifiedIndex) || null;
80
+ const prevMatch = [...matchedPairs].reverse().find(pair => pair.modifiedIndex < modifiedIndex) || null;
81
+ const referenceMatch = nextMatch || prevMatch;
82
+ if (!referenceMatch) return null;
83
+ const referenceListInfo = listInfos[referenceMatch.originalIndex] || listInfos[0];
84
+ if (!referenceListInfo) return null;
85
+
86
+ const entry = parsedLines[modifiedIndex];
87
+ const relativeLevel = Math.max(0, entry.level - baseIndentLevel);
88
+ const explicitOutlineLevel = Number.isInteger(entry.outlineLevel) ? entry.outlineLevel : null;
89
+ return {
90
+ text: entry.text,
91
+ markerType: entry.markerType,
92
+ ilvl: explicitOutlineLevel != null
93
+ ? explicitOutlineLevel
94
+ : Math.max(0, (referenceListInfo.ilvl || 0) + relativeLevel),
95
+ numId: String(referenceListInfo.numId),
96
+ insertBeforeOriginalIndex: nextMatch ? nextMatch.originalIndex : null
97
+ };
98
+ }).filter(Boolean);
99
+ }
100
+
101
+ function countWords(text) {
102
+ return String(text || '').trim().split(/\s+/).filter(Boolean).length;
103
+ }
104
+
105
+ function hasMultipleInlineListMarkers(text) {
106
+ const source = String(text || '');
107
+ if (!source) return false;
108
+ let count = 0;
109
+ const regex = new RegExp(INLINE_LIST_MARKER_REGEX.source, INLINE_LIST_MARKER_REGEX.flags);
110
+ while (regex.exec(source)) {
111
+ count += 1;
112
+ if (count >= 2) return true;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ export function deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, modifiedText) {
118
+ const rawCurrent = String(currentParagraphText || '').trim();
119
+ const rawModified = String(modifiedText || '').trim();
120
+ if (!rawCurrent || !rawModified || rawModified === rawCurrent || rawModified.includes('\n')) return null;
121
+ const normalizedCurrent = normalizeWhitespaceForTargeting(rawCurrent);
122
+ const sanitizeCandidate = text => stripRedundantLeadingListMarkers(String(text || '').trim()).trim();
123
+ const buildCandidate = (position, text) => {
124
+ const cleanedText = sanitizeCandidate(text);
125
+ const cleanedNormalized = normalizeWhitespaceForTargeting(cleanedText);
126
+ if (!cleanedText || cleanedText === rawCurrent || countWords(cleanedText) < 6) return null;
127
+ if (hasMultipleInlineListMarkers(cleanedText)) return null;
128
+ if (normalizedCurrent && cleanedNormalized.includes(normalizedCurrent)) return null;
129
+ return { position, text: cleanedText };
130
+ };
131
+
132
+ if (rawModified.endsWith(rawCurrent)) {
133
+ const candidate = buildCandidate('before', rawModified.slice(0, -rawCurrent.length));
134
+ if (candidate) return candidate;
135
+ }
136
+ if (rawModified.startsWith(rawCurrent)) {
137
+ const candidate = buildCandidate('after', rawModified.slice(rawCurrent.length));
138
+ if (candidate) return candidate;
139
+ }
140
+
141
+ const normalizedModified = normalizeWhitespaceForTargeting(rawModified);
142
+ if (!normalizedCurrent || normalizedCurrent === normalizedModified) return null;
143
+ if (normalizedModified.endsWith(normalizedCurrent)) {
144
+ const candidate = buildCandidate('before', normalizedModified.slice(0, -normalizedCurrent.length));
145
+ if (candidate) return candidate;
146
+ }
147
+ if (normalizedModified.startsWith(normalizedCurrent)) {
148
+ const candidate = buildCandidate('after', normalizedModified.slice(normalizedCurrent.length));
149
+ if (candidate) return candidate;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ export function deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, modifiedText) {
155
+ const rawCurrent = String(currentParagraphText || '').trim();
156
+ const rawModified = String(modifiedText || '');
157
+ if (!rawCurrent || !rawModified || !rawModified.includes('\n')) return null;
158
+
159
+ const lines = rawModified.split(/\r?\n/g).map(line => String(line || '').trim()).filter(Boolean);
160
+ if (lines.length < 2) return null;
161
+ const normalize = value => normalizeWhitespaceForTargeting(String(value || ''));
162
+ const normalizedCurrent = normalize(rawCurrent);
163
+
164
+ if (normalize(lines[lines.length - 1]) === normalizedCurrent) {
165
+ const paragraphs = lines.slice(0, -1).filter(Boolean);
166
+ if (paragraphs.length > 0) return { position: 'before', paragraphs };
167
+ }
168
+ if (normalize(lines[0]) === normalizedCurrent) {
169
+ const paragraphs = lines.slice(1).filter(Boolean);
170
+ if (paragraphs.length > 0) return { position: 'after', paragraphs };
171
+ }
172
+ return null;
173
+ }