@ansonlai/docx-redline-js 0.1.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 +176 -0
- package/ARCHITECTURE.md +121 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/adapters/config.js +43 -0
- package/adapters/logger.js +89 -0
- package/adapters/xml-adapter.js +74 -0
- package/core/list-targeting.js +398 -0
- package/core/ooxml-identifiers.js +15 -0
- package/core/paragraph-offset-policy.js +50 -0
- package/core/paragraph-targeting.js +501 -0
- package/core/table-targeting.js +233 -0
- package/core/types.js +204 -0
- package/core/xml-query.js +99 -0
- package/dist/docx-redline-js.esm.js +8801 -0
- package/dist/docx-redline-js.esm.js.map +7 -0
- package/dist/docx-redline-js.esm.min.js +195 -0
- package/dist/docx-redline-js.esm.min.js.map +7 -0
- package/engine/format-application.js +358 -0
- package/engine/format-extraction.js +232 -0
- package/engine/format-paragraph-targeting.js +208 -0
- package/engine/format-span-application.js +178 -0
- package/engine/formatting-removal.js +330 -0
- package/engine/oxml-engine.js +279 -0
- package/engine/reconstruction-mapper.js +270 -0
- package/engine/reconstruction-mode.js +38 -0
- package/engine/reconstruction-writer.js +276 -0
- package/engine/rpr-helpers.js +194 -0
- package/engine/run-builders.js +235 -0
- package/engine/surgical-mode.js +520 -0
- package/engine/table-cell-context.js +151 -0
- package/engine/table-mode.js +172 -0
- package/index.js +308 -0
- package/orchestration/list-markdown.js +141 -0
- package/orchestration/list-parsing.js +73 -0
- package/orchestration/list-structural-fallback.js +530 -0
- package/orchestration/redline-operation-converter.js +141 -0
- package/orchestration/route-plan.js +160 -0
- package/package.json +76 -0
- package/pipeline/content-analysis.js +107 -0
- package/pipeline/diff-engine.js +204 -0
- package/pipeline/ingestion-export.js +255 -0
- package/pipeline/ingestion-paragraph.js +351 -0
- package/pipeline/ingestion-table.js +169 -0
- package/pipeline/ingestion-xml.js +39 -0
- package/pipeline/ingestion.js +8 -0
- package/pipeline/list-generation.js +280 -0
- package/pipeline/list-markers.js +77 -0
- package/pipeline/markdown-processor.js +160 -0
- package/pipeline/patching.js +408 -0
- package/pipeline/pipeline.js +326 -0
- package/pipeline/serialization.js +395 -0
- package/services/browser-demo-prompt-context.js +345 -0
- package/services/comment-builders.js +60 -0
- package/services/comment-engine.js +248 -0
- package/services/comment-locator.js +197 -0
- package/services/comment-package.js +113 -0
- package/services/numbering-helpers.js +416 -0
- package/services/numbering-service.js +290 -0
- package/services/package-builder.js +147 -0
- package/services/standalone-docx-plumbing.js +443 -0
- package/services/standalone-operation-runner.js +1169 -0
- package/services/table-reconciliation.js +344 -0
- package/standalone.js +5 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared paragraph-targeting helpers for standalone/add-in consumers.
|
|
3
|
+
*
|
|
4
|
+
* This module centralizes target parsing and matching used by callers that
|
|
5
|
+
* apply per-paragraph operations (for example chat redlines/comments/highlights).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function toArray(nodeList) {
|
|
9
|
+
return Array.from(nodeList || []);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const WORD_MAIN_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
13
|
+
|
|
14
|
+
function getElementsByLocalName(node, localName) {
|
|
15
|
+
if (!node) return [];
|
|
16
|
+
|
|
17
|
+
if (typeof node.getElementsByTagNameNS === 'function') {
|
|
18
|
+
const namespaced = toArray(node.getElementsByTagNameNS('*', localName));
|
|
19
|
+
if (namespaced.length > 0) return namespaced;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof node.getElementsByTagName !== 'function') return [];
|
|
23
|
+
|
|
24
|
+
const prefixed = toArray(node.getElementsByTagName(`w:${localName}`));
|
|
25
|
+
if (prefixed.length > 0) return prefixed;
|
|
26
|
+
|
|
27
|
+
return toArray(node.getElementsByTagName(localName));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toParagraphText(paragraph) {
|
|
31
|
+
const textNodes = getElementsByLocalName(paragraph, 't');
|
|
32
|
+
return textNodes.map(node => node.textContent || '').join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads visible text from a paragraph by concatenating `w:t` nodes.
|
|
37
|
+
*
|
|
38
|
+
* @param {Element|null|undefined} paragraph - OOXML paragraph node
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function getParagraphText(paragraph) {
|
|
42
|
+
if (!paragraph) return '';
|
|
43
|
+
return toParagraphText(paragraph);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns body paragraphs for a document, or all paragraphs as fallback.
|
|
48
|
+
*
|
|
49
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document root
|
|
50
|
+
* @returns {Element[]}
|
|
51
|
+
*/
|
|
52
|
+
export function getDocumentParagraphNodes(xmlDoc) {
|
|
53
|
+
if (!xmlDoc) return [];
|
|
54
|
+
const bodies = getElementsByLocalName(xmlDoc, 'body');
|
|
55
|
+
const searchRoot = bodies.length > 0 ? bodies[0] : xmlDoc;
|
|
56
|
+
return getElementsByLocalName(searchRoot, 'p');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalizes whitespace for paragraph-comparison matching.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} text - Input text
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeWhitespaceForTargeting(text) {
|
|
66
|
+
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detects markdown table syntax used for table reconciliation.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text - Candidate markdown text
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function isMarkdownTableText(text) {
|
|
76
|
+
const trimmed = String(text || '').trim();
|
|
77
|
+
return /^\|.+\|/.test(trimmed) && trimmed.includes('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parses paragraph references such as `P12`, `[P12]`, `12`, or `P12.3`.
|
|
82
|
+
*
|
|
83
|
+
* @param {string|number|null|undefined} rawValue - Reference input
|
|
84
|
+
* @returns {number|null}
|
|
85
|
+
*/
|
|
86
|
+
export function parseParagraphReference(rawValue) {
|
|
87
|
+
if (rawValue == null) return null;
|
|
88
|
+
if (typeof rawValue === 'number' && Number.isInteger(rawValue) && rawValue > 0) return rawValue;
|
|
89
|
+
|
|
90
|
+
const text = String(rawValue).trim();
|
|
91
|
+
if (!text) return null;
|
|
92
|
+
|
|
93
|
+
const prefixed = text.match(/^\[?P(\d+)(?:\.\d+)?\]?$/i);
|
|
94
|
+
if (prefixed) return Number.parseInt(prefixed[1], 10);
|
|
95
|
+
|
|
96
|
+
const numeric = text.match(/^(\d+)$/);
|
|
97
|
+
if (numeric) return Number.parseInt(numeric[1], 10);
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Removes leading paragraph labels (for example `[P12]`) from text fields.
|
|
104
|
+
*
|
|
105
|
+
* @param {string|null|undefined} text - Input text
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function stripLeadingParagraphMarker(text) {
|
|
109
|
+
if (text == null) return '';
|
|
110
|
+
return String(text).replace(/^\s*\[P\d+(?:\.\d+)?\]\s*/i, '').trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Splits a leading paragraph label from text.
|
|
115
|
+
*
|
|
116
|
+
* @param {string|null|undefined} text - Input text
|
|
117
|
+
* @returns {{ text: string, targetRef: number|null }}
|
|
118
|
+
*/
|
|
119
|
+
export function splitLeadingParagraphMarker(text) {
|
|
120
|
+
const raw = String(text || '');
|
|
121
|
+
const marker = raw.match(/^\s*\[P(\d+)(?:\.\d+)?\]\s*/i);
|
|
122
|
+
if (!marker) return { text: raw.trim(), targetRef: null };
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
text: raw.replace(/^\s*\[P\d+(?:\.\d+)?\]\s*/i, '').trim(),
|
|
126
|
+
targetRef: Number.parseInt(marker[1], 10)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolves a paragraph by 1-based paragraph index.
|
|
132
|
+
*
|
|
133
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document
|
|
134
|
+
* @param {number|null|undefined} targetRef - 1-based paragraph number
|
|
135
|
+
* @returns {Element|null}
|
|
136
|
+
*/
|
|
137
|
+
export function findParagraphByReference(xmlDoc, targetRef) {
|
|
138
|
+
if (!Number.isInteger(targetRef) || targetRef < 1) return null;
|
|
139
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
140
|
+
return paragraphs[targetRef - 1] || null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Finds the closest ancestor matching Word namespace + localName.
|
|
145
|
+
*
|
|
146
|
+
* @param {Node|null|undefined} node - Start node
|
|
147
|
+
* @param {string} localName - WordprocessingML local element name (for example `tbl`, `tc`)
|
|
148
|
+
* @param {string} [namespaceUri] - Namespace URI to match
|
|
149
|
+
* @returns {Element|null}
|
|
150
|
+
*/
|
|
151
|
+
export function findContainingWordElement(node, localName, namespaceUri = WORD_MAIN_NS) {
|
|
152
|
+
let current = node;
|
|
153
|
+
while (current) {
|
|
154
|
+
if (
|
|
155
|
+
current.nodeType === 1 &&
|
|
156
|
+
current.namespaceURI === namespaceUri &&
|
|
157
|
+
current.localName === localName
|
|
158
|
+
) {
|
|
159
|
+
return current;
|
|
160
|
+
}
|
|
161
|
+
current = current.parentNode;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Finds paragraph by exact/normalized text equality.
|
|
168
|
+
*
|
|
169
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document
|
|
170
|
+
* @param {string} targetText - Target paragraph text
|
|
171
|
+
* @returns {Element|null}
|
|
172
|
+
*/
|
|
173
|
+
export function findParagraphByStrictText(xmlDoc, targetText) {
|
|
174
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
175
|
+
const normalizedTarget = String(targetText || '').trim();
|
|
176
|
+
if (!normalizedTarget) return null;
|
|
177
|
+
|
|
178
|
+
const exact = paragraphs.find(p => getParagraphText(p).trim() === normalizedTarget);
|
|
179
|
+
if (exact) return exact;
|
|
180
|
+
|
|
181
|
+
const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
|
|
182
|
+
return paragraphs.find(p => normalizeWhitespaceForTargeting(getParagraphText(p)) === normTarget) || null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Finds paragraph by strict match, then fuzzy fallback heuristics.
|
|
187
|
+
*
|
|
188
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document
|
|
189
|
+
* @param {string} targetText - Target paragraph text
|
|
190
|
+
* @param {{ onInfo?: (msg:string)=>void }} [options] - Optional logger callbacks
|
|
191
|
+
* @returns {Element|null}
|
|
192
|
+
*/
|
|
193
|
+
export function findParagraphByBestTextMatch(xmlDoc, targetText, options = {}) {
|
|
194
|
+
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
195
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
196
|
+
const normalizedTarget = String(targetText || '').trim();
|
|
197
|
+
if (!normalizedTarget) return null;
|
|
198
|
+
|
|
199
|
+
const strictMatch = findParagraphByStrictText(xmlDoc, normalizedTarget);
|
|
200
|
+
if (strictMatch) return strictMatch;
|
|
201
|
+
|
|
202
|
+
const normTarget = normalizeWhitespaceForTargeting(normalizedTarget);
|
|
203
|
+
|
|
204
|
+
const startsWithMatch = paragraphs.find(p => {
|
|
205
|
+
const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
|
|
206
|
+
return paragraphText.length > 10 && normTarget.startsWith(paragraphText);
|
|
207
|
+
});
|
|
208
|
+
if (startsWithMatch) {
|
|
209
|
+
onInfo(`[Fuzzy] Prefix match (target starts with paragraph): "${getParagraphText(startsWithMatch).trim().slice(0, 60)}..."`);
|
|
210
|
+
return startsWithMatch;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const containsMatch = paragraphs.find(p => {
|
|
214
|
+
const paragraphText = normalizeWhitespaceForTargeting(getParagraphText(p));
|
|
215
|
+
return paragraphText.length > 15 && normTarget.includes(paragraphText);
|
|
216
|
+
});
|
|
217
|
+
if (containsMatch) {
|
|
218
|
+
onInfo(`[Fuzzy] Contains match: "${getParagraphText(containsMatch).trim().slice(0, 60)}..."`);
|
|
219
|
+
return containsMatch;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let bestScore = 0;
|
|
223
|
+
let bestParagraph = null;
|
|
224
|
+
const targetWords = new Set(normTarget.toLowerCase().split(/\s+/).filter(word => word.length > 2));
|
|
225
|
+
for (const paragraph of paragraphs) {
|
|
226
|
+
const paragraphText = getParagraphText(paragraph).trim();
|
|
227
|
+
if (!paragraphText) continue;
|
|
228
|
+
|
|
229
|
+
const paragraphWords = normalizeWhitespaceForTargeting(paragraphText)
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.split(/\s+/)
|
|
232
|
+
.filter(word => word.length > 2);
|
|
233
|
+
const overlap = paragraphWords.filter(word => targetWords.has(word)).length;
|
|
234
|
+
const score = overlap / Math.max(targetWords.size, 1);
|
|
235
|
+
if (score > bestScore && score > 0.5) {
|
|
236
|
+
bestScore = score;
|
|
237
|
+
bestParagraph = paragraph;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (bestParagraph) {
|
|
242
|
+
onInfo(`[Fuzzy] Best word-overlap match (${(bestScore * 100).toFixed(0)}%): "${getParagraphText(bestParagraph).trim().slice(0, 60)}..."`);
|
|
243
|
+
return bestParagraph;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolves a target paragraph from `targetRef` + `targetText`.
|
|
251
|
+
*
|
|
252
|
+
* Resolution order:
|
|
253
|
+
* 1) `targetRef` when provided and valid
|
|
254
|
+
* 2) strict text match
|
|
255
|
+
* 3) fuzzy text match
|
|
256
|
+
*
|
|
257
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document
|
|
258
|
+
* @param {{
|
|
259
|
+
* targetText?: string,
|
|
260
|
+
* targetRef?: string|number|null,
|
|
261
|
+
* opType?: string,
|
|
262
|
+
* onInfo?: (msg:string)=>void,
|
|
263
|
+
* onWarn?: (msg:string)=>void
|
|
264
|
+
* }} options - Resolution options
|
|
265
|
+
* @returns {{ paragraph: Element, resolvedBy: 'ref'|'strict_text'|'fuzzy_text'|'strict_text_after_ref_drift'|'fuzzy_text_after_ref_drift' }}
|
|
266
|
+
*/
|
|
267
|
+
export function resolveTargetParagraph(xmlDoc, options = {}) {
|
|
268
|
+
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
269
|
+
const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
|
|
270
|
+
const opType = options.opType || 'operation';
|
|
271
|
+
const cleanTargetText = String(options.targetText || '').trim();
|
|
272
|
+
const parsedRef = parseParagraphReference(options.targetRef);
|
|
273
|
+
|
|
274
|
+
if (parsedRef) {
|
|
275
|
+
const byRef = findParagraphByReference(xmlDoc, parsedRef);
|
|
276
|
+
if (byRef) {
|
|
277
|
+
if (cleanTargetText) {
|
|
278
|
+
const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
|
|
279
|
+
const byRefText = getParagraphText(byRef).trim();
|
|
280
|
+
const byRefNorm = normalizeWhitespaceForTargeting(byRefText);
|
|
281
|
+
const targetNorm = normalizeWhitespaceForTargeting(cleanTargetText);
|
|
282
|
+
const hasDrift = byRefNorm !== targetNorm;
|
|
283
|
+
|
|
284
|
+
if (hasDrift && strictMatch && strictMatch !== byRef) {
|
|
285
|
+
onInfo(`[Target] [P${parsedRef}] drifted for ${opType}; using strict text rematch.`);
|
|
286
|
+
return { paragraph: strictMatch, resolvedBy: 'strict_text_after_ref_drift' };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (hasDrift) {
|
|
290
|
+
const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
|
|
291
|
+
if (fuzzyMatch && fuzzyMatch !== byRef) {
|
|
292
|
+
onInfo(`[Target] [P${parsedRef}] drifted for ${opType}; using fuzzy text rematch.`);
|
|
293
|
+
return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text_after_ref_drift' };
|
|
294
|
+
}
|
|
295
|
+
onInfo(`[Target] Using [P${parsedRef}] fallback for ${opType}; target text drifted.`);
|
|
296
|
+
} else if (strictMatch && strictMatch !== byRef) {
|
|
297
|
+
onInfo(`[Target] [P${parsedRef}] disambiguated duplicate target text for ${opType}.`);
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
onInfo(`[Target] Using [P${parsedRef}] fallback for ${opType}.`);
|
|
301
|
+
}
|
|
302
|
+
return { paragraph: byRef, resolvedBy: 'ref' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
onWarn(`[WARN] Target reference [P${parsedRef}] not found; falling back to text matching for ${opType}.`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (cleanTargetText) {
|
|
309
|
+
const strictMatch = findParagraphByStrictText(xmlDoc, cleanTargetText);
|
|
310
|
+
if (strictMatch) return { paragraph: strictMatch, resolvedBy: 'strict_text' };
|
|
311
|
+
|
|
312
|
+
const fuzzyMatch = findParagraphByBestTextMatch(xmlDoc, cleanTargetText, { onInfo });
|
|
313
|
+
if (fuzzyMatch) return { paragraph: fuzzyMatch, resolvedBy: 'fuzzy_text' };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (cleanTargetText) throw new Error(`Target paragraph not found: "${cleanTargetText}"`);
|
|
317
|
+
if (parsedRef) throw new Error(`Target paragraph reference not found: [P${parsedRef}]`);
|
|
318
|
+
throw new Error('Operation target missing: provide "target" text or "targetRef" ([P#]).');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isParagraphInTable(paragraph) {
|
|
322
|
+
return !!findContainingWordElement(paragraph, 'tbl');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function findStrictTargetCandidates(xmlDoc, targetText) {
|
|
326
|
+
const normalizedTarget = normalizeWhitespaceForTargeting(targetText);
|
|
327
|
+
if (!normalizedTarget) return [];
|
|
328
|
+
|
|
329
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
330
|
+
const candidates = [];
|
|
331
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
332
|
+
const paragraph = paragraphs[i];
|
|
333
|
+
const paragraphText = getParagraphText(paragraph).trim();
|
|
334
|
+
if (!paragraphText) continue;
|
|
335
|
+
if (normalizeWhitespaceForTargeting(paragraphText) !== normalizedTarget) continue;
|
|
336
|
+
candidates.push({
|
|
337
|
+
paragraph,
|
|
338
|
+
index: i + 1,
|
|
339
|
+
inTable: isParagraphInTable(paragraph)
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return candidates;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function selectBestTargetCandidate(candidates, parsedRef, expectedInTable = null) {
|
|
346
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
347
|
+
|
|
348
|
+
let scoped = candidates.slice();
|
|
349
|
+
if (typeof expectedInTable === 'boolean') {
|
|
350
|
+
const sameContext = scoped.filter(candidate => candidate.inTable === expectedInTable);
|
|
351
|
+
if (sameContext.length > 0) scoped = sameContext;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (Number.isInteger(parsedRef) && parsedRef > 0) {
|
|
355
|
+
scoped.sort((a, b) => Math.abs(a.index - parsedRef) - Math.abs(b.index - parsedRef));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return scoped[0] || null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Builds a turn-start paragraph snapshot keyed by 1-based paragraph index.
|
|
363
|
+
*
|
|
364
|
+
* Intended for callers that apply multiple operations sequentially and need to
|
|
365
|
+
* detect `targetRef` drift after earlier structural edits.
|
|
366
|
+
*
|
|
367
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document root
|
|
368
|
+
* @returns {Map<number, { text: string, normalizedText: string, inTable: boolean }>}
|
|
369
|
+
*/
|
|
370
|
+
export function buildTargetReferenceSnapshot(xmlDoc) {
|
|
371
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
372
|
+
const snapshot = new Map();
|
|
373
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
374
|
+
const paragraph = paragraphs[i];
|
|
375
|
+
const text = getParagraphText(paragraph).trim();
|
|
376
|
+
snapshot.set(i + 1, {
|
|
377
|
+
text,
|
|
378
|
+
normalizedText: normalizeWhitespaceForTargeting(text),
|
|
379
|
+
inTable: isParagraphInTable(paragraph)
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return snapshot;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolves a paragraph using the standard resolver, then corrects stale
|
|
387
|
+
* `targetRef` mappings via strict rematch when a turn-start snapshot is provided.
|
|
388
|
+
*
|
|
389
|
+
* @param {Document|Element|null|undefined} xmlDoc - OOXML document
|
|
390
|
+
* @param {{
|
|
391
|
+
* targetText?: string,
|
|
392
|
+
* targetRef?: string|number|null,
|
|
393
|
+
* opType?: string,
|
|
394
|
+
* targetRefSnapshot?: Map<number, { text?: string, inTable?: boolean }>|null,
|
|
395
|
+
* onInfo?: (msg:string)=>void,
|
|
396
|
+
* onWarn?: (msg:string)=>void
|
|
397
|
+
* }} options - Resolution options
|
|
398
|
+
* @returns {{ paragraph: Element, resolvedBy: 'ref'|'strict_text'|'fuzzy_text'|'strict_text_after_ref_drift' }}
|
|
399
|
+
*/
|
|
400
|
+
export function resolveTargetParagraphWithSnapshot(xmlDoc, options = {}) {
|
|
401
|
+
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
402
|
+
const resolved = resolveTargetParagraph(xmlDoc, options);
|
|
403
|
+
|
|
404
|
+
const parsedRef = parseParagraphReference(options.targetRef);
|
|
405
|
+
if (!parsedRef || resolved?.resolvedBy !== 'ref') return resolved;
|
|
406
|
+
|
|
407
|
+
const snapshotEntry = options.targetRefSnapshot instanceof Map
|
|
408
|
+
? (options.targetRefSnapshot.get(parsedRef) || null)
|
|
409
|
+
: null;
|
|
410
|
+
if (!snapshotEntry) return resolved;
|
|
411
|
+
|
|
412
|
+
const cleanTargetText = String(options.targetText || '').trim();
|
|
413
|
+
const expectedText = cleanTargetText || snapshotEntry.text || '';
|
|
414
|
+
const expectedNorm = normalizeWhitespaceForTargeting(expectedText);
|
|
415
|
+
if (!expectedNorm) return resolved;
|
|
416
|
+
|
|
417
|
+
const resolvedNorm = normalizeWhitespaceForTargeting(getParagraphText(resolved.paragraph));
|
|
418
|
+
if (resolvedNorm === expectedNorm) return resolved;
|
|
419
|
+
|
|
420
|
+
const candidateTexts = [];
|
|
421
|
+
if (cleanTargetText) candidateTexts.push(cleanTargetText);
|
|
422
|
+
if (snapshotEntry.text) {
|
|
423
|
+
const snapshotNorm = normalizeWhitespaceForTargeting(snapshotEntry.text);
|
|
424
|
+
if (snapshotNorm && !candidateTexts.some(text => normalizeWhitespaceForTargeting(text) === snapshotNorm)) {
|
|
425
|
+
candidateTexts.push(snapshotEntry.text);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let bestCandidate = null;
|
|
430
|
+
for (const candidateText of candidateTexts) {
|
|
431
|
+
const candidates = findStrictTargetCandidates(xmlDoc, candidateText);
|
|
432
|
+
const selected = selectBestTargetCandidate(candidates, parsedRef, snapshotEntry.inTable);
|
|
433
|
+
if (!selected) continue;
|
|
434
|
+
if (!bestCandidate) bestCandidate = selected;
|
|
435
|
+
if (selected.paragraph !== resolved.paragraph) {
|
|
436
|
+
bestCandidate = selected;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (bestCandidate && bestCandidate.paragraph !== resolved.paragraph) {
|
|
442
|
+
const opType = options.opType || 'operation';
|
|
443
|
+
onInfo(`[Target] [P${parsedRef}] appears stale after prior edits; using strict text rematch for ${opType}.`);
|
|
444
|
+
return { paragraph: bestCandidate.paragraph, resolvedBy: 'strict_text_after_ref_drift' };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return resolved;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Resolves a contiguous paragraph range using paragraph references.
|
|
452
|
+
*
|
|
453
|
+
* @param {Document} xmlDoc - XML document
|
|
454
|
+
* @param {string|number|null} startRef - Start paragraph reference (e.g. P12)
|
|
455
|
+
* @param {string|number|null} endRef - End paragraph reference (e.g. P15)
|
|
456
|
+
* @param {Object} [options={}] - Resolution options
|
|
457
|
+
* @param {string} [options.opType='redline'] - Operation type hint
|
|
458
|
+
* @param {Array|null} [options.targetRefSnapshot=null] - Optional target snapshot
|
|
459
|
+
* @param {(message: string) => void} [options.onInfo] - Optional info logger
|
|
460
|
+
* @param {(message: string) => void} [options.onWarn] - Optional warn logger
|
|
461
|
+
* @returns {Element[]|null}
|
|
462
|
+
*/
|
|
463
|
+
export function resolveParagraphRangeByRefs(xmlDoc, startRef, endRef, options = {}) {
|
|
464
|
+
if (!xmlDoc || !startRef || !endRef) return null;
|
|
465
|
+
|
|
466
|
+
const opType = options?.opType || 'redline';
|
|
467
|
+
const targetRefSnapshot = options?.targetRefSnapshot || null;
|
|
468
|
+
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
469
|
+
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
470
|
+
|
|
471
|
+
const start = resolveTargetParagraphWithSnapshot(xmlDoc, {
|
|
472
|
+
targetRef: startRef,
|
|
473
|
+
opType,
|
|
474
|
+
targetRefSnapshot,
|
|
475
|
+
onInfo,
|
|
476
|
+
onWarn
|
|
477
|
+
})?.paragraph;
|
|
478
|
+
if (!start) return null;
|
|
479
|
+
|
|
480
|
+
const end = resolveTargetParagraphWithSnapshot(xmlDoc, {
|
|
481
|
+
targetRef: endRef,
|
|
482
|
+
opType,
|
|
483
|
+
targetRefSnapshot,
|
|
484
|
+
onInfo,
|
|
485
|
+
onWarn
|
|
486
|
+
})?.paragraph;
|
|
487
|
+
if (!end) return null;
|
|
488
|
+
|
|
489
|
+
const allParagraphs = Array.from(xmlDoc.getElementsByTagNameNS('*', 'p'));
|
|
490
|
+
const startIdx = allParagraphs.indexOf(start);
|
|
491
|
+
const endIdx = allParagraphs.indexOf(end);
|
|
492
|
+
if (startIdx < 0 || endIdx < startIdx) return null;
|
|
493
|
+
|
|
494
|
+
const range = allParagraphs.slice(startIdx, endIdx + 1);
|
|
495
|
+
if (range.length === 0) return null;
|
|
496
|
+
|
|
497
|
+
const parent = range[0]?.parentNode || null;
|
|
498
|
+
if (!parent) return null;
|
|
499
|
+
if (!range.every(node => node && node.parentNode === parent)) return null;
|
|
500
|
+
return range;
|
|
501
|
+
}
|