@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,1733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML mutation implementations used by the document operation applier.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
|
+
import { findReconstructionParagraphRange } from '../engine/reconstruction-mapper.js';
|
|
7
|
+
import { createRevisionMetadata } from '../core/types.js';
|
|
8
|
+
import { containsTrackedChanges, getTrackedChangeAuthors, createWordElement, withOoxmlSourceType } from '../core/word-xml.js';
|
|
9
|
+
import {
|
|
10
|
+
markParagraphMarkInserted,
|
|
11
|
+
markParagraphMarkDeleted,
|
|
12
|
+
snapshotAndAttachRPrChange,
|
|
13
|
+
snapshotAndAttachPPrChange
|
|
14
|
+
} from '../engine/run-builders.js';
|
|
15
|
+
import {
|
|
16
|
+
applyCharacterFormatToRPr,
|
|
17
|
+
checkRunPropertiesChanged,
|
|
18
|
+
applyParagraphPropertiesToPPr,
|
|
19
|
+
checkParagraphPropertiesChanged
|
|
20
|
+
} from '../engine/rpr-helpers.js';
|
|
21
|
+
import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
|
|
22
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
23
|
+
import { applyRedlineToOxml as applyRedlineToOxmlEngine } from '../engine/oxml-engine.js';
|
|
24
|
+
import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
|
|
25
|
+
import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
|
|
26
|
+
import { injectCommentsIntoOoxml } from './comment-engine.js';
|
|
27
|
+
import {
|
|
28
|
+
getParagraphText as getParagraphTextFromOxml,
|
|
29
|
+
getParagraphId,
|
|
30
|
+
createParagraphFingerprint,
|
|
31
|
+
isMarkdownTableText,
|
|
32
|
+
findContainingWordElement,
|
|
33
|
+
resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
|
|
34
|
+
resolveParagraphRangeByRefs,
|
|
35
|
+
validateParagraphBoundaryMutation
|
|
36
|
+
} from '../core/paragraph-targeting.js';
|
|
37
|
+
import {
|
|
38
|
+
synthesizeExpandedListScopeEdit,
|
|
39
|
+
planListInsertionOnlyEdit,
|
|
40
|
+
getParagraphListInfo,
|
|
41
|
+
stripRedundantLeadingListMarkers
|
|
42
|
+
} from '../core/list-targeting.js';
|
|
43
|
+
import {
|
|
44
|
+
synthesizeTableMarkdownFromMultilineCellEdit,
|
|
45
|
+
inferTableReplacementParagraphBlock
|
|
46
|
+
} from '../core/table-targeting.js';
|
|
47
|
+
import {
|
|
48
|
+
buildSingleLineListStructuralFallbackPlan,
|
|
49
|
+
executeSingleLineListStructuralFallback,
|
|
50
|
+
resolveSingleLineListFallbackNumberingAction,
|
|
51
|
+
recordSingleLineListFallbackExplicitSequence,
|
|
52
|
+
clearSingleLineListFallbackExplicitSequence,
|
|
53
|
+
enforceListBindingOnParagraphNodes,
|
|
54
|
+
stripSingleLineListMarkerPrefix
|
|
55
|
+
} from '../orchestration/list-structural-fallback.js';
|
|
56
|
+
import {
|
|
57
|
+
reserveNextNumberingIdPair,
|
|
58
|
+
remapNumberingPayloadForDocument,
|
|
59
|
+
overwriteParagraphNumIds,
|
|
60
|
+
extractFirstParagraphNumId,
|
|
61
|
+
buildExplicitDecimalMultilevelNumberingXml
|
|
62
|
+
} from './numbering-helpers.js';
|
|
63
|
+
import {
|
|
64
|
+
extractReplacementNodesFromOoxml,
|
|
65
|
+
normalizeBodySectionOrderStandalone
|
|
66
|
+
} from './standalone-docx-plumbing.js';
|
|
67
|
+
import { prepareRevisionAllocator } from './document-operation-session.js';
|
|
68
|
+
import {
|
|
69
|
+
buildExplicitRangeInsertionEntries,
|
|
70
|
+
deriveSingleParagraphListAdjacencyInsertion,
|
|
71
|
+
deriveSingleParagraphPlainAdjacencyInsertion
|
|
72
|
+
} from './operation-heuristics.js';
|
|
73
|
+
import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
|
|
74
|
+
|
|
75
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
76
|
+
|
|
77
|
+
function getCommentIdsInElement(element) {
|
|
78
|
+
const ids = new Set();
|
|
79
|
+
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
80
|
+
for (const node of Array.from(element?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
|
|
81
|
+
const id = node.getAttribute('w:id') || node.getAttribute('id');
|
|
82
|
+
if (id !== '') ids.add(id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function commentDetailsForIds(ids, detailsById) {
|
|
89
|
+
if (!detailsById || typeof detailsById !== 'object') return [];
|
|
90
|
+
return ids.map(id => detailsById[id]).filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveMutationDocument(documentXml, options = {}) {
|
|
94
|
+
const operationSession = options?._documentOperationSession || null;
|
|
95
|
+
if (operationSession?.valid && operationSession.document) {
|
|
96
|
+
return {
|
|
97
|
+
xmlDoc: operationSession.document,
|
|
98
|
+
serializer: operationSession.serializer,
|
|
99
|
+
operationSession
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
xmlDoc: parseOoxmlSafe(documentXml, 'application/xml').doc,
|
|
104
|
+
serializer: createSerializer(),
|
|
105
|
+
operationSession: null
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function completedDocumentXml(xmlDoc, serializer, documentXml, operationSession) {
|
|
110
|
+
return operationSession ? documentXml : serializer.serializeToString(xmlDoc);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
114
|
+
const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
|
|
115
|
+
if (result?.useNativeApi && typeof result?.oxml !== 'string') {
|
|
116
|
+
return withOoxmlSourceType({
|
|
117
|
+
...result,
|
|
118
|
+
oxml,
|
|
119
|
+
hasChanges: false,
|
|
120
|
+
warnings: [
|
|
121
|
+
...(Array.isArray(result?.warnings) ? result.warnings : []),
|
|
122
|
+
'Standalone mode cannot execute native Word API fallback for this operation.'
|
|
123
|
+
]
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, options = {}) {
|
|
130
|
+
const sourceOoxml = typeof oxml === 'string' ? oxml : '';
|
|
131
|
+
const tableText = typeof markdownTable === 'string' ? markdownTable : String(markdownTable || '');
|
|
132
|
+
let tableData;
|
|
133
|
+
try {
|
|
134
|
+
tableData = parseMarkdownTable(tableText);
|
|
135
|
+
} catch {
|
|
136
|
+
tableData = { headers: [], rows: [] };
|
|
137
|
+
}
|
|
138
|
+
if (!((tableData?.headers?.length || 0) > 0 || (tableData?.rows?.length || 0) > 0)) {
|
|
139
|
+
return {
|
|
140
|
+
oxml: sourceOoxml,
|
|
141
|
+
hasChanges: false,
|
|
142
|
+
isMarkdownTable: false,
|
|
143
|
+
warnings: ['Could not parse Markdown table from input.']
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
...await applyRedlineToOxml(sourceOoxml, originalText || '', tableText, options),
|
|
148
|
+
isMarkdownTable: true,
|
|
149
|
+
tableData
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getParagraphText(paragraph) {
|
|
154
|
+
return getParagraphTextFromOxml(paragraph);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
158
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
159
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
160
|
+
const session = options?._documentOperationSession || null;
|
|
161
|
+
const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
|
|
162
|
+
|
|
163
|
+
if (options?.targetDescriptor?.captureRef) {
|
|
164
|
+
try {
|
|
165
|
+
const resolved = resolveTargetFromCapture(xmlDoc, session, options.targetDescriptor, opType, options);
|
|
166
|
+
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
167
|
+
const paragraph = resolved.paragraph;
|
|
168
|
+
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
169
|
+
Object.assign(options._resolutionCapture, {
|
|
170
|
+
resolvedBy: resolved.resolvedBy,
|
|
171
|
+
resolvedTarget: {
|
|
172
|
+
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
173
|
+
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
174
|
+
text: metadata?.text ?? getParagraphText(paragraph),
|
|
175
|
+
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
176
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return resolved;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
error: {
|
|
184
|
+
code: typeof error?.code === 'string' && error.code ? error.code : 'TARGET_NOT_FOUND',
|
|
185
|
+
message: error?.message || String(error)
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const resolved = resolveTargetParagraphWithSnapshotShared(xmlDoc, {
|
|
192
|
+
targetText,
|
|
193
|
+
targetRef,
|
|
194
|
+
opType,
|
|
195
|
+
targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
|
|
196
|
+
targetDescriptor: options?.targetDescriptor || null,
|
|
197
|
+
strictAmbiguity: options?.strictTargets === true,
|
|
198
|
+
paragraphMetadataIndex,
|
|
199
|
+
onInfo,
|
|
200
|
+
onWarn
|
|
201
|
+
});
|
|
202
|
+
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
203
|
+
const paragraph = resolved.paragraph;
|
|
204
|
+
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
205
|
+
Object.assign(options._resolutionCapture, {
|
|
206
|
+
resolvedBy: resolved.resolvedBy,
|
|
207
|
+
resolvedTarget: {
|
|
208
|
+
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
209
|
+
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
210
|
+
text: metadata?.text ?? getParagraphText(paragraph),
|
|
211
|
+
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
212
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return resolved;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function extractReplacementNodes(outputOxml) {
|
|
220
|
+
return extractReplacementNodesFromOoxml(outputOxml);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function removeListPackagingSentinel(replacementNodes, warnings) {
|
|
224
|
+
if (!Array.isArray(replacementNodes) || replacementNodes.length === 0) return replacementNodes;
|
|
225
|
+
if (!Array.isArray(warnings) || !warnings.includes('Paragraph expanded to list fragment')) return replacementNodes;
|
|
226
|
+
|
|
227
|
+
const last = replacementNodes[replacementNodes.length - 1];
|
|
228
|
+
if (!last || last.localName !== 'p') return replacementNodes;
|
|
229
|
+
const meaningfulContent = Array.from(last.childNodes || []).some(child => (
|
|
230
|
+
child.nodeType === 1 && child.localName !== 'pPr'
|
|
231
|
+
));
|
|
232
|
+
if (meaningfulContent) return replacementNodes;
|
|
233
|
+
return replacementNodes.slice(0, -1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeBodySectionOrder(xmlDoc) {
|
|
237
|
+
normalizeBodySectionOrderStandalone(xmlDoc);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function removeProofErrNodes(paragraph) {
|
|
241
|
+
for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'proofErr') || [])) {
|
|
242
|
+
node.parentNode?.removeChild(node);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function preprocessRedlineTargetParagraph(targetParagraph) {
|
|
247
|
+
if (!targetParagraph) return;
|
|
248
|
+
removeProofErrNodes(targetParagraph);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getDirectWordChild(element, localName) {
|
|
252
|
+
if (!element) return null;
|
|
253
|
+
return Array.from(element.childNodes || []).find(
|
|
254
|
+
node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
|
|
255
|
+
) || null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function computeTableIndexInDocument(xmlDoc, tableElement) {
|
|
259
|
+
if (!xmlDoc || !tableElement) return null;
|
|
260
|
+
const tables = Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'tbl'));
|
|
261
|
+
const idx = tables.indexOf(tableElement);
|
|
262
|
+
return idx >= 0 ? idx + 1 : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeMultilineTableStructuralPayload(text) {
|
|
266
|
+
return String(text || '')
|
|
267
|
+
.replace(/\r\n/g, '\n')
|
|
268
|
+
.split('\n')
|
|
269
|
+
.map(line => line.trim())
|
|
270
|
+
.filter(Boolean)
|
|
271
|
+
.join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText) {
|
|
275
|
+
const tableIndex = computeTableIndexInDocument(xmlDoc, containingTable);
|
|
276
|
+
if (!Number.isInteger(tableIndex) || tableIndex < 1) return null;
|
|
277
|
+
const normalizedPayload = normalizeMultilineTableStructuralPayload(modifiedText);
|
|
278
|
+
if (!normalizedPayload) return null;
|
|
279
|
+
return `table:${tableIndex}|payload:${normalizedPayload}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function ensureListProperties(xmlDoc, paragraph, ilvl, numId) {
|
|
283
|
+
let pPr = getDirectWordChild(paragraph, 'pPr');
|
|
284
|
+
if (!pPr) {
|
|
285
|
+
pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
286
|
+
paragraph.insertBefore(pPr, paragraph.firstChild);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let numPr = getDirectWordChild(pPr, 'numPr');
|
|
290
|
+
if (!numPr) {
|
|
291
|
+
numPr = createWordElement(xmlDoc, 'w:numPr');
|
|
292
|
+
pPr.appendChild(numPr);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let ilvlEl = getDirectWordChild(numPr, 'ilvl');
|
|
296
|
+
if (!ilvlEl) {
|
|
297
|
+
ilvlEl = createWordElement(xmlDoc, 'w:ilvl');
|
|
298
|
+
numPr.appendChild(ilvlEl);
|
|
299
|
+
}
|
|
300
|
+
ilvlEl.setAttribute('w:val', String(Math.max(0, Number.parseInt(ilvl, 10) || 0)));
|
|
301
|
+
|
|
302
|
+
let numIdEl = getDirectWordChild(numPr, 'numId');
|
|
303
|
+
if (!numIdEl) {
|
|
304
|
+
numIdEl = createWordElement(xmlDoc, 'w:numId');
|
|
305
|
+
numPr.appendChild(numIdEl);
|
|
306
|
+
}
|
|
307
|
+
numIdEl.setAttribute('w:val', String(numId));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMetadata, author, options = {}) {
|
|
311
|
+
const generateRedlines = options.generateRedlines !== false;
|
|
312
|
+
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
313
|
+
|
|
314
|
+
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
315
|
+
if (anchorPPr) {
|
|
316
|
+
paragraph.appendChild(anchorPPr.cloneNode(true));
|
|
317
|
+
}
|
|
318
|
+
ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
|
|
319
|
+
|
|
320
|
+
// A new list item is a whole inserted paragraph, not merely inserted text.
|
|
321
|
+
// Tracking its paragraph mark lets Word (and our accept/reject helpers)
|
|
322
|
+
// remove the list paragraph itself on Reject All instead of leaving an
|
|
323
|
+
// empty bullet or number behind.
|
|
324
|
+
if (generateRedlines) {
|
|
325
|
+
markParagraphMarkInserted(xmlDoc, paragraph, author);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
329
|
+
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
330
|
+
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
331
|
+
if (anchorRunPr) {
|
|
332
|
+
run.appendChild(anchorRunPr.cloneNode(true));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
336
|
+
const safeText = String(entry.text || '').trim();
|
|
337
|
+
if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
|
|
338
|
+
textNode.textContent = safeText;
|
|
339
|
+
run.appendChild(textNode);
|
|
340
|
+
if (generateRedlines) {
|
|
341
|
+
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
|
|
342
|
+
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
343
|
+
ins.setAttribute('w:id', String(metadata.id));
|
|
344
|
+
ins.setAttribute('w:author', metadata.author);
|
|
345
|
+
ins.setAttribute('w:date', metadata.date);
|
|
346
|
+
ins.appendChild(run);
|
|
347
|
+
paragraph.appendChild(ins);
|
|
348
|
+
} else {
|
|
349
|
+
paragraph.appendChild(run);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return paragraph;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function serializeParagraphRangeAsDocument(paragraphs, serializer) {
|
|
356
|
+
const paragraphXml = (paragraphs || [])
|
|
357
|
+
.map(paragraph => serializer.serializeToString(paragraph))
|
|
358
|
+
.join('');
|
|
359
|
+
return `<w:document xmlns:w="${NS_W}"><w:body>${paragraphXml}</w:body></w:document>`;
|
|
360
|
+
}
|
|
361
|
+
function applyExplicitRangeListInsertions({
|
|
362
|
+
xmlDoc,
|
|
363
|
+
explicitRangeParagraphs,
|
|
364
|
+
insertionEntries,
|
|
365
|
+
generateRedlines,
|
|
366
|
+
author
|
|
367
|
+
}) {
|
|
368
|
+
if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return false;
|
|
369
|
+
if (!Array.isArray(insertionEntries) || insertionEntries.length === 0) return false;
|
|
370
|
+
|
|
371
|
+
const parent = explicitRangeParagraphs[0].parentNode;
|
|
372
|
+
if (!parent || explicitRangeParagraphs.some(paragraph => paragraph.parentNode !== parent)) return false;
|
|
373
|
+
|
|
374
|
+
const tailInsertionPoint = explicitRangeParagraphs[explicitRangeParagraphs.length - 1].nextSibling;
|
|
375
|
+
for (const entry of insertionEntries) {
|
|
376
|
+
const referenceParagraph = entry.insertBeforeOriginalIndex != null
|
|
377
|
+
? explicitRangeParagraphs[entry.insertBeforeOriginalIndex]
|
|
378
|
+
: explicitRangeParagraphs[explicitRangeParagraphs.length - 1];
|
|
379
|
+
if (!referenceParagraph) return false;
|
|
380
|
+
|
|
381
|
+
const listParagraph = buildInsertedListParagraph(
|
|
382
|
+
xmlDoc,
|
|
383
|
+
referenceParagraph,
|
|
384
|
+
{
|
|
385
|
+
ilvl: entry.ilvl,
|
|
386
|
+
markerType: entry.markerType,
|
|
387
|
+
numId: entry.numId,
|
|
388
|
+
text: entry.text
|
|
389
|
+
},
|
|
390
|
+
generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
|
|
391
|
+
author,
|
|
392
|
+
{ generateRedlines }
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
if (entry.insertBeforeOriginalIndex != null) {
|
|
396
|
+
parent.insertBefore(listParagraph, referenceParagraph);
|
|
397
|
+
} else {
|
|
398
|
+
parent.insertBefore(listParagraph, tailInsertionPoint);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
|
|
406
|
+
const generateRedlines = options.generateRedlines !== false;
|
|
407
|
+
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
408
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
409
|
+
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
410
|
+
const safeText = String(text || '');
|
|
411
|
+
if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
|
|
412
|
+
textNode.textContent = safeText;
|
|
413
|
+
run.appendChild(textNode);
|
|
414
|
+
|
|
415
|
+
if (generateRedlines) {
|
|
416
|
+
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
|
|
417
|
+
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
418
|
+
ins.setAttribute('w:id', String(metadata.id));
|
|
419
|
+
ins.setAttribute('w:author', metadata.author);
|
|
420
|
+
ins.setAttribute('w:date', metadata.date);
|
|
421
|
+
ins.appendChild(run);
|
|
422
|
+
paragraph.appendChild(ins);
|
|
423
|
+
} else {
|
|
424
|
+
paragraph.appendChild(run);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return paragraph;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
|
|
431
|
+
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
432
|
+
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
433
|
+
if (anchorPPr) paragraph.appendChild(anchorPPr.cloneNode(true));
|
|
434
|
+
|
|
435
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
436
|
+
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
437
|
+
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
438
|
+
if (anchorRunPr) run.appendChild(anchorRunPr.cloneNode(true));
|
|
439
|
+
|
|
440
|
+
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
441
|
+
textNode.textContent = '';
|
|
442
|
+
run.appendChild(textNode);
|
|
443
|
+
paragraph.appendChild(run);
|
|
444
|
+
return paragraph;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author) {
|
|
448
|
+
const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
|
|
449
|
+
const pPr = getDirectWordChild(paragraph, 'pPr');
|
|
450
|
+
if (pPr) wrappedParagraph.appendChild(pPr.cloneNode(true));
|
|
451
|
+
|
|
452
|
+
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
453
|
+
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
|
|
454
|
+
ins.setAttribute('w:id', String(metadata.id));
|
|
455
|
+
ins.setAttribute('w:author', metadata.author);
|
|
456
|
+
ins.setAttribute('w:date', metadata.date);
|
|
457
|
+
|
|
458
|
+
for (const child of Array.from(paragraph.childNodes || [])) {
|
|
459
|
+
if (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
|
|
460
|
+
ins.appendChild(child.cloneNode(true));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
wrappedParagraph.appendChild(ins);
|
|
464
|
+
return wrappedParagraph;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisionMetadata, author, options = {}) {
|
|
468
|
+
const generateRedlines = options.generateRedlines !== false;
|
|
469
|
+
const serializer = createSerializer();
|
|
470
|
+
const templateParagraph = buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph);
|
|
471
|
+
const templateXml = serializer.serializeToString(templateParagraph);
|
|
472
|
+
const markdownResult = await applyRedlineToOxml(
|
|
473
|
+
templateXml,
|
|
474
|
+
'',
|
|
475
|
+
String(text || ''),
|
|
476
|
+
{
|
|
477
|
+
author,
|
|
478
|
+
generateRedlines: false
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
let sourceParagraph = null;
|
|
483
|
+
if (typeof markdownResult?.oxml === 'string') {
|
|
484
|
+
const extracted = extractReplacementNodes(markdownResult.oxml);
|
|
485
|
+
sourceParagraph = (extracted.replacementNodes || []).find(
|
|
486
|
+
node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
487
|
+
) || null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!sourceParagraph) {
|
|
491
|
+
return buildFallbackInsertedPlainParagraph(
|
|
492
|
+
xmlDoc,
|
|
493
|
+
text,
|
|
494
|
+
revisionMetadata,
|
|
495
|
+
author,
|
|
496
|
+
{ generateRedlines }
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!generateRedlines) {
|
|
501
|
+
return sourceParagraph;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function collectNumberingIdsFromNodes(nodes) {
|
|
508
|
+
const ids = new Set();
|
|
509
|
+
for (const node of nodes || []) {
|
|
510
|
+
const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
|
|
511
|
+
for (const numIdNode of numIdNodes) {
|
|
512
|
+
const val = numIdNode.getAttribute('w:val') || numIdNode.getAttribute('val');
|
|
513
|
+
if (val != null && val !== '') {
|
|
514
|
+
ids.add(String(val));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return Array.from(ids);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function tryExplicitDecimalHeaderListConversion({
|
|
522
|
+
xmlDoc,
|
|
523
|
+
serializer,
|
|
524
|
+
documentXml,
|
|
525
|
+
operationSession,
|
|
526
|
+
targetParagraph,
|
|
527
|
+
currentParagraphText,
|
|
528
|
+
modifiedText,
|
|
529
|
+
author,
|
|
530
|
+
runtimeContext,
|
|
531
|
+
revisionIdAllocator,
|
|
532
|
+
generateRedlines = true,
|
|
533
|
+
onInfo = () => { },
|
|
534
|
+
options = {}
|
|
535
|
+
}) {
|
|
536
|
+
if (!targetParagraph) return null;
|
|
537
|
+
const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
|
|
538
|
+
const explicitPlan = buildSingleLineListStructuralFallbackPlan({
|
|
539
|
+
oxml: scopedParagraphOxml,
|
|
540
|
+
originalText: currentParagraphText,
|
|
541
|
+
modifiedText,
|
|
542
|
+
allowExistingList: false
|
|
543
|
+
});
|
|
544
|
+
if (
|
|
545
|
+
!explicitPlan ||
|
|
546
|
+
explicitPlan.numberingKey !== 'numbered:decimal:single' ||
|
|
547
|
+
!Number.isInteger(explicitPlan.startAt) ||
|
|
548
|
+
explicitPlan.startAt < 1
|
|
549
|
+
) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const strippedContent = stripSingleLineListMarkerPrefix(explicitPlan.listInput || modifiedText);
|
|
554
|
+
if (!strippedContent) return null;
|
|
555
|
+
|
|
556
|
+
onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
|
|
557
|
+
const redlineResult = await applyRedlineToOxml(
|
|
558
|
+
serializer.serializeToString(targetParagraph),
|
|
559
|
+
currentParagraphText,
|
|
560
|
+
strippedContent,
|
|
561
|
+
{
|
|
562
|
+
author,
|
|
563
|
+
generateRedlines,
|
|
564
|
+
_revisionIdAllocator: revisionIdAllocator
|
|
565
|
+
}
|
|
566
|
+
);
|
|
567
|
+
if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
|
|
568
|
+
|
|
569
|
+
const extracted = extractReplacementNodes(redlineResult.oxml);
|
|
570
|
+
const replacementNodes = extracted.replacementNodes;
|
|
571
|
+
const numberingAction = resolveSingleLineListFallbackNumberingAction(
|
|
572
|
+
explicitPlan,
|
|
573
|
+
runtimeContext?.listFallbackSequenceState || null
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const explicitStart = explicitPlan.startAt;
|
|
577
|
+
const numberingState = runtimeContext?.numberingIdState || null;
|
|
578
|
+
let appliedNumId = null;
|
|
579
|
+
let numberingXml = null;
|
|
580
|
+
|
|
581
|
+
if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
|
|
582
|
+
appliedNumId = String(numberingAction.numId);
|
|
583
|
+
onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}, next ${explicitStart + 1}).`);
|
|
584
|
+
} else {
|
|
585
|
+
const reservedPair = reserveNextNumberingIdPair(numberingState);
|
|
586
|
+
if (!reservedPair) return null;
|
|
587
|
+
|
|
588
|
+
appliedNumId = String(reservedPair.numId);
|
|
589
|
+
numberingXml = buildExplicitDecimalMultilevelNumberingXml(
|
|
590
|
+
reservedPair.numId,
|
|
591
|
+
reservedPair.abstractNumId,
|
|
592
|
+
explicitStart
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
if (numberingAction.type === 'explicitStartNew') {
|
|
596
|
+
onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}).`);
|
|
597
|
+
}
|
|
598
|
+
onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
|
|
602
|
+
runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
|
|
606
|
+
recordSingleLineListFallbackExplicitSequence(
|
|
607
|
+
runtimeContext?.listFallbackSequenceState || null,
|
|
608
|
+
numberingAction.numberingKey || explicitPlan.numberingKey,
|
|
609
|
+
appliedNumId,
|
|
610
|
+
explicitStart
|
|
611
|
+
);
|
|
612
|
+
} else {
|
|
613
|
+
clearSingleLineListFallbackExplicitSequence(
|
|
614
|
+
runtimeContext?.listFallbackSequenceState || null,
|
|
615
|
+
numberingAction.numberingKey || explicitPlan.numberingKey
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
enforceListBindingOnParagraphNodes(replacementNodes, {
|
|
620
|
+
numId: appliedNumId,
|
|
621
|
+
ilvl: 0,
|
|
622
|
+
clearParagraphPropertyChanges: true,
|
|
623
|
+
removeListPropertyNode: true
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const parent = targetParagraph.parentNode;
|
|
627
|
+
if (!parent) return null;
|
|
628
|
+
for (const node of replacementNodes) {
|
|
629
|
+
const imported = xmlDoc.importNode(node, true);
|
|
630
|
+
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
631
|
+
const inserted = parent.insertBefore(imported, targetParagraph);
|
|
632
|
+
options?._mutationLiveNodes?.push(inserted);
|
|
633
|
+
}
|
|
634
|
+
options?._mutationRemovedNodes?.push(targetParagraph);
|
|
635
|
+
parent.removeChild(targetParagraph);
|
|
636
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
637
|
+
if (operationSession?.receiptCollector && numberingXml) {
|
|
638
|
+
const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
|
|
639
|
+
for (const id of numIds) {
|
|
640
|
+
operationSession.receiptCollector.recordNumbering(id);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
645
|
+
hasChanges: true,
|
|
646
|
+
numberingXml
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function trySingleParagraphListStructuralFallback({
|
|
651
|
+
xmlDoc,
|
|
652
|
+
serializer,
|
|
653
|
+
documentXml,
|
|
654
|
+
operationSession,
|
|
655
|
+
targetParagraph,
|
|
656
|
+
currentParagraphText,
|
|
657
|
+
modifiedText,
|
|
658
|
+
author,
|
|
659
|
+
runtimeContext,
|
|
660
|
+
revisionIdAllocator,
|
|
661
|
+
generateRedlines = true,
|
|
662
|
+
onInfo = () => { },
|
|
663
|
+
options = {}
|
|
664
|
+
}) {
|
|
665
|
+
if (!targetParagraph) return null;
|
|
666
|
+
|
|
667
|
+
const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
|
|
668
|
+
const fallbackPlan = buildSingleLineListStructuralFallbackPlan({
|
|
669
|
+
oxml: scopedParagraphOxml,
|
|
670
|
+
originalText: currentParagraphText,
|
|
671
|
+
modifiedText,
|
|
672
|
+
allowExistingList: false
|
|
673
|
+
});
|
|
674
|
+
if (!fallbackPlan) return null;
|
|
675
|
+
|
|
676
|
+
onInfo('[List] No textual diff but list marker detected; forcing structural list conversion fallback.');
|
|
677
|
+
const fallbackResult = await executeSingleLineListStructuralFallback(fallbackPlan, {
|
|
678
|
+
author,
|
|
679
|
+
generateRedlines,
|
|
680
|
+
revisionIdAllocator,
|
|
681
|
+
setAbstractStartOverride: false
|
|
682
|
+
});
|
|
683
|
+
if (!fallbackResult?.hasChanges || !fallbackResult?.oxml) {
|
|
684
|
+
onInfo('[List] Structural list fallback produced no valid OOXML payload.');
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const extracted = extractReplacementNodes(fallbackResult.oxml);
|
|
689
|
+
let replacementNodes = extracted.replacementNodes;
|
|
690
|
+
let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
|
|
691
|
+
const hasExplicitStartAt = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
|
|
692
|
+
const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
|
|
693
|
+
const numberingAction = resolveSingleLineListFallbackNumberingAction(
|
|
694
|
+
fallbackPlan,
|
|
695
|
+
runtimeContext?.listFallbackSequenceState || null
|
|
696
|
+
);
|
|
697
|
+
if (hasExplicitStartAt) {
|
|
698
|
+
const explicitStart = fallbackPlan.startAt;
|
|
699
|
+
let explicitNumIdForBinding = null;
|
|
700
|
+
const numberingState = runtimeContext?.numberingIdState || null;
|
|
701
|
+
if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
|
|
702
|
+
explicitNumIdForBinding = String(numberingAction.numId);
|
|
703
|
+
numberingXml = null;
|
|
704
|
+
onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${explicitNumIdForBinding}, next ${explicitStart + 1}).`);
|
|
705
|
+
} else if (numberingState) {
|
|
706
|
+
const reservedPair = reserveNextNumberingIdPair(numberingState);
|
|
707
|
+
if (!reservedPair) return null;
|
|
708
|
+
overwriteParagraphNumIds(replacementNodes, reservedPair.numId);
|
|
709
|
+
explicitNumIdForBinding = String(reservedPair.numId);
|
|
710
|
+
numberingXml = buildExplicitDecimalMultilevelNumberingXml(
|
|
711
|
+
reservedPair.numId,
|
|
712
|
+
reservedPair.abstractNumId,
|
|
713
|
+
explicitStart
|
|
714
|
+
);
|
|
715
|
+
if (numberingAction.type === 'explicitStartNew') {
|
|
716
|
+
onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${reservedPair.numId}).`);
|
|
717
|
+
}
|
|
718
|
+
onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${reservedPair.numId}, abstractNumId ${reservedPair.abstractNumId}).`);
|
|
719
|
+
} else {
|
|
720
|
+
const generatedNumId = extractFirstParagraphNumId(replacementNodes);
|
|
721
|
+
explicitNumIdForBinding = generatedNumId ? String(generatedNumId) : null;
|
|
722
|
+
onInfo(`[List] Using isolated list numbering with explicit start ${explicitStart}${generatedNumId ? ` (numId ${generatedNumId})` : ''}.`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
|
|
726
|
+
runtimeContext.listFallbackSharedNumIdByKey.delete(numberingKey);
|
|
727
|
+
}
|
|
728
|
+
if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
|
|
729
|
+
recordSingleLineListFallbackExplicitSequence(
|
|
730
|
+
runtimeContext?.listFallbackSequenceState || null,
|
|
731
|
+
numberingAction.numberingKey || numberingKey,
|
|
732
|
+
explicitNumIdForBinding,
|
|
733
|
+
explicitStart
|
|
734
|
+
);
|
|
735
|
+
} else {
|
|
736
|
+
clearSingleLineListFallbackExplicitSequence(
|
|
737
|
+
runtimeContext?.listFallbackSequenceState || null,
|
|
738
|
+
numberingAction.numberingKey || numberingKey
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (explicitNumIdForBinding) {
|
|
743
|
+
enforceListBindingOnParagraphNodes(replacementNodes, {
|
|
744
|
+
numId: explicitNumIdForBinding,
|
|
745
|
+
ilvl: 0,
|
|
746
|
+
clearParagraphPropertyChanges: true,
|
|
747
|
+
removeListPropertyNode: true
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
if (numberingXml && runtimeContext?.numberingIdState) {
|
|
752
|
+
const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
|
|
753
|
+
replacementNodes = normalizedNumbering.replacementNodes;
|
|
754
|
+
numberingXml = normalizedNumbering.numberingXml;
|
|
755
|
+
}
|
|
756
|
+
clearSingleLineListFallbackExplicitSequence(
|
|
757
|
+
runtimeContext?.listFallbackSequenceState || null,
|
|
758
|
+
numberingAction.numberingKey || numberingKey
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (!hasExplicitStartAt && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
|
|
763
|
+
const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
|
|
764
|
+
if (sharedNumId) {
|
|
765
|
+
overwriteParagraphNumIds(replacementNodes, sharedNumId);
|
|
766
|
+
numberingXml = null;
|
|
767
|
+
onInfo(`[List] Reusing shared list numbering (${numberingKey} -> numId ${sharedNumId}).`);
|
|
768
|
+
} else if (numberingKey) {
|
|
769
|
+
const generatedNumId = extractFirstParagraphNumId(replacementNodes);
|
|
770
|
+
if (generatedNumId) {
|
|
771
|
+
runtimeContext.listFallbackSharedNumIdByKey.set(numberingKey, generatedNumId);
|
|
772
|
+
onInfo(`[List] Captured shared list numbering (${numberingKey} -> numId ${generatedNumId}).`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const parent = targetParagraph.parentNode;
|
|
778
|
+
if (!parent) return null;
|
|
779
|
+
for (const node of replacementNodes) {
|
|
780
|
+
const imported = xmlDoc.importNode(node, true);
|
|
781
|
+
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
782
|
+
const inserted = parent.insertBefore(imported, targetParagraph);
|
|
783
|
+
options?._mutationLiveNodes?.push(inserted);
|
|
784
|
+
}
|
|
785
|
+
options?._mutationRemovedNodes?.push(targetParagraph);
|
|
786
|
+
parent.removeChild(targetParagraph);
|
|
787
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
788
|
+
if (operationSession?.receiptCollector && numberingXml) {
|
|
789
|
+
const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
|
|
790
|
+
for (const id of numIds) {
|
|
791
|
+
operationSession.receiptCollector.recordNumbering(id);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
796
|
+
hasChanges: true,
|
|
797
|
+
numberingXml
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
|
|
802
|
+
const generateRedlines = options.generateRedlines !== false;
|
|
803
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
804
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
805
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
806
|
+
if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
|
|
807
|
+
const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
|
|
808
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, {
|
|
809
|
+
...options,
|
|
810
|
+
onInfo,
|
|
811
|
+
onWarn
|
|
812
|
+
});
|
|
813
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
814
|
+
return {
|
|
815
|
+
documentXml,
|
|
816
|
+
hasChanges: false,
|
|
817
|
+
numberingXml: null,
|
|
818
|
+
status: 'error',
|
|
819
|
+
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
const targetParagraph = resolved.paragraph;
|
|
823
|
+
preprocessRedlineTargetParagraph(targetParagraph);
|
|
824
|
+
const currentParagraphText = getParagraphText(targetParagraph);
|
|
825
|
+
if (modifiedText === '') {
|
|
826
|
+
const commentIds = getCommentIdsInElement(targetParagraph);
|
|
827
|
+
if (commentIds.length > 0) {
|
|
828
|
+
const comments = commentDetailsForIds(commentIds, options._existingCommentDetails);
|
|
829
|
+
return {
|
|
830
|
+
documentXml,
|
|
831
|
+
hasChanges: false,
|
|
832
|
+
numberingXml: null,
|
|
833
|
+
status: 'error',
|
|
834
|
+
error: {
|
|
835
|
+
code: 'COMMENTED_CONTENT_DELETE',
|
|
836
|
+
message: 'Refusing to delete a paragraph with existing comments. Resolve or explicitly remove the comments before deleting the paragraph.',
|
|
837
|
+
commentIds,
|
|
838
|
+
...(comments.length > 0 ? { comments } : {})
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const boundaryCheck = validateParagraphBoundaryMutation(targetParagraph, modifiedText, options);
|
|
844
|
+
if (!boundaryCheck.valid) {
|
|
845
|
+
return {
|
|
846
|
+
documentXml,
|
|
847
|
+
hasChanges: false,
|
|
848
|
+
numberingXml: null,
|
|
849
|
+
status: 'error',
|
|
850
|
+
error: {
|
|
851
|
+
code: boundaryCheck.code,
|
|
852
|
+
message: boundaryCheck.message
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
const hasRevisions = containsTrackedChanges(targetParagraph);
|
|
857
|
+
const existingPolicy = options.existingRevisions || 'merge-same-author';
|
|
858
|
+
if (
|
|
859
|
+
hasRevisions
|
|
860
|
+
&& existingPolicy !== 'accept-all-first'
|
|
861
|
+
&& existingPolicy !== 'accept-all-first-keep-normalized'
|
|
862
|
+
) {
|
|
863
|
+
if (existingPolicy === 'merge-same-author') {
|
|
864
|
+
const authors = getTrackedChangeAuthors(targetParagraph);
|
|
865
|
+
const opAuthor = String(author || '').trim().toLowerCase();
|
|
866
|
+
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
867
|
+
if (!allSame) {
|
|
868
|
+
return {
|
|
869
|
+
documentXml,
|
|
870
|
+
hasChanges: false,
|
|
871
|
+
numberingXml: null,
|
|
872
|
+
status: 'error',
|
|
873
|
+
error: {
|
|
874
|
+
code: 'EXISTING_REVISIONS',
|
|
875
|
+
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
const mergeCommentIds = getCommentIdsInElement(targetParagraph);
|
|
880
|
+
if (mergeCommentIds.length > 0) {
|
|
881
|
+
const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
|
|
882
|
+
return {
|
|
883
|
+
documentXml,
|
|
884
|
+
hasChanges: false,
|
|
885
|
+
numberingXml: null,
|
|
886
|
+
status: 'error',
|
|
887
|
+
error: {
|
|
888
|
+
code: 'COMMENTED_CONTENT_MERGE',
|
|
889
|
+
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
890
|
+
commentIds: mergeCommentIds,
|
|
891
|
+
...(comments.length > 0 ? { comments } : {})
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
|
|
897
|
+
const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
898
|
+
|| targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
899
|
+
if (hasDel) {
|
|
900
|
+
return {
|
|
901
|
+
documentXml,
|
|
902
|
+
hasChanges: false,
|
|
903
|
+
numberingXml: null,
|
|
904
|
+
status: 'error',
|
|
905
|
+
error: {
|
|
906
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
907
|
+
message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
if (hasMove) {
|
|
912
|
+
return {
|
|
913
|
+
documentXml,
|
|
914
|
+
hasChanges: false,
|
|
915
|
+
numberingXml: null,
|
|
916
|
+
status: 'error',
|
|
917
|
+
error: {
|
|
918
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
919
|
+
message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
documentXml,
|
|
925
|
+
hasChanges: false,
|
|
926
|
+
numberingXml: null,
|
|
927
|
+
status: 'error',
|
|
928
|
+
error: {
|
|
929
|
+
code: 'EXISTING_REVISIONS',
|
|
930
|
+
message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (
|
|
936
|
+
modifiedText === ''
|
|
937
|
+
&& currentParagraphText === ''
|
|
938
|
+
&& !(hasRevisions && existingPolicy === 'merge-same-author')
|
|
939
|
+
) {
|
|
940
|
+
if (generateRedlines) {
|
|
941
|
+
markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
|
|
942
|
+
options?._mutationLiveNodes?.push(targetParagraph);
|
|
943
|
+
} else {
|
|
944
|
+
options?._mutationRemovedNodes?.push(targetParagraph);
|
|
945
|
+
targetParagraph.parentNode?.removeChild(targetParagraph);
|
|
946
|
+
}
|
|
947
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
948
|
+
return {
|
|
949
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
950
|
+
hasChanges: true,
|
|
951
|
+
numberingXml: null
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
const containingTable = findContainingWordElement(targetParagraph, 'tbl');
|
|
955
|
+
const rawTableStructuralCandidate = !!containingTable
|
|
956
|
+
&& !targetEndRef
|
|
957
|
+
&& typeof modifiedText === 'string'
|
|
958
|
+
&& modifiedText.includes('\n')
|
|
959
|
+
&& !isMarkdownTableText(modifiedText);
|
|
960
|
+
const rawTableStructuralDedupeKey = rawTableStructuralCandidate
|
|
961
|
+
? computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText)
|
|
962
|
+
: null;
|
|
963
|
+
const tableStructuralDedupes = runtimeContext?.tableStructuralRedlineKeys instanceof Set
|
|
964
|
+
? runtimeContext.tableStructuralRedlineKeys
|
|
965
|
+
: null;
|
|
966
|
+
if (rawTableStructuralDedupeKey && tableStructuralDedupes?.has(rawTableStructuralDedupeKey)) {
|
|
967
|
+
onInfo('[Table] Skipping duplicate table-structural redline for the same table/payload in this turn.');
|
|
968
|
+
return {
|
|
969
|
+
documentXml,
|
|
970
|
+
hasChanges: false,
|
|
971
|
+
numberingXml: null,
|
|
972
|
+
warnings: ['Skipped duplicate table-structural redline in the same turn.']
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
const synthesizedTableMarkdown = containingTable
|
|
976
|
+
? synthesizeTableMarkdownFromMultilineCellEdit(targetParagraph, modifiedText, {
|
|
977
|
+
tableElement: containingTable,
|
|
978
|
+
currentParagraphText,
|
|
979
|
+
onInfo,
|
|
980
|
+
onWarn
|
|
981
|
+
})
|
|
982
|
+
: null;
|
|
983
|
+
let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
|
|
984
|
+
const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
|
|
985
|
+
const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
|
|
986
|
+
const explicitRangeParagraphs = targetEndRef
|
|
987
|
+
? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
|
|
988
|
+
opType: 'redline',
|
|
989
|
+
targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
|
|
990
|
+
onInfo,
|
|
991
|
+
onWarn
|
|
992
|
+
})
|
|
993
|
+
: (typeof targetText === 'string' && /\r?\n/.test(targetText)
|
|
994
|
+
? findReconstructionParagraphRange(xmlDoc, targetText)
|
|
995
|
+
: null);
|
|
996
|
+
const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
|
|
997
|
+
if (!useTableScope && hasExplicitRangeScope) {
|
|
998
|
+
const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
|
|
999
|
+
if (insertionEntries && insertionEntries.length > 0) {
|
|
1000
|
+
onInfo(`[List] Applying explicit-range insertion-only heuristic (${insertionEntries.length} new item(s)).`);
|
|
1001
|
+
for (const entry of insertionEntries) {
|
|
1002
|
+
onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
|
|
1003
|
+
}
|
|
1004
|
+
const applied = applyExplicitRangeListInsertions({
|
|
1005
|
+
xmlDoc,
|
|
1006
|
+
explicitRangeParagraphs,
|
|
1007
|
+
insertionEntries,
|
|
1008
|
+
generateRedlines,
|
|
1009
|
+
author
|
|
1010
|
+
});
|
|
1011
|
+
if (applied) {
|
|
1012
|
+
return {
|
|
1013
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1014
|
+
hasChanges: true,
|
|
1015
|
+
numberingXml: null
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
let inferredTableRangeParagraphs = null;
|
|
1021
|
+
if (!explicitRangeParagraphs && !useTableScope && isTableMarkdownEdit) {
|
|
1022
|
+
inferredTableRangeParagraphs = inferTableReplacementParagraphBlock(targetParagraph, {
|
|
1023
|
+
getParagraphText
|
|
1024
|
+
});
|
|
1025
|
+
if (inferredTableRangeParagraphs?.length > 1) {
|
|
1026
|
+
onInfo(`[Table] Heuristic range expansion selected ${inferredTableRangeParagraphs.length} paragraph(s) for replacement.`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const targetListInfo = getParagraphListInfo(targetParagraph);
|
|
1030
|
+
if (
|
|
1031
|
+
targetListInfo &&
|
|
1032
|
+
typeof effectiveModifiedText === 'string' &&
|
|
1033
|
+
!effectiveModifiedText.includes('\n')
|
|
1034
|
+
) {
|
|
1035
|
+
const strippedListPrefix = stripRedundantLeadingListMarkers(effectiveModifiedText);
|
|
1036
|
+
if (strippedListPrefix && strippedListPrefix !== effectiveModifiedText.trim()) {
|
|
1037
|
+
onInfo('[List] Stripped redundant manual list marker prefix from single-line list item edit.');
|
|
1038
|
+
effectiveModifiedText = strippedListPrefix;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (useTableScope) {
|
|
1042
|
+
onInfo('[Table] Markdown table edit detected in table cell target; applying reconciliation at table scope.');
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const bypassSingleParagraphHeuristics = options.explicitStructuredContent === true;
|
|
1046
|
+
|
|
1047
|
+
const adjacencyInsertionCandidate = (
|
|
1048
|
+
!bypassSingleParagraphHeuristics
|
|
1049
|
+
&& !useTableScope
|
|
1050
|
+
&& !hasExplicitRangeScope
|
|
1051
|
+
&& targetListInfo
|
|
1052
|
+
)
|
|
1053
|
+
? deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
|
|
1054
|
+
: null;
|
|
1055
|
+
if (adjacencyInsertionCandidate) {
|
|
1056
|
+
onInfo(`[List] Applying single-paragraph list adjacency insertion heuristic (${adjacencyInsertionCandidate.position}).`);
|
|
1057
|
+
const parent = targetParagraph.parentNode;
|
|
1058
|
+
if (!parent) throw new Error('Target paragraph has no parent for adjacency list insertion');
|
|
1059
|
+
|
|
1060
|
+
const listParagraph = buildInsertedListParagraph(
|
|
1061
|
+
xmlDoc,
|
|
1062
|
+
targetParagraph,
|
|
1063
|
+
{
|
|
1064
|
+
ilvl: targetListInfo.ilvl,
|
|
1065
|
+
numId: targetListInfo.numId,
|
|
1066
|
+
markerType: 'numbered',
|
|
1067
|
+
text: adjacencyInsertionCandidate.text
|
|
1068
|
+
},
|
|
1069
|
+
generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
|
|
1070
|
+
author,
|
|
1071
|
+
{ generateRedlines }
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
const insertionPoint = adjacencyInsertionCandidate.position === 'before'
|
|
1075
|
+
? targetParagraph
|
|
1076
|
+
: targetParagraph.nextSibling;
|
|
1077
|
+
ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
|
|
1078
|
+
parent.insertBefore(listParagraph, insertionPoint);
|
|
1079
|
+
options?._mutationLiveNodes?.push(listParagraph);
|
|
1080
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1081
|
+
return {
|
|
1082
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1083
|
+
hasChanges: true,
|
|
1084
|
+
numberingXml: null
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const plainAdjacencyInsertionCandidate = (
|
|
1089
|
+
!bypassSingleParagraphHeuristics
|
|
1090
|
+
&& !useTableScope
|
|
1091
|
+
&& !hasExplicitRangeScope
|
|
1092
|
+
&& !targetListInfo
|
|
1093
|
+
)
|
|
1094
|
+
? deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
|
|
1095
|
+
: null;
|
|
1096
|
+
if (plainAdjacencyInsertionCandidate) {
|
|
1097
|
+
onInfo(
|
|
1098
|
+
`[Text] Applying single-paragraph plain adjacency insertion heuristic `
|
|
1099
|
+
+ `(${plainAdjacencyInsertionCandidate.position}, count=${plainAdjacencyInsertionCandidate.paragraphs.length}).`
|
|
1100
|
+
);
|
|
1101
|
+
const parent = targetParagraph.parentNode;
|
|
1102
|
+
if (!parent) throw new Error('Target paragraph has no parent for plain adjacency insertion');
|
|
1103
|
+
|
|
1104
|
+
const insertionPoint = plainAdjacencyInsertionCandidate.position === 'before'
|
|
1105
|
+
? targetParagraph
|
|
1106
|
+
: targetParagraph.nextSibling;
|
|
1107
|
+
|
|
1108
|
+
for (const paragraphText of plainAdjacencyInsertionCandidate.paragraphs) {
|
|
1109
|
+
const plainParagraph = await buildInsertedPlainParagraph(
|
|
1110
|
+
xmlDoc,
|
|
1111
|
+
targetParagraph,
|
|
1112
|
+
paragraphText,
|
|
1113
|
+
generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
|
|
1114
|
+
author,
|
|
1115
|
+
{ generateRedlines }
|
|
1116
|
+
);
|
|
1117
|
+
const imported = xmlDoc.importNode(plainParagraph, true);
|
|
1118
|
+
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
1119
|
+
const inserted = parent.insertBefore(imported, insertionPoint);
|
|
1120
|
+
options?._mutationLiveNodes?.push(inserted);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1124
|
+
return {
|
|
1125
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1126
|
+
hasChanges: true,
|
|
1127
|
+
numberingXml: null
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const insertionOnlyPlan = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
|
|
1132
|
+
? planListInsertionOnlyEdit(targetParagraph, effectiveModifiedText, {
|
|
1133
|
+
currentParagraphText,
|
|
1134
|
+
onInfo,
|
|
1135
|
+
onWarn
|
|
1136
|
+
})
|
|
1137
|
+
: null;
|
|
1138
|
+
if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
|
|
1139
|
+
onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
|
|
1140
|
+
for (const entry of insertionOnlyPlan.entries) {
|
|
1141
|
+
onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
|
|
1142
|
+
}
|
|
1143
|
+
const parent = targetParagraph.parentNode;
|
|
1144
|
+
if (!parent) throw new Error('Target paragraph has no parent for list insertion');
|
|
1145
|
+
const insertionPoint = targetParagraph.nextSibling;
|
|
1146
|
+
for (const entry of insertionOnlyPlan.entries) {
|
|
1147
|
+
const listParagraph = buildInsertedListParagraph(
|
|
1148
|
+
xmlDoc,
|
|
1149
|
+
targetParagraph,
|
|
1150
|
+
{ ...entry, numId: insertionOnlyPlan.numId },
|
|
1151
|
+
generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
|
|
1152
|
+
author,
|
|
1153
|
+
{ generateRedlines }
|
|
1154
|
+
);
|
|
1155
|
+
ensureParagraphIdsOnImportedNode(listParagraph, operationSession);
|
|
1156
|
+
parent.insertBefore(listParagraph, insertionPoint);
|
|
1157
|
+
options?._mutationLiveNodes?.push(listParagraph);
|
|
1158
|
+
}
|
|
1159
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1160
|
+
return {
|
|
1161
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1162
|
+
hasChanges: true,
|
|
1163
|
+
numberingXml: null
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const listScopeEdit = (!bypassSingleParagraphHeuristics && !useTableScope && !hasExplicitRangeScope)
|
|
1168
|
+
? synthesizeExpandedListScopeEdit(targetParagraph, effectiveModifiedText, {
|
|
1169
|
+
currentParagraphText,
|
|
1170
|
+
onInfo,
|
|
1171
|
+
onWarn
|
|
1172
|
+
})
|
|
1173
|
+
: null;
|
|
1174
|
+
const useListScope = !!listScopeEdit && Array.isArray(listScopeEdit.paragraphs) && listScopeEdit.paragraphs.length > 0;
|
|
1175
|
+
if (useListScope) {
|
|
1176
|
+
effectiveModifiedText = listScopeEdit.modifiedText;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (!bypassSingleParagraphHeuristics && !useTableScope && !useListScope && !hasExplicitRangeScope) {
|
|
1180
|
+
const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
|
|
1181
|
+
xmlDoc,
|
|
1182
|
+
serializer,
|
|
1183
|
+
documentXml,
|
|
1184
|
+
operationSession,
|
|
1185
|
+
targetParagraph,
|
|
1186
|
+
currentParagraphText,
|
|
1187
|
+
modifiedText: effectiveModifiedText,
|
|
1188
|
+
author,
|
|
1189
|
+
runtimeContext,
|
|
1190
|
+
revisionIdAllocator,
|
|
1191
|
+
generateRedlines,
|
|
1192
|
+
onInfo,
|
|
1193
|
+
options
|
|
1194
|
+
});
|
|
1195
|
+
if (explicitHeaderListConversion) return explicitHeaderListConversion;
|
|
1196
|
+
|
|
1197
|
+
const listFallback = await trySingleParagraphListStructuralFallback({
|
|
1198
|
+
xmlDoc,
|
|
1199
|
+
serializer,
|
|
1200
|
+
documentXml,
|
|
1201
|
+
operationSession,
|
|
1202
|
+
targetParagraph,
|
|
1203
|
+
currentParagraphText,
|
|
1204
|
+
modifiedText: effectiveModifiedText,
|
|
1205
|
+
author,
|
|
1206
|
+
runtimeContext,
|
|
1207
|
+
revisionIdAllocator,
|
|
1208
|
+
generateRedlines,
|
|
1209
|
+
onInfo,
|
|
1210
|
+
options
|
|
1211
|
+
});
|
|
1212
|
+
if (listFallback) return listFallback;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const originalTextForApply = useListScope
|
|
1216
|
+
? listScopeEdit.originalText
|
|
1217
|
+
: (
|
|
1218
|
+
explicitRangeParagraphs
|
|
1219
|
+
? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1220
|
+
: (inferredTableRangeParagraphs
|
|
1221
|
+
? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1222
|
+
: (currentParagraphText || targetText))
|
|
1223
|
+
);
|
|
1224
|
+
const scopedXml = useTableScope
|
|
1225
|
+
? serializer.serializeToString(containingTable)
|
|
1226
|
+
: (
|
|
1227
|
+
useListScope
|
|
1228
|
+
? serializeParagraphRangeAsDocument(listScopeEdit.paragraphs, serializer)
|
|
1229
|
+
: (
|
|
1230
|
+
explicitRangeParagraphs
|
|
1231
|
+
? serializeParagraphRangeAsDocument(explicitRangeParagraphs, serializer)
|
|
1232
|
+
: (inferredTableRangeParagraphs
|
|
1233
|
+
? serializeParagraphRangeAsDocument(inferredTableRangeParagraphs, serializer)
|
|
1234
|
+
: serializer.serializeToString(targetParagraph))
|
|
1235
|
+
)
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
const result = isTableMarkdownEdit
|
|
1239
|
+
? await reconcileMarkdownTableOoxml(scopedXml, originalTextForApply, effectiveModifiedText, {
|
|
1240
|
+
author,
|
|
1241
|
+
generateRedlines,
|
|
1242
|
+
existingRevisions: options.existingRevisions || 'merge-same-author',
|
|
1243
|
+
structuredContent: options.structuredContent !== false,
|
|
1244
|
+
explicitStructuredContent: options.explicitStructuredContent === true,
|
|
1245
|
+
_revisionIdAllocator: revisionIdAllocator,
|
|
1246
|
+
_isolatedTableCell: useTableScope
|
|
1247
|
+
})
|
|
1248
|
+
: await applyRedlineToOxml(scopedXml, originalTextForApply, effectiveModifiedText, {
|
|
1249
|
+
author,
|
|
1250
|
+
generateRedlines,
|
|
1251
|
+
existingRevisions: options.existingRevisions || 'merge-same-author',
|
|
1252
|
+
structuredContent: options.structuredContent !== false,
|
|
1253
|
+
explicitStructuredContent: options.explicitStructuredContent === true,
|
|
1254
|
+
pairReplacements: options.pairReplacements === true,
|
|
1255
|
+
insertionAffinity: options.insertionAffinity || null,
|
|
1256
|
+
_revisionIdAllocator: revisionIdAllocator,
|
|
1257
|
+
_isolatedTableCell: useTableScope
|
|
1258
|
+
});
|
|
1259
|
+
if (!result?.hasChanges) {
|
|
1260
|
+
return {
|
|
1261
|
+
documentXml,
|
|
1262
|
+
hasChanges: false,
|
|
1263
|
+
numberingXml: null,
|
|
1264
|
+
status: result?.status || 'no-op',
|
|
1265
|
+
error: result?.error
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
if (result.useNativeApi && !result.oxml) {
|
|
1269
|
+
const warning = 'Format-only fallback requires native Word API; browser demo skipped this operation.';
|
|
1270
|
+
onWarn(`[WARN] ${warning}`);
|
|
1271
|
+
return { documentXml, hasChanges: false, numberingXml: null, warnings: [warning] };
|
|
1272
|
+
}
|
|
1273
|
+
if (typeof result.oxml !== 'string') {
|
|
1274
|
+
throw new Error('Reconciliation engine did not return OOXML for a changed redline operation');
|
|
1275
|
+
}
|
|
1276
|
+
const extracted = extractReplacementNodes(result.oxml);
|
|
1277
|
+
let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
|
|
1278
|
+
let numberingXml = extracted.numberingXml;
|
|
1279
|
+
if (numberingXml && runtimeContext?.numberingIdState) {
|
|
1280
|
+
const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
|
|
1281
|
+
replacementNodes = normalizedNumbering.replacementNodes;
|
|
1282
|
+
numberingXml = normalizedNumbering.numberingXml;
|
|
1283
|
+
}
|
|
1284
|
+
const scopeNodes = useTableScope
|
|
1285
|
+
? [containingTable]
|
|
1286
|
+
: (
|
|
1287
|
+
useListScope
|
|
1288
|
+
? listScopeEdit.paragraphs
|
|
1289
|
+
: (
|
|
1290
|
+
explicitRangeParagraphs
|
|
1291
|
+
? explicitRangeParagraphs
|
|
1292
|
+
: (inferredTableRangeParagraphs || [targetParagraph])
|
|
1293
|
+
)
|
|
1294
|
+
);
|
|
1295
|
+
const anchorNode = scopeNodes[0];
|
|
1296
|
+
const parent = anchorNode.parentNode;
|
|
1297
|
+
for (const node of replacementNodes) {
|
|
1298
|
+
const imported = xmlDoc.importNode(node, true);
|
|
1299
|
+
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
1300
|
+
const inserted = parent.insertBefore(imported, anchorNode);
|
|
1301
|
+
options?._mutationLiveNodes?.push(inserted);
|
|
1302
|
+
}
|
|
1303
|
+
for (const scopeNode of scopeNodes) {
|
|
1304
|
+
if (scopeNode && scopeNode.parentNode === parent) {
|
|
1305
|
+
options?._mutationRemovedNodes?.push(scopeNode);
|
|
1306
|
+
parent.removeChild(scopeNode);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1310
|
+
if (rawTableStructuralDedupeKey && tableStructuralDedupes && (useTableScope || containingTable)) {
|
|
1311
|
+
tableStructuralDedupes.add(rawTableStructuralDedupeKey);
|
|
1312
|
+
}
|
|
1313
|
+
if (operationSession?.receiptCollector && numberingXml) {
|
|
1314
|
+
const numIds = collectNumberingIdsFromNodes(options?._mutationLiveNodes);
|
|
1315
|
+
for (const id of numIds) {
|
|
1316
|
+
operationSession.receiptCollector.recordNumbering(id);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return {
|
|
1320
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1321
|
+
hasChanges: true,
|
|
1322
|
+
numberingXml,
|
|
1323
|
+
status: 'ok',
|
|
1324
|
+
...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {})
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
export async function applyHighlightToParagraphByExactText(documentXml, targetText, textToHighlight, color, author, targetRef = null, runtimeContext = null, options = {}) {
|
|
1329
|
+
const generateRedlines = options.generateRedlines !== false;
|
|
1330
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
1331
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
1332
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
1333
|
+
if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
|
|
1334
|
+
const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
|
|
1335
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'highlight', runtimeContext, {
|
|
1336
|
+
...options,
|
|
1337
|
+
onInfo,
|
|
1338
|
+
onWarn
|
|
1339
|
+
});
|
|
1340
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
1341
|
+
return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
|
|
1342
|
+
}
|
|
1343
|
+
const targetParagraph = resolved.paragraph;
|
|
1344
|
+
const paragraphXml = serializer.serializeToString(targetParagraph);
|
|
1345
|
+
const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, {
|
|
1346
|
+
generateRedlines,
|
|
1347
|
+
author,
|
|
1348
|
+
_revisionIdAllocator: revisionIdAllocator
|
|
1349
|
+
});
|
|
1350
|
+
if (!highlightedXml || highlightedXml === paragraphXml) return { documentXml, hasChanges: false };
|
|
1351
|
+
const { replacementNodes } = extractReplacementNodes(highlightedXml);
|
|
1352
|
+
const parent = targetParagraph.parentNode;
|
|
1353
|
+
for (const node of replacementNodes) {
|
|
1354
|
+
const imported = xmlDoc.importNode(node, true);
|
|
1355
|
+
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
1356
|
+
const inserted = parent.insertBefore(imported, targetParagraph);
|
|
1357
|
+
options?._mutationLiveNodes?.push(inserted);
|
|
1358
|
+
}
|
|
1359
|
+
options?._mutationRemovedNodes?.push(targetParagraph);
|
|
1360
|
+
parent.removeChild(targetParagraph);
|
|
1361
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1362
|
+
return {
|
|
1363
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1364
|
+
hasChanges: true
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
export async function applyCommentToParagraphByExactText(documentXml, targetText, textToComment, commentContent, author, targetRef = null, runtimeContext = null, options = {}) {
|
|
1369
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
1370
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
1371
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
1372
|
+
if (!xmlDoc) return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
|
|
1373
|
+
if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
|
|
1374
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'comment', runtimeContext, {
|
|
1375
|
+
...options,
|
|
1376
|
+
onInfo,
|
|
1377
|
+
onWarn
|
|
1378
|
+
});
|
|
1379
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
1380
|
+
return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
|
|
1381
|
+
}
|
|
1382
|
+
const targetParagraph = resolved.paragraph;
|
|
1383
|
+
const paragraphXml = serializer.serializeToString(targetParagraph);
|
|
1384
|
+
const commentAnchor = typeof textToComment === 'string' && textToComment.length > 0
|
|
1385
|
+
? textToComment
|
|
1386
|
+
: getParagraphText(targetParagraph);
|
|
1387
|
+
const commentResult = injectCommentsIntoOoxml(paragraphXml, [{
|
|
1388
|
+
paragraphIndex: 1,
|
|
1389
|
+
textToFind: commentAnchor,
|
|
1390
|
+
commentContent
|
|
1391
|
+
}], { author, commentIdAllocator: options.commentIdAllocator });
|
|
1392
|
+
if (commentResult.status === 'error' || commentResult.error || !commentResult.commentsApplied) {
|
|
1393
|
+
return {
|
|
1394
|
+
documentXml,
|
|
1395
|
+
hasChanges: false,
|
|
1396
|
+
commentsXml: null,
|
|
1397
|
+
status: 'error',
|
|
1398
|
+
error: commentResult.error || {
|
|
1399
|
+
code: 'ANCHOR_INSERTION_FAILED',
|
|
1400
|
+
message: 'The comment anchor was not applied to the resolved paragraph.'
|
|
1401
|
+
},
|
|
1402
|
+
warnings: commentResult.warnings || []
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
const { replacementNodes } = extractReplacementNodes(commentResult.oxml);
|
|
1406
|
+
const parent = targetParagraph.parentNode;
|
|
1407
|
+
for (const node of replacementNodes) {
|
|
1408
|
+
const imported = xmlDoc.importNode(node, true);
|
|
1409
|
+
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
1410
|
+
const inserted = parent.insertBefore(imported, targetParagraph);
|
|
1411
|
+
options?._mutationLiveNodes?.push(inserted);
|
|
1412
|
+
}
|
|
1413
|
+
options?._mutationRemovedNodes?.push(targetParagraph);
|
|
1414
|
+
parent.removeChild(targetParagraph);
|
|
1415
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1416
|
+
if (operationSession?.receiptCollector && Array.isArray(commentResult.placedComments)) {
|
|
1417
|
+
for (const c of commentResult.placedComments) {
|
|
1418
|
+
operationSession.receiptCollector.recordComment(c.id);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return {
|
|
1422
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1423
|
+
hasChanges: true,
|
|
1424
|
+
commentsXml: commentResult.commentsXml || null,
|
|
1425
|
+
warnings: commentResult.warnings || [],
|
|
1426
|
+
resolvedAnchor: commentResult.resolvedAnchors?.[0] || null
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function splitRunAtTextOffset(xmlDoc, run, localOffset, revisionIdAllocator) {
|
|
1431
|
+
const run1 = run.cloneNode(true);
|
|
1432
|
+
const run2 = run.cloneNode(true);
|
|
1433
|
+
refreshRunPropertyChangeIds(run2, revisionIdAllocator);
|
|
1434
|
+
|
|
1435
|
+
const isTextPiece = (node) => {
|
|
1436
|
+
if (node.nodeType !== 1) return false;
|
|
1437
|
+
const ln = node.localName || node.nodeName.replace(/^w:/, '');
|
|
1438
|
+
return ['t', 'tab', 'br', 'noBreakHyphen', 'delText'].includes(ln);
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
Array.from(run1.childNodes).forEach(c => { if (isTextPiece(c)) run1.removeChild(c); });
|
|
1442
|
+
Array.from(run2.childNodes).forEach(c => { if (isTextPiece(c)) run2.removeChild(c); });
|
|
1443
|
+
|
|
1444
|
+
let fullText = '';
|
|
1445
|
+
for (const child of Array.from(run.childNodes)) {
|
|
1446
|
+
if (child.nodeType !== 1) continue;
|
|
1447
|
+
const ln = child.localName || child.nodeName.replace(/^w:/, '');
|
|
1448
|
+
if (ln === 't') fullText += child.textContent || '';
|
|
1449
|
+
else if (ln === 'tab') fullText += '\t';
|
|
1450
|
+
else if (ln === 'br') fullText += '\n';
|
|
1451
|
+
else if (ln === 'noBreakHyphen') fullText += '\u2011';
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const text1 = fullText.slice(0, localOffset);
|
|
1455
|
+
const text2 = fullText.slice(localOffset);
|
|
1456
|
+
|
|
1457
|
+
const appendPieces = (targetRun, text) => {
|
|
1458
|
+
const parts = text.split(/(\t|\n|\u2011)/);
|
|
1459
|
+
for (const part of parts) {
|
|
1460
|
+
if (!part) continue;
|
|
1461
|
+
if (part === '\t') {
|
|
1462
|
+
targetRun.appendChild(createWordElement(xmlDoc, 'w:tab'));
|
|
1463
|
+
} else if (part === '\n') {
|
|
1464
|
+
targetRun.appendChild(createWordElement(xmlDoc, 'w:br'));
|
|
1465
|
+
} else if (part === '\u2011') {
|
|
1466
|
+
targetRun.appendChild(createWordElement(xmlDoc, 'w:noBreakHyphen'));
|
|
1467
|
+
} else {
|
|
1468
|
+
const tEl = createWordElement(xmlDoc, 'w:t');
|
|
1469
|
+
if (/^\s|\s$/.test(part)) tEl.setAttribute('xml:space', 'preserve');
|
|
1470
|
+
tEl.textContent = part;
|
|
1471
|
+
targetRun.appendChild(tEl);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
appendPieces(run1, text1);
|
|
1477
|
+
appendPieces(run2, text2);
|
|
1478
|
+
|
|
1479
|
+
const parent = run.parentNode;
|
|
1480
|
+
parent.insertBefore(run1, run);
|
|
1481
|
+
parent.insertBefore(run2, run);
|
|
1482
|
+
parent.removeChild(run);
|
|
1483
|
+
|
|
1484
|
+
return [run1, run2];
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
export async function applyFormattingToParagraphByExactText(
|
|
1488
|
+
documentXml,
|
|
1489
|
+
targetText,
|
|
1490
|
+
textToFormat,
|
|
1491
|
+
properties = {},
|
|
1492
|
+
author,
|
|
1493
|
+
targetRef = null,
|
|
1494
|
+
runtimeContext = null,
|
|
1495
|
+
options = {}
|
|
1496
|
+
) {
|
|
1497
|
+
const generateRedlines = options.generateRedlines !== false;
|
|
1498
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
1499
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
1500
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
1501
|
+
if (!xmlDoc) {
|
|
1502
|
+
return {
|
|
1503
|
+
documentXml,
|
|
1504
|
+
hasChanges: false,
|
|
1505
|
+
status: 'error',
|
|
1506
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
const revisionIdAllocator = operationSession?.revisionIdAllocator || prepareRevisionAllocator(xmlDoc, options);
|
|
1510
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'format', runtimeContext, {
|
|
1511
|
+
...options,
|
|
1512
|
+
onInfo,
|
|
1513
|
+
onWarn
|
|
1514
|
+
});
|
|
1515
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
1516
|
+
return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
|
|
1517
|
+
}
|
|
1518
|
+
const targetParagraph = resolved.paragraph;
|
|
1519
|
+
|
|
1520
|
+
const collectVisibleRuns = () => {
|
|
1521
|
+
const runEntries = [];
|
|
1522
|
+
const allRuns = Array.from(targetParagraph.getElementsByTagNameNS(NS_W, 'r'));
|
|
1523
|
+
for (const run of allRuns) {
|
|
1524
|
+
let parent = run.parentNode;
|
|
1525
|
+
let isDel = false;
|
|
1526
|
+
while (parent && parent !== targetParagraph) {
|
|
1527
|
+
if (parent.nodeType === 1 && (parent.localName === 'del' || parent.nodeName === 'w:del')) {
|
|
1528
|
+
isDel = true;
|
|
1529
|
+
break;
|
|
1530
|
+
}
|
|
1531
|
+
parent = parent.parentNode;
|
|
1532
|
+
}
|
|
1533
|
+
if (isDel) continue;
|
|
1534
|
+
|
|
1535
|
+
let runText = '';
|
|
1536
|
+
for (const child of Array.from(run.childNodes)) {
|
|
1537
|
+
if (child.nodeType !== 1) continue;
|
|
1538
|
+
const ln = child.localName || child.nodeName.replace(/^w:/, '');
|
|
1539
|
+
if (ln === 't') runText += child.textContent || '';
|
|
1540
|
+
else if (ln === 'tab') runText += '\t';
|
|
1541
|
+
else if (ln === 'br') runText += '\n';
|
|
1542
|
+
else if (ln === 'noBreakHyphen') runText += '\u2011';
|
|
1543
|
+
}
|
|
1544
|
+
if (runText.length > 0) {
|
|
1545
|
+
runEntries.push({ run, text: runText });
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return runEntries;
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
let runEntries = collectVisibleRuns();
|
|
1552
|
+
let fullParaText = '';
|
|
1553
|
+
let spanRanges = [];
|
|
1554
|
+
for (const entry of runEntries) {
|
|
1555
|
+
const start = fullParaText.length;
|
|
1556
|
+
fullParaText += entry.text;
|
|
1557
|
+
const end = fullParaText.length;
|
|
1558
|
+
spanRanges.push({ ...entry, start, end });
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const targetDescriptor = options.targetDescriptor;
|
|
1562
|
+
const occurrence = targetDescriptor?.occurrence ?? null;
|
|
1563
|
+
let matchStart = -1;
|
|
1564
|
+
if (occurrence != null && occurrence > 0) {
|
|
1565
|
+
let count = 0;
|
|
1566
|
+
let offset = 0;
|
|
1567
|
+
while (offset <= fullParaText.length - textToFormat.length) {
|
|
1568
|
+
const idx = fullParaText.indexOf(textToFormat, offset);
|
|
1569
|
+
if (idx === -1) break;
|
|
1570
|
+
count++;
|
|
1571
|
+
if (count === occurrence) {
|
|
1572
|
+
matchStart = idx;
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
offset = idx + 1;
|
|
1576
|
+
}
|
|
1577
|
+
} else {
|
|
1578
|
+
matchStart = fullParaText.indexOf(textToFormat);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (matchStart === -1) {
|
|
1582
|
+
return {
|
|
1583
|
+
documentXml,
|
|
1584
|
+
hasChanges: false,
|
|
1585
|
+
status: 'error',
|
|
1586
|
+
error: {
|
|
1587
|
+
code: 'TARGET_NOT_FOUND',
|
|
1588
|
+
message: `Text to format was not found in target paragraph: "${textToFormat}".`
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
const matchEnd = matchStart + textToFormat.length;
|
|
1593
|
+
|
|
1594
|
+
// Split runs at matchStart boundary
|
|
1595
|
+
let currentRuns = [];
|
|
1596
|
+
for (const span of spanRanges) {
|
|
1597
|
+
if (span.start < matchStart && span.end > matchStart) {
|
|
1598
|
+
const localOffset = matchStart - span.start;
|
|
1599
|
+
const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
|
|
1600
|
+
currentRuns.push({ run: r1, start: span.start, end: matchStart, text: span.text.slice(0, localOffset) });
|
|
1601
|
+
currentRuns.push({ run: r2, start: matchStart, end: span.end, text: span.text.slice(localOffset) });
|
|
1602
|
+
} else {
|
|
1603
|
+
currentRuns.push(span);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Split runs at matchEnd boundary
|
|
1608
|
+
const finalRuns = [];
|
|
1609
|
+
for (const span of currentRuns) {
|
|
1610
|
+
if (span.start < matchEnd && span.end > matchEnd) {
|
|
1611
|
+
const localOffset = matchEnd - span.start;
|
|
1612
|
+
const [r1, r2] = splitRunAtTextOffset(xmlDoc, span.run, localOffset, revisionIdAllocator);
|
|
1613
|
+
finalRuns.push({ run: r1, start: span.start, end: matchEnd, text: span.text.slice(0, localOffset) });
|
|
1614
|
+
finalRuns.push({ run: r2, start: matchEnd, end: span.end, text: span.text.slice(localOffset) });
|
|
1615
|
+
} else {
|
|
1616
|
+
finalRuns.push(span);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
const targetRuns = finalRuns.filter(span => span.start >= matchStart && span.end <= matchEnd);
|
|
1621
|
+
|
|
1622
|
+
let anyChanged = false;
|
|
1623
|
+
for (const entry of targetRuns) {
|
|
1624
|
+
const rPr = entry.run.getElementsByTagNameNS(NS_W, 'rPr')[0] || null;
|
|
1625
|
+
if (checkRunPropertiesChanged(rPr, properties)) {
|
|
1626
|
+
anyChanged = true;
|
|
1627
|
+
break;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (!anyChanged) {
|
|
1632
|
+
return { documentXml, hasChanges: false, status: 'no-op' };
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
for (const entry of targetRuns) {
|
|
1636
|
+
const run = entry.run;
|
|
1637
|
+
let rPr = run.getElementsByTagNameNS(NS_W, 'rPr')[0];
|
|
1638
|
+
if (!rPr) {
|
|
1639
|
+
rPr = createWordElement(xmlDoc, 'w:rPr');
|
|
1640
|
+
run.insertBefore(rPr, run.firstChild);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
let insAncestor = run.parentNode;
|
|
1644
|
+
let insideSameAuthorIns = false;
|
|
1645
|
+
while (insAncestor && insAncestor !== targetParagraph) {
|
|
1646
|
+
if (insAncestor.nodeType === 1 && (insAncestor.localName === 'ins' || insAncestor.nodeName === 'w:ins')) {
|
|
1647
|
+
const insAuthor = insAncestor.getAttribute('w:author') || insAncestor.getAttributeNS(NS_W, 'author');
|
|
1648
|
+
if (insAuthor === (author || getDefaultAuthor())) {
|
|
1649
|
+
insideSameAuthorIns = true;
|
|
1650
|
+
}
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
insAncestor = insAncestor.parentNode;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const coalesce = insideSameAuthorIns && options.formattingRevisionPolicy === 'coalesce-own-insertion';
|
|
1657
|
+
|
|
1658
|
+
if (generateRedlines && !coalesce) {
|
|
1659
|
+
snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor());
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
applyCharacterFormatToRPr(xmlDoc, rPr, properties);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1666
|
+
ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
|
|
1667
|
+
options?._mutationLiveNodes?.push(targetParagraph);
|
|
1668
|
+
|
|
1669
|
+
return {
|
|
1670
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1671
|
+
hasChanges: true,
|
|
1672
|
+
status: 'ok'
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
export async function applyParagraphFormatToParagraphByExactText(
|
|
1677
|
+
documentXml,
|
|
1678
|
+
targetText,
|
|
1679
|
+
properties = {},
|
|
1680
|
+
author,
|
|
1681
|
+
targetRef = null,
|
|
1682
|
+
runtimeContext = null,
|
|
1683
|
+
options = {}
|
|
1684
|
+
) {
|
|
1685
|
+
const generateRedlines = options.generateRedlines !== false;
|
|
1686
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
1687
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
1688
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
1689
|
+
if (!xmlDoc) {
|
|
1690
|
+
return {
|
|
1691
|
+
documentXml,
|
|
1692
|
+
hasChanges: false,
|
|
1693
|
+
status: 'error',
|
|
1694
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
if (!operationSession) prepareRevisionAllocator(xmlDoc, options);
|
|
1698
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'paragraph-format', runtimeContext, {
|
|
1699
|
+
...options,
|
|
1700
|
+
onInfo,
|
|
1701
|
+
onWarn
|
|
1702
|
+
});
|
|
1703
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
1704
|
+
return { documentXml, hasChanges: false, status: 'error', error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' } };
|
|
1705
|
+
}
|
|
1706
|
+
const targetParagraph = resolved.paragraph;
|
|
1707
|
+
|
|
1708
|
+
let pPr = targetParagraph.getElementsByTagNameNS(NS_W, 'pPr')[0];
|
|
1709
|
+
if (!pPr) {
|
|
1710
|
+
pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
1711
|
+
targetParagraph.insertBefore(pPr, targetParagraph.firstChild);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const hasChanges = checkParagraphPropertiesChanged(pPr, properties);
|
|
1715
|
+
if (!hasChanges) {
|
|
1716
|
+
return { documentXml, hasChanges: false, status: 'no-op' };
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
if (generateRedlines) {
|
|
1720
|
+
snapshotAndAttachPPrChange(xmlDoc, pPr, author || getDefaultAuthor());
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
applyParagraphPropertiesToPPr(xmlDoc, pPr, properties);
|
|
1724
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1725
|
+
ensureParagraphIdsOnImportedNode(targetParagraph, operationSession);
|
|
1726
|
+
options?._mutationLiveNodes?.push(targetParagraph);
|
|
1727
|
+
|
|
1728
|
+
return {
|
|
1729
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1730
|
+
hasChanges: true,
|
|
1731
|
+
status: 'ok'
|
|
1732
|
+
};
|
|
1733
|
+
}
|