@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,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared list-targeting helpers for per-paragraph redline callers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
WORD_MAIN_NS,
|
|
7
|
+
getParagraphText,
|
|
8
|
+
normalizeWhitespaceForTargeting
|
|
9
|
+
} from './paragraph-targeting.js';
|
|
10
|
+
|
|
11
|
+
function getFirstDescendantByLocalName(node, localName) {
|
|
12
|
+
if (!node || typeof node.getElementsByTagNameNS !== 'function') return null;
|
|
13
|
+
const namespaced = node.getElementsByTagNameNS(WORD_MAIN_NS, localName);
|
|
14
|
+
if (namespaced.length > 0) return namespaced[0];
|
|
15
|
+
const anyNs = node.getElementsByTagNameNS('*', localName);
|
|
16
|
+
return anyNs.length > 0 ? anyNs[0] : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readValAttribute(element) {
|
|
20
|
+
if (!element) return null;
|
|
21
|
+
if (typeof element.getAttributeNS === 'function') {
|
|
22
|
+
const namespaced = element.getAttributeNS(WORD_MAIN_NS, 'val');
|
|
23
|
+
if (namespaced) return namespaced;
|
|
24
|
+
}
|
|
25
|
+
return element.getAttribute('w:val') || element.getAttribute('val') || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseOutlineLevelFromMarker(marker) {
|
|
29
|
+
const normalized = String(marker || '').trim();
|
|
30
|
+
if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
|
|
31
|
+
const parts = normalized.replace(/\.$/, '').split('.');
|
|
32
|
+
return Math.max(0, parts.length - 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const REDUNDANT_LIST_PREFIX_REGEX = /^(?:(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Strips redundant manual list markers from the start of list item text.
|
|
39
|
+
*
|
|
40
|
+
* Useful when model output contains doubled markers like:
|
|
41
|
+
* - "2.1. - Item text"
|
|
42
|
+
* - "- 2.1. Item text"
|
|
43
|
+
*
|
|
44
|
+
* @param {string} text - Candidate list item text
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function stripRedundantLeadingListMarkers(text) {
|
|
48
|
+
let value = String(text || '').trim();
|
|
49
|
+
let passes = 0;
|
|
50
|
+
while (passes < 4 && REDUNDANT_LIST_PREFIX_REGEX.test(value)) {
|
|
51
|
+
value = value.replace(REDUNDANT_LIST_PREFIX_REGEX, '').trimStart();
|
|
52
|
+
passes++;
|
|
53
|
+
}
|
|
54
|
+
return value.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseModifiedListItems(modifiedText) {
|
|
58
|
+
const rawLines = String(modifiedText || '').split(/\r?\n/g);
|
|
59
|
+
const items = [];
|
|
60
|
+
let hasListMarkers = false;
|
|
61
|
+
|
|
62
|
+
for (const rawLine of rawLines) {
|
|
63
|
+
const line = rawLine.trimEnd();
|
|
64
|
+
if (!line.trim()) continue;
|
|
65
|
+
|
|
66
|
+
const markerMatch = line.match(/^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/);
|
|
67
|
+
if (markerMatch) {
|
|
68
|
+
hasListMarkers = true;
|
|
69
|
+
const marker = markerMatch[2];
|
|
70
|
+
const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
|
|
71
|
+
const level = Math.floor((markerMatch[1] || '').length / 2);
|
|
72
|
+
items.push({
|
|
73
|
+
kind: 'list',
|
|
74
|
+
markerType,
|
|
75
|
+
level,
|
|
76
|
+
marker,
|
|
77
|
+
outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
|
|
78
|
+
text: stripRedundantLeadingListMarkers(markerMatch[3])
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
items.push({
|
|
84
|
+
kind: 'text',
|
|
85
|
+
text: line.trim()
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { items, hasListMarkers };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toMarkdownLine(level, markerType, text) {
|
|
93
|
+
const indent = ' '.repeat(Math.max(0, level));
|
|
94
|
+
const marker = markerType === 'numbered' ? '1.' : '-';
|
|
95
|
+
return `${indent}${marker} ${String(text || '').trim()}`.trimEnd();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isNormalizedTextEqual(a, b) {
|
|
99
|
+
return normalizeWhitespaceForTargeting(a) === normalizeWhitespaceForTargeting(b);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveInsertionLevel(item, anchorLevel, baselineLevel) {
|
|
103
|
+
if (Number.isInteger(item?.outlineLevel)) {
|
|
104
|
+
return Math.max(0, item.outlineLevel);
|
|
105
|
+
}
|
|
106
|
+
return Math.max(0, anchorLevel + ((item?.level || 0) - baselineLevel));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function shouldPromoteBulletInsertionsToChildDepth(parsedItems, normalizedTargetText, anchorLevel) {
|
|
110
|
+
if (!Array.isArray(parsedItems) || parsedItems.length < 2) return false;
|
|
111
|
+
if (!Number.isInteger(anchorLevel) || anchorLevel < 1) return false;
|
|
112
|
+
|
|
113
|
+
const firstItem = parsedItems[0];
|
|
114
|
+
const trailingListItems = parsedItems.slice(1).filter(item => item.kind === 'list');
|
|
115
|
+
if (trailingListItems.length === 0) return false;
|
|
116
|
+
if (trailingListItems.some(item => item.markerType !== 'bullet')) return false;
|
|
117
|
+
if (trailingListItems.some(item => Number.isInteger(item.outlineLevel))) return false;
|
|
118
|
+
|
|
119
|
+
if (firstItem?.kind === 'text') {
|
|
120
|
+
return isNormalizedTextEqual(firstItem.text, normalizedTargetText);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (firstItem?.kind === 'list' && firstItem.markerType === 'numbered') {
|
|
124
|
+
return isNormalizedTextEqual(firstItem.text, normalizedTargetText);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function promoteBulletInsertionsToChildDepth(entries, anchorLevel) {
|
|
131
|
+
return entries.map(entry => {
|
|
132
|
+
const relativeDepth = Math.max(0, (entry.ilvl || 0) - anchorLevel);
|
|
133
|
+
return {
|
|
134
|
+
...entry,
|
|
135
|
+
ilvl: Math.min(8, anchorLevel + 1 + relativeDepth)
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildListEntriesForInsertion(parsedItems, normalizedTargetText, anchorLevel, defaultMarkerType) {
|
|
141
|
+
const firstItem = parsedItems[0];
|
|
142
|
+
const trailingListItems = parsedItems.slice(1).filter(item => item.kind === 'list');
|
|
143
|
+
|
|
144
|
+
// Pattern A: plain anchor text + list item lines (target line + inserted lines)
|
|
145
|
+
if (
|
|
146
|
+
firstItem?.kind === 'text' &&
|
|
147
|
+
isNormalizedTextEqual(firstItem.text, normalizedTargetText) &&
|
|
148
|
+
trailingListItems.length > 0
|
|
149
|
+
) {
|
|
150
|
+
const firstTrailingLevel = trailingListItems[0].level;
|
|
151
|
+
return trailingListItems.map(item => ({
|
|
152
|
+
ilvl: resolveInsertionLevel(item, anchorLevel, firstTrailingLevel),
|
|
153
|
+
markerType: item.markerType || defaultMarkerType,
|
|
154
|
+
text: item.text
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Pattern B: list lines where first line repeats target item.
|
|
159
|
+
if (parsedItems.every(item => item.kind === 'list')) {
|
|
160
|
+
const firstList = parsedItems[0];
|
|
161
|
+
if (!firstList || !isNormalizedTextEqual(firstList.text, normalizedTargetText)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const firstLevel = firstList.level;
|
|
165
|
+
return parsedItems
|
|
166
|
+
.slice(1)
|
|
167
|
+
.map(item => ({
|
|
168
|
+
ilvl: resolveInsertionLevel(item, anchorLevel, firstLevel),
|
|
169
|
+
markerType: item.markerType || defaultMarkerType,
|
|
170
|
+
text: item.text
|
|
171
|
+
}))
|
|
172
|
+
.filter(item => item.text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reads list numbering metadata from paragraph OOXML.
|
|
180
|
+
*
|
|
181
|
+
* @param {Element} paragraph - Paragraph element
|
|
182
|
+
* @returns {{ numId: string, ilvl: number }|null}
|
|
183
|
+
*/
|
|
184
|
+
export function getParagraphListInfo(paragraph) {
|
|
185
|
+
if (!paragraph) return null;
|
|
186
|
+
|
|
187
|
+
const pPr = getFirstDescendantByLocalName(paragraph, 'pPr');
|
|
188
|
+
if (!pPr) return null;
|
|
189
|
+
const numPr = getFirstDescendantByLocalName(pPr, 'numPr');
|
|
190
|
+
if (!numPr) return null;
|
|
191
|
+
const numIdEl = getFirstDescendantByLocalName(numPr, 'numId');
|
|
192
|
+
if (!numIdEl) return null;
|
|
193
|
+
|
|
194
|
+
const numId = readValAttribute(numIdEl);
|
|
195
|
+
if (!numId) return null;
|
|
196
|
+
|
|
197
|
+
const ilvlEl = getFirstDescendantByLocalName(numPr, 'ilvl');
|
|
198
|
+
const ilvlRaw = readValAttribute(ilvlEl);
|
|
199
|
+
const ilvl = Number.parseInt(ilvlRaw || '0', 10);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
numId: String(numId),
|
|
203
|
+
ilvl: Number.isFinite(ilvl) ? ilvl : 0
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Collects contiguous sibling list paragraphs sharing the same `numId`.
|
|
209
|
+
*
|
|
210
|
+
* @param {Element} targetParagraph - Target paragraph
|
|
211
|
+
* @returns {Element[]|null}
|
|
212
|
+
*/
|
|
213
|
+
export function collectContiguousListParagraphBlock(targetParagraph) {
|
|
214
|
+
const targetInfo = getParagraphListInfo(targetParagraph);
|
|
215
|
+
if (!targetInfo) return null;
|
|
216
|
+
|
|
217
|
+
const parent = targetParagraph.parentNode;
|
|
218
|
+
if (!parent) return null;
|
|
219
|
+
|
|
220
|
+
const siblings = Array.from(parent.childNodes || []).filter(
|
|
221
|
+
node =>
|
|
222
|
+
node &&
|
|
223
|
+
node.nodeType === 1 &&
|
|
224
|
+
node.namespaceURI === WORD_MAIN_NS &&
|
|
225
|
+
node.localName === 'p'
|
|
226
|
+
);
|
|
227
|
+
const targetIndex = siblings.indexOf(targetParagraph);
|
|
228
|
+
if (targetIndex < 0) return null;
|
|
229
|
+
|
|
230
|
+
let start = targetIndex;
|
|
231
|
+
while (start > 0) {
|
|
232
|
+
const prevInfo = getParagraphListInfo(siblings[start - 1]);
|
|
233
|
+
if (!prevInfo || prevInfo.numId !== targetInfo.numId) break;
|
|
234
|
+
start--;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let end = targetIndex;
|
|
238
|
+
while (end < siblings.length - 1) {
|
|
239
|
+
const nextInfo = getParagraphListInfo(siblings[end + 1]);
|
|
240
|
+
if (!nextInfo || nextInfo.numId !== targetInfo.numId) break;
|
|
241
|
+
end++;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return siblings.slice(start, end + 1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Synthesizes block-level list markdown edits when a single list item receives
|
|
249
|
+
* multiline list content (for example insert-between-item intent).
|
|
250
|
+
*
|
|
251
|
+
* @param {Element} targetParagraph - Resolved target paragraph
|
|
252
|
+
* @param {string} modifiedText - Proposed replacement text
|
|
253
|
+
* @param {{
|
|
254
|
+
* currentParagraphText?: string,
|
|
255
|
+
* onInfo?: (msg:string)=>void,
|
|
256
|
+
* onWarn?: (msg:string)=>void
|
|
257
|
+
* }} [options] - Optional logging/context options
|
|
258
|
+
* @returns {{ paragraphs: Element[], originalText: string, modifiedText: string }|null}
|
|
259
|
+
*/
|
|
260
|
+
export function synthesizeExpandedListScopeEdit(targetParagraph, modifiedText, options = {}) {
|
|
261
|
+
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
262
|
+
const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
|
|
263
|
+
|
|
264
|
+
const rawModified = String(modifiedText || '');
|
|
265
|
+
if (!rawModified.includes('\n')) return null;
|
|
266
|
+
|
|
267
|
+
const targetListInfo = getParagraphListInfo(targetParagraph);
|
|
268
|
+
if (!targetListInfo) return null;
|
|
269
|
+
|
|
270
|
+
const blockParagraphs = collectContiguousListParagraphBlock(targetParagraph);
|
|
271
|
+
if (!blockParagraphs || blockParagraphs.length === 0) return null;
|
|
272
|
+
|
|
273
|
+
const parsed = parseModifiedListItems(rawModified);
|
|
274
|
+
if (!parsed.hasListMarkers || parsed.items.length < 2) return null;
|
|
275
|
+
|
|
276
|
+
const normalizedTargetText = normalizeWhitespaceForTargeting(
|
|
277
|
+
options.currentParagraphText || getParagraphText(targetParagraph)
|
|
278
|
+
);
|
|
279
|
+
const listItemsOnly = parsed.items.filter(item => item.kind === 'list');
|
|
280
|
+
const firstListType = listItemsOnly[0]?.markerType || 'bullet';
|
|
281
|
+
|
|
282
|
+
const blockInfos = blockParagraphs.map(paragraph => ({
|
|
283
|
+
paragraph,
|
|
284
|
+
list: getParagraphListInfo(paragraph),
|
|
285
|
+
text: String(getParagraphText(paragraph) || '').trim()
|
|
286
|
+
}));
|
|
287
|
+
const targetIndex = blockParagraphs.indexOf(targetParagraph);
|
|
288
|
+
if (targetIndex < 0) return null;
|
|
289
|
+
|
|
290
|
+
const baseLevel = Math.min(...blockInfos.map(info => info.list?.ilvl ?? 0));
|
|
291
|
+
const originalMarkdownLines = blockInfos.map(info =>
|
|
292
|
+
toMarkdownLine((info.list?.ilvl ?? 0) - baseLevel, firstListType, info.text)
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
let replacementEntries = null;
|
|
296
|
+
const firstItem = parsed.items[0];
|
|
297
|
+
const trailingListItems = parsed.items.slice(1).filter(item => item.kind === 'list');
|
|
298
|
+
|
|
299
|
+
if (firstItem?.kind === 'text' && isNormalizedTextEqual(firstItem.text, normalizedTargetText) && trailingListItems.length > 0) {
|
|
300
|
+
const anchorLevel = Math.max(0, (blockInfos[targetIndex].list?.ilvl ?? 0) - baseLevel);
|
|
301
|
+
const firstTrailingLevel = trailingListItems[0].level;
|
|
302
|
+
replacementEntries = [
|
|
303
|
+
{
|
|
304
|
+
level: anchorLevel,
|
|
305
|
+
markerType: firstListType,
|
|
306
|
+
text: blockInfos[targetIndex].text
|
|
307
|
+
},
|
|
308
|
+
...trailingListItems.map(item => ({
|
|
309
|
+
level: resolveInsertionLevel(item, anchorLevel, firstTrailingLevel),
|
|
310
|
+
markerType: item.markerType || firstListType,
|
|
311
|
+
text: item.text
|
|
312
|
+
}))
|
|
313
|
+
];
|
|
314
|
+
} else if (parsed.items.every(item => item.kind === 'list')) {
|
|
315
|
+
const anchorLevel = Math.max(0, (blockInfos[targetIndex].list?.ilvl ?? 0) - baseLevel);
|
|
316
|
+
const firstLevel = parsed.items[0].level;
|
|
317
|
+
replacementEntries = parsed.items.map(item => ({
|
|
318
|
+
level: resolveInsertionLevel(item, anchorLevel, firstLevel),
|
|
319
|
+
markerType: item.markerType || firstListType,
|
|
320
|
+
text: item.text
|
|
321
|
+
}));
|
|
322
|
+
} else {
|
|
323
|
+
onWarn('[List] Multiline list edit did not match supported insertion/replace patterns; skipping list-block synthesis.');
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const replacementLines = replacementEntries.map(entry => toMarkdownLine(entry.level, entry.markerType, entry.text));
|
|
328
|
+
const modifiedMarkdownLines = originalMarkdownLines
|
|
329
|
+
.slice(0, targetIndex)
|
|
330
|
+
.concat(replacementLines)
|
|
331
|
+
.concat(originalMarkdownLines.slice(targetIndex + 1));
|
|
332
|
+
|
|
333
|
+
const originalText = originalMarkdownLines.join('\n');
|
|
334
|
+
const nextModifiedText = modifiedMarkdownLines.join('\n');
|
|
335
|
+
if (nextModifiedText === originalText) return null;
|
|
336
|
+
|
|
337
|
+
onInfo('[List] Expanded single-item list edit to contiguous list block for stable middle insertion.');
|
|
338
|
+
return {
|
|
339
|
+
paragraphs: blockParagraphs,
|
|
340
|
+
originalText,
|
|
341
|
+
modifiedText: nextModifiedText
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Plans insertion-only list edits for multiline middle-insert requests.
|
|
347
|
+
*
|
|
348
|
+
* This returns only new list items to insert after the target paragraph, so
|
|
349
|
+
* callers can emit insertion-only redlines instead of deleting/reinserting
|
|
350
|
+
* whole list blocks.
|
|
351
|
+
*
|
|
352
|
+
* @param {Element} targetParagraph - Resolved target list paragraph
|
|
353
|
+
* @param {string} modifiedText - Proposed replacement text
|
|
354
|
+
* @param {{
|
|
355
|
+
* currentParagraphText?: string,
|
|
356
|
+
* onInfo?: (msg:string)=>void,
|
|
357
|
+
* onWarn?: (msg:string)=>void
|
|
358
|
+
* }} [options] - Optional context/log callbacks
|
|
359
|
+
* @returns {{ targetParagraph: Element, numId: string, entries: Array<{ ilvl: number, text: string, markerType: 'bullet'|'numbered' }> }|null}
|
|
360
|
+
*/
|
|
361
|
+
export function planListInsertionOnlyEdit(targetParagraph, modifiedText, options = {}) {
|
|
362
|
+
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
363
|
+
const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
|
|
364
|
+
|
|
365
|
+
const rawModified = String(modifiedText || '');
|
|
366
|
+
if (!rawModified.includes('\n')) return null;
|
|
367
|
+
|
|
368
|
+
const targetListInfo = getParagraphListInfo(targetParagraph);
|
|
369
|
+
if (!targetListInfo) return null;
|
|
370
|
+
|
|
371
|
+
const parsed = parseModifiedListItems(rawModified);
|
|
372
|
+
if (!parsed.hasListMarkers || parsed.items.length < 2) return null;
|
|
373
|
+
|
|
374
|
+
const normalizedTargetText = normalizeWhitespaceForTargeting(
|
|
375
|
+
options.currentParagraphText || getParagraphText(targetParagraph)
|
|
376
|
+
);
|
|
377
|
+
const listItemsOnly = parsed.items.filter(item => item.kind === 'list');
|
|
378
|
+
const defaultMarkerType = listItemsOnly[0]?.markerType || 'bullet';
|
|
379
|
+
const anchorLevel = Math.max(0, targetListInfo.ilvl);
|
|
380
|
+
let entries = buildListEntriesForInsertion(parsed.items, normalizedTargetText, anchorLevel, defaultMarkerType);
|
|
381
|
+
|
|
382
|
+
if (!entries || entries.length === 0) {
|
|
383
|
+
onWarn('[List] Could not derive insertion-only entries from multiline list edit.');
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (shouldPromoteBulletInsertionsToChildDepth(parsed.items, normalizedTargetText, anchorLevel)) {
|
|
388
|
+
entries = promoteBulletInsertionsToChildDepth(entries, anchorLevel);
|
|
389
|
+
onInfo('[List] Promoted bullet insertion to child depth for nested numbered-list intent.');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
onInfo('[List] Planned insertion-only list redline entries (no block rewrite).');
|
|
393
|
+
return {
|
|
394
|
+
targetParagraph,
|
|
395
|
+
numId: targetListInfo.numId,
|
|
396
|
+
entries
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML identity extraction helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts a paragraph identity token from OOXML (`w14:paraId` when present).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} ooxml - OOXML payload
|
|
9
|
+
* @returns {string|null}
|
|
10
|
+
*/
|
|
11
|
+
export function extractParagraphIdFromOoxml(ooxml) {
|
|
12
|
+
if (!ooxml || typeof ooxml !== 'string') return null;
|
|
13
|
+
const match = ooxml.match(/\b(?:w14:paraId|w:paraId|paraId)="([^"]+)"/i);
|
|
14
|
+
return match ? match[1] : null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paragraph offset policy for text-model alignment.
|
|
3
|
+
*
|
|
4
|
+
* The reconciliation stack treats paragraph boundaries as a single newline
|
|
5
|
+
* separator inserted between adjacent paragraphs (never after the last one).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PARAGRAPH_BOUNDARY_TEXT = '\n';
|
|
9
|
+
export const PARAGRAPH_BOUNDARY_LENGTH = PARAGRAPH_BOUNDARY_TEXT.length;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns true when a paragraph boundary separator should be inserted.
|
|
13
|
+
*
|
|
14
|
+
* @param {number} paragraphIndex - Zero-based paragraph index
|
|
15
|
+
* @param {number} paragraphCount - Total paragraph count
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function hasParagraphBoundaryAfter(paragraphIndex, paragraphCount) {
|
|
19
|
+
return paragraphIndex < paragraphCount - 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Appends paragraph boundary text when policy requires one.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} text - Source text
|
|
26
|
+
* @param {number} paragraphIndex - Zero-based paragraph index
|
|
27
|
+
* @param {number} paragraphCount - Total paragraph count
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function appendParagraphBoundary(text, paragraphIndex, paragraphCount) {
|
|
31
|
+
if (!hasParagraphBoundaryAfter(paragraphIndex, paragraphCount)) {
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
return text + PARAGRAPH_BOUNDARY_TEXT;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Advances an offset by paragraph-boundary length when policy requires one.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} offset - Current offset
|
|
41
|
+
* @param {number} paragraphIndex - Zero-based paragraph index
|
|
42
|
+
* @param {number} paragraphCount - Total paragraph count
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
export function advanceOffsetForParagraphBoundary(offset, paragraphIndex, paragraphCount) {
|
|
46
|
+
if (!hasParagraphBoundaryAfter(paragraphIndex, paragraphCount)) {
|
|
47
|
+
return offset;
|
|
48
|
+
}
|
|
49
|
+
return offset + PARAGRAPH_BOUNDARY_LENGTH;
|
|
50
|
+
}
|