@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.
- package/AGENTS.md +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /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
|
-
|
|
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
|
-
|
|
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
|
+
}
|