@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
|
@@ -15,6 +15,13 @@ function createTargetNotFoundError(message) {
|
|
|
15
15
|
return error;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function createTargetError(code, message, candidates = null) {
|
|
19
|
+
const error = new Error(message);
|
|
20
|
+
error.code = code;
|
|
21
|
+
if (Array.isArray(candidates)) error.candidates = candidates;
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export const WORD_MAIN_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
19
26
|
|
|
20
27
|
function getElementsByLocalName(node, localName) {
|
|
@@ -33,26 +40,8 @@ function getElementsByLocalName(node, localName) {
|
|
|
33
40
|
return toArray(node.getElementsByTagName(localName));
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const visit = node => {
|
|
40
|
-
for (const child of toArray(node?.childNodes)) {
|
|
41
|
-
if (child?.nodeType !== 1) continue;
|
|
42
|
-
const localName = String(child.localName || child.nodeName || '').replace(/^.*:/, '');
|
|
43
|
-
if (localName === 't') {
|
|
44
|
-
text += child.textContent || '';
|
|
45
|
-
} else if (localName === 'tab') {
|
|
46
|
-
text += '\t';
|
|
47
|
-
} else {
|
|
48
|
-
visit(child);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
visit(paragraph);
|
|
54
|
-
return text;
|
|
55
|
-
}
|
|
43
|
+
import { extractCanonicalParagraphText } from './paragraph-text.js';
|
|
44
|
+
import { isWordElement } from './word-xml.js';
|
|
56
45
|
|
|
57
46
|
/**
|
|
58
47
|
* Reads visible text from a paragraph by concatenating `w:t` nodes and mapping
|
|
@@ -63,7 +52,7 @@ function toParagraphText(paragraph) {
|
|
|
63
52
|
*/
|
|
64
53
|
export function getParagraphText(paragraph) {
|
|
65
54
|
if (!paragraph) return '';
|
|
66
|
-
return
|
|
55
|
+
return extractCanonicalParagraphText(paragraph);
|
|
67
56
|
}
|
|
68
57
|
|
|
69
58
|
/**
|
|
@@ -79,6 +68,94 @@ export function getDocumentParagraphNodes(xmlDoc) {
|
|
|
79
68
|
return getElementsByLocalName(searchRoot, 'p');
|
|
80
69
|
}
|
|
81
70
|
|
|
71
|
+
export function getParagraphId(paragraph) {
|
|
72
|
+
if (!paragraph) return null;
|
|
73
|
+
const attribute = toArray(paragraph.attributes).find(candidate =>
|
|
74
|
+
String(candidate?.localName || candidate?.name || '').replace(/^.*:/, '') === 'paraId'
|
|
75
|
+
);
|
|
76
|
+
return attribute?.value || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createParagraphFingerprint(paragraph, metadata = {}) {
|
|
80
|
+
if (!paragraph) return null;
|
|
81
|
+
const revisionView = metadata.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
82
|
+
const text = typeof metadata.text === 'string'
|
|
83
|
+
? metadata.text
|
|
84
|
+
: extractCanonicalParagraphText(paragraph, { revisionView });
|
|
85
|
+
const documentIndex = Number.isInteger(metadata.index)
|
|
86
|
+
? metadata.index
|
|
87
|
+
: getDocumentParagraphNodes(paragraph.ownerDocument || paragraph).indexOf(paragraph) + 1;
|
|
88
|
+
const paragraphId = metadata.paragraphId === undefined ? getParagraphId(paragraph) : metadata.paragraphId;
|
|
89
|
+
const inTable = typeof metadata.inTable === 'boolean' ? metadata.inTable : isParagraphInTable(paragraph);
|
|
90
|
+
const viewPart = revisionView === 'rejected' ? 'rejected\u001f' : '';
|
|
91
|
+
const identity = `${paragraphId || ''}\u001f${documentIndex}\u001f${inTable ? 'table' : 'body'}\u001f${viewPart}${text}`;
|
|
92
|
+
let hash = 0x811c9dc5;
|
|
93
|
+
for (let i = 0; i < identity.length; i++) {
|
|
94
|
+
hash ^= identity.charCodeAt(i);
|
|
95
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
96
|
+
}
|
|
97
|
+
return `fnv1a32:${hash.toString(16).padStart(8, '0')}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Builds immutable paragraph metadata and grouped lookup maps for one document
|
|
102
|
+
* state. The index is intentionally session-scoped because it retains DOM
|
|
103
|
+
* nodes and must be discarded after a mutation.
|
|
104
|
+
*
|
|
105
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document root
|
|
106
|
+
* @param {Object} [options={}] - Options
|
|
107
|
+
* @param {'accepted'|'rejected'} [options.revisionView='accepted'] - Revision view
|
|
108
|
+
* @returns {{revisionView: 'accepted'|'rejected', entries: ReadonlyArray<Object>, byParagraph: Map<Element,Object>, byId: Map<string,Object>, byNormalizedText: Map<string,ReadonlyArray<Object>>}}
|
|
109
|
+
*/
|
|
110
|
+
export function buildParagraphMetadataIndex(xmlDoc, options = {}) {
|
|
111
|
+
const revisionView = options.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
112
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
113
|
+
const entries = [];
|
|
114
|
+
const byParagraph = new Map();
|
|
115
|
+
const byId = new Map();
|
|
116
|
+
const grouped = new Map();
|
|
117
|
+
|
|
118
|
+
for (let offset = 0; offset < paragraphs.length; offset++) {
|
|
119
|
+
const paragraph = paragraphs[offset];
|
|
120
|
+
const text = extractCanonicalParagraphText(paragraph, { revisionView });
|
|
121
|
+
const paragraphId = getParagraphId(paragraph);
|
|
122
|
+
const inTable = isParagraphInTable(paragraph);
|
|
123
|
+
const normalizedText = normalizeWhitespaceForTargeting(text);
|
|
124
|
+
const entry = Object.freeze({
|
|
125
|
+
paragraph,
|
|
126
|
+
index: offset + 1,
|
|
127
|
+
paragraphId,
|
|
128
|
+
text,
|
|
129
|
+
normalizedText,
|
|
130
|
+
revisionView,
|
|
131
|
+
fingerprint: createParagraphFingerprint(paragraph, {
|
|
132
|
+
text,
|
|
133
|
+
index: offset + 1,
|
|
134
|
+
paragraphId,
|
|
135
|
+
inTable,
|
|
136
|
+
revisionView
|
|
137
|
+
}),
|
|
138
|
+
inTable
|
|
139
|
+
});
|
|
140
|
+
entries.push(entry);
|
|
141
|
+
byParagraph.set(paragraph, entry);
|
|
142
|
+
if (paragraphId && !byId.has(paragraphId)) byId.set(paragraphId, entry);
|
|
143
|
+
if (normalizedText) {
|
|
144
|
+
if (!grouped.has(normalizedText)) grouped.set(normalizedText, []);
|
|
145
|
+
grouped.get(normalizedText).push(entry);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const [key, values] of grouped) grouped.set(key, Object.freeze(values));
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
revisionView,
|
|
152
|
+
entries: Object.freeze(entries),
|
|
153
|
+
byParagraph,
|
|
154
|
+
byId,
|
|
155
|
+
byNormalizedText: grouped
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
82
159
|
/**
|
|
83
160
|
* Normalizes whitespace for paragraph-comparison matching.
|
|
84
161
|
*
|
|
@@ -157,10 +234,12 @@ export function splitLeadingParagraphMarker(text) {
|
|
|
157
234
|
* @param {number|null|undefined} targetRef - 1-based paragraph number
|
|
158
235
|
* @returns {Element|null}
|
|
159
236
|
*/
|
|
160
|
-
export function findParagraphByReference(xmlDoc, targetRef) {
|
|
237
|
+
export function findParagraphByReference(xmlDoc, targetRef, paragraphMetadataIndex = null) {
|
|
161
238
|
if (!Number.isInteger(targetRef) || targetRef < 1) return null;
|
|
162
|
-
|
|
163
|
-
|
|
239
|
+
if (paragraphMetadataIndex?.entries) {
|
|
240
|
+
return paragraphMetadataIndex.entries[targetRef - 1]?.paragraph || null;
|
|
241
|
+
}
|
|
242
|
+
return getDocumentParagraphNodes(xmlDoc)[targetRef - 1] || null;
|
|
164
243
|
}
|
|
165
244
|
|
|
166
245
|
/**
|
|
@@ -193,15 +272,22 @@ export function findContainingWordElement(node, localName, namespaceUri = WORD_M
|
|
|
193
272
|
* @param {string} targetText - Target paragraph text
|
|
194
273
|
* @returns {Element|null}
|
|
195
274
|
*/
|
|
196
|
-
export function findParagraphByStrictText(xmlDoc, targetText) {
|
|
197
|
-
const
|
|
275
|
+
export function findParagraphByStrictText(xmlDoc, targetText, options = {}) {
|
|
276
|
+
const metadataIndex = options.paragraphMetadataIndex || null;
|
|
277
|
+
const entries = metadataIndex?.entries || null;
|
|
278
|
+
const paragraphs = entries ? null : getDocumentParagraphNodes(xmlDoc);
|
|
198
279
|
const normalizedTarget = String(targetText || '').trim();
|
|
199
280
|
if (!normalizedTarget) return null;
|
|
200
281
|
|
|
201
|
-
const exact =
|
|
282
|
+
const exact = entries
|
|
283
|
+
? entries.find(entry => entry.text.trim() === normalizedTarget)?.paragraph
|
|
284
|
+
: paragraphs.find(p => getParagraphText(p).trim() === normalizedTarget);
|
|
202
285
|
if (exact) return exact;
|
|
203
286
|
|
|
204
287
|
const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
|
|
288
|
+
if (metadataIndex?.byNormalizedText) {
|
|
289
|
+
return metadataIndex.byNormalizedText.get(normTarget)?.[0]?.paragraph || null;
|
|
290
|
+
}
|
|
205
291
|
return paragraphs.find(p => normalizeWhitespaceForTargeting(getParagraphText(p)) === normTarget) || null;
|
|
206
292
|
}
|
|
207
293
|
|
|
@@ -215,28 +301,34 @@ export function findParagraphByStrictText(xmlDoc, targetText) {
|
|
|
215
301
|
*/
|
|
216
302
|
export function findParagraphByBestTextMatch(xmlDoc, targetText, options = {}) {
|
|
217
303
|
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
218
|
-
const
|
|
304
|
+
const metadataIndex = options.paragraphMetadataIndex || null;
|
|
305
|
+
const entries = metadataIndex?.entries || null;
|
|
306
|
+
const paragraphs = entries ? null : getDocumentParagraphNodes(xmlDoc);
|
|
219
307
|
const normalizedTarget = String(targetText || '').trim();
|
|
220
308
|
if (!normalizedTarget) return null;
|
|
221
309
|
|
|
222
|
-
const strictMatch = findParagraphByStrictText(xmlDoc, normalizedTarget);
|
|
310
|
+
const strictMatch = findParagraphByStrictText(xmlDoc, normalizedTarget, { paragraphMetadataIndex: metadataIndex });
|
|
223
311
|
if (strictMatch) return strictMatch;
|
|
224
312
|
|
|
225
313
|
const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
|
|
226
314
|
|
|
227
|
-
const startsWithMatch =
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
315
|
+
const startsWithMatch = entries
|
|
316
|
+
? (entries.find(entry => entry.normalizedText.length > 10 && normTarget.startsWith(entry.normalizedText))?.paragraph || null)
|
|
317
|
+
: paragraphs.find(p => {
|
|
318
|
+
const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
|
|
319
|
+
return paragraphText.length > 10 && normTarget.startsWith(paragraphText);
|
|
320
|
+
});
|
|
231
321
|
if (startsWithMatch) {
|
|
232
322
|
onInfo(`[Fuzzy] Prefix match (target starts with paragraph): "${getParagraphText(startsWithMatch).trim().slice(0, 60)}..."`);
|
|
233
323
|
return startsWithMatch;
|
|
234
324
|
}
|
|
235
325
|
|
|
236
|
-
const containsMatch =
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
326
|
+
const containsMatch = entries
|
|
327
|
+
? (entries.find(entry => entry.normalizedText.length > 15 && normTarget.includes(entry.normalizedText))?.paragraph || null)
|
|
328
|
+
: paragraphs.find(p => {
|
|
329
|
+
const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
|
|
330
|
+
return paragraphText.length > 15 && normTarget.includes(paragraphText);
|
|
331
|
+
});
|
|
240
332
|
if (containsMatch) {
|
|
241
333
|
onInfo(`[Fuzzy] Contains match: "${getParagraphText(containsMatch).trim().slice(0, 60)}..."`);
|
|
242
334
|
return containsMatch;
|
|
@@ -245,8 +337,10 @@ export function findParagraphByBestTextMatch(xmlDoc, targetText, options = {}) {
|
|
|
245
337
|
let bestScore = 0;
|
|
246
338
|
let bestParagraph = null;
|
|
247
339
|
const targetWords = new Set(normTarget.toLowerCase().split(/\s+/).filter(word => word.length > 2));
|
|
248
|
-
|
|
249
|
-
|
|
340
|
+
const candidateCount = entries?.length ?? paragraphs.length;
|
|
341
|
+
for (let index = 0; index < candidateCount; index++) {
|
|
342
|
+
const paragraph = entries?.[index]?.paragraph ?? paragraphs[index];
|
|
343
|
+
const paragraphText = (entries?.[index]?.text ?? getParagraphText(paragraph)).trim();
|
|
250
344
|
if (!paragraphText) continue;
|
|
251
345
|
|
|
252
346
|
const paragraphWords = normalizeWhitespaceForTargeting(paragraphText)
|
|
@@ -291,15 +385,173 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
|
|
|
291
385
|
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
292
386
|
const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
|
|
293
387
|
const opType = options.opType || 'operation';
|
|
294
|
-
const
|
|
295
|
-
|
|
388
|
+
const descriptor = options.targetDescriptor && typeof options.targetDescriptor === 'object'
|
|
389
|
+
? options.targetDescriptor
|
|
390
|
+
: null;
|
|
391
|
+
const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
392
|
+
const cleanTargetText = String(descriptor?.exactText ?? descriptor?.text ?? options.targetText ?? '').trim();
|
|
393
|
+
const parsedRef = parseParagraphReference(descriptor?.index ?? descriptor?.paragraphIndex ?? options.targetRef);
|
|
394
|
+
const strictAmbiguity = options.strictAmbiguity === true;
|
|
395
|
+
let paragraphMetadataIndex = options.paragraphMetadataIndex || null;
|
|
396
|
+
if (paragraphMetadataIndex && paragraphMetadataIndex.revisionView !== revisionView) {
|
|
397
|
+
paragraphMetadataIndex = options.metadataIndices?.[revisionView]
|
|
398
|
+
|| buildParagraphMetadataIndex(xmlDoc, { revisionView });
|
|
399
|
+
} else if (!paragraphMetadataIndex) {
|
|
400
|
+
paragraphMetadataIndex = buildParagraphMetadataIndex(xmlDoc, { revisionView });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (descriptor?.paragraphId) {
|
|
404
|
+
const byId = findParagraphById(xmlDoc, descriptor.paragraphId, paragraphMetadataIndex);
|
|
405
|
+
if (!byId) {
|
|
406
|
+
throw createTargetError(
|
|
407
|
+
'TARGET_NOT_FOUND',
|
|
408
|
+
`Target paragraphId not found: "${descriptor.paragraphId}".`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const cachedEntry = paragraphMetadataIndex?.byParagraph?.get(byId) || null;
|
|
412
|
+
const actualIndex = cachedEntry?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(byId) + 1;
|
|
413
|
+
const actualFingerprint = cachedEntry?.fingerprint
|
|
414
|
+
|| createParagraphFingerprint(byId, { revisionView });
|
|
415
|
+
const actualInTable = cachedEntry?.inTable ?? isParagraphInTable(byId);
|
|
416
|
+
const actualText = cachedEntry?.normalizedText
|
|
417
|
+
|| normalizeWhitespaceForTargeting(extractCanonicalParagraphText(byId, { revisionView }));
|
|
418
|
+
|
|
419
|
+
if (parsedRef != null && parsedRef !== actualIndex) {
|
|
420
|
+
throw createTargetError(
|
|
421
|
+
'TARGET_INDEX_MISMATCH',
|
|
422
|
+
`Target paragraphId "${descriptor.paragraphId}" (index ${actualIndex}) does not match requested index ${parsedRef}.`,
|
|
423
|
+
cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (descriptor.fingerprint && descriptor.fingerprint !== actualFingerprint) {
|
|
427
|
+
throw createTargetError(
|
|
428
|
+
'TARGET_FINGERPRINT_MISMATCH',
|
|
429
|
+
`Target paragraphId "${descriptor.paragraphId}" no longer matches its source fingerprint.`,
|
|
430
|
+
cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (typeof descriptor.inTable === 'boolean' && descriptor.inTable !== actualInTable) {
|
|
434
|
+
throw createTargetError(
|
|
435
|
+
'TARGET_CONTEXT_MISMATCH',
|
|
436
|
+
`Target paragraphId "${descriptor.paragraphId}" does not match the requested table context.`,
|
|
437
|
+
cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (cleanTargetText && actualText !== normalizeWhitespaceForTargeting(cleanTargetText)) {
|
|
441
|
+
throw createTargetError(
|
|
442
|
+
'TARGET_TEXT_MISMATCH',
|
|
443
|
+
`Target paragraphId "${descriptor.paragraphId}" no longer matches the supplied text.`,
|
|
444
|
+
cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (descriptor.occurrence != null) {
|
|
448
|
+
const textToFind = cleanTargetText || actualText;
|
|
449
|
+
const textCandidates = findStrictTargetCandidates(xmlDoc, textToFind, paragraphMetadataIndex);
|
|
450
|
+
const actualOccurrence = textCandidates.findIndex(c => c.paragraph === byId) + 1;
|
|
451
|
+
if (actualOccurrence === 0 || actualOccurrence !== descriptor.occurrence) {
|
|
452
|
+
throw createTargetError(
|
|
453
|
+
'TARGET_OCCURRENCE_MISMATCH',
|
|
454
|
+
`Target paragraphId "${descriptor.paragraphId}" matches occurrence ${actualOccurrence}, not requested occurrence ${descriptor.occurrence}.`,
|
|
455
|
+
textCandidates.map(serializeTargetCandidate)
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { paragraph: byId, resolvedBy: 'paragraph_id' };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let candidates = [];
|
|
463
|
+
if (cleanTargetText) {
|
|
464
|
+
const unfilteredCandidates = findStrictTargetCandidates(xmlDoc, cleanTargetText, paragraphMetadataIndex);
|
|
465
|
+
candidates = filterTargetCandidates(unfilteredCandidates, descriptor);
|
|
466
|
+
|
|
467
|
+
if (descriptor?.fingerprint && unfilteredCandidates.length > 0 && candidates.length === 0) {
|
|
468
|
+
throw createTargetError(
|
|
469
|
+
'TARGET_FINGERPRINT_MISMATCH',
|
|
470
|
+
'Target text matched, but no paragraph matched the supplied source fingerprint.',
|
|
471
|
+
unfilteredCandidates.map(serializeTargetCandidate)
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (
|
|
476
|
+
typeof descriptor?.inTable === 'boolean'
|
|
477
|
+
&& unfilteredCandidates.length > 0
|
|
478
|
+
&& candidates.length === 0
|
|
479
|
+
) {
|
|
480
|
+
throw createTargetError(
|
|
481
|
+
'TARGET_CONTEXT_MISMATCH',
|
|
482
|
+
'Target text matched, but no paragraph matched the requested table context.',
|
|
483
|
+
unfilteredCandidates.map(serializeTargetCandidate)
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
if (descriptor?.occurrence) {
|
|
489
|
+
const occurrenceMatch = candidates[descriptor.occurrence - 1] || null;
|
|
490
|
+
if (!occurrenceMatch) {
|
|
491
|
+
throw createTargetError(
|
|
492
|
+
'TARGET_NOT_FOUND',
|
|
493
|
+
`Target occurrence ${descriptor.occurrence} was not found.`,
|
|
494
|
+
candidates.map(serializeTargetCandidate)
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return { paragraph: occurrenceMatch.paragraph, resolvedBy: 'occurrence' };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (strictAmbiguity) {
|
|
501
|
+
if (parsedRef) {
|
|
502
|
+
const byReference = candidates.find(candidate => candidate.index === parsedRef) || null;
|
|
503
|
+
if (byReference) return { paragraph: byReference.paragraph, resolvedBy: 'ref' };
|
|
504
|
+
if (descriptor?.fingerprint && candidates.length > 0) {
|
|
505
|
+
throw createTargetError(
|
|
506
|
+
'TARGET_FINGERPRINT_MISMATCH',
|
|
507
|
+
`Target fingerprint does not match paragraph reference [P${parsedRef}].`,
|
|
508
|
+
candidates.map(serializeTargetCandidate)
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (candidates.length === 1) {
|
|
512
|
+
return { paragraph: candidates[0].paragraph, resolvedBy: 'strict_text_after_ref_drift' };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (candidates.length > 1) {
|
|
516
|
+
throw createTargetError(
|
|
517
|
+
'AMBIGUOUS_TARGET',
|
|
518
|
+
`Target text matched ${candidates.length} paragraphs; provide paragraphId, index, occurrence, or fingerprint.`,
|
|
519
|
+
candidates.map(serializeTargetCandidate)
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (candidates.length === 0) {
|
|
523
|
+
throw createTargetNotFoundError(`Target paragraph not found: "${cleanTargetText}"`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!parsedRef && candidates.length === 1) {
|
|
528
|
+
const candidate = candidates[0];
|
|
529
|
+
return {
|
|
530
|
+
paragraph: candidate.paragraph,
|
|
531
|
+
resolvedBy: descriptor?.fingerprint ? 'fingerprint' : 'strict_text'
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
296
535
|
|
|
297
536
|
if (parsedRef) {
|
|
298
|
-
const byRef = findParagraphByReference(xmlDoc, parsedRef);
|
|
537
|
+
const byRef = findParagraphByReference(xmlDoc, parsedRef, paragraphMetadataIndex);
|
|
299
538
|
if (byRef) {
|
|
539
|
+
const cached = paragraphMetadataIndex?.byParagraph?.get(byRef) || null;
|
|
540
|
+
if (descriptor?.fingerprint && descriptor.fingerprint !== cached?.fingerprint) {
|
|
541
|
+
throw createTargetError(
|
|
542
|
+
'TARGET_FINGERPRINT_MISMATCH',
|
|
543
|
+
`Target fingerprint does not match paragraph reference [P${parsedRef}].`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (typeof descriptor?.inTable === 'boolean' && descriptor.inTable !== cached?.inTable) {
|
|
547
|
+
throw createTargetError(
|
|
548
|
+
'TARGET_CONTEXT_MISMATCH',
|
|
549
|
+
`Target paragraph reference [P${parsedRef}] does not match requested table context.`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
300
552
|
if (cleanTargetText) {
|
|
301
|
-
const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
|
|
302
|
-
const byRefText =
|
|
553
|
+
const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText, { paragraphMetadataIndex });
|
|
554
|
+
const byRefText = (cached?.text || extractCanonicalParagraphText(byRef, { revisionView })).trim();
|
|
303
555
|
const byRefNorm = normalizeWhitespaceForTargeting(byRefText);
|
|
304
556
|
const targetNorm = normalizeWhitespaceForTargeting(cleanTargetText);
|
|
305
557
|
const hasDrift = byRefNorm !== targetNorm;
|
|
@@ -310,7 +562,7 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
|
|
|
310
562
|
}
|
|
311
563
|
|
|
312
564
|
if (hasDrift) {
|
|
313
|
-
const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
|
|
565
|
+
const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo, paragraphMetadataIndex });
|
|
314
566
|
if (fuzzyMatch && fuzzyMatch !== byRef) {
|
|
315
567
|
onInfo(`[Target] [P${parsedRef}] drifted for ${opType}; using fuzzy text rematch.`);
|
|
316
568
|
return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text_after_ref_drift' };
|
|
@@ -328,11 +580,23 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
|
|
|
328
580
|
onWarn(`[WARN] Target reference [P${parsedRef}] not found; falling back to text matching for ${opType}.`);
|
|
329
581
|
}
|
|
330
582
|
|
|
331
|
-
if (cleanTargetText) {
|
|
332
|
-
const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
|
|
333
|
-
if (strictMatch)
|
|
583
|
+
if (cleanTargetText && !strictAmbiguity) {
|
|
584
|
+
const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText, { paragraphMetadataIndex });
|
|
585
|
+
if (strictMatch) {
|
|
586
|
+
const candidateCount = candidates.length;
|
|
587
|
+
if (candidateCount > 1) {
|
|
588
|
+
const warningMsg = `AMBIGUOUS_TARGET_HEURISTIC_USED: Target text matched ${candidateCount} paragraphs; permissive resolution chose candidate 1. Migrate to strict targeting (e.g. strictTargets: true with paragraphId, index, occurrence, or fingerprint) before v1.0.0.`;
|
|
589
|
+
onWarn(warningMsg);
|
|
590
|
+
return {
|
|
591
|
+
paragraph: strictMatch,
|
|
592
|
+
resolvedBy: 'strict_text',
|
|
593
|
+
warnings: [warningMsg]
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
return { paragraph: strictMatch, resolvedBy: 'strict_text' };
|
|
597
|
+
}
|
|
334
598
|
|
|
335
|
-
const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
|
|
599
|
+
const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo, paragraphMetadataIndex });
|
|
336
600
|
if (fuzzyMatch) return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text' };
|
|
337
601
|
}
|
|
338
602
|
|
|
@@ -345,26 +609,76 @@ function isParagraphInTable(paragraph) {
|
|
|
345
609
|
return !!findContainingWordElement(paragraph, 'tbl');
|
|
346
610
|
}
|
|
347
611
|
|
|
348
|
-
function findStrictTargetCandidates(xmlDoc, targetText) {
|
|
612
|
+
export function findStrictTargetCandidates(xmlDoc, targetText, optionsOrIndex = null) {
|
|
349
613
|
const normalizedTarget = normalizeWhitespaceForTargeting(targetText);
|
|
350
614
|
if (!normalizedTarget) return [];
|
|
351
615
|
|
|
616
|
+
const metadataIndex = optionsOrIndex?.byNormalizedText
|
|
617
|
+
? optionsOrIndex
|
|
618
|
+
: (optionsOrIndex?.paragraphMetadataIndex || null);
|
|
619
|
+
const revisionView = optionsOrIndex?.revisionView
|
|
620
|
+
|| metadataIndex?.revisionView
|
|
621
|
+
|| 'accepted';
|
|
622
|
+
|
|
623
|
+
if (metadataIndex?.byNormalizedText && metadataIndex.revisionView === revisionView) {
|
|
624
|
+
return Array.from(metadataIndex.byNormalizedText.get(normalizedTarget) || []);
|
|
625
|
+
}
|
|
626
|
+
|
|
352
627
|
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
353
628
|
const candidates = [];
|
|
354
629
|
for (let i = 0; i < paragraphs.length; i++) {
|
|
355
630
|
const paragraph = paragraphs[i];
|
|
356
|
-
const paragraphText =
|
|
631
|
+
const paragraphText = extractCanonicalParagraphText(paragraph, { revisionView }).trim();
|
|
357
632
|
if (!paragraphText) continue;
|
|
358
633
|
if (normalizeWhitespaceForTargeting(paragraphText) !== normalizedTarget) continue;
|
|
359
634
|
candidates.push({
|
|
360
635
|
paragraph,
|
|
361
636
|
index: i + 1,
|
|
362
|
-
inTable: isParagraphInTable(paragraph)
|
|
637
|
+
inTable: isParagraphInTable(paragraph),
|
|
638
|
+
paragraphId: getParagraphId(paragraph),
|
|
639
|
+
fingerprint: createParagraphFingerprint(paragraph, {
|
|
640
|
+
text: paragraphText,
|
|
641
|
+
index: i + 1,
|
|
642
|
+
revisionView
|
|
643
|
+
}),
|
|
644
|
+
text: paragraphText,
|
|
645
|
+
revisionView
|
|
363
646
|
});
|
|
364
647
|
}
|
|
365
648
|
return candidates;
|
|
366
649
|
}
|
|
367
650
|
|
|
651
|
+
function serializeTargetCandidate(candidate) {
|
|
652
|
+
return {
|
|
653
|
+
index: candidate.index,
|
|
654
|
+
paragraphId: candidate.paragraphId || null,
|
|
655
|
+
text: candidate.text,
|
|
656
|
+
inTable: candidate.inTable,
|
|
657
|
+
fingerprint: candidate.fingerprint,
|
|
658
|
+
revisionView: candidate.revisionView || 'accepted'
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function filterTargetCandidates(candidates, descriptor) {
|
|
663
|
+
let scoped = candidates.slice();
|
|
664
|
+
if (descriptor?.paragraphId) {
|
|
665
|
+
scoped = scoped.filter(candidate => candidate.paragraphId === descriptor.paragraphId);
|
|
666
|
+
}
|
|
667
|
+
if (typeof descriptor?.inTable === 'boolean') {
|
|
668
|
+
scoped = scoped.filter(candidate => candidate.inTable === descriptor.inTable);
|
|
669
|
+
}
|
|
670
|
+
if (descriptor?.fingerprint) {
|
|
671
|
+
scoped = scoped.filter(candidate => candidate.fingerprint === descriptor.fingerprint);
|
|
672
|
+
}
|
|
673
|
+
return scoped;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function findParagraphById(xmlDoc, paragraphId, paragraphMetadataIndex = null) {
|
|
677
|
+
if (!paragraphId) return null;
|
|
678
|
+
if (paragraphMetadataIndex?.byId) return paragraphMetadataIndex.byId.get(paragraphId)?.paragraph || null;
|
|
679
|
+
return getDocumentParagraphNodes(xmlDoc).find(paragraph => getParagraphId(paragraph) === paragraphId) || null;
|
|
680
|
+
}
|
|
681
|
+
|
|
368
682
|
function selectBestTargetCandidate(candidates, parsedRef, expectedInTable = null) {
|
|
369
683
|
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
370
684
|
|
|
@@ -390,16 +704,18 @@ function selectBestTargetCandidate(candidates, parsedRef, expectedInTable = null
|
|
|
390
704
|
* @param {Document|Element|null|undefined} xmlDoc - OOXML document root
|
|
391
705
|
* @returns {Map<number, { text: string, normalizedText: string, inTable: boolean }>}
|
|
392
706
|
*/
|
|
393
|
-
export function buildTargetReferenceSnapshot(xmlDoc) {
|
|
394
|
-
const
|
|
707
|
+
export function buildTargetReferenceSnapshot(xmlDoc, paragraphMetadataIndex = null) {
|
|
708
|
+
const entries = paragraphMetadataIndex?.entries || null;
|
|
709
|
+
const paragraphs = entries ? null : getDocumentParagraphNodes(xmlDoc);
|
|
395
710
|
const snapshot = new Map();
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
711
|
+
const paragraphCount = entries?.length ?? paragraphs.length;
|
|
712
|
+
for (let i = 0; i < paragraphCount; i++) {
|
|
713
|
+
const paragraph = entries?.[i]?.paragraph ?? paragraphs[i];
|
|
714
|
+
const text = (entries?.[i]?.text ?? getParagraphText(paragraph)).trim();
|
|
399
715
|
snapshot.set(i + 1, {
|
|
400
716
|
text,
|
|
401
|
-
normalizedText: normalizeWhitespaceForTargeting(text),
|
|
402
|
-
inTable: isParagraphInTable(paragraph)
|
|
717
|
+
normalizedText: entries?.[i]?.normalizedText ?? normalizeWhitespaceForTargeting(text),
|
|
718
|
+
inTable: entries?.[i]?.inTable ?? isParagraphInTable(paragraph)
|
|
403
719
|
});
|
|
404
720
|
}
|
|
405
721
|
return snapshot;
|
|
@@ -437,7 +753,8 @@ export function resolveTargetParagraphWithSnapshot(xmlDoc, options = {}) {
|
|
|
437
753
|
const expectedNorm = normalizeWhitespaceForTargeting(expectedText);
|
|
438
754
|
if (!expectedNorm) return resolved;
|
|
439
755
|
|
|
440
|
-
const resolvedNorm =
|
|
756
|
+
const resolvedNorm = options.paragraphMetadataIndex?.byParagraph?.get(resolved.paragraph)?.normalizedText
|
|
757
|
+
|| normalizeWhitespaceForTargeting(getParagraphText(resolved.paragraph));
|
|
441
758
|
if (resolvedNorm === expectedNorm) return resolved;
|
|
442
759
|
|
|
443
760
|
const candidateTexts = [];
|
|
@@ -451,7 +768,7 @@ export function resolveTargetParagraphWithSnapshot(xmlDoc, options = {}) {
|
|
|
451
768
|
|
|
452
769
|
let bestCandidate = null;
|
|
453
770
|
for (const candidateText of candidateTexts) {
|
|
454
|
-
const candidates = findStrictTargetCandidates(xmlDoc, candidateText);
|
|
771
|
+
const candidates = findStrictTargetCandidates(xmlDoc, candidateText, options.paragraphMetadataIndex || null);
|
|
455
772
|
const selected = selectBestTargetCandidate(candidates, parsedRef, snapshotEntry.inTable);
|
|
456
773
|
if (!selected) continue;
|
|
457
774
|
if (!bestCandidate) bestCandidate = selected;
|
|
@@ -525,3 +842,126 @@ export function resolveParagraphRangeByRefs(xmlDoc, startRef, endRef, options =
|
|
|
525
842
|
if (!range.every(node => node && node.parentNode === parent)) return null;
|
|
526
843
|
return range;
|
|
527
844
|
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Validates a paragraph boundary operation (split, delete, join) before mutation.
|
|
848
|
+
* Returns { valid: true } or { valid: false, code: 'UNSAFE_PARAGRAPH_BOUNDARY', message: string }.
|
|
849
|
+
*
|
|
850
|
+
* @param {Element} targetParagraph - Target paragraph node
|
|
851
|
+
* @param {string} modifiedText - Replacement text
|
|
852
|
+
* @param {Object} [options={}] - Options
|
|
853
|
+
* @returns {{ valid: boolean, code?: string, message?: string }}
|
|
854
|
+
*/
|
|
855
|
+
export function validateParagraphBoundaryMutation(targetParagraph, modifiedText, options = {}) {
|
|
856
|
+
if (!targetParagraph || !targetParagraph.parentNode) {
|
|
857
|
+
return { valid: true };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const isDelete = modifiedText === '' || options.operationKind === 'delete';
|
|
861
|
+
const isSplit = typeof modifiedText === 'string' && modifiedText.includes('\n');
|
|
862
|
+
const targetEndParagraph = options.targetEndParagraph || null;
|
|
863
|
+
|
|
864
|
+
// 1. Table cell boundary checks
|
|
865
|
+
const containingTc = findContainingWordElement(targetParagraph, 'tc');
|
|
866
|
+
if (containingTc) {
|
|
867
|
+
if (isDelete) {
|
|
868
|
+
const cellParagraphs = Array.from(containingTc.childNodes).filter(node => isWordElement(node, 'p'));
|
|
869
|
+
if (cellParagraphs.length <= 1) {
|
|
870
|
+
return {
|
|
871
|
+
valid: false,
|
|
872
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
873
|
+
message: 'Refusing to delete the sole terminal paragraph of a table cell.'
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (targetEndParagraph) {
|
|
878
|
+
const endTc = findContainingWordElement(targetEndParagraph, 'tc');
|
|
879
|
+
if (endTc !== containingTc) {
|
|
880
|
+
return {
|
|
881
|
+
valid: false,
|
|
882
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
883
|
+
message: 'Refusing paragraph boundary join across different table cells.'
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
} else if (targetEndParagraph) {
|
|
888
|
+
const endTc = findContainingWordElement(targetEndParagraph, 'tc');
|
|
889
|
+
if (endTc) {
|
|
890
|
+
return {
|
|
891
|
+
valid: false,
|
|
892
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
893
|
+
message: 'Refusing paragraph boundary join across table boundaries.'
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// 2. Section break (w:sectPr) checks
|
|
899
|
+
const hasSectPr = targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'sectPr').length > 0;
|
|
900
|
+
if (hasSectPr && isDelete) {
|
|
901
|
+
return {
|
|
902
|
+
valid: false,
|
|
903
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
904
|
+
message: 'Refusing to delete paragraph containing section properties (w:sectPr).'
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (targetEndParagraph) {
|
|
909
|
+
let cursor = targetParagraph;
|
|
910
|
+
while (cursor && cursor !== targetEndParagraph) {
|
|
911
|
+
if (isWordElement(cursor, 'p') && cursor.getElementsByTagNameNS(WORD_MAIN_NS, 'sectPr').length > 0) {
|
|
912
|
+
return {
|
|
913
|
+
valid: false,
|
|
914
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
915
|
+
message: 'Refusing paragraph boundary join across section break.'
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
cursor = cursor.nextSibling;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// 3. Field instruction checks during split
|
|
923
|
+
if (isSplit) {
|
|
924
|
+
const fldSimples = targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'fldSimple');
|
|
925
|
+
if (fldSimples.length > 0) {
|
|
926
|
+
return {
|
|
927
|
+
valid: false,
|
|
928
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
929
|
+
message: 'Refusing to split paragraph containing a simple field instruction (w:fldSimple).'
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const fldChars = Array.from(targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'fldChar'));
|
|
934
|
+
if (fldChars.length > 0) {
|
|
935
|
+
let activeFields = 0;
|
|
936
|
+
for (const fc of fldChars) {
|
|
937
|
+
const fldCharType = fc.getAttributeNS(WORD_MAIN_NS, 'fldCharType') || fc.getAttribute('w:fldCharType');
|
|
938
|
+
if (fldCharType === 'begin') activeFields++;
|
|
939
|
+
else if (fldCharType === 'end') activeFields = Math.max(0, activeFields - 1);
|
|
940
|
+
}
|
|
941
|
+
if (activeFields > 0) {
|
|
942
|
+
return {
|
|
943
|
+
valid: false,
|
|
944
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
945
|
+
message: 'Refusing to split paragraph across an unclosed field instruction.'
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// 4. Bookmark range boundary checks
|
|
951
|
+
const bookmarkStarts = Array.from(targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'bookmarkStart'));
|
|
952
|
+
const bookmarkEnds = Array.from(targetParagraph.getElementsByTagNameNS(WORD_MAIN_NS, 'bookmarkEnd'));
|
|
953
|
+
const bStartIds = new Set(bookmarkStarts.map(b => b.getAttributeNS(WORD_MAIN_NS, 'id') || b.getAttribute('w:id')));
|
|
954
|
+
const bEndIds = new Set(bookmarkEnds.map(b => b.getAttributeNS(WORD_MAIN_NS, 'id') || b.getAttribute('w:id')));
|
|
955
|
+
for (const id of bStartIds) {
|
|
956
|
+
if (id && !bEndIds.has(id)) {
|
|
957
|
+
return {
|
|
958
|
+
valid: false,
|
|
959
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
960
|
+
message: `Refusing to split paragraph across open bookmark range (ID: ${id}).`
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return { valid: true };
|
|
967
|
+
}
|